feat(server)!: replace readiness waits with grouped deferred fetches (#4683)
diff --git a/.claude/skills/connector-runtime/SKILL.md b/.claude/skills/connector-runtime/SKILL.md
index 68a3cc4..66f79e9 100644
--- a/.claude/skills/connector-runtime/SKILL.md
+++ b/.claude/skills/connector-runtime/SKILL.md
@@ -133,7 +133,8 @@
 
 1. Build / ensure consumer group `iggy-connect-sink-{key}` (`connect` matches the docker image name `apache/iggy-connect` - see `runtime/src/sink.rs::default_consumer_group`). Overridable via the stream config's `consumer_group`.
 2. Spawn one task per topic (`spawn_consume_tasks`).
-3. Poll Iggy → batch messages (default `batch_length = 1000`). A `consumer.next()` `Err` (transport/decode at the Iggy client boundary) bumps `errors` and `continue`s. A rejected poll committed nothing, so the consumer retries the same offset. Polls commit via `AutoCommit::When(AutoCommitWhen::PollingMessages)`, so only a reply lost after the server committed can skip its batch.
+3. Poll Iggy → batch messages (default `batch_length = 1000`, the poll `count`). Polls use `CommitPolicy::OnRead`: the server stores the offset of each reply, so a reply lost after that store skips its batch. A `consumer.next()` `Err` (a `ReadError`) bumps `errors` and `continue`s.
+   The reader retries most errors by itself. For a partition that it stops, `handle_read_error` seeks: after a read with an unknown outcome (`FetchProgressUncertain`, `PartitionLaneBlocked`) it first calls `abandon_operation` and then seeks `Next` (after the stored offset). It seeks the `skip` of a message that does not decode, and `Next` after `HistoryUnavailable`.
 4. Decode via `Schema::decoder()`. Decode failure logs + bumps `errors`. `messages_filtered` is reserved for the transform filter contract (`Ok(None)`).
 5. Apply transforms (chain). A transform `Err` is logged, bumps `errors`, and drops only that message (drop-and-continue, mirroring the source) - one bad payload never kills the batch. `Ok(None)` filters the message - bumps `messages_filtered{connector_type="sink"}`.
 6. Postcard-encode batch as `RawMessages`. Headers as a separate postcard blob. Per-message failures (missing field, payload conversion, header serialization) bump `errors` + skip. Batch-level failures (postcard serialization of metadata / `RawMessages`) propagate `Err` -> `spawn_consume_tasks` wrapper bumps `errors` + `set_error`. The total histogram and benchmark emit still fire for the failed batch before the `Err` propagates.
@@ -147,7 +148,7 @@
 Gotchas:
 
 - Batch flushes when a message is the last one in its partition (`message_offset == current_offset`), when the batch is full, OR before a message of another partition, because the plugin gets one partition id per batch. `max_wait` and `min_count` on the stream make the server collect larger replies.
-- Auto-commit happens on poll, so sink delivery is at-most-once: a crash after the poll and before the plugin write loses that batch.
+- The server stores the offset on read (`OnRead`), so sink delivery is at-most-once while the primary stays up: a crash after the poll and before the plugin write loses that batch. A failover before the store replicates can deliver the last batch again.
 - A stop still flushes the batch the loop holds, and the task then awaits `IggyConsumer::shutdown`, which rewinds over the batches the consumer holds. Without either step, the next run skips messages that the polls committed.
 
 ## Logging format
diff --git a/.github/actions/java-gradle/pre-merge/action.yml b/.github/actions/java-gradle/pre-merge/action.yml
index 9f4dcb7..60ee66a 100644
--- a/.github/actions/java-gradle/pre-merge/action.yml
+++ b/.github/actions/java-gradle/pre-merge/action.yml
@@ -160,6 +160,67 @@
         pid-file: ${{ steps.iggy.outputs.pid_file }}
         log-file: ${{ steps.iggy.outputs.log_file }}
 
+    # Both replicas of the roster in `core/server/config.toml`: a two-replica
+    # cluster commits on 2 acks, so a lone node commits nothing. The suite then
+    # reads through the client's partition routing.
+    - name: Start Iggy VSR cluster node 0
+      id: iggy-cluster-0
+      if: inputs.task == 'test'
+      uses: ./.github/actions/utils/server-start
+      with:
+        bin: target/debug/iggy-server
+        encryption-enabled: "true"
+        encryption-key: "/rvT1xP4V8u1EAhk4xDdqzqM2UOPXyy9XYkl4uRShgE="
+        replica-id: "0"
+        pid-file: ${{ runner.temp }}/iggy-java-cluster-0.pid
+        log-file: ${{ runner.temp }}/iggy-java-cluster-0.log
+        wait-timeout-seconds: "90"
+      env:
+        IGGY_CLUSTER_ENABLED: "true"
+        IGGY_PATH: ${{ runner.temp }}/iggy-java-cluster-0-data
+
+    - name: Start Iggy VSR cluster node 1
+      id: iggy-cluster-1
+      if: inputs.task == 'test'
+      uses: ./.github/actions/utils/server-start
+      with:
+        bin: target/debug/iggy-server
+        encryption-enabled: "true"
+        encryption-key: "/rvT1xP4V8u1EAhk4xDdqzqM2UOPXyy9XYkl4uRShgE="
+        replica-id: "1"
+        tcp_address: 127.0.0.1:8091
+        http_address: 127.0.0.1:3001
+        pid-file: ${{ runner.temp }}/iggy-java-cluster-1.pid
+        log-file: ${{ runner.temp }}/iggy-java-cluster-1.log
+        wait-timeout-seconds: "90"
+      env:
+        IGGY_CLUSTER_ENABLED: "true"
+        IGGY_PATH: ${{ runner.temp }}/iggy-java-cluster-1-data
+
+    - name: Test (cluster)
+      if: inputs.task == 'test'
+      shell: bash
+      working-directory: foreign/java
+      env:
+        USE_EXTERNAL_SERVER: true
+      run: |
+        TIMEFORMAT='Java tests against a two-node cluster: %3R seconds'
+        time ./gradlew :iggy:test --rerun
+
+    - name: Stop Iggy VSR cluster node 1
+      if: always() && inputs.task == 'test'
+      uses: ./.github/actions/utils/server-stop
+      with:
+        pid-file: ${{ steps.iggy-cluster-1.outputs.pid_file }}
+        log-file: ${{ steps.iggy-cluster-1.outputs.log_file }}
+
+    - name: Stop Iggy VSR cluster node 0
+      if: always() && inputs.task == 'test'
+      uses: ./.github/actions/utils/server-stop
+      with:
+        pid-file: ${{ steps.iggy-cluster-0.outputs.pid_file }}
+        log-file: ${{ steps.iggy-cluster-0.outputs.log_file }}
+
     # No encryption key, the shipped default, so this lane also covers a server
     # that keeps streams plaintext and refuses client stream keys.
     - name: Start Iggy server (TLS)
diff --git a/.github/actions/node-npm/pre-merge/action.yml b/.github/actions/node-npm/pre-merge/action.yml
index 1d68238..365252c 100644
--- a/.github/actions/node-npm/pre-merge/action.yml
+++ b/.github/actions/node-npm/pre-merge/action.yml
@@ -149,6 +149,65 @@
         pid-file: ${{ steps.iggy-default.outputs.pid_file }}
         log-file: ${{ steps.iggy-default.outputs.log_file }}
 
+    # Both replicas of the roster in `core/server/config.toml`: a two-replica
+    # cluster commits on 2 acks, so a lone node commits nothing. The suite then
+    # reads through the client's partition routing.
+    - name: Start Iggy VSR cluster node 0
+      id: iggy-cluster-0
+      if: inputs.task == 'e2e'
+      uses: ./.github/actions/utils/server-start
+      with:
+        bin: target/debug/iggy-server
+        encryption-enabled: "true"
+        encryption-key: "/rvT1xP4V8u1EAhk4xDdqzqM2UOPXyy9XYkl4uRShgE="
+        replica-id: "0"
+        pid-file: ${{ runner.temp }}/iggy-node-cluster-0.pid
+        log-file: ${{ runner.temp }}/iggy-node-cluster-0.log
+        wait-timeout-seconds: "90"
+      env:
+        IGGY_CLUSTER_ENABLED: "true"
+        IGGY_PATH: ${{ runner.temp }}/iggy-node-cluster-0-data
+
+    - name: Start Iggy VSR cluster node 1
+      id: iggy-cluster-1
+      if: inputs.task == 'e2e'
+      uses: ./.github/actions/utils/server-start
+      with:
+        bin: target/debug/iggy-server
+        encryption-enabled: "true"
+        encryption-key: "/rvT1xP4V8u1EAhk4xDdqzqM2UOPXyy9XYkl4uRShgE="
+        replica-id: "1"
+        tcp_address: 127.0.0.1:8091
+        http_address: 127.0.0.1:3001
+        pid-file: ${{ runner.temp }}/iggy-node-cluster-1.pid
+        log-file: ${{ runner.temp }}/iggy-node-cluster-1.log
+        wait-timeout-seconds: "90"
+      env:
+        IGGY_CLUSTER_ENABLED: "true"
+        IGGY_PATH: ${{ runner.temp }}/iggy-node-cluster-1-data
+
+    - name: E2E tests (cluster)
+      if: inputs.task == 'e2e'
+      run: |
+        cd foreign/node
+        node --import @swc-node/register/esm-register --test --test-force-exit \
+          $(ls src/e2e/*.e2e.ts | grep -v -e tls.system)
+      shell: bash
+
+    - name: Stop Iggy VSR cluster node 1
+      if: always() && inputs.task == 'e2e'
+      uses: ./.github/actions/utils/server-stop
+      with:
+        pid-file: ${{ steps.iggy-cluster-1.outputs.pid_file }}
+        log-file: ${{ steps.iggy-cluster-1.outputs.log_file }}
+
+    - name: Stop Iggy VSR cluster node 0
+      if: always() && inputs.task == 'e2e'
+      uses: ./.github/actions/utils/server-stop
+      with:
+        pid-file: ${{ steps.iggy-cluster-0.outputs.pid_file }}
+        log-file: ${{ steps.iggy-cluster-0.outputs.log_file }}
+
     - name: Start Iggy server (TLS)
       id: iggy-tls
       if: inputs.task == 'e2e'
diff --git a/.github/actions/rust/pre-merge/action.yml b/.github/actions/rust/pre-merge/action.yml
index 63210a1..c62d1b3 100644
--- a/.github/actions/rust/pre-merge/action.yml
+++ b/.github/actions/rust/pre-merge/action.yml
@@ -134,7 +134,7 @@
       run: cargo fmt --all -- --check
       shell: bash
 
-    # clippy-macos-aarch64 runs the same lint on macos-14. Code behind
+    # clippy-macos-aarch64 runs the same lint on macos-15. Code behind
     # cfg(target_os) splits is dead to the Linux leg, so its warnings would
     # otherwise reach master unseen.
     - name: Cargo clippy
diff --git a/.github/actions/swift/pre-merge/action.yml b/.github/actions/swift/pre-merge/action.yml
index d06030f..bb37f83 100644
--- a/.github/actions/swift/pre-merge/action.yml
+++ b/.github/actions/swift/pre-merge/action.yml
@@ -39,7 +39,7 @@
       if: inputs.task == 'lint'
       run: |
         cd foreign/swift
-        swift format lint --strict --recursive Sources Tests Package.swift
+        swift format lint --strict --recursive Sources Tests Examples Package.swift
 
     # A warning-free build is the lint gate the Swift compiler provides: the
     # SDK builds in Swift 6 language mode with strict concurrency checking.
diff --git a/.github/actions/utils/setup-rust/action.yml b/.github/actions/utils/setup-rust/action.yml
index 9d0a789..05477b1 100644
--- a/.github/actions/utils/setup-rust/action.yml
+++ b/.github/actions/utils/setup-rust/action.yml
@@ -76,12 +76,12 @@
 
     - name: Setup Rust toolchain
       run: |
-        # macOS runners provision Rust via the Homebrew `rustup` formula, which
-        # installs only `rustup-init` plus brew shims that resolve back to it in
-        # installer mode - so `cargo` errors out and ~/.cargo/bin has no proxies.
-        # Run rustup-init once to generate the real cargo/rustc proxies.
-        if [ "$RUNNER_OS" = "macOS" ]; then
-          rustup-init -y --no-modify-path --default-toolchain none
+        # Runner images differ in how they ship Rust. Bootstrap rustup only
+        # when the image has none, so the step works on every image.
+        if ! command -v rustup >/dev/null 2>&1; then
+          curl --proto '=https' --tlsv1.2 -fsSL --retry 5 --retry-all-errors https://sh.rustup.rs \
+            | sh -s -- -y --no-modify-path --default-toolchain none
+          export PATH="$HOME/.cargo/bin:$PATH"
         fi
         rustup show
         # rust-cache hashes every installed toolchain, including unused runner defaults.
@@ -93,6 +93,9 @@
             rustup toolchain uninstall "$toolchain"
           fi
         done <<< "$installed_toolchains"
+        # Later steps unpack cargo tools here, and an image whose rustup lives
+        # elsewhere does not create it.
+        mkdir -p "$HOME/.cargo/bin"
         echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
       shell: bash
 
diff --git a/.github/config/components.yml b/.github/config/components.yml
index b8971f5..b0df5eb 100644
--- a/.github/config/components.yml
+++ b/.github/config/components.yml
@@ -145,7 +145,7 @@
   # existing deployments, which no other check covers. Matching is literal
   # glob with no crate-graph expansion, so every crate has to be spelled out:
   # `core/server/**` does not cover `core/server_common/**`.
-  # `breaking:storage` on the PR skips it (see .github/workflows/_rust.yml).
+  # `breaking:storage` or `breaking:protocol` on the PR skips it (see .github/workflows/_rust.yml).
   rust-storage-compat:
     depends_on:
       - "rust-workspace"
@@ -403,6 +403,7 @@
     paths:
       - "bdd/php/**"
       - "bdd/scenarios/basic_messaging.feature"
+      - "bdd/scenarios/deferred_polling.feature"
     tasks: ["bdd-php"]
 
   bdd-go:
@@ -468,6 +469,17 @@
       - "bdd/scenarios/**"
     tasks: ["bdd-cpp"]
 
+  bdd-swift:
+    depends_on:
+      - "rust-server"
+      - "sdk-swift"
+      - "bdd-infrastructure"
+      - "ci-infrastructure"
+    paths:
+      - "bdd/swift/**"
+      - "bdd/scenarios/deferred_polling.feature"
+    tasks: ["bdd-swift"]
+
   examples-rust:
     depends_on:
       - "rust-sdk"
diff --git a/.github/workflows/_rust.yml b/.github/workflows/_rust.yml
index 4217775..b3a23c7 100644
--- a/.github/workflows/_rust.yml
+++ b/.github/workflows/_rust.yml
@@ -38,7 +38,7 @@
         include: ${{ fromJSON(inputs.plan).checks }}
     runs-on: ${{
       (matrix.task == 'build-aarch64-gnu' || matrix.task == 'build-aarch64-musl') && 'ubuntu-26.04-arm' ||
-      (matrix.task == 'build-macos-aarch64' || matrix.task == 'clippy-macos-aarch64') && 'macos-14' ||
+      (matrix.task == 'build-macos-aarch64' || matrix.task == 'clippy-macos-aarch64') && 'macos-15' ||
       matrix.task == 'build-windows-sdk' && 'windows-latest' ||
       'ubuntu-26.04'
       }}
@@ -48,6 +48,8 @@
 
       # Re-runs replay the original event labels. Read live labels so applying
       # breaking:storage followed by a re-run can skip an intentional format break.
+      # breaking:protocol skips it for a protocol bump that a baseline server
+      # refuses at login, with the storage format unchanged.
       - name: Resolve breaking:storage escape hatch
         id: storage_hatch
         if: matrix.task == 'test-storage-compat' && github.event.pull_request.number
@@ -65,11 +67,13 @@
                   per_page: 100,
                 },
               );
-              skip = labels.some(l => l.name === 'breaking:storage');
+              skip = labels.some(
+                l => l.name === 'breaking:storage' || l.name === 'breaking:protocol' || l.name === 'breaking:client-to-replica-protocol',
+              );
             } catch (e) {
               core.warning(`listLabelsOnIssue failed (${e.status ?? e.code ?? 'network'}), running the check`);
             }
-            core.info(`breaking:storage escape hatch: skip=${skip}`);
+            core.info(`breaking:storage or breaking:protocol escape hatch: skip=${skip}`);
             core.setOutput('skip', String(skip));
 
       - name: Run Rust check
diff --git a/.github/workflows/coverage-baseline.yml b/.github/workflows/coverage-baseline.yml
index 2ac6ad8..5d1acf6 100644
--- a/.github/workflows/coverage-baseline.yml
+++ b/.github/workflows/coverage-baseline.yml
@@ -598,7 +598,7 @@
     name: Warm macOS cache
     needs: detect
     if: needs.detect.outputs.rust == 'true'
-    runs-on: macos-14
+    runs-on: macos-15
     timeout-minutes: 120
     steps:
       - uses: actions/checkout@v7.0.1
diff --git a/.gitignore b/.gitignore
index 2dfad02..944c044 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,6 +21,7 @@
 !/foreign/python/src/bin
 *.iml
 foreign/cpp/build
+/foreign/csharp/TestResults/
 foreign/java/**/build
 foreign/java/**/out
 foreign/node/**/dist
diff --git a/Cargo.lock b/Cargo.lock
index 6e9b186..333ee7a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2164,6 +2164,7 @@
 dependencies = [
  "bytes",
  "cucumber",
+ "futures",
  "iggy",
  "tokio",
 ]
@@ -7090,7 +7091,7 @@
 
 [[package]]
 name = "iggy"
-version = "0.11.1-edge.5"
+version = "0.11.1-edge.6"
 dependencies = [
  "async-broadcast",
  "async-dropper",
@@ -7128,7 +7129,7 @@
 
 [[package]]
 name = "iggy-bench"
-version = "0.6.1-edge.4"
+version = "0.6.1-edge.5"
 dependencies = [
  "async-trait",
  "bench-report",
@@ -7187,7 +7188,7 @@
 
 [[package]]
 name = "iggy-cli"
-version = "0.14.1-edge.4"
+version = "0.14.1-edge.5"
 dependencies = [
  "anyhow",
  "apple-native-keyring-store",
@@ -7221,7 +7222,7 @@
 
 [[package]]
 name = "iggy-connectors"
-version = "0.5.1-edge.4"
+version = "0.5.1-edge.5"
 dependencies = [
  "apache-avro 0.22.0",
  "async-trait",
@@ -7281,7 +7282,7 @@
 
 [[package]]
 name = "iggy-gateway-kafka"
-version = "0.1.0-edge.1"
+version = "0.1.0-edge.2"
 dependencies = [
  "arc-swap",
  "async-trait",
@@ -7318,7 +7319,7 @@
 
 [[package]]
 name = "iggy-mcp"
-version = "0.5.1-edge.4"
+version = "0.5.1-edge.5"
 dependencies = [
  "axum",
  "axum-server",
@@ -7353,7 +7354,7 @@
 
 [[package]]
 name = "iggy_binary_protocol"
-version = "0.11.1-edge.4"
+version = "0.11.1-edge.5"
 dependencies = [
  "aligned-vec",
  "bytemuck",
@@ -7368,7 +7369,7 @@
 
 [[package]]
 name = "iggy_common"
-version = "0.11.1-edge.5"
+version = "0.11.1-edge.6"
 dependencies = [
  "aes-gcm 0.11.1",
  "aes-gcm-siv",
@@ -7378,6 +7379,7 @@
  "blake3",
  "bon",
  "byte-unit",
+ "bytemuck",
  "bytes",
  "chrono",
  "clap",
@@ -12997,7 +12999,7 @@
 
 [[package]]
 name = "server"
-version = "0.9.1-edge.4"
+version = "0.9.1-edge.5"
 dependencies = [
  "ahash",
  "arc-swap",
diff --git a/Cargo.toml b/Cargo.toml
index 22a6be8..6cdab18 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -214,11 +214,11 @@
 iceberg = "0.10.1"
 iceberg-catalog-rest = "0.10.1"
 iceberg-storage-opendal = "0.10.1"
-iggy = { path = "core/sdk", version = "0.11.1-edge.5" }
-iggy-cli = { path = "core/cli", version = "0.14.1-edge.4" }
+iggy = { path = "core/sdk", version = "0.11.1-edge.6" }
+iggy-cli = { path = "core/cli", version = "0.14.1-edge.5" }
 iggy-gateway-kafka = { path = "gateways/kafka" }
-iggy_binary_protocol = { path = "core/binary_protocol", version = "0.11.1-edge.4" }
-iggy_common = { path = "core/common", version = "0.11.1-edge.5" }
+iggy_binary_protocol = { path = "core/binary_protocol", version = "0.11.1-edge.5" }
+iggy_common = { path = "core/common", version = "0.11.1-edge.6" }
 iggy_connector_doris_sink = { path = "core/connectors/sinks/doris_sink" }
 iggy_connector_sdk = { path = "core/connectors/sdk", version = "0.5.1-edge.2" }
 iggy_kafka_wire = { path = "core/kafka_wire" }
diff --git a/README.md b/README.md
index 11c6ebe..d30d8b5 100644
--- a/README.md
+++ b/README.md
@@ -471,19 +471,24 @@
 let messages = vec![IggyMessage::from_str("Hello Apache Iggy")?];
 producer.send(messages).await?;
 
-// Create a consumer for the given stream and one of its topics
+// Create a consumer group member for the given stream and one of its topics.
+// It stores the last processed message every second and after each batch.
 let mut consumer = client
     .consumer_group("my_app", "dev01", "events")?
-    .auto_commit(AutoCommit::IntervalOrWhen(
-        NonZeroIggyDuration::from_str("1s")?,
-        AutoCommitWhen::ConsumingAllMessages,
-    ))
     .create_consumer_group_if_not_exists()
-    .auto_join_consumer_group()
     .polling_strategy(PollingStrategy::next())
-    .batch_length(1000)
+    .poll_options(PollOptions {
+        count: 1000,
+        commit: CommitPolicy::Auto(AutoCommit {
+            interval: Some(NonZeroIggyDuration::from_str("1s")?),
+            every: Some(CommitEvery::Batch),
+            after: CommitAfter::Processing,
+        }),
+        ..PollOptions::wait(IggyDuration::from_str("1s")?)
+    })
     .build();
 
+// Joins the group and starts reading
 consumer.init().await?;
 
 // Start consuming the messages
@@ -492,6 +497,8 @@
 }
 ```
 
+For batches of many partitions, use `PartitionReader` from `client.reader(...)`. The [Rust SDK README](core/sdk/README.md#reading-messages) describes the three ways to read, the commit options and what to do after a lost reply.
+
 ---
 
 ## Benchmarks
diff --git a/bdd/cpp/features/step_definitions/deferred_polling_steps.cpp b/bdd/cpp/features/step_definitions/deferred_polling_steps.cpp
new file mode 100644
index 0000000..c4f6866
--- /dev/null
+++ b/bdd/cpp/features/step_definitions/deferred_polling_steps.cpp
@@ -0,0 +1,401 @@
+/*
+ * 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.
+ */
+
+// Unique step-class prefix so the generated CukeObject<n> symbols do not clash with the other
+// step files when linked together (see background_steps.cpp for details).
+#define CUKE_OBJECT_PREFIX IggyBddDeferredPolling
+
+#include <gtest/gtest.h>
+
+#include <cucumber-cpp/autodetect.hpp>
+
+#include <chrono>
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <thread>
+#include <utility>
+#include <vector>
+
+#include "world.hpp"
+
+namespace {
+
+constexpr std::uint32_t kMessagesCount = 10;
+// A message that reaches the server before the call answers it at once, so a start step waits
+// until its call is parked.
+constexpr auto kParkTime = std::chrono::milliseconds(100);
+// The wait of a consumer whose step names none.
+constexpr auto kConsumerMaxWait            = std::chrono::seconds(10);
+constexpr std::uint32_t kDefaultConsumerId = 1;
+// How long a poll whose connection dropped may take to fail.
+constexpr auto kLostPollTimeout                 = std::chrono::seconds(10);
+constexpr std::uint32_t kPartitionLaneBlocked   = 14009;
+constexpr std::uint32_t kFetchProgressUncertain = 14010;
+
+// The SDK defaults with the count of these steps and the max wait of the step.
+iggy::PollOptions poll_options(const std::chrono::microseconds max_wait) {
+    return iggy::PollOptions().SetCount(kMessagesCount).SetMaxWait(max_wait);
+}
+
+std::string payload_text(const iggy::IggyMessagePolled &message) {
+    return std::string(message.Payload().begin(), message.Payload().end());
+}
+
+std::vector<std::string> payloads_of(const iggy::PolledMessages &polled) {
+    std::vector<std::string> payloads;
+    for (const auto &message : polled.Messages()) {
+        payloads.push_back(payload_text(message));
+    }
+    return payloads;
+}
+
+iggy::Identifier stream_of(const bdd::GlobalContext &context) {
+    return iggy::Identifier::String(context.last_stream_name.value());
+}
+
+iggy::Identifier topic_of(const bdd::GlobalContext &context) {
+    return iggy::Identifier::String(context.last_topic_name.value());
+}
+
+iggy::PendingPoll take_deferred_poll(bdd::GlobalContext &context) {
+    iggy::PendingPoll poll = std::move(context.deferred_poll.value());
+    context.deferred_poll.reset();
+    return poll;
+}
+
+void start_poll(bdd::GlobalContext &context,
+                const std::uint32_t partition_id,
+                const iggy::Consumer &consumer,
+                const iggy::PollingStrategy &strategy,
+                const iggy::PollOptions &options) {
+    if (!context.background) {
+        context.background.emplace(bdd::connect_root_client());
+    }
+    // The first read of a new topic can wait for its partition, so a read that waits for nothing goes first and the
+    // poll then leaves at once.
+    (void)context.background->PollMessages(iggy::Consumer::Single(iggy::Identifier::Numeric(kDefaultConsumerId)),
+                                           stream_of(context), topic_of(context), partition_id,
+                                           iggy::PollingStrategy::Offset(0), iggy::PollOptions().SetCount(1));
+    context.deferred_poll_started = std::chrono::steady_clock::now();
+    context.deferred_poll.emplace(context.background->StartPollMessages(consumer, stream_of(context), topic_of(context),
+                                                                        partition_id, strategy, options));
+    std::this_thread::sleep_for(kParkTime);
+    ASSERT_FALSE(context.deferred_poll->Wait(std::chrono::microseconds(0)).has_value())
+        << "the deferred poll must park";
+}
+
+iggy::PolledMessages poll_next(bdd::GlobalContext &context,
+                               const std::uint32_t partition_id,
+                               const std::string &consumer_name,
+                               const iggy::CommitPolicy commit) {
+    // One message from next, so a poll that commits stores only the offset of the payload it returns.
+    auto &client = context.poll_owner != nullptr ? *context.poll_owner : bdd::sdk_client(context);
+    return client.PollMessages(iggy::Consumer::Single(iggy::Identifier::String(consumer_name)), stream_of(context),
+                               topic_of(context), partition_id, iggy::PollingStrategy::Next(),
+                               iggy::PollOptions().SetCount(1).SetCommit(commit));
+}
+
+void poll_next_and_expect(bdd::GlobalContext &context,
+                          const std::uint32_t partition_id,
+                          const std::string &consumer_name,
+                          const iggy::CommitPolicy commit,
+                          const std::string &payload) {
+    const auto polled = poll_next(context, partition_id, consumer_name, commit);
+    ASSERT_FALSE(polled.Messages().empty()) << "the poll must return the payload " << payload;
+    EXPECT_EQ(payload_text(polled.Messages().front()), payload);
+}
+
+void open_group_consumer(bdd::GlobalContext &context, const std::string &group, const iggy::PollOptions &options) {
+    context.consumer.emplace(bdd::sdk_client(context).GroupConsumer(group, stream_of(context), topic_of(context),
+                                                                    iggy::ConsumerOptions().SetPollOptions(options)));
+}
+
+}  // namespace
+
+GIVEN("^a stream named \"([^\"]+)\" with a topic named \"([^\"]+)\" with ([0-9]+) partitions exists$") {
+    REGEX_PARAM(std::string, stream_name);
+    REGEX_PARAM(std::string, topic_name);
+    REGEX_PARAM(int, partitions);
+    cucumber::ScenarioScope<bdd::GlobalContext> context;
+
+    auto &client = bdd::sdk_client(*context);
+    for (const auto &stream : client.GetStreams()) {
+        if (stream.Name() == stream_name) {
+            client.DeleteStream(iggy::Identifier::String(stream_name));
+        }
+    }
+    client.CreateStream(stream_name);
+    client.CreateTopic(iggy::Identifier::String(stream_name), topic_name,
+                       iggy::TopicCreateOptions().SetPartitionsCount(static_cast<std::uint32_t>(partitions)));
+    context->last_stream_name = stream_name;
+    context->last_topic_name  = topic_name;
+}
+
+WHEN("^I start a deferred poll of partition ([0-9]+) from offset ([0-9]+) with a max wait of ([0-9]+) seconds?$") {
+    REGEX_PARAM(int, partition_id);
+    REGEX_PARAM(int, offset);
+    REGEX_PARAM(int, max_wait_seconds);
+    cucumber::ScenarioScope<bdd::GlobalContext> context;
+
+    start_poll(*context, static_cast<std::uint32_t>(partition_id),
+               iggy::Consumer::Single(iggy::Identifier::Numeric(kDefaultConsumerId)),
+               iggy::PollingStrategy::Offset(static_cast<std::uint64_t>(offset)),
+               poll_options(std::chrono::seconds(max_wait_seconds)));
+}
+
+WHEN(
+    "^I start a deferred poll of partition ([0-9]+) for consumer \"([^\"]+)\" with the on read commit policy, a min "
+    "count of ([0-9]+) and a max wait of ([0-9]+) seconds?$") {
+    REGEX_PARAM(int, partition_id);
+    REGEX_PARAM(std::string, consumer_name);
+    REGEX_PARAM(int, min_count);
+    REGEX_PARAM(int, max_wait_seconds);
+    cucumber::ScenarioScope<bdd::GlobalContext> context;
+
+    start_poll(*context, static_cast<std::uint32_t>(partition_id),
+               iggy::Consumer::Single(iggy::Identifier::String(consumer_name)), iggy::PollingStrategy::Next(),
+               poll_options(std::chrono::seconds(max_wait_seconds))
+                   .SetMinCount(static_cast<std::uint32_t>(min_count))
+                   .SetCommit(iggy::CommitPolicy::OnRead));
+}
+
+WHEN(
+    "^I start a deferred poll of partition ([0-9]+) for consumer \"([^\"]+)\" with the on read commit policy and a "
+    "max wait of ([0-9]+) seconds?$") {
+    REGEX_PARAM(int, partition_id);
+    REGEX_PARAM(std::string, consumer_name);
+    REGEX_PARAM(int, max_wait_seconds);
+    cucumber::ScenarioScope<bdd::GlobalContext> context;
+
+    start_poll(*context, static_cast<std::uint32_t>(partition_id),
+               iggy::Consumer::Single(iggy::Identifier::String(consumer_name)), iggy::PollingStrategy::Next(),
+               poll_options(std::chrono::seconds(max_wait_seconds)).SetCommit(iggy::CommitPolicy::OnRead));
+}
+
+WHEN("^the client disconnects before the deferred poll answers$") {
+    cucumber::ScenarioScope<bdd::GlobalContext> context;
+    ASSERT_TRUE(context->deferred_poll.has_value()) << "deferred poll started";
+    ASSERT_FALSE(context->deferred_poll->Wait(std::chrono::microseconds(0)).has_value())
+        << "the deferred poll must still wait";
+
+    context->poll_owner = &context->background.value();
+    context->poll_owner->Disconnect();
+}
+
+THEN("^the deferred poll should fail as uncertain$") {
+    cucumber::ScenarioScope<bdd::GlobalContext> context;
+
+    auto poll = take_deferred_poll(*context);
+    try {
+        (void)poll.Wait(kLostPollTimeout);
+        FAIL() << "the deferred poll must fail as uncertain";
+    } catch (const iggy::IggyException &error) {
+        EXPECT_EQ(error.Code(), kFetchProgressUncertain) << error.what();
+        EXPECT_EQ(error.Kind(), "fetch_progress_uncertain");
+        ASSERT_TRUE(error.OperationId().has_value()) << error.what();
+        context->lost_operation_id = error.OperationId();
+    }
+}
+
+WHEN("^the client connects again$") {
+    cucumber::ScenarioScope<bdd::GlobalContext> context;
+    ASSERT_NE(context->poll_owner, nullptr) << "the client disconnected";
+
+    // The automatic login of the client signs it in again.
+    context->poll_owner->Connect();
+}
+
+THEN(
+    "^a poll of partition ([0-9]+) for consumer \"([^\"]+)\" with the next strategy and the on read commit policy "
+    "should fail as blocked by the deferred poll$") {
+    REGEX_PARAM(int, partition_id);
+    REGEX_PARAM(std::string, consumer_name);
+    cucumber::ScenarioScope<bdd::GlobalContext> context;
+
+    try {
+        (void)poll_next(*context, static_cast<std::uint32_t>(partition_id), consumer_name, iggy::CommitPolicy::OnRead);
+        FAIL() << "the poll must fail as blocked";
+    } catch (const iggy::IggyException &error) {
+        EXPECT_EQ(error.Code(), kPartitionLaneBlocked) << error.what();
+        EXPECT_EQ(error.Kind(), "partition_lane_blocked");
+        EXPECT_EQ(error.BlockedPartitionId(), static_cast<std::uint32_t>(partition_id));
+        EXPECT_EQ(error.OperationId(), context->lost_operation_id);
+    }
+}
+
+WHEN("^I abandon the operation of the deferred poll$") {
+    cucumber::ScenarioScope<bdd::GlobalContext> context;
+    ASSERT_NE(context->poll_owner, nullptr) << "the client disconnected";
+    ASSERT_TRUE(context->lost_operation_id.has_value()) << "the deferred poll failed as uncertain";
+
+    context->poll_owner->AbandonOperation(*context->lost_operation_id);
+}
+
+WHEN("^I send a message with payload \"([^\"]+)\" to partition ([0-9]+)$") {
+    iggy::ffi::ErrorInfo error_info{};
+    REGEX_PARAM(std::string, payload);
+    REGEX_PARAM(int, partition_id);
+    cucumber::ScenarioScope<bdd::GlobalContext> context;
+
+    rust::Vec<iggy::ffi::IggyMessageToSend> messages;
+    messages.push_back(iggy::ffi::make_message(bdd::to_payload(payload), rust::Vec<iggy::ffi::HeaderEntry>{}));
+    context->client->send_messages(bdd::make_string_identifier(context->last_stream_name.value()),
+                                   bdd::make_string_identifier(context->last_topic_name.value()), "partition_id",
+                                   bdd::partition_id_bytes(static_cast<std::uint32_t>(partition_id)),
+                                   std::move(messages), error_info);
+}
+
+THEN("^the deferred poll should return the payload \"([^\"]+)\" within ([0-9]+) seconds?$") {
+    REGEX_PARAM(std::string, payload);
+    REGEX_PARAM(int, within_seconds);
+    cucumber::ScenarioScope<bdd::GlobalContext> context;
+
+    auto poll         = take_deferred_poll(*context);
+    const auto polled = poll.Wait(std::chrono::seconds(within_seconds));
+    ASSERT_TRUE(polled.has_value()) << "the deferred poll did not return within " << within_seconds << " s";
+    EXPECT_EQ(payloads_of(*polled), std::vector<std::string>{payload});
+}
+
+THEN("^the deferred poll should return no messages between ([0-9]+) and ([0-9]+) seconds after it started$") {
+    REGEX_PARAM(int, from_seconds);
+    REGEX_PARAM(int, to_seconds);
+    cucumber::ScenarioScope<bdd::GlobalContext> context;
+
+    const auto started   = context->deferred_poll_started;
+    auto poll            = take_deferred_poll(*context);
+    const auto remaining = started + std::chrono::seconds(to_seconds) - std::chrono::steady_clock::now();
+    const auto polled    = poll.Wait(std::chrono::duration_cast<std::chrono::microseconds>(remaining));
+    const auto elapsed   = std::chrono::steady_clock::now() - started;
+    ASSERT_TRUE(polled.has_value()) << "the deferred poll did not return within " << to_seconds << " s";
+    EXPECT_TRUE(polled->Messages().empty()) << "the deferred poll must return no messages";
+    EXPECT_GE(elapsed, std::chrono::seconds(from_seconds)) << "the deferred poll returned too early";
+}
+
+WHEN("^I cancel the deferred poll after ([0-9]+) milliseconds$") {
+    REGEX_PARAM(int, after_milliseconds);
+    cucumber::ScenarioScope<bdd::GlobalContext> context;
+
+    std::this_thread::sleep_for(std::chrono::milliseconds(after_milliseconds));
+    auto poll = take_deferred_poll(*context);
+    ASSERT_FALSE(poll.Wait(std::chrono::microseconds(0)).has_value())
+        << "the deferred poll must still be parked when it is cancelled";
+    // Dropping the parked request sends the cancellation.
+    poll.Cancel();
+}
+
+WHEN("^I wait ([0-9]+) seconds?$") {
+    REGEX_PARAM(int, seconds);
+
+    std::this_thread::sleep_for(std::chrono::seconds(seconds));
+}
+
+THEN(
+    "^a poll of partition ([0-9]+) for consumer \"([^\"]+)\" with the next strategy should return the payload "
+    "\"([^\"]+)\"$") {
+    REGEX_PARAM(int, partition_id);
+    REGEX_PARAM(std::string, consumer_name);
+    REGEX_PARAM(std::string, payload);
+    cucumber::ScenarioScope<bdd::GlobalContext> context;
+
+    poll_next_and_expect(*context, static_cast<std::uint32_t>(partition_id), consumer_name, iggy::CommitPolicy::Manual,
+                         payload);
+}
+
+THEN(
+    "^a poll of partition ([0-9]+) for consumer \"([^\"]+)\" with the next strategy and the on read commit policy "
+    "should return the payload \"([^\"]+)\"$") {
+    REGEX_PARAM(int, partition_id);
+    REGEX_PARAM(std::string, consumer_name);
+    REGEX_PARAM(std::string, payload);
+    cucumber::ScenarioScope<bdd::GlobalContext> context;
+
+    poll_next_and_expect(*context, static_cast<std::uint32_t>(partition_id), consumer_name, iggy::CommitPolicy::OnRead,
+                         payload);
+}
+
+WHEN(
+    "^I open a partition reader on partitions ([0-9]+), ([0-9]+) and ([0-9]+) from offset ([0-9]+) with a max wait "
+    "of ([0-9]+) seconds?$") {
+    REGEX_PARAM(int, first);
+    REGEX_PARAM(int, second);
+    REGEX_PARAM(int, third);
+    REGEX_PARAM(int, offset);
+    REGEX_PARAM(int, max_wait_seconds);
+    cucumber::ScenarioScope<bdd::GlobalContext> context;
+
+    context->reader.emplace(bdd::sdk_client(*context).Reader(
+        iggy::Consumer::Single(iggy::Identifier::Numeric(kDefaultConsumerId)), stream_of(*context), topic_of(*context),
+        iggy::ReaderOptions()
+            .SetPartitions({static_cast<std::uint32_t>(first), static_cast<std::uint32_t>(second),
+                            static_cast<std::uint32_t>(third)})
+            .SetStart(iggy::PollingStrategy::Offset(static_cast<std::uint64_t>(offset)))
+            .SetPollOptions(poll_options(std::chrono::seconds(max_wait_seconds)))));
+}
+
+THEN("^the partition reader should return the payload \"([^\"]+)\" from partition ([0-9]+) within ([0-9]+) seconds?$") {
+    REGEX_PARAM(std::string, payload);
+    REGEX_PARAM(int, partition_id);
+    REGEX_PARAM(int, within_seconds);
+    cucumber::ScenarioScope<bdd::GlobalContext> context;
+    ASSERT_TRUE(context->reader.has_value()) << "partition reader open";
+
+    // The reader never yields an empty batch, so its first batch comes from the first partition
+    // that gets a message.
+    const auto polled = context->reader->Next(std::chrono::seconds(within_seconds));
+    ASSERT_TRUE(polled.has_value()) << "the partition reader did not return within " << within_seconds << " s";
+    EXPECT_EQ(polled->PartitionId(), static_cast<std::uint32_t>(partition_id));
+    EXPECT_EQ(payloads_of(*polled), std::vector<std::string>{payload});
+}
+
+WHEN("^I open a consumer in group \"([^\"]+)\" on the topic with a max wait of ([0-9]+) seconds?$") {
+    REGEX_PARAM(std::string, group);
+    REGEX_PARAM(int, max_wait_seconds);
+    cucumber::ScenarioScope<bdd::GlobalContext> context;
+
+    open_group_consumer(*context, group, poll_options(std::chrono::seconds(max_wait_seconds)));
+}
+
+WHEN("^I open a consumer in group \"([^\"]+)\" on the topic that commits after processing$") {
+    REGEX_PARAM(std::string, group);
+    cucumber::ScenarioScope<bdd::GlobalContext> context;
+
+    open_group_consumer(
+        *context, group,
+        poll_options(kConsumerMaxWait).SetAutoCommit(iggy::AutoCommit().SetAfter(iggy::CommitAfter::Processing)));
+}
+
+THEN("^the consumer should return the payload \"([^\"]+)\" within ([0-9]+) seconds?$") {
+    REGEX_PARAM(std::string, payload);
+    REGEX_PARAM(int, within_seconds);
+    cucumber::ScenarioScope<bdd::GlobalContext> context;
+    ASSERT_TRUE(context->consumer.has_value()) << "consumer open";
+
+    const auto received = context->consumer->Next(std::chrono::seconds(within_seconds));
+    ASSERT_TRUE(received.has_value()) << "the consumer did not return within " << within_seconds << " s";
+    EXPECT_EQ(payload_text(received->Message()), payload);
+}
+
+WHEN("^I close the consumer$") {
+    cucumber::ScenarioScope<bdd::GlobalContext> context;
+    ASSERT_TRUE(context->consumer.has_value()) << "consumer open";
+
+    context->consumer->Close();
+    context->consumer.reset();
+}
diff --git a/bdd/cpp/features/step_definitions/messaging_steps.cpp b/bdd/cpp/features/step_definitions/messaging_steps.cpp
index cfe5599..07d52b1 100644
--- a/bdd/cpp/features/step_definitions/messaging_steps.cpp
+++ b/bdd/cpp/features/step_definitions/messaging_steps.cpp
@@ -154,8 +154,8 @@
     const auto polled = context->client->poll_messages(
         bdd::make_numeric_identifier(static_cast<std::uint32_t>(stream_id)),
         bdd::make_numeric_identifier(static_cast<std::uint32_t>(topic_id)), static_cast<std::uint32_t>(partition_id),
-        "consumer", bdd::make_numeric_identifier(1), "offset", static_cast<std::uint64_t>(offset), 100, false,
-        error_info);
+        "consumer", bdd::make_numeric_identifier(1), bdd::make_offset_strategy(static_cast<std::uint64_t>(offset)),
+        bdd::make_poll_options(100), error_info);
 
     context->polled.count = polled.count;
     context->polled.offsets.clear();
diff --git a/bdd/cpp/features/step_definitions/resource_options_steps.cpp b/bdd/cpp/features/step_definitions/resource_options_steps.cpp
index 81ef226..af6c790 100644
--- a/bdd/cpp/features/step_definitions/resource_options_steps.cpp
+++ b/bdd/cpp/features/step_definitions/resource_options_steps.cpp
@@ -139,10 +139,10 @@
 
 iggy::ffi::IggyMessagePolled PollFirst(bdd::GlobalContext &context) {
     iggy::ffi::ErrorInfo error_info{};
-    auto polled =
-        context.client->poll_messages(bdd::make_numeric_identifier(context.last_stream_id.value()),
-                                      bdd::make_numeric_identifier(context.last_topic_id.value()), kPartitionId,
-                                      "consumer", bdd::make_numeric_identifier(1), "offset", 0, 1, false, error_info);
+    auto polled = context.client->poll_messages(bdd::make_numeric_identifier(context.last_stream_id.value()),
+                                                bdd::make_numeric_identifier(context.last_topic_id.value()),
+                                                kPartitionId, "consumer", bdd::make_numeric_identifier(1),
+                                                bdd::make_offset_strategy(0), bdd::make_poll_options(1), error_info);
     if (polled.messages.empty()) {
         throw std::runtime_error("No message was polled");
     }
diff --git a/bdd/cpp/features/step_definitions/world.hpp b/bdd/cpp/features/step_definitions/world.hpp
index 9ddd576..69e6fa1 100644
--- a/bdd/cpp/features/step_definitions/world.hpp
+++ b/bdd/cpp/features/step_definitions/world.hpp
@@ -19,6 +19,7 @@
 
 #pragma once
 
+#include <chrono>
 #include <cstdint>
 #include <cstdlib>
 #include <optional>
@@ -65,6 +66,20 @@
     std::optional<iggy::MetadataChangeCursor> last_change_cursor;
     std::vector<iggy::MetadataChangeRecord> last_changes;
     std::vector<iggy::MetadataCurrentItem> last_current_items;
+    // Deferred polling steps. A parked poll holds its connection, so it runs on a client of
+    // its own, which outlives the poll so that a cancelled poll still has a link for its
+    // cancellation.
+    std::optional<std::string> last_stream_name;
+    std::optional<std::string> last_topic_name;
+    std::optional<iggy::IggyBlockingClient> background;
+    std::optional<iggy::PendingPoll> deferred_poll;
+    std::chrono::steady_clock::time_point deferred_poll_started;
+    std::optional<iggy::PartitionReader> reader;
+    std::optional<iggy::IggyConsumer> consumer;
+    // The client that disconnected under its poll. Its partition block belongs to it, so the
+    // later polls of the scenario run on it.
+    iggy::IggyBlockingClient *poll_owner = nullptr;
+    std::optional<std::uint64_t> lost_operation_id;
 
     GlobalContext()                                 = default;
     GlobalContext(const GlobalContext &)            = delete;
@@ -89,15 +104,20 @@
     return std::string(value);
 }
 
-// Public client for the option and metadata change steps, connected as root on first use.
+inline iggy::IggyBlockingClient connect_root_client() {
+    auto client = iggy::IggyBlockingClient::Builder()
+                      .WithServerAddress(required_env("IGGY_TCP_ADDRESS"))
+                      .WithAutoLogin(required_env("IGGY_ROOT_USERNAME"), required_env("IGGY_ROOT_PASSWORD"))
+                      .Build();
+    client.Connect();
+    return client;
+}
+
+// Public client for the option, metadata change and deferred polling steps, connected as root
+// on first use.
 inline iggy::IggyBlockingClient &sdk_client(GlobalContext &context) {
     if (!context.sdk) {
-        auto client = iggy::IggyBlockingClient::Builder()
-                          .WithServerAddress(required_env("IGGY_TCP_ADDRESS"))
-                          .WithAutoLogin(required_env("IGGY_ROOT_USERNAME"), required_env("IGGY_ROOT_PASSWORD"))
-                          .Build();
-        client.Connect();
-        context.sdk.emplace(std::move(client));
+        context.sdk.emplace(connect_root_client());
     }
     return *context.sdk;
 }
@@ -114,6 +134,19 @@
     return identifier;
 }
 
+inline iggy::ffi::PollingStrategy make_offset_strategy(const std::uint64_t offset) {
+    iggy::ffi::PollingStrategy strategy{};
+    strategy.kind  = "offset";
+    strategy.value = offset;
+    return strategy;
+}
+
+inline iggy::ffi::PollOptions make_poll_options(const std::uint32_t count) {
+    iggy::ffi::PollOptions options = iggy::ffi::default_poll_options();
+    options.count                  = count;
+    return options;
+}
+
 inline rust::Vec<std::uint8_t> to_payload(const std::string &value) {
     rust::Vec<std::uint8_t> bytes;
     for (const char c : value) {
diff --git a/bdd/cpp/scripts/entrypoint.sh b/bdd/cpp/scripts/entrypoint.sh
index 0be4d59..acb9bc3 100755
--- a/bdd/cpp/scripts/entrypoint.sh
+++ b/bdd/cpp/scripts/entrypoint.sh
@@ -62,6 +62,7 @@
     resource_options_patch) tags=(--tags @resource-options-patch) ;;
     metadata_changes) tags=(--tags @metadata-changes) ;;
     session_contract) tags=(--tags @session-contract) ;;
+    deferred_polling) tags=(--tags @deferred-polling) ;;
 esac
 
 # --strict fails the run on undefined steps; without it a feature step with no matching C++
diff --git a/bdd/docker-compose.coverage.yml b/bdd/docker-compose.coverage.yml
index 68421a4..b6e4e14 100644
--- a/bdd/docker-compose.coverage.yml
+++ b/bdd/docker-compose.coverage.yml
@@ -39,7 +39,7 @@
         trap 'cargo llvm-cov report --lcov --output-path /reports/rust-bdd-coverage.lcov' EXIT
         TEST_ARGS=()
         case "$$BDD_FEATURE" in
-          basic_messaging|leader_redirection|raw_command|session_contract|stream_crud|stream_options|resource_options_patch|metadata_changes) TEST_ARGS=(--test "$$BDD_FEATURE") ;;
+          basic_messaging|leader_redirection|raw_command|session_contract|stream_crud|stream_options|resource_options_patch|metadata_changes|deferred_polling) TEST_ARGS=(--test "$$BDD_FEATURE") ;;
         esac
         cargo test --locked -p bdd --features "$$BDD_RUST_FEATURES" "$${TEST_ARGS[@]}"
 
@@ -106,6 +106,7 @@
           stream_options)     FILTER='--filter-trait Category=stream-options' ;;
           resource_options_patch) FILTER='--filter-trait Category=resource-options-patch' ;;
           metadata_changes)   FILTER='--filter-trait Category=metadata-changes' ;;
+          deferred_polling)   FILTER='--filter-trait Category=deferred-polling' ;;
           session_contract)   FILTER='--filter-trait Category=session-contract' ;;
         esac
         dotnet test $$FILTER --coverage --coverage-output-format cobertura --coverage-output csharp-bdd-coverage.cobertura.xml --results-directory /reports
@@ -126,5 +127,6 @@
           stream_options)     export CUCUMBER_FILTER_TAGS='@stream-options' ;;
           resource_options_patch) export CUCUMBER_FILTER_TAGS='@resource-options-patch' ;;
           metadata_changes)   export CUCUMBER_FILTER_TAGS='@metadata-changes' ;;
+          deferred_polling)   export CUCUMBER_FILTER_TAGS='@deferred-polling' ;;
         esac
         gradle --no-daemon test jacocoTestReport && cp build/reports/jacoco/test/jacocoTestReport.xml /reports/java-bdd-coverage.xml
diff --git a/bdd/docker-compose.no-quorum.yml b/bdd/docker-compose.no-quorum.yml
index 296d505..be6ed77 100644
--- a/bdd/docker-compose.no-quorum.yml
+++ b/bdd/docker-compose.no-quorum.yml
@@ -123,6 +123,9 @@
   cpp-bdd:
     <<: *no-quorum-bdd-deps
 
+  swift-bdd:
+    <<: *no-quorum-bdd-deps
+
 networks:
   iggy-bdd-network:
     driver: bridge
diff --git a/bdd/docker-compose.server.yml b/bdd/docker-compose.server.yml
index b9a8733..9578e7f 100644
--- a/bdd/docker-compose.server.yml
+++ b/bdd/docker-compose.server.yml
@@ -105,5 +105,8 @@
   cpp-bdd:
     <<: *server-bdd-deps
 
+  swift-bdd:
+    <<: *server-bdd-deps
+
 volumes:
   iggy_data:
diff --git a/bdd/docker-compose.yml b/bdd/docker-compose.yml
index 3e3e582..c84273a 100644
--- a/bdd/docker-compose.yml
+++ b/bdd/docker-compose.yml
@@ -39,6 +39,7 @@
       - ./scenarios/stream_options.feature:/app/features/stream_options.feature
       - ./scenarios/resource_options_patch.feature:/app/features/resource_options_patch.feature
       - ./scenarios/metadata_changes.feature:/app/features/metadata_changes.feature
+      - ./scenarios/deferred_polling.feature:/app/features/deferred_polling.feature
     command:
       - sh
       - -c
@@ -52,6 +53,7 @@
           stream_options)     cargo test -p bdd --features "$$BDD_RUST_FEATURES" --test stream_options ;;
           resource_options_patch) cargo test -p bdd --features "$$BDD_RUST_FEATURES" --test resource_options_patch ;;
           metadata_changes)   cargo test -p bdd --features "$$BDD_RUST_FEATURES" --test metadata_changes ;;
+          deferred_polling)   cargo test -p bdd --features "$$BDD_RUST_FEATURES" --test deferred_polling ;;
           *)                  cargo test -p bdd --features "$$BDD_RUST_FEATURES" ;;
         esac
     networks:
@@ -73,6 +75,7 @@
       - ./scenarios/resource_options_patch.feature:/app/features/resource_options_patch.feature
       - ./scenarios/metadata_changes.feature:/app/features/metadata_changes.feature
       - ./scenarios/session_contract.feature:/app/features/session_contract.feature
+      - ./scenarios/deferred_polling.feature:/app/features/deferred_polling.feature
     working_dir: /app
     command:
       - sh
@@ -85,6 +88,7 @@
           stream_options)         TEST_PATH='tests/test_stream_options.py' ;;
           resource_options_patch) TEST_PATH='tests/test_resource_options_patch.py' ;;
           metadata_changes)       TEST_PATH='tests/test_metadata_changes.py' ;;
+          deferred_polling)       TEST_PATH='tests/test_deferred_polling.py' ;;
           *)                      TEST_PATH='tests/' ;;
         esac
         uv run --project /workspace/bdd/python pytest $$TEST_PATH -v
@@ -105,6 +109,7 @@
       - ./scenarios/stream_options.feature:/app/features/stream_options.feature
       - ./scenarios/session_contract.feature:/app/features/session_contract.feature
       - ./scenarios/metadata_changes.feature:/app/features/metadata_changes.feature
+      - ./scenarios/deferred_polling.feature:/app/features/deferred_polling.feature
     command:
       - sh
       - -c
@@ -115,6 +120,7 @@
           session_contract) GROUP='--group session-contract' ;;
           stream_options)   GROUP='--group stream-options' ;;
           metadata_changes) GROUP='--group metadata-changes' ;;
+          deferred_polling) GROUP='--group deferred-polling' ;;
           *)                GROUP='' ;;
         esac
         ./scripts/test.sh --configuration /workspace/bdd/php/phpunit.xml.dist $$GROUP
@@ -155,6 +161,7 @@
           stream_options)         TAGS='--tags @stream-options' ;;
           resource_options_patch) TAGS='--tags @resource-options-patch' ;;
           metadata_changes)       TAGS='--tags @metadata-changes' ;;
+          deferred_polling)       TAGS='--tags @deferred-polling' ;;
           *)                      TAGS='' ;;
         esac
         npm run test:bdd -- $$TAGS
@@ -182,6 +189,7 @@
           stream_options)     FILTER='--filter-trait Category=stream-options' ;;
           resource_options_patch) FILTER='--filter-trait Category=resource-options-patch' ;;
           metadata_changes)   FILTER='--filter-trait Category=metadata-changes' ;;
+          deferred_polling)   FILTER='--filter-trait Category=deferred-polling' ;;
           session_contract)   FILTER='--filter-trait Category=session-contract' ;;
         esac
         dotnet test $$FILTER
@@ -206,6 +214,7 @@
       - ./scenarios/stream_options.feature:/app/features/stream_options.feature
       - ./scenarios/resource_options_patch.feature:/app/features/resource_options_patch.feature
       - ./scenarios/metadata_changes.feature:/app/features/metadata_changes.feature
+      - ./scenarios/deferred_polling.feature:/app/features/deferred_polling.feature
     command:
       - sh
       - -c
@@ -219,6 +228,7 @@
           stream_options)     export CUCUMBER_FILTER_TAGS='@stream-options' ;;
           resource_options_patch) export CUCUMBER_FILTER_TAGS='@resource-options-patch' ;;
           metadata_changes)   export CUCUMBER_FILTER_TAGS='@metadata-changes' ;;
+          deferred_polling)   export CUCUMBER_FILTER_TAGS='@deferred-polling' ;;
         esac
         gradle --no-daemon test
     networks:
@@ -240,6 +250,22 @@
       - ./scenarios/resource_options_patch.feature:/app/features/resource_options_patch.feature
       - ./scenarios/metadata_changes.feature:/app/features/metadata_changes.feature
       - ./scenarios/session_contract.feature:/app/features/session_contract.feature
+      - ./scenarios/deferred_polling.feature:/app/features/deferred_polling.feature
+    networks:
+      - iggy-bdd-network
+
+  # Runs only the deferred polling feature.
+  swift-bdd:
+    image: iggy-bdd-swift
+    build:
+      context: ..
+      dockerfile: bdd/swift/Dockerfile
+    environment:
+      - IGGY_ROOT_USERNAME=iggy
+      - IGGY_ROOT_PASSWORD=iggy
+      - BDD_FEATURE=${BDD_FEATURE:-all}
+    volumes:
+      - ./scenarios/deferred_polling.feature:/app/features/deferred_polling.feature
     networks:
       - iggy-bdd-network
 
diff --git a/bdd/go/tests/basic_messaging.go b/bdd/go/tests/basic_messaging.go
index ef91b20..5c6735f 100644
--- a/bdd/go/tests/basic_messaging.go
+++ b/bdd/go/tests/basic_messaging.go
@@ -134,15 +134,16 @@
 	streamIdentifier, _ := iggcon.NewIdentifier(streamID)
 	topicIdentifier, _ := iggcon.NewIdentifier(topicID)
 	uint32PartitionID := partitionID
+	options := iggcon.DefaultPollOptions()
+	options.Count = 100
 	polledMessages, err := c.client.PollMessages(
 		ctx,
 		streamIdentifier,
 		topicIdentifier,
+		uint32PartitionID,
 		consumer,
 		iggcon.OffsetPollingStrategy(startOffset),
-		100,
-		false,
-		&uint32PartitionID,
+		options,
 	)
 	if err != nil {
 		return fmt.Errorf("failed to poll messages: %w", err)
diff --git a/bdd/go/tests/deferred_polling.go b/bdd/go/tests/deferred_polling.go
new file mode 100644
index 0000000..39a45a9
--- /dev/null
+++ b/bdd/go/tests/deferred_polling.go
@@ -0,0 +1,533 @@
+// 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.
+
+package tests
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"slices"
+	"time"
+
+	"github.com/apache/iggy/bdd/go/tests/env"
+	"github.com/apache/iggy/foreign/go/client/reader"
+	iggcon "github.com/apache/iggy/foreign/go/contracts"
+	ierror "github.com/apache/iggy/foreign/go/errors"
+	"github.com/cucumber/godog"
+)
+
+const (
+	// A message that reaches the server before the call answers it at once,
+	// so a start step waits until its call is parked.
+	deferredPollingParkTime = 100 * time.Millisecond
+)
+
+type deferredPollingCtxKey struct{}
+
+type deferredPollingCtx struct {
+	partitions uint32
+	poll       *deferredCall[*iggcon.PolledMessage]
+	reader     *reader.PartitionReader
+	consumer   *reader.IggyConsumer
+	// lostOperation is the operation of the deferred poll that failed as uncertain.
+	lostOperation uint64
+}
+
+func getDeferredPollingCtx(ctx context.Context) *deferredPollingCtx {
+	return ctx.Value(deferredPollingCtxKey{}).(*deferredPollingCtx)
+}
+
+type deferredPollingSteps struct{}
+
+func (deferredPollingSteps) givenStreamWithTopic(ctx context.Context, stream, topic string, partitions uint32) error {
+	client := getResourceOptionsCtx(ctx).client
+	name, err := iggcon.NewIdentifier(stream)
+	if err != nil {
+		return fmt.Errorf("stream id %q: %w", stream, err)
+	}
+	// A run that ended before its cleanup leaves the stream behind.
+	_, err = client.GetStream(ctx, name)
+	switch {
+	case err == nil:
+		if err := client.DeleteStream(ctx, name); err != nil {
+			return fmt.Errorf("delete stream %s: %w", stream, err)
+		}
+	case !errors.Is(err, ierror.ErrStreamIdNotFound):
+		return fmt.Errorf("get stream %s: %w", stream, err)
+	}
+	options := resourceOptionsSteps{}
+	if err := options.createStream(ctx, stream, nil); err != nil {
+		return err
+	}
+	if err := options.whenCreateTopic(ctx, topic, partitions); err != nil {
+		return err
+	}
+	getDeferredPollingCtx(ctx).partitions = partitions
+	return nil
+}
+
+func (s deferredPollingSteps) whenStartDeferredPoll(ctx context.Context, partition uint32, offset uint64, maxWait int) error {
+	return s.startPoll(ctx, partition, iggcon.DefaultConsumer(), iggcon.OffsetPollingStrategy(offset),
+		waitOptions(maxWait))
+}
+
+func (s deferredPollingSteps) whenStartCommittingDeferredPoll(
+	ctx context.Context,
+	partition uint32,
+	consumerName string,
+	minCount uint32,
+	maxWait int,
+) error {
+	consumer, err := namedConsumer(consumerName)
+	if err != nil {
+		return err
+	}
+	options := waitOptions(maxWait)
+	options.MinCount = minCount
+	options.Commit = iggcon.OnReadCommit()
+	return s.startPoll(ctx, partition, consumer, iggcon.NextPollingStrategy(), options)
+}
+
+func (s deferredPollingSteps) whenStartOnReadDeferredPoll(ctx context.Context, partition uint32, consumerName string, maxWait int) error {
+	// A read that stores nothing looks up the route first, so the committing
+	// poll reaches the server before a later step drops the connection.
+	state := getResourceOptionsCtx(ctx)
+	if _, err := state.client.PollMessages(ctx, streamIdentifier(state), topicIdentifier(state), partition,
+		iggcon.DefaultConsumer(), iggcon.OffsetPollingStrategy(0), iggcon.DefaultPollOptions()); err != nil {
+		return fmt.Errorf("look up the route of partition %d: %w", partition, err)
+	}
+	return s.whenStartCommittingDeferredPoll(ctx, partition, consumerName, 1, maxWait)
+}
+
+func (deferredPollingSteps) startPoll(
+	ctx context.Context,
+	partition uint32,
+	consumer iggcon.Consumer,
+	strategy iggcon.PollingStrategy,
+	options iggcon.PollOptions,
+) error {
+	state := getResourceOptionsCtx(ctx)
+	streamId, topicId := streamIdentifier(state), topicIdentifier(state)
+	poll := startDeferredCall(ctx, func(callCtx context.Context) (*iggcon.PolledMessage, error) {
+		return state.client.PollMessages(callCtx, streamId, topicId, partition, consumer, strategy, options)
+	})
+	getDeferredPollingCtx(ctx).poll = poll
+	return poll.parked()
+}
+
+func (deferredPollingSteps) whenSendToPartition(ctx context.Context, payload string, partition uint32) error {
+	state := getResourceOptionsCtx(ctx)
+	message, err := iggcon.NewIggyMessage([]byte(payload))
+	if err != nil {
+		return fmt.Errorf("build message: %w", err)
+	}
+	if _, err := state.client.SendMessages(ctx, streamIdentifier(state), topicIdentifier(state),
+		iggcon.PartitionId(partition), []iggcon.IggyMessage{message}); err != nil {
+		return fmt.Errorf("send message: %w", err)
+	}
+	return nil
+}
+
+func (deferredPollingSteps) thenDeferredPollReturns(ctx context.Context, payload string, within int) error {
+	polled, err := getDeferredPollingCtx(ctx).poll.result(time.Duration(within) * time.Second)
+	if err != nil {
+		return fmt.Errorf("deferred poll: %w", err)
+	}
+	return singlePayload(polled, payload)
+}
+
+func (deferredPollingSteps) thenDeferredPollReturnsNothing(ctx context.Context, from, to int) error {
+	poll := getDeferredPollingCtx(ctx).poll
+	polled, err := poll.result(time.Until(poll.started.Add(time.Duration(to) * time.Second)))
+	if err != nil {
+		return fmt.Errorf("deferred poll: %w", err)
+	}
+	if len(polled.Messages) != 0 {
+		return fmt.Errorf("the deferred poll returned %d messages, want none", len(polled.Messages))
+	}
+	if took := poll.ended.Sub(poll.started); took < time.Duration(from)*time.Second {
+		return fmt.Errorf("the deferred poll returned after %v, before %d s", took, from)
+	}
+	return nil
+}
+
+func (deferredPollingSteps) whenOpenReader(ctx context.Context, first, second, third uint32, offset uint64, maxWait int) error {
+	state := getResourceOptionsCtx(ctx)
+	partitionReader, err := reader.NewPartitionReader(ctx, state.client, streamIdentifier(state), topicIdentifier(state),
+		iggcon.DefaultConsumer(), reader.WithPartitions(first, second, third),
+		reader.WithStart(iggcon.OffsetPollingStrategy(offset)), reader.WithPollOptions(waitOptions(maxWait)))
+	if err != nil {
+		return fmt.Errorf("open partition reader: %w", err)
+	}
+	getDeferredPollingCtx(ctx).reader = partitionReader
+	return nil
+}
+
+func (deferredPollingSteps) thenReaderReturns(ctx context.Context, payload string, partition uint32, within int) error {
+	readCtx, cancel := context.WithTimeout(ctx, time.Duration(within)*time.Second)
+	defer cancel()
+	polled, err := getDeferredPollingCtx(ctx).reader.Next(readCtx)
+	if err != nil {
+		return fmt.Errorf("partition reader: %w", err)
+	}
+	if polled.PartitionId != partition {
+		return fmt.Errorf("the reader returned partition %d, want %d", polled.PartitionId, partition)
+	}
+	return singlePayload(polled, payload)
+}
+
+func (s deferredPollingSteps) whenOpenGroupConsumer(ctx context.Context, group string, maxWait int) error {
+	return s.openConsumer(ctx, group, waitOptions(maxWait))
+}
+
+func (s deferredPollingSteps) whenOpenProcessingConsumer(ctx context.Context, group string) error {
+	options := iggcon.WaitPollOptions(reader.DefaultMaxWait)
+	options.Commit = iggcon.AutoCommitPolicy(iggcon.DefaultAutoCommit())
+	return s.openConsumer(ctx, group, options)
+}
+
+func (deferredPollingSteps) openConsumer(ctx context.Context, group string, options iggcon.PollOptions) error {
+	state := getResourceOptionsCtx(ctx)
+	streamId, topicId := streamIdentifier(state), topicIdentifier(state)
+	groupId, err := iggcon.NewIdentifier(group)
+	if err != nil {
+		return fmt.Errorf("group id %q: %w", group, err)
+	}
+	if _, err := state.client.GetConsumerGroup(ctx, streamId, topicId, groupId); err != nil {
+		if _, err := state.client.CreateConsumerGroup(ctx, streamId, topicId, group); err != nil {
+			return fmt.Errorf("create consumer group %s: %w", group, err)
+		}
+	}
+	consumer, err := reader.NewIggyConsumer(ctx, state.client, streamId, topicId, iggcon.NewGroupConsumer(groupId),
+		reader.WithPollOptions(options))
+	if err != nil {
+		return fmt.Errorf("open consumer: %w", err)
+	}
+	getDeferredPollingCtx(ctx).consumer = consumer
+	return nil
+}
+
+func (deferredPollingSteps) thenConsumerReturns(ctx context.Context, payload string, within int) error {
+	readCtx, cancel := context.WithTimeout(ctx, time.Duration(within)*time.Second)
+	defer cancel()
+	message, err := getDeferredPollingCtx(ctx).consumer.Next(readCtx)
+	if err != nil {
+		return fmt.Errorf("consumer: %w", err)
+	}
+	if got := string(message.Message.Payload); got != payload {
+		return fmt.Errorf("the consumer returned the payload %q, want %q", got, payload)
+	}
+	return nil
+}
+
+func (deferredPollingSteps) whenCloseConsumer(ctx context.Context) error {
+	state := getDeferredPollingCtx(ctx)
+	consumer := state.consumer
+	state.consumer = nil
+	return consumer.Shutdown(ctx)
+}
+
+func (deferredPollingSteps) thenOnReadPollReturns(ctx context.Context, partition uint32, consumerName, payload string) error {
+	options := iggcon.DefaultPollOptions()
+	options.Count = 1
+	options.Commit = iggcon.OnReadCommit()
+	return pollNext(ctx, partition, consumerName, payload, options)
+}
+
+func (deferredPollingSteps) whenCancelDeferredPoll(ctx context.Context, after int) error {
+	poll := getDeferredPollingCtx(ctx).poll
+	time.Sleep(time.Duration(after) * time.Millisecond)
+	if !poll.running() {
+		return fmt.Errorf("the deferred poll ended before its cancellation, with %+v and %v", poll.value, poll.err)
+	}
+	poll.stop()
+	// The server answers a cancelled poll that stores its offset once, refused
+	// when the cancel came before the read, which stored nothing.
+	if poll.value != nil || !errors.Is(poll.err, ierror.ErrTransientNotAccepted) {
+		return fmt.Errorf("the cancelled deferred poll ended with %+v and %v, want %v",
+			poll.value, poll.err, ierror.ErrTransientNotAccepted)
+	}
+	return nil
+}
+
+// whenClientDisconnects drops the connection of the client that owns the poll, without a sign-out.
+func (deferredPollingSteps) whenClientDisconnects(ctx context.Context) error {
+	poll := getDeferredPollingCtx(ctx).poll
+	if !poll.running() {
+		return fmt.Errorf("the deferred poll ended before the disconnect, with %+v and %v", poll.value, poll.err)
+	}
+	client, err := disconnectable(getResourceOptionsCtx(ctx).client)
+	if err != nil {
+		return err
+	}
+	return client.Disconnect()
+}
+
+func (deferredPollingSteps) thenDeferredPollUncertain(ctx context.Context) error {
+	state := getDeferredPollingCtx(ctx)
+	polled, err := state.poll.result(5 * time.Second)
+	var unknown ierror.FetchProgressUncertain
+	if !errors.As(err, &unknown) {
+		return fmt.Errorf("the deferred poll ended with %+v and %v, want %v", polled, err, ierror.ErrFetchProgressUncertain)
+	}
+	state.lostOperation = unknown.OperationID
+	return nil
+}
+
+func (deferredPollingSteps) whenClientConnectsAgain(ctx context.Context) error {
+	client := getResourceOptionsCtx(ctx).client
+	if err := client.Connect(ctx); err != nil {
+		return fmt.Errorf("connect client: %w", err)
+	}
+	username, password := env.RootCredentials()
+	if _, err := client.LoginUser(ctx, username, password); err != nil {
+		return fmt.Errorf("authenticate client: %w", err)
+	}
+	return nil
+}
+
+func (deferredPollingSteps) thenOnReadPollBlocked(ctx context.Context, partition uint32, consumerName string) error {
+	state := getResourceOptionsCtx(ctx)
+	consumer, err := namedConsumer(consumerName)
+	if err != nil {
+		return err
+	}
+	options := iggcon.DefaultPollOptions()
+	options.Count = 1
+	options.Commit = iggcon.OnReadCommit()
+	_, err = state.client.PollMessages(ctx, streamIdentifier(state), topicIdentifier(state), partition, consumer,
+		iggcon.NextPollingStrategy(), options)
+	lost := getDeferredPollingCtx(ctx).lostOperation
+	var blocked ierror.PartitionLaneBlocked
+	if !errors.As(err, &blocked) || blocked.PartitionID != partition || blocked.OperationID != lost {
+		return fmt.Errorf("the poll ended with %v, want %v", err, ierror.PartitionLaneBlocked{PartitionID: partition, OperationID: lost})
+	}
+	return nil
+}
+
+func (deferredPollingSteps) whenAbandonDeferredPoll(ctx context.Context) error {
+	operation := getDeferredPollingCtx(ctx).lostOperation
+	if err := getResourceOptionsCtx(ctx).client.AbandonOperation(ctx, operation); err != nil {
+		return fmt.Errorf("abandon operation %d: %w", operation, err)
+	}
+	return nil
+}
+
+func (deferredPollingSteps) whenWait(seconds int) error {
+	time.Sleep(time.Duration(seconds) * time.Second)
+	return nil
+}
+
+func (deferredPollingSteps) thenNextPollReturns(ctx context.Context, partition uint32, consumerName, payload string) error {
+	return pollNext(ctx, partition, consumerName, payload, iggcon.DefaultPollOptions())
+}
+
+// pollNext polls partition from the next offset of consumerName and expects payload first.
+func pollNext(ctx context.Context, partition uint32, consumerName, payload string, options iggcon.PollOptions) error {
+	state := getResourceOptionsCtx(ctx)
+	consumer, err := namedConsumer(consumerName)
+	if err != nil {
+		return err
+	}
+	polled, err := state.client.PollMessages(ctx, streamIdentifier(state), topicIdentifier(state), partition, consumer,
+		iggcon.NextPollingStrategy(), options)
+	if err != nil {
+		return fmt.Errorf("poll messages: %w", err)
+	}
+	if len(polled.Messages) == 0 {
+		return fmt.Errorf("the poll returned no message, want the payload %q", payload)
+	}
+	if first := string(polled.Messages[0].Payload); first != payload {
+		return fmt.Errorf("the first polled message has the payload %q, want %q", first, payload)
+	}
+	return nil
+}
+
+func initDeferredPollingScenario(sc *godog.ScenarioContext) {
+	sc.Before(func(context.Context, *godog.Scenario) (context.Context, error) {
+		ctx := context.WithValue(context.Background(), resourceOptionsCtxKey{}, &resourceOptionsCtx{})
+		return context.WithValue(ctx, deferredPollingCtxKey{}, &deferredPollingCtx{}), nil
+	})
+	options := resourceOptionsSteps{}
+	s := deferredPollingSteps{}
+	sc.Step(`^I have a running Iggy server$`, options.givenRunningServer)
+	sc.Step(`^I am authenticated as the root user$`, options.givenAuthenticationAsRoot)
+	sc.Step(`^a stream named "([^"]+)" with a topic named "([^"]+)" with (\d+) partitions exists$`, s.givenStreamWithTopic)
+	sc.Step(`^I start a deferred poll of partition (\d+) from offset (\d+) with a max wait of (\d+) seconds?$`,
+		s.whenStartDeferredPoll)
+	sc.Step(`^I start a deferred poll of partition (\d+) for consumer "([^"]+)" with the on read commit policy, a min count of (\d+) and a max wait of (\d+) seconds?$`,
+		s.whenStartCommittingDeferredPoll)
+	sc.Step(`^I start a deferred poll of partition (\d+) for consumer "([^"]+)" with the on read commit policy and a max wait of (\d+) seconds?$`,
+		s.whenStartOnReadDeferredPoll)
+	sc.Step(`^the client disconnects before the deferred poll answers$`, s.whenClientDisconnects)
+	sc.Step(`^the deferred poll should fail as uncertain$`, s.thenDeferredPollUncertain)
+	sc.Step(`^the client connects again$`, s.whenClientConnectsAgain)
+	sc.Step(`^a poll of partition (\d+) for consumer "([^"]+)" with the next strategy and the on read commit policy should fail as blocked by the deferred poll$`,
+		s.thenOnReadPollBlocked)
+	sc.Step(`^I abandon the operation of the deferred poll$`, s.whenAbandonDeferredPoll)
+	sc.Step(`^I send a message with payload "([^"]+)" to partition (\d+)$`, s.whenSendToPartition)
+	sc.Step(`^the deferred poll should return the payload "([^"]+)" within (\d+) seconds?$`, s.thenDeferredPollReturns)
+	sc.Step(`^the deferred poll should return no messages between (\d+) and (\d+) seconds after it started$`,
+		s.thenDeferredPollReturnsNothing)
+	sc.Step(`^a poll of partition (\d+) for consumer "([^"]+)" with the next strategy and the on read commit policy should return the payload "([^"]+)"$`,
+		s.thenOnReadPollReturns)
+	sc.Step(`^I cancel the deferred poll after (\d+) milliseconds$`, s.whenCancelDeferredPoll)
+	sc.Step(`^I wait (\d+) seconds?$`, s.whenWait)
+	sc.Step(`^a poll of partition (\d+) for consumer "([^"]+)" with the next strategy should return the payload "([^"]+)"$`,
+		s.thenNextPollReturns)
+	sc.Step(`^I open a partition reader on partitions (\d+), (\d+) and (\d+) from offset (\d+) with a max wait of (\d+) seconds?$`,
+		s.whenOpenReader)
+	sc.Step(`^the partition reader should return the payload "([^"]+)" from partition (\d+) within (\d+) seconds?$`,
+		s.thenReaderReturns)
+	sc.Step(`^I open a consumer in group "([^"]+)" on the topic with a max wait of (\d+) seconds?$`, s.whenOpenGroupConsumer)
+	sc.Step(`^I open a consumer in group "([^"]+)" on the topic that commits after processing$`, s.whenOpenProcessingConsumer)
+	sc.Step(`^the consumer should return the payload "([^"]+)" within (\d+) seconds?$`, s.thenConsumerReturns)
+	sc.Step(`^I close the consumer$`, s.whenCloseConsumer)
+	sc.After(func(ctx context.Context, scenario *godog.Scenario, scenarioError error) (context.Context, error) {
+		// A poll, a reader or a consumer that still runs must end before the client closes.
+		state := getDeferredPollingCtx(ctx)
+		if state.poll != nil {
+			state.poll.stop()
+		}
+		if state.reader != nil {
+			_ = state.reader.Close(ctx)
+		}
+		if state.consumer != nil {
+			_ = state.consumer.Shutdown(ctx)
+		}
+		return resourceOptionsCleanup(ctx, scenario, scenarioError)
+	})
+}
+
+// deferredCall is a deferred poll or fetch that runs while the
+// scenario goes on.
+type deferredCall[T any] struct {
+	started time.Time
+	cancel  context.CancelFunc
+	done    chan struct{}
+	// value, err and ended are set before done closes.
+	value T
+	err   error
+	ended time.Time
+}
+
+func startDeferredCall[T any](ctx context.Context, call func(context.Context) (T, error)) *deferredCall[T] {
+	callCtx, cancel := context.WithCancel(ctx)
+	running := &deferredCall[T]{started: time.Now(), cancel: cancel, done: make(chan struct{})}
+	go func() {
+		defer close(running.done)
+		running.value, running.err = call(callCtx)
+		running.ended = time.Now()
+	}()
+	return running
+}
+
+func (c *deferredCall[T]) parked() error {
+	return parked([]*deferredCall[T]{c})
+}
+
+func (c *deferredCall[T]) running() bool {
+	select {
+	case <-c.done:
+		return false
+	default:
+		return true
+	}
+}
+
+// result waits up to within for the call and returns what it returned. A
+// call that still runs then is cancelled, so that it never outlives its step.
+func (c *deferredCall[T]) result(within time.Duration) (T, error) {
+	timer := time.NewTimer(within)
+	defer timer.Stop()
+	select {
+	case <-c.done:
+	case <-timer.C:
+		if c.running() {
+			c.stop()
+			var none T
+			return none, fmt.Errorf("the call did not end within %v", within)
+		}
+	}
+	return c.value, c.err
+}
+
+// stop cancels the call and waits until it ends.
+func (c *deferredCall[T]) stop() {
+	c.stopping()()
+}
+
+// stopping cancels the call and returns the wait for its end. A committing
+// fetch ignores the cancel and ends with its reply.
+func (c *deferredCall[T]) stopping() func() {
+	c.cancel()
+	return func() { <-c.done }
+}
+
+// parked waits until the calls had time to park and fails when one of them
+// ended before.
+func parked[T any](calls []*deferredCall[T]) error {
+	time.Sleep(deferredPollingParkTime)
+	for _, call := range calls {
+		if !call.running() {
+			return fmt.Errorf("the call ended before it parked, with %+v and %v", call.value, call.err)
+		}
+	}
+	return nil
+}
+
+// waitOptions are the poll defaults with the max wait of the step.
+func waitOptions(maxWait int) iggcon.PollOptions {
+	return iggcon.WaitPollOptions(time.Duration(maxWait) * time.Second)
+}
+
+// disconnecter is a client that drops its connection without a sign-out.
+type disconnecter interface{ Disconnect() error }
+
+// disconnectable is client, or the client that it wraps, as a disconnecter.
+func disconnectable(client iggcon.Client) (disconnecter, error) {
+	for {
+		if dropping, ok := client.(disconnecter); ok {
+			return dropping, nil
+		}
+		wrapper, ok := client.(interface{ Unwrap() iggcon.Client })
+		if !ok {
+			return nil, fmt.Errorf("the client %T cannot disconnect", client)
+		}
+		client = wrapper.Unwrap()
+	}
+}
+
+func namedConsumer(name string) (iggcon.Consumer, error) {
+	id, err := iggcon.NewIdentifier(name)
+	if err != nil {
+		return iggcon.Consumer{}, fmt.Errorf("consumer id %q: %w", name, err)
+	}
+	return iggcon.NewSingleConsumer(id), nil
+}
+
+func singlePayload(polled *iggcon.PolledMessage, want string) error {
+	payloads := make([]string, len(polled.Messages))
+	for index, message := range polled.Messages {
+		payloads[index] = string(message.Payload)
+	}
+	if !slices.Equal(payloads, []string{want}) {
+		return fmt.Errorf("the poll returned the payloads %q, want only %q", payloads, want)
+	}
+	return nil
+}
diff --git a/bdd/go/tests/resource_options.go b/bdd/go/tests/resource_options.go
index db0c62f..f14b4d0 100644
--- a/bdd/go/tests/resource_options.go
+++ b/bdd/go/tests/resource_options.go
@@ -261,8 +261,10 @@
 func (resourceOptionsSteps) pollFirst(ctx context.Context) (iggcon.IggyMessage, error) {
 	state := getResourceOptionsCtx(ctx)
 	partition := uint32(resourceOptionsPartition)
-	polled, err := state.client.PollMessages(ctx, streamIdentifier(state), topicIdentifier(state),
-		iggcon.DefaultConsumer(), iggcon.OffsetPollingStrategy(0), 1, false, &partition)
+	options := iggcon.DefaultPollOptions()
+	options.Count = 1
+	polled, err := state.client.PollMessages(ctx, streamIdentifier(state), topicIdentifier(state), partition,
+		iggcon.DefaultConsumer(), iggcon.OffsetPollingStrategy(0), options)
 	if err != nil {
 		return iggcon.IggyMessage{}, fmt.Errorf("poll messages: %w", err)
 	}
diff --git a/bdd/go/tests/suite_test.go b/bdd/go/tests/suite_test.go
index ddbff00..44bbacb 100644
--- a/bdd/go/tests/suite_test.go
+++ b/bdd/go/tests/suite_test.go
@@ -111,6 +111,18 @@
 		})
 	}
 
+	if feature == "all" || feature == "deferred_polling" {
+		suites = append(suites, godog.TestSuite{
+			ScenarioInitializer: initDeferredPollingScenario,
+			Options: &godog.Options{
+				Format:   "pretty",
+				Paths:    []string{"../../scenarios/deferred_polling.feature"},
+				Strict:   true,
+				TestingT: t,
+			},
+		})
+	}
+
 	if len(suites) == 0 {
 		t.Fatalf("unknown BDD_FEATURE=%q", feature)
 	}
diff --git a/bdd/go/tests/tcp_test/messages_steps.go b/bdd/go/tests/tcp_test/messages_steps.go
index 4be24ba..51d8f4b 100644
--- a/bdd/go/tests/tcp_test/messages_steps.go
+++ b/bdd/go/tests/tcp_test/messages_steps.go
@@ -47,15 +47,17 @@
 func itShouldSuccessfullyPublishMessages(streamId uint32, topicId uint32, messages []iggcon.IggyMessage, client iggcon.Client) {
 	streamIdentifier, _ := iggcon.NewIdentifier(streamId)
 	topicIdentifier, _ := iggcon.NewIdentifier(topicId)
+	options := iggcon.DefaultPollOptions()
+	options.Count = uint32(len(messages))
+	options.Commit = iggcon.OnReadCommit()
 	result, err := client.PollMessages(
 		context.Background(),
 		streamIdentifier,
 		topicIdentifier,
+		0,
 		iggcon.NewSingleConsumer(randomU32Identifier()),
 		iggcon.FirstPollingStrategy(),
-		uint32(len(messages)),
-		true,
-		nil)
+		options)
 
 	ginkgo.It("It should not be nil", func() {
 		gomega.Expect(result).NotTo(gomega.BeNil())
diff --git a/bdd/java/src/test/java/org/apache/iggy/bdd/DeferredPollingSteps.java b/bdd/java/src/test/java/org/apache/iggy/bdd/DeferredPollingSteps.java
new file mode 100644
index 0000000..1f7484d
--- /dev/null
+++ b/bdd/java/src/test/java/org/apache/iggy/bdd/DeferredPollingSteps.java
@@ -0,0 +1,387 @@
+/*
+ * 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.
+ */
+
+package org.apache.iggy.bdd;
+
+import io.cucumber.java.After;
+import io.cucumber.java.en.Given;
+import io.cucumber.java.en.Then;
+import io.cucumber.java.en.When;
+import org.apache.iggy.client.async.reader.IggyConsumer;
+import org.apache.iggy.client.async.reader.PartitionReader;
+import org.apache.iggy.client.async.reader.ReceivedMessage;
+import org.apache.iggy.client.async.tcp.AsyncIggyTcpClient;
+import org.apache.iggy.consumergroup.Consumer;
+import org.apache.iggy.exception.IggyErrorCode;
+import org.apache.iggy.exception.IggyOperationException;
+import org.apache.iggy.identifier.ConsumerId;
+import org.apache.iggy.identifier.StreamId;
+import org.apache.iggy.identifier.TopicId;
+import org.apache.iggy.message.CommitPolicy;
+import org.apache.iggy.message.Message;
+import org.apache.iggy.message.Partitioning;
+import org.apache.iggy.message.PollOptions;
+import org.apache.iggy.message.PolledMessages;
+import org.apache.iggy.message.PollingStrategy;
+
+import java.math.BigInteger;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.List;
+import java.util.OptionalLong;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public final class DeferredPollingSteps {
+
+    // The Java SDK has no default consumer, so this is the default consumer id of the Rust SDK.
+    private static final Consumer DEFAULT_CONSUMER = Consumer.of(1L);
+    private static final Duration CLOSE_TIMEOUT = Duration.ofSeconds(30);
+    // Lets a committing poll that starts on a routed partition leave the client and park on the server.
+    private static final Duration PARK_TIME = Duration.ofMillis(100);
+
+    private AsyncIggyTcpClient client;
+    private Long streamId;
+    private String topicName;
+    private CompletableFuture<PolledMessages> poll;
+    private long pollStartedAtNanos;
+    private PartitionReader reader;
+    private IggyConsumer consumer;
+    private long lostOperation;
+
+    @After
+    public void close() throws InterruptedException, ExecutionException, TimeoutException {
+        if (client == null) {
+            return;
+        }
+        try {
+            if (poll != null) {
+                poll.cancel(true);
+            }
+            if (reader != null) {
+                reader.closeAsync().get(CLOSE_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            }
+            closeConsumer();
+            if (streamId != null) {
+                client.streams().deleteStream(StreamId.of(streamId)).join();
+            }
+        } finally {
+            client.close().join();
+        }
+    }
+
+    @Given("a stream named {string} with a topic named {string} with {int} partitions exists")
+    public void streamWithTopic(String streamName, String topic, int partitions) {
+        client().streams()
+                .getStream(StreamId.of(streamName))
+                .join()
+                .ifPresent(existing -> client().streams()
+                        .deleteStream(StreamId.of(existing.id()))
+                        .join());
+        streamId = client().streams().createStream(streamName).join().id();
+        client().topics().createTopic(stream(), topic, partitions).join();
+        topicName = topic;
+    }
+
+    @When("I start a deferred poll of partition {int} from offset {int} with a max wait of {int} second(s)")
+    public void startOffsetPoll(int partition, int offset, int maxWaitSeconds) {
+        startPoll(partition, DEFAULT_CONSUMER, offset(offset), waitFor(maxWaitSeconds));
+    }
+
+    @When(
+            "I start a deferred poll of partition {int} for consumer {string} with the on read commit policy, a min count of {int} and a max wait of {int} second(s)")
+    public void startCommittingPoll(int partition, String consumerName, int minCount, int maxWaitSeconds) {
+        startPoll(
+                partition,
+                Consumer.of(ConsumerId.of(consumerName)),
+                PollingStrategy.next(),
+                waitFor(maxWaitSeconds).withMinCount(minCount).withCommit(CommitPolicy.onRead()));
+    }
+
+    @When(
+            "I start a deferred poll of partition {int} for consumer {string} with the on read commit policy and a max wait of {int} second(s)")
+    public void startOnReadPoll(int partition, String consumerName, int maxWaitSeconds) throws InterruptedException {
+        Consumer pollConsumer = Consumer.of(ConsumerId.of(consumerName));
+        // A read that stores nothing routes the partition first, so the committing poll leaves the client at once. A
+        // disconnect before it left would end it as never sent.
+        client().messages()
+                .pollMessages(
+                        stream(),
+                        topic(),
+                        partition,
+                        pollConsumer,
+                        offset(0),
+                        PollOptions.defaults().withCount(1))
+                .join();
+        startPoll(
+                partition,
+                pollConsumer,
+                PollingStrategy.next(),
+                waitFor(maxWaitSeconds).withCommit(CommitPolicy.onRead()));
+        Thread.sleep(PARK_TIME.toMillis());
+        assertFalse(poll.isDone(), "The deferred poll must park");
+    }
+
+    @When("the client disconnects before the deferred poll answers")
+    public void disconnectBeforeThePollAnswers() {
+        assertFalse(poll.isDone(), "The deferred poll answered before the disconnect");
+        client.disconnect().join();
+    }
+
+    @Then("the deferred poll should fail as uncertain")
+    public void pollFailsAsUncertain() throws InterruptedException {
+        IggyOperationException uncertain = operationError(poll, IggyErrorCode.FETCH_PROGRESS_UNCERTAIN);
+        poll = null;
+        lostOperation = uncertain.getOperationId();
+    }
+
+    @When("the client connects again")
+    public void connectAgain() {
+        client.connect().join();
+        client.users()
+                .login(TestEnvironment.rootUsername(), TestEnvironment.rootPassword())
+                .join();
+    }
+
+    @Then(
+            "a poll of partition {int} for consumer {string} with the next strategy and the on read commit policy should fail as blocked by the deferred poll")
+    public void committingPollIsBlocked(int partition, String consumerName) throws InterruptedException {
+        IggyOperationException blocked = operationError(
+                client().messages()
+                        .pollMessages(
+                                stream(),
+                                topic(),
+                                partition,
+                                Consumer.of(ConsumerId.of(consumerName)),
+                                PollingStrategy.next(),
+                                PollOptions.defaults().withCount(1).withCommit(CommitPolicy.onRead())),
+                IggyErrorCode.PARTITION_LANE_BLOCKED);
+
+        assertEquals(lostOperation, blocked.getOperationId());
+        assertEquals(OptionalLong.of(partition), blocked.getPartitionId());
+    }
+
+    @When("I abandon the operation of the deferred poll")
+    public void abandonTheLostPoll() {
+        client().abandonOperation(lostOperation).join();
+    }
+
+    @When("I send a message with payload {string} to partition {int}")
+    public void sendToPartition(String payload, int partition) {
+        client().messages()
+                .sendMessages(
+                        stream(), topic(), Partitioning.partitionId((long) partition), List.of(Message.of(payload)))
+                .join();
+    }
+
+    @Then("the deferred poll should return the payload {string} within {int} seconds")
+    public void pollReturnsPayload(String payload, int seconds) throws InterruptedException, ExecutionException {
+        assertEquals(List.of(payload), payloads(within(poll, Duration.ofSeconds(seconds))));
+    }
+
+    @Then("the deferred poll should return no messages between {int} and {int} seconds after it started")
+    public void pollReturnsNothing(int fromSeconds, int toSeconds) throws InterruptedException, ExecutionException {
+        Duration bound = Duration.ofSeconds(toSeconds).minusNanos(System.nanoTime() - pollStartedAtNanos);
+        PolledMessages polled = within(poll, bound);
+        Duration elapsed = Duration.ofNanos(System.nanoTime() - pollStartedAtNanos);
+
+        assertTrue(polled.messages().isEmpty(), polled.toString());
+        assertTrue(elapsed.compareTo(Duration.ofSeconds(fromSeconds)) >= 0, "The poll ended after " + elapsed);
+    }
+
+    @Then(
+            "a poll of partition {int} for consumer {string} with the next strategy and the on read commit policy should return the payload {string}")
+    public void committingPollReturnsPayload(int partition, String consumerName, String payload) {
+        assertEquals(
+                List.of(payload),
+                payloads(onePoll(
+                        partition,
+                        consumerName,
+                        PollOptions.defaults().withCount(1).withCommit(CommitPolicy.onRead()))));
+    }
+
+    @Then("a poll of partition {int} for consumer {string} with the next strategy should return the payload {string}")
+    public void pollOfNextReturnsPayload(int partition, String consumerName, String payload) {
+        PolledMessages polled = onePoll(partition, consumerName, PollOptions.defaults());
+
+        assertFalse(polled.messages().isEmpty(), "The poll read no message");
+        assertEquals(payload, payloads(polled).get(0));
+    }
+
+    @When("I cancel the deferred poll after {int} milliseconds")
+    public void cancelPoll(int millis) throws InterruptedException {
+        Thread.sleep(millis);
+
+        assertTrue(poll.cancel(true), "The poll ended before the cancel");
+        assertTrue(poll.isCancelled());
+    }
+
+    @When("I wait {int} seconds")
+    public void waitSeconds(int seconds) throws InterruptedException {
+        Thread.sleep(Duration.ofSeconds(seconds).toMillis());
+    }
+
+    @When(
+            "I open a partition reader on partitions {int}, {int} and {int} from offset {int} with a max wait of {int} second(s)")
+    public void openReader(int first, int second, int third, int offset, int maxWaitSeconds)
+            throws InterruptedException, ExecutionException, TimeoutException {
+        reader = client().reader(stream(), topic(), DEFAULT_CONSUMER)
+                .partitions(List.of((long) first, (long) second, (long) third))
+                .start(offset(offset))
+                .options(waitFor(maxWaitSeconds))
+                .build()
+                .get(CLOSE_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+    }
+
+    @Then("the partition reader should return the payload {string} from partition {int} within {int} second(s)")
+    public void readerReturnsPayload(String payload, int partition, int seconds)
+            throws InterruptedException, ExecutionException {
+        PolledMessages batch = within(reader.next(), Duration.ofSeconds(seconds));
+
+        assertEquals(partition, batch.partitionId());
+        assertEquals(List.of(payload), payloads(batch));
+    }
+
+    @When("I open a consumer in group {string} on the topic with a max wait of {int} second(s)")
+    public void openGroupConsumer(String group, int maxWaitSeconds)
+            throws InterruptedException, ExecutionException, TimeoutException {
+        openConsumer(group, waitFor(maxWaitSeconds));
+    }
+
+    @When("I open a consumer in group {string} on the topic that commits after processing")
+    public void openProcessingConsumer(String group) throws InterruptedException, ExecutionException, TimeoutException {
+        openConsumer(group, PollOptions.waitFor(Duration.ofSeconds(5)).withCommit(CommitPolicy.auto()));
+    }
+
+    @Then("the consumer should return the payload {string} within {int} second(s)")
+    public void consumerReturnsPayload(String payload, int seconds) throws InterruptedException, ExecutionException {
+        ReceivedMessage message = within(consumer.next(), Duration.ofSeconds(seconds));
+
+        assertEquals(payload, new String(message.message().payload(), StandardCharsets.UTF_8));
+    }
+
+    @When("I close the consumer")
+    public void closeConsumer() throws InterruptedException, ExecutionException, TimeoutException {
+        if (consumer != null) {
+            IggyConsumer closing = consumer;
+            consumer = null;
+            closing.shutdown().get(CLOSE_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    private void openConsumer(String group, PollOptions options)
+            throws InterruptedException, ExecutionException, TimeoutException {
+        ConsumerId id = ConsumerId.of(group);
+        if (client().consumerGroups()
+                .getConsumerGroup(stream(), topic(), id)
+                .join()
+                .isEmpty()) {
+            client().consumerGroups()
+                    .createConsumerGroup(stream(), topic(), group)
+                    .join();
+        }
+        consumer = IggyConsumer.build(
+                        client().reader(stream(), topic(), Consumer.group(id)).options(options))
+                .get(CLOSE_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+    }
+
+    private PolledMessages onePoll(int partition, String consumerName, PollOptions options) {
+        return client().messages()
+                .pollMessages(
+                        stream(),
+                        topic(),
+                        partition,
+                        Consumer.of(ConsumerId.of(consumerName)),
+                        PollingStrategy.next(),
+                        options)
+                .join();
+    }
+
+    private void startPoll(int partition, Consumer pollConsumer, PollingStrategy strategy, PollOptions options) {
+        pollStartedAtNanos = System.nanoTime();
+        poll = client().messages().pollMessages(stream(), topic(), partition, pollConsumer, strategy, options);
+    }
+
+    private AsyncIggyTcpClient client() {
+        if (client == null) {
+            String[] address = TestEnvironment.serverAddress().split(":", 2);
+            client = AsyncIggyTcpClient.builder()
+                    .host(address[0])
+                    .port(Integer.parseInt(address[1]))
+                    .build();
+            client.connect().join();
+            client.users()
+                    .login(TestEnvironment.rootUsername(), TestEnvironment.rootPassword())
+                    .join();
+        }
+        return client;
+    }
+
+    private StreamId stream() {
+        return StreamId.of(streamId);
+    }
+
+    private TopicId topic() {
+        return TopicId.of(topicName);
+    }
+
+    private static PollOptions waitFor(int maxWaitSeconds) {
+        return PollOptions.waitFor(Duration.ofSeconds(maxWaitSeconds));
+    }
+
+    private static PollingStrategy offset(int offset) {
+        return PollingStrategy.offset(BigInteger.valueOf(offset));
+    }
+
+    private static <T> T within(CompletableFuture<T> call, Duration bound)
+            throws InterruptedException, ExecutionException {
+        try {
+            return call.get(Math.max(0, bound.toNanos()), TimeUnit.NANOSECONDS);
+        } catch (TimeoutException timeout) {
+            throw new AssertionError("The call did not end within " + bound, timeout);
+        }
+    }
+
+    private static IggyOperationException operationError(CompletableFuture<?> call, IggyErrorCode expected)
+            throws InterruptedException {
+        try {
+            call.get(CLOSE_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        } catch (ExecutionException failed) {
+            if (failed.getCause() instanceof IggyOperationException operation && operation.getErrorCode() == expected) {
+                return operation;
+            }
+            throw new AssertionError("The call failed with another error than " + expected, failed.getCause());
+        } catch (TimeoutException timeout) {
+            throw new AssertionError("The call did not end within " + CLOSE_TIMEOUT, timeout);
+        }
+        throw new AssertionError("The call did not fail with " + expected);
+    }
+
+    private static List<String> payloads(PolledMessages polled) {
+        return polled.messages().stream()
+                .map(message -> new String(message.payload(), StandardCharsets.UTF_8))
+                .toList();
+    }
+}
diff --git a/bdd/node/Dockerfile b/bdd/node/Dockerfile
index 7f209a7..3cadca2 100644
--- a/bdd/node/Dockerfile
+++ b/bdd/node/Dockerfile
@@ -24,6 +24,7 @@
 COPY ./bdd/scenarios/resource_options_patch.feature ./bdd/
 COPY ./bdd/scenarios/session_contract.feature ./bdd/
 COPY ./bdd/scenarios/metadata_changes.feature ./bdd/
+COPY ./bdd/scenarios/deferred_polling.feature ./bdd/
 
 RUN npm ci
 
diff --git a/bdd/php/tests/BasicMessagingFeatureTest.php b/bdd/php/tests/BasicMessagingFeatureTest.php
index 8c4f8b0..76431e1 100644
--- a/bdd/php/tests/BasicMessagingFeatureTest.php
+++ b/bdd/php/tests/BasicMessagingFeatureTest.php
@@ -22,7 +22,9 @@
 require_once __DIR__ . '/TestEnvironment.php';
 
 use Iggy\Client as IggyClient;
+use Iggy\CommitPolicy;
 use Iggy\PollingStrategy;
+use Iggy\PollOptions;
 use Iggy\SendMessage;
 use PHPUnit\Framework\Attributes\DataProvider;
 use PHPUnit\Framework\Attributes\Group;
@@ -31,6 +33,8 @@
 
 final class BasicMessagingFeatureTest extends TestCase
 {
+    private const CONSUMER_ID = 1;
+
     private ?IggyClient $client = null;
     private ?int $lastStreamId = null;
     private ?string $lastStreamName = null;
@@ -179,10 +183,10 @@
                 (int) $matches[1],
                 (int) $matches[2],
                 (int) $matches[3],
+                self::CONSUMER_ID,
                 PollingStrategy::offset((int) $matches[4]),
-                $this->requireSentMessageCount(),
-                true,
-            );
+                new PollOptions(count: $this->requireSentMessageCount(), commit: CommitPolicy::onRead()),
+            )->messages;
 
             return;
         }
diff --git a/bdd/php/tests/DeferredPollingFeatureTest.php b/bdd/php/tests/DeferredPollingFeatureTest.php
new file mode 100644
index 0000000..39878ba
--- /dev/null
+++ b/bdd/php/tests/DeferredPollingFeatureTest.php
@@ -0,0 +1,454 @@
+<?php
+// 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.
+
+declare(strict_types=1);
+
+require_once __DIR__ . '/SharedFeatureParser.php';
+require_once __DIR__ . '/TestEnvironment.php';
+
+use Iggy\AutoCommit;
+use Iggy\Client as IggyClient;
+use Iggy\CommitPolicy;
+use Iggy\Consumer as IggyConsumer;
+use Iggy\Exception\PendingOperationException;
+use Iggy\PartitionReader;
+use Iggy\PendingPoll;
+use Iggy\PolledMessages;
+use Iggy\PollingStrategy;
+use Iggy\PollOptions;
+use Iggy\ReceiveMessage;
+use Iggy\SendMessage;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\Group;
+use PHPUnit\Framework\Attributes\TestDox;
+use PHPUnit\Framework\TestCase;
+
+final class DeferredPollingFeatureTest extends TestCase
+{
+    private const CONSUMER_ID = 1;
+    private const MESSAGES_COUNT = 10;
+    // A message that reaches the server before the call answers it at once, so a start step
+    // waits until its call is parked.
+    private const PARK_TIME_MICROS = 100_000;
+    // The wait of a consumer whose step names none.
+    private const CONSUMER_MAX_WAIT_SECONDS = 10;
+    // How long a poll whose connection dropped may take to fail.
+    private const LOST_POLL_TIMEOUT_SECONDS = 10;
+    private const PARTITION_LANE_BLOCKED_CODE = 14009;
+    private const FETCH_PROGRESS_UNCERTAIN_CODE = 14010;
+
+    private ?IggyClient $client = null;
+    private ?IggyClient $backgroundClient = null;
+    private ?string $streamName = null;
+    private ?string $topicName = null;
+    private ?PendingPoll $deferredPoll = null;
+    private ?float $deferredPollStarted = null;
+    private ?PartitionReader $reader = null;
+    private ?IggyConsumer $consumer = null;
+    // The client that disconnected under its poll. Its partition block belongs to it, so the
+    // later polls of the scenario run on it.
+    private ?IggyClient $pollOwner = null;
+    private ?string $lostOperationId = null;
+
+    #[DataProvider('scenarioCases')]
+    #[Group('deferred-polling')]
+    #[TestDox('Deferred polling shared BDD scenario passes for the PHP SDK')]
+    public function testDeferredPollingScenario(string $scenarioName, array $steps): void
+    {
+        assert_true($scenarioName !== '', 'scenario name must not be empty');
+
+        foreach ($steps as $step) {
+            $this->runStep($step);
+        }
+    }
+
+    protected function tearDown(): void
+    {
+        $this->deferredPoll?->cancel();
+        $this->consumer?->shutdown();
+        $this->reader?->close();
+        if ($this->client !== null && $this->streamName !== null) {
+            cleanup_stream_with_topics(
+                $this->client,
+                $this->streamName,
+                $this->topicName !== null ? [$this->topicName] : [],
+            );
+        }
+
+        parent::tearDown();
+    }
+
+    public static function scenarioCases(): array
+    {
+        return SharedFeatureParser::load(__DIR__ . '/../../scenarios/deferred_polling.feature');
+    }
+
+    private function runStep(string $step): void
+    {
+        if ($step === 'I have a running Iggy server') {
+            $this->client = new IggyClient(TestEnvironment::serverAddress());
+            $this->client->connect();
+            $this->client->ping();
+
+            return;
+        }
+
+        if ($step === 'I am authenticated as the root user') {
+            $this->requireClient()->loginUser(TestEnvironment::rootUsername(), TestEnvironment::rootPassword());
+
+            return;
+        }
+
+        if (preg_match('/^a stream named "([^"]+)" with a topic named "([^"]+)" with (\d+) partitions exists$/', $step, $matches) === 1) {
+            $client = $this->requireClient();
+            if ($client->getStream($matches[1]) !== null) {
+                $client->deleteStream($matches[1]);
+            }
+            $client->createStream($matches[1]);
+            $client->createTopic($matches[1], $matches[2], (int) $matches[3]);
+            $this->streamName = $matches[1];
+            $this->topicName = $matches[2];
+
+            return;
+        }
+
+        if (preg_match('/^I start a deferred poll of partition (\d+) from offset (\d+) with a max wait of (\d+) seconds?$/', $step, $matches) === 1) {
+            $this->startPoll(
+                (int) $matches[1],
+                self::CONSUMER_ID,
+                PollingStrategy::offset((int) $matches[2]),
+                new PollOptions(count: self::MESSAGES_COUNT, max_wait_micros: micros((int) $matches[3])),
+            );
+
+            return;
+        }
+
+        if (preg_match('/^I start a deferred poll of partition (\d+) for consumer "([^"]+)" with the on read commit policy, a min count of (\d+) and a max wait of (\d+) seconds?$/', $step, $matches) === 1) {
+            $this->startPoll(
+                (int) $matches[1],
+                $matches[2],
+                PollingStrategy::next(),
+                new PollOptions(
+                    count: self::MESSAGES_COUNT,
+                    max_wait_micros: micros((int) $matches[4]),
+                    min_count: (int) $matches[3],
+                    commit: CommitPolicy::onRead(),
+                ),
+            );
+
+            return;
+        }
+
+        if (preg_match('/^I start a deferred poll of partition (\d+) for consumer "([^"]+)" with the on read commit policy and a max wait of (\d+) seconds?$/', $step, $matches) === 1) {
+            $this->startPoll(
+                (int) $matches[1],
+                $matches[2],
+                PollingStrategy::next(),
+                new PollOptions(
+                    count: self::MESSAGES_COUNT,
+                    max_wait_micros: micros((int) $matches[3]),
+                    commit: CommitPolicy::onRead(),
+                ),
+            );
+
+            return;
+        }
+
+        if ($step === 'the client disconnects before the deferred poll answers') {
+            assert_not_null($this->deferredPoll, 'deferred poll started');
+            assert_null($this->deferredPoll->wait(0), 'the deferred poll must still wait');
+            $this->pollOwner = $this->backgroundClient();
+            $this->pollOwner->disconnect();
+
+            return;
+        }
+
+        if ($step === 'the deferred poll should fail as uncertain') {
+            $poll = $this->takeDeferredPoll();
+            $error = assert_throws(static fn () => $poll->wait(micros(self::LOST_POLL_TIMEOUT_SECONDS)));
+            assert_instance_of(PendingOperationException::class, $error, $error->getMessage());
+            assert_same(self::FETCH_PROGRESS_UNCERTAIN_CODE, $error->getCode(), $error->getMessage());
+            assert_same('fetch_progress_uncertain', $error->kind);
+            $this->lostOperationId = $error->operation_id;
+
+            return;
+        }
+
+        if ($step === 'the client connects again') {
+            assert_not_null($this->pollOwner, 'the client disconnected');
+            $this->pollOwner->connect();
+            $this->pollOwner->loginUser(TestEnvironment::rootUsername(), TestEnvironment::rootPassword());
+
+            return;
+        }
+
+        if (preg_match('/^a poll of partition (\d+) for consumer "([^"]+)" with the next strategy and the on read commit policy should fail as blocked by the deferred poll$/', $step, $matches) === 1) {
+            $partitionId = (int) $matches[1];
+            $error = assert_throws(fn () => $this->pollNext($partitionId, $matches[2], CommitPolicy::onRead()));
+            assert_instance_of(PendingOperationException::class, $error, $error->getMessage());
+            assert_same(self::PARTITION_LANE_BLOCKED_CODE, $error->getCode(), $error->getMessage());
+            assert_same('lane_blocked', $error->kind);
+            assert_same($partitionId, $error->partition_id);
+            assert_same($this->lostOperationId, $error->operation_id);
+
+            return;
+        }
+
+        if ($step === 'I abandon the operation of the deferred poll') {
+            assert_not_null($this->pollOwner, 'the client disconnected');
+            assert_not_null($this->lostOperationId, 'the deferred poll failed as uncertain');
+            $this->pollOwner->abandonOperation($this->lostOperationId);
+
+            return;
+        }
+
+        if (preg_match('/^I send a message with payload "([^"]+)" to partition (\d+)$/', $step, $matches) === 1) {
+            $this->requireClient()->sendMessages(
+                $this->requireStreamName(),
+                $this->requireTopicName(),
+                (int) $matches[2],
+                [new SendMessage($matches[1])],
+            );
+
+            return;
+        }
+
+        if (preg_match('/^the deferred poll should return the payload "([^"]+)" within (\d+) seconds?$/', $step, $matches) === 1) {
+            $polled = $this->takeDeferredPoll()->wait(micros((int) $matches[2]));
+            assert_not_null($polled, "the deferred poll did not return within {$matches[2]} s");
+            assert_same([$matches[1]], collect_payloads($polled->messages));
+
+            return;
+        }
+
+        if (preg_match('/^the deferred poll should return no messages between (\d+) and (\d+) seconds after it started$/', $step, $matches) === 1) {
+            assert_not_null($this->deferredPollStarted, 'deferred poll started');
+            $remaining = $this->deferredPollStarted + (int) $matches[2] - microtime(true);
+            $polled = $this->takeDeferredPoll()->wait(max(0, (int) ($remaining * 1_000_000)));
+            $elapsed = microtime(true) - $this->deferredPollStarted;
+            assert_not_null($polled, "the deferred poll did not return within {$matches[2]} s");
+            assert_same([], collect_payloads($polled->messages));
+            assert_true($elapsed >= (int) $matches[1], "the deferred poll returned after {$elapsed} s, before {$matches[1]} s");
+
+            return;
+        }
+
+        if (preg_match('/^I cancel the deferred poll after (\d+) milliseconds$/', $step, $matches) === 1) {
+            usleep((int) $matches[1] * 1_000);
+            $poll = $this->takeDeferredPoll();
+            assert_null($poll->wait(0), 'the deferred poll must still be parked when it is cancelled');
+            // Dropping the parked request sends the cancellation.
+            $poll->cancel();
+
+            return;
+        }
+
+        if (preg_match('/^I wait (\d+) seconds?$/', $step, $matches) === 1) {
+            sleep((int) $matches[1]);
+
+            return;
+        }
+
+        if (preg_match('/^a poll of partition (\d+) for consumer "([^"]+)" with the next strategy should return the payload "([^"]+)"$/', $step, $matches) === 1) {
+            $this->pollNextAndExpect((int) $matches[1], $matches[2], CommitPolicy::manual(), $matches[3]);
+
+            return;
+        }
+
+        if (preg_match('/^a poll of partition (\d+) for consumer "([^"]+)" with the next strategy and the on read commit policy should return the payload "([^"]+)"$/', $step, $matches) === 1) {
+            $this->pollNextAndExpect((int) $matches[1], $matches[2], CommitPolicy::onRead(), $matches[3]);
+
+            return;
+        }
+
+        if (preg_match('/^I open a partition reader on partitions (\d+), (\d+) and (\d+) from offset (\d+) with a max wait of (\d+) seconds?$/', $step, $matches) === 1) {
+            $this->reader = $this->requireClient()->reader(
+                $this->requireStreamName(),
+                $this->requireTopicName(),
+                self::CONSUMER_ID,
+                [(int) $matches[1], (int) $matches[2], (int) $matches[3]],
+                PollingStrategy::offset((int) $matches[4]),
+                new PollOptions(count: self::MESSAGES_COUNT, max_wait_micros: micros((int) $matches[5])),
+            );
+
+            return;
+        }
+
+        if (preg_match('/^the partition reader should return the payload "([^"]+)" from partition (\d+) within (\d+) seconds?$/', $step, $matches) === 1) {
+            assert_not_null($this->reader, 'partition reader open');
+            // The reader never yields an empty batch, so its first batch comes from the first
+            // partition that gets a message.
+            $polled = $this->reader->next(micros((int) $matches[3]));
+            assert_not_null($polled, "the partition reader did not return within {$matches[3]} s");
+            assert_same((int) $matches[2], $polled->partition_id);
+            assert_same([$matches[1]], collect_payloads($polled->messages));
+
+            return;
+        }
+
+        if (preg_match('/^I open a consumer in group "([^"]+)" on the topic with a max wait of (\d+) seconds?$/', $step, $matches) === 1) {
+            $this->openConsumer(
+                $matches[1],
+                new PollOptions(count: self::MESSAGES_COUNT, max_wait_micros: micros((int) $matches[2])),
+            );
+
+            return;
+        }
+
+        if (preg_match('/^I open a consumer in group "([^"]+)" on the topic that commits after processing$/', $step, $matches) === 1) {
+            // The default AutoCommit counts a message after processing.
+            $this->openConsumer(
+                $matches[1],
+                new PollOptions(
+                    count: self::MESSAGES_COUNT,
+                    max_wait_micros: micros(self::CONSUMER_MAX_WAIT_SECONDS),
+                    commit: CommitPolicy::auto(AutoCommit::default()),
+                ),
+            );
+
+            return;
+        }
+
+        if (preg_match('/^the consumer should return the payload "([^"]+)" within (\d+) seconds?$/', $step, $matches) === 1) {
+            assert_not_null($this->consumer, 'consumer open');
+            $payloads = [];
+            $count = $this->consumer->consumeMessages(
+                static function (ReceiveMessage $message) use (&$payloads): void {
+                    $payloads[] = $message->payload();
+                },
+                1,
+                micros((int) $matches[2]),
+            );
+            assert_same(1, $count, "the consumer did not return within {$matches[2]} s");
+            assert_same([$matches[1]], $payloads);
+
+            return;
+        }
+
+        if ($step === 'I close the consumer') {
+            assert_not_null($this->consumer, 'consumer open');
+            $consumer = $this->consumer;
+            $this->consumer = null;
+            $consumer->shutdown();
+
+            return;
+        }
+
+        self::fail("Unsupported BDD step: {$step}");
+    }
+
+    private function startPoll(int $partitionId, int|string $consumer, PollingStrategy $strategy, PollOptions $options): void
+    {
+        // The first read of a new topic can wait for its partition, so a read that waits for
+        // nothing goes first and the poll then leaves at once.
+        $this->backgroundClient()->pollMessages(
+            $this->requireStreamName(),
+            $this->requireTopicName(),
+            $partitionId,
+            self::CONSUMER_ID,
+            PollingStrategy::offset(0),
+            new PollOptions(count: 1),
+        );
+        $this->deferredPollStarted = microtime(true);
+        $this->deferredPoll = $this->backgroundClient()->startPollMessages(
+            $this->requireStreamName(),
+            $this->requireTopicName(),
+            $partitionId,
+            $consumer,
+            $strategy,
+            $options,
+        );
+        usleep(self::PARK_TIME_MICROS);
+        assert_null($this->deferredPoll->wait(0), 'the deferred poll must park');
+    }
+
+    // A parked poll holds its connection, so it runs on a client of its own. The test keeps
+    // that client, so that a cancelled call still has a link for its cancellation.
+    private function backgroundClient(): IggyClient
+    {
+        if ($this->backgroundClient === null) {
+            $this->backgroundClient = new IggyClient(TestEnvironment::serverAddress());
+            $this->backgroundClient->connect();
+            $this->backgroundClient->loginUser(TestEnvironment::rootUsername(), TestEnvironment::rootPassword());
+        }
+
+        return $this->backgroundClient;
+    }
+
+    private function takeDeferredPoll(): PendingPoll
+    {
+        assert_not_null($this->deferredPoll, 'deferred poll started');
+        $poll = $this->deferredPoll;
+        $this->deferredPoll = null;
+
+        return $poll;
+    }
+
+    private function pollNextAndExpect(int $partitionId, string $consumer, CommitPolicy $commit, string $payload): void
+    {
+        $polled = $this->pollNext($partitionId, $consumer, $commit);
+        assert_instance_of(PolledMessages::class, $polled);
+        assert_same([$payload], array_slice(collect_payloads($polled->messages), 0, 1));
+    }
+
+    // Polls one message from next, so a poll that commits stores only the offset of the payload
+    // it returns.
+    private function pollNext(int $partitionId, string $consumer, CommitPolicy $commit): PolledMessages
+    {
+        return ($this->pollOwner ?? $this->requireClient())->pollMessages(
+            $this->requireStreamName(),
+            $this->requireTopicName(),
+            $partitionId,
+            $consumer,
+            PollingStrategy::next(),
+            new PollOptions(count: 1, commit: $commit),
+        );
+    }
+
+    private function openConsumer(string $group, PollOptions $options): void
+    {
+        $this->consumer = $this->requireClient()->consumerGroup(
+            $group,
+            $this->requireStreamName(),
+            $this->requireTopicName(),
+            null,
+            $options,
+        );
+    }
+
+    private function requireClient(): IggyClient
+    {
+        assert_not_null($this->client, 'BDD client has not been initialized');
+
+        return $this->client;
+    }
+
+    private function requireStreamName(): string
+    {
+        assert_not_null($this->streamName, 'stream name has not been captured');
+
+        return $this->streamName;
+    }
+
+    private function requireTopicName(): string
+    {
+        assert_not_null($this->topicName, 'topic name has not been captured');
+
+        return $this->topicName;
+    }
+}
diff --git a/bdd/php/tests/StreamOptionsFeatureTest.php b/bdd/php/tests/StreamOptionsFeatureTest.php
index fe0335c..39c8eac 100644
--- a/bdd/php/tests/StreamOptionsFeatureTest.php
+++ b/bdd/php/tests/StreamOptionsFeatureTest.php
@@ -26,6 +26,7 @@
 use Iggy\OptionSpec;
 use Iggy\OptionsScope;
 use Iggy\PollingStrategy;
+use Iggy\PollOptions;
 use Iggy\SendMessage;
 use Iggy\StreamDetails;
 use Iggy\StreamOptionKey;
@@ -37,6 +38,8 @@
 {
     use StreamSteps;
 
+    private const CONSUMER_ID = 1;
+
     private bool $updateRejected = false;
     /** @var array<string, OptionSpec> */
     private array $streamOptionSpecs = [];
@@ -124,8 +127,9 @@
         if (preg_match('/^polling the topic should return the payload "([^"]+)"$/', $step, $matches) === 1) {
             assert_not_null($this->topicName);
             $messages = $this->requireClient()->pollMessages(
-                $this->requireStreamId(), $this->topicName, 0, PollingStrategy::offset(0), 10, false,
-            );
+                $this->requireStreamId(), $this->topicName, 0, self::CONSUMER_ID, PollingStrategy::offset(0),
+                new PollOptions(count: 10),
+            )->messages;
             assert_same([$matches[1]], collect_payloads($messages));
             return;
         }
diff --git a/bdd/python/tests/conftest.py b/bdd/python/tests/conftest.py
index 4924f23..2cfdf80 100644
--- a/bdd/python/tests/conftest.py
+++ b/bdd/python/tests/conftest.py
@@ -53,7 +53,7 @@
     last_topic_id: int | None = None
     last_topic_name: str | None = None
     last_topic_partitions: int | None = None
-    last_polled_messages: list[ReceiveMessage] | None = None
+    last_polled_messages: tuple[ReceiveMessage, ...] | None = None
     last_sent_message: str | None = None  # Store message payload as string
     last_raw_response: bytes | None = None
     last_raw_error: RuntimeError | None = None
@@ -81,6 +81,7 @@
         "stream-options: shared stream option scenarios",
         "resource-options-patch: shared resource patch scenarios",
         "metadata-changes: shared metadata change log scenarios",
+        "deferred-polling: shared deferred polling scenarios",
         "serial: scenarios that must not share the server with other runs",
         "message-headers: scenarios that assert message headers",
         "describe-options: scenarios that assert the option catalog",
diff --git a/bdd/python/tests/test_basic_messaging.py b/bdd/python/tests/test_basic_messaging.py
index 6684db0..a7a6d69 100644
--- a/bdd/python/tests/test_basic_messaging.py
+++ b/bdd/python/tests/test_basic_messaging.py
@@ -22,7 +22,15 @@
 import asyncio
 import socket
 
-from apache_iggy import Consumer, IggyClient, PollingStrategy, SendMessage, TopicOptions
+from apache_iggy import (
+    CommitPolicy,
+    Consumer,
+    IggyClient,
+    PollingStrategy,
+    PollOptions,
+    SendMessage,
+    TopicOptions,
+)
 from pytest_bdd import given, parsers, scenarios, then, when
 
 # Load scenarios from the shared feature file
@@ -220,15 +228,15 @@
     """Poll messages from specified location"""
 
     async def _poll():
-        context.last_polled_messages = await context.client.poll_messages(
+        polled = await context.client.poll_messages(
             stream=stream_id,
             topic=topic_id,
             consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.Offset(value=start_offset),
-            count=100,  # Poll up to 100 messages
-            auto_commit=True,
+            options=PollOptions(count=100, commit=CommitPolicy.OnRead()),
         )
+        context.last_polled_messages = polled.messages
 
     asyncio.run(_poll())
 
diff --git a/bdd/python/tests/test_deferred_polling.py b/bdd/python/tests/test_deferred_polling.py
new file mode 100644
index 0000000..114aa18
--- /dev/null
+++ b/bdd/python/tests/test_deferred_polling.py
@@ -0,0 +1,584 @@
+# 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.
+
+"""
+Deferred polling BDD test implementation for Python SDK
+"""
+
+import asyncio
+import concurrent.futures
+import socket
+import threading
+import time
+from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
+from dataclasses import dataclass
+from datetime import timedelta
+from typing import Any
+
+import pytest
+from apache_iggy import (
+    AutoCommit,
+    CommitAfter,
+    CommitPolicy,
+    Consumer,
+    IggyClient,
+    IggyConsumer,
+    PartitionReader,
+    PendingOperationError,
+    PolledMessages,
+    PollingStrategy,
+    PollOptions,
+    ReceiveMessage,
+    SendMessage,
+    TopicOptions,
+)
+from pytest_bdd import given, parsers, scenarios, then, when
+
+scenarios("/app/features/deferred_polling.feature")
+
+MESSAGES_COUNT = 10
+# A message that reaches the server before the call answers it at once, so a
+# start step waits until its call is parked.
+PARK_TIME_SECONDS = 0.1
+# The wait of a consumer whose step names none.
+CONSUMER_MAX_WAIT = timedelta(seconds=10)
+DEFAULT_CONSUMER = Consumer.Single(1)
+# How long a poll whose connection dropped may take to fail.
+LOST_POLL_TIMEOUT_SECONDS = 10
+PARTITION_LANE_BLOCKED = 14009
+FETCH_PROGRESS_UNCERTAIN = 14010
+
+
+@dataclass
+class DeferredState:
+    """The loop that runs the calls of a scenario and what they left open."""
+
+    loop: asyncio.AbstractEventLoop
+    background_client: IggyClient | None = None
+    deferred_poll: concurrent.futures.Future[PolledMessages] | None = None
+    deferred_poll_started: float | None = None
+    reader: PartitionReader | None = None
+    consumer: IggyConsumer | None = None
+    consumer_messages: AsyncIterator[ReceiveMessage] | None = None
+    # The client that disconnected under its poll. Its partition block belongs
+    # to it, so the later polls of the scenario run on it.
+    poll_owner: IggyClient | None = None
+    lost_operation_id: int | None = None
+
+    def run(
+        self, call: Callable[[], Awaitable[Any]], timeout: float | None = None
+    ) -> Any:
+        """Runs `call` on the scenario loop.
+
+        The SDK binds an awaitable to the loop that runs when the call is made,
+        so the call itself runs on that loop.
+        """
+
+        async def on_loop():
+            return await call()
+
+        future = asyncio.run_coroutine_threadsafe(on_loop(), self.loop)
+        return future.result(timeout)
+
+
+@pytest.fixture
+def deferred() -> Iterator[DeferredState]:
+    """One event loop for the scenario, so a call can outlive its step."""
+    loop = asyncio.new_event_loop()
+    thread = threading.Thread(target=loop.run_forever, daemon=True)
+    thread.start()
+    state = DeferredState(loop=loop)
+    yield state
+    if state.deferred_poll is not None:
+        state.deferred_poll.cancel()
+    if state.consumer is not None:
+        state.run(state.consumer.shutdown, 30)
+    if state.reader is not None:
+        state.run(state.reader.close, 30)
+    loop.call_soon_threadsafe(loop.stop)
+    thread.join(5)
+    loop.close()
+
+
+@given(
+    parsers.re(
+        r'a stream named "(?P<stream_name>[^"]+)" with a topic named '
+        r'"(?P<topic_name>[^"]+)" with (?P<partitions>\d+) partitions exists'
+    ),
+    converters={"partitions": int},
+)
+def stream_with_topic(context, deferred, stream_name, topic_name, partitions):
+    async def create():
+        if await context.client.get_stream(stream_name) is not None:
+            await context.client.delete_stream(stream_name)
+        await context.client.create_stream(stream_name)
+        await context.client.create_topic(
+            stream=stream_name,
+            name=topic_name,
+            options=TopicOptions(partitions_count=partitions),
+        )
+        stream = await context.client.get_stream(stream_name)
+        topic = await context.client.get_topic(stream_name, topic_name)
+        context.last_stream_id = stream.id
+        context.last_stream_name = stream_name
+        context.last_topic_id = topic.id
+        context.last_topic_name = topic_name
+        context.last_topic_partitions = partitions
+
+    deferred.run(create)
+
+
+@when(
+    parsers.re(
+        r"I start a deferred poll of partition (?P<partition>\d+) from offset "
+        r"(?P<offset>\d+) with a max wait of (?P<seconds>\d+) seconds?"
+    ),
+    converters={"partition": int, "offset": int, "seconds": int},
+)
+def start_deferred_poll(
+    context, deferred, root_credentials, partition, offset, seconds
+):
+    start_poll(
+        context,
+        deferred,
+        root_credentials,
+        partition,
+        DEFAULT_CONSUMER,
+        PollingStrategy.Offset(value=offset),
+        PollOptions(count=MESSAGES_COUNT, max_wait=timedelta(seconds=seconds)),
+    )
+
+
+@when(
+    parsers.re(
+        r"I start a deferred poll of partition (?P<partition>\d+) for consumer "
+        r'"(?P<name>[^"]+)" with the on read commit policy, a min count of '
+        r"(?P<min_count>\d+) and a max wait of (?P<seconds>\d+) seconds?"
+    ),
+    converters={"partition": int, "min_count": int, "seconds": int},
+)
+def start_committing_deferred_poll(
+    context, deferred, root_credentials, partition, name, min_count, seconds
+):
+    start_poll(
+        context,
+        deferred,
+        root_credentials,
+        partition,
+        Consumer.Single(name),
+        PollingStrategy.Next(),
+        PollOptions(
+            count=MESSAGES_COUNT,
+            max_wait=timedelta(seconds=seconds),
+            min_count=min_count,
+            commit=CommitPolicy.OnRead(),
+        ),
+    )
+
+
+@when(
+    parsers.re(
+        r"I start a deferred poll of partition (?P<partition>\d+) for consumer "
+        r'"(?P<name>[^"]+)" with the on read commit policy and a max wait of '
+        r"(?P<seconds>\d+) seconds?"
+    ),
+    converters={"partition": int, "seconds": int},
+)
+def start_on_read_deferred_poll(
+    context, deferred, root_credentials, partition, name, seconds
+):
+    start_poll(
+        context,
+        deferred,
+        root_credentials,
+        partition,
+        Consumer.Single(name),
+        PollingStrategy.Next(),
+        PollOptions(
+            count=MESSAGES_COUNT,
+            max_wait=timedelta(seconds=seconds),
+            commit=CommitPolicy.OnRead(),
+        ),
+    )
+
+
+@when("the client disconnects before the deferred poll answers")
+def disconnect_poll_owner(deferred):
+    client = deferred.background_client
+    poll = deferred.deferred_poll
+    assert client is not None and poll is not None, "deferred poll started"
+    assert not poll.done(), "the deferred poll must still wait"
+    deferred.run(client.disconnect)
+    deferred.poll_owner = client
+
+
+@then("the deferred poll should fail as uncertain")
+def deferred_poll_uncertain(deferred):
+    poll = take_deferred_poll(deferred)
+    with pytest.raises(PendingOperationError) as raised:
+        poll.result(LOST_POLL_TIMEOUT_SECONDS)
+    error = raised.value
+    assert error.code == FETCH_PROGRESS_UNCERTAIN, str(error)
+    assert error.kind == "fetch_progress_uncertain"
+    deferred.lost_operation_id = error.operation_id
+
+
+@when("the client connects again")
+def reconnect_poll_owner(deferred, root_credentials):
+    client = deferred.poll_owner
+    assert client is not None, "the client disconnected"
+
+    async def connect():
+        await client.connect()
+        await client.login_user(*root_credentials)
+
+    deferred.run(connect)
+
+
+@then(
+    parsers.re(
+        r'a poll of partition (?P<partition>\d+) for consumer "(?P<name>[^"]+)" '
+        r"with the next strategy and the on read commit policy should fail as "
+        r"blocked by the deferred poll"
+    ),
+    converters={"partition": int},
+)
+def on_read_poll_blocked(context, deferred, partition, name):
+    with pytest.raises(PendingOperationError) as raised:
+        poll_next(context, deferred, partition, name, CommitPolicy.OnRead())
+    error = raised.value
+    assert error.code == PARTITION_LANE_BLOCKED, str(error)
+    assert error.kind == "lane_blocked"
+    assert error.partition_id == partition
+    assert error.operation_id == deferred.lost_operation_id
+
+
+@when("I abandon the operation of the deferred poll")
+def abandon_lost_poll(deferred):
+    client, operation_id = deferred.poll_owner, deferred.lost_operation_id
+    assert client is not None and operation_id is not None, "the poll was lost"
+    deferred.run(lambda: client.abandon_operation(operation_id))
+
+
+@when(
+    parsers.re(
+        r'I send a message with payload "(?P<payload>[^"]+)" to partition '
+        r"(?P<partition>\d+)"
+    ),
+    converters={"partition": int},
+)
+def send_to_partition(context, deferred, payload, partition):
+    deferred.run(
+        lambda: context.client.send_messages(
+            stream=context.last_stream_id,
+            topic=context.last_topic_id,
+            partitioning=partition,
+            messages=[SendMessage(payload)],
+        )
+    )
+
+
+@then(
+    parsers.re(
+        r'the deferred poll should return the payload "(?P<payload>[^"]+)" '
+        r"within (?P<seconds>\d+) seconds?"
+    ),
+    converters={"seconds": int},
+)
+def deferred_poll_returns(deferred, payload, seconds):
+    poll = take_deferred_poll(deferred)
+    try:
+        polled = poll.result(seconds)
+    except TimeoutError:
+        pytest.fail(f"the deferred poll did not return within {seconds} s")
+    assert payloads(polled) == [payload]
+
+
+@then(
+    parsers.re(
+        r"the deferred poll should return no messages between (?P<low>\d+) and "
+        r"(?P<high>\d+) seconds after it started"
+    ),
+    converters={"low": int, "high": int},
+)
+def deferred_poll_returns_nothing(deferred, low, high):
+    started = deferred.deferred_poll_started
+    poll = take_deferred_poll(deferred)
+    try:
+        polled = poll.result(max(0.0, started + high - time.monotonic()))
+    except TimeoutError:
+        pytest.fail(f"the deferred poll did not return within {high} s")
+    elapsed = time.monotonic() - started
+    assert payloads(polled) == []
+    assert elapsed >= low, f"the poll returned after {elapsed:.3f} s, before {low} s"
+
+
+@when(
+    parsers.re(r"I cancel the deferred poll after (?P<milliseconds>\d+) milliseconds"),
+    converters={"milliseconds": int},
+)
+def cancel_deferred_poll(deferred, milliseconds):
+    time.sleep(milliseconds / 1000)
+    poll = take_deferred_poll(deferred)
+    # Cancelling the task drops the parked request, which sends the cancellation.
+    assert poll.cancel(), "the deferred poll must still be parked when cancelled"
+
+
+@when(parsers.re(r"I wait (?P<seconds>\d+) seconds?"), converters={"seconds": int})
+def wait_seconds(seconds):
+    time.sleep(seconds)
+
+
+@then(
+    parsers.re(
+        r'a poll of partition (?P<partition>\d+) for consumer "(?P<name>[^"]+)" '
+        r'with the next strategy should return the payload "(?P<payload>[^"]+)"'
+    ),
+    converters={"partition": int},
+)
+def next_poll_returns(context, deferred, partition, name, payload):
+    poll_next_and_expect(
+        context, deferred, partition, name, CommitPolicy.Manual(), payload
+    )
+
+
+@then(
+    parsers.re(
+        r'a poll of partition (?P<partition>\d+) for consumer "(?P<name>[^"]+)" '
+        r"with the next strategy and the on read commit policy should return "
+        r'the payload "(?P<payload>[^"]+)"'
+    ),
+    converters={"partition": int},
+)
+def on_read_poll_returns(context, deferred, partition, name, payload):
+    poll_next_and_expect(
+        context, deferred, partition, name, CommitPolicy.OnRead(), payload
+    )
+
+
+@when(
+    parsers.re(
+        r"I open a partition reader on partitions (?P<first>\d+), "
+        r"(?P<second>\d+) and (?P<third>\d+) from offset (?P<offset>\d+) "
+        r"with a max wait of (?P<seconds>\d+) seconds?"
+    ),
+    converters={
+        "first": int,
+        "second": int,
+        "third": int,
+        "offset": int,
+        "seconds": int,
+    },
+)
+def open_partition_reader(context, deferred, first, second, third, offset, seconds):
+    deferred.reader = deferred.run(
+        lambda: context.client.reader(
+            context.last_stream_id,
+            context.last_topic_id,
+            consumer=DEFAULT_CONSUMER,
+            partitions=[first, second, third],
+            start=PollingStrategy.Offset(value=offset),
+            options=PollOptions(
+                count=MESSAGES_COUNT, max_wait=timedelta(seconds=seconds)
+            ),
+        )
+    )
+
+
+@then(
+    parsers.re(
+        r'the partition reader should return the payload "(?P<payload>[^"]+)" '
+        r"from partition (?P<partition>\d+) within (?P<seconds>\d+) seconds?"
+    ),
+    converters={"partition": int, "seconds": int},
+)
+def partition_reader_returns(deferred, payload, partition, seconds):
+    reader = deferred.reader
+    assert reader is not None, "partition reader open"
+    # The reader never yields an empty batch, so its first batch comes from the
+    # first partition that gets a message.
+    polled = within(deferred, reader.next, seconds, "the partition reader")
+    assert polled.partition_id == partition
+    assert payloads(polled) == [payload]
+
+
+@when(
+    parsers.re(
+        r'I open a consumer in group "(?P<group>[^"]+)" on the topic with a max '
+        r"wait of (?P<seconds>\d+) seconds?"
+    ),
+    converters={"seconds": int},
+)
+def open_group_consumer(context, deferred, group, seconds):
+    options = PollOptions(count=MESSAGES_COUNT, max_wait=timedelta(seconds=seconds))
+    open_consumer(context, deferred, group, options)
+
+
+@when(
+    parsers.re(
+        r'I open a consumer in group "(?P<group>[^"]+)" on the topic that commits '
+        r"after processing"
+    )
+)
+def open_processing_group_consumer(context, deferred, group):
+    defaults = AutoCommit.default()
+    auto_commit = AutoCommit(
+        interval=defaults.interval,
+        every=defaults.every,
+        after=CommitAfter.Processing,
+    )
+    options = PollOptions(
+        count=MESSAGES_COUNT,
+        max_wait=CONSUMER_MAX_WAIT,
+        commit=CommitPolicy.Auto(auto_commit),
+    )
+    open_consumer(context, deferred, group, options)
+
+
+@then(
+    parsers.re(
+        r'the consumer should return the payload "(?P<payload>[^"]+)" within '
+        r"(?P<seconds>\d+) seconds?"
+    ),
+    converters={"seconds": int},
+)
+def consumer_returns(deferred, payload, seconds):
+    messages = deferred.consumer_messages
+    assert messages is not None, "consumer open"
+    message = within(deferred, lambda: anext(messages), seconds, "the consumer")
+    assert message.payload().decode() == payload
+
+
+@when("I close the consumer")
+def close_consumer(deferred):
+    consumer = deferred.consumer
+    assert consumer is not None, "consumer open"
+    deferred.consumer = None
+    deferred.consumer_messages = None
+    deferred.run(consumer.shutdown)
+
+
+def start_poll(
+    context, deferred, root_credentials, partition, consumer, strategy, options
+):
+    client = background_client(context, deferred, root_credentials)
+    stream, topic = context.last_stream_id, context.last_topic_id
+    # The first read of a new topic can wait for its partition, so a read that
+    # waits for nothing goes first and the poll then leaves at once.
+    deferred.run(
+        lambda: poll_once(
+            client,
+            stream,
+            topic,
+            partition,
+            DEFAULT_CONSUMER,
+            PollingStrategy.Offset(value=0),
+            PollOptions(count=1),
+        )
+    )
+    deferred.deferred_poll_started = time.monotonic()
+    deferred.deferred_poll = asyncio.run_coroutine_threadsafe(
+        poll_once(client, stream, topic, partition, consumer, strategy, options),
+        deferred.loop,
+    )
+    time.sleep(PARK_TIME_SECONDS)
+    assert not deferred.deferred_poll.done(), "the deferred poll must park"
+
+
+async def poll_once(client, stream, topic, partition, consumer, strategy, options):
+    return await client.poll_messages(
+        stream=stream,
+        topic=topic,
+        partition_id=partition,
+        consumer=consumer,
+        polling_strategy=strategy,
+        options=options,
+    )
+
+
+def background_client(context, deferred, root_credentials) -> IggyClient:
+    """A parked poll holds its connection, so it runs on a client of its own.
+
+    The state keeps that client, so that a cancelled call still has a link for
+    its cancellation.
+    """
+    if deferred.background_client is None:
+        host, port = context.server_addr.split(":")
+        client = IggyClient(f"{socket.gethostbyname(host)}:{port}")
+
+        async def connect():
+            await client.connect()
+            await client.login_user(*root_credentials)
+
+        deferred.run(connect)
+        deferred.background_client = client
+    return deferred.background_client
+
+
+def take_deferred_poll(deferred) -> concurrent.futures.Future[PolledMessages]:
+    poll = deferred.deferred_poll
+    assert poll is not None, "deferred poll started"
+    deferred.deferred_poll = None
+    return poll
+
+
+def poll_next_and_expect(context, deferred, partition, name, commit, payload):
+    polled = poll_next(context, deferred, partition, name, commit)
+    assert payloads(polled)[:1] == [payload]
+
+
+def poll_next(context, deferred, partition, name, commit) -> PolledMessages:
+    """Polls one message from `Next`, so a poll that commits stores only the
+    offset of the payload it returns."""
+    client = deferred.poll_owner or context.client
+    return deferred.run(
+        lambda: client.poll_messages(
+            stream=context.last_stream_id,
+            topic=context.last_topic_id,
+            partition_id=partition,
+            consumer=Consumer.Single(name),
+            polling_strategy=PollingStrategy.Next(),
+            options=PollOptions(count=1, commit=commit),
+        )
+    )
+
+
+def open_consumer(context, deferred, group, options):
+    async def open_group():
+        consumer = await context.client.consumer_group(
+            group,
+            context.last_stream_name,
+            context.last_topic_name,
+            poll_options=options,
+        )
+        return consumer, consumer.iter_messages()
+
+    deferred.consumer, deferred.consumer_messages = deferred.run(open_group)
+
+
+def within(deferred, call, seconds, name):
+    async def wait():
+        return await asyncio.wait_for(call(), seconds)
+
+    try:
+        return deferred.run(wait)
+    except TimeoutError:
+        pytest.fail(f"{name} did not return within {seconds} s")
+
+
+def payloads(polled: PolledMessages) -> list[str]:
+    return [message.payload().decode() for message in polled.messages]
diff --git a/bdd/python/tests/test_stream_options.py b/bdd/python/tests/test_stream_options.py
index f83e5ce..abe53e6 100644
--- a/bdd/python/tests/test_stream_options.py
+++ b/bdd/python/tests/test_stream_options.py
@@ -27,6 +27,7 @@
     Consumer,
     OptionKey,
     PollingStrategy,
+    PollOptions,
     SendMessage,
     StreamDetails,
     encryption_key_fingerprint,
@@ -200,11 +201,10 @@
             consumer=Consumer.Single(1),
             partition_id=PARTITION_ID,
             polling_strategy=PollingStrategy.Offset(value=0),
-            count=1,
-            auto_commit=False,
+            options=PollOptions(count=1),
         )
 
-    messages = asyncio.run(poll())
+    messages = asyncio.run(poll()).messages
     assert messages
     return messages[0]
 
diff --git a/bdd/python/uv.lock b/bdd/python/uv.lock
index ac06d9f..a9760ae 100644
--- a/bdd/python/uv.lock
+++ b/bdd/python/uv.lock
@@ -8,7 +8,7 @@
 
 [[package]]
 name = "apache-iggy"
-version = "0.9.1.dev5"
+version = "0.9.1.dev6"
 source = { directory = "../../foreign/python" }
 
 [package.metadata]
diff --git a/bdd/rust/Cargo.toml b/bdd/rust/Cargo.toml
index c6b83c9..b493c9d 100644
--- a/bdd/rust/Cargo.toml
+++ b/bdd/rust/Cargo.toml
@@ -29,6 +29,7 @@
 [dev-dependencies]
 bytes = { workspace = true }
 cucumber = { workspace = true }
+futures = { workspace = true }
 iggy = { workspace = true }
 tokio = { workspace = true }
 
@@ -75,5 +76,10 @@
 harness = false
 required-features = ["bdd"]
 
+[[test]]
+name = "deferred_polling"
+harness = false
+required-features = ["bdd"]
+
 [lints]
 workspace = true
diff --git a/bdd/rust/tests/common/global_context.rs b/bdd/rust/tests/common/global_context.rs
index d0565a0..ef1b1ee 100644
--- a/bdd/rust/tests/common/global_context.rs
+++ b/bdd/rust/tests/common/global_context.rs
@@ -19,9 +19,14 @@
 use cucumber::World;
 use iggy::clients::client::IggyClient;
 use iggy::prelude::{
-    IggyError, IggyMessage, MetadataChangeCursor, MetadataChangeGroup, MetadataChangeRecord,
-    MetadataCurrentItem, OptionSpec, PolledMessages, StreamEncryptionKey,
+    IggyConsumer, IggyError, IggyMessage, MetadataChangeCursor, MetadataChangeGroup,
+    MetadataChangeRecord, MetadataCurrentItem, OptionSpec, PartitionReader, PolledMessages,
+    StreamEncryptionKey,
 };
+use std::fmt;
+use std::sync::Arc;
+use tokio::task::JoinHandle;
+use tokio::time::Instant;
 
 #[derive(Debug, World, Default)]
 pub struct GlobalContext {
@@ -45,4 +50,25 @@
     pub last_change_groups: Vec<MetadataChangeGroup>,
     pub last_changes: Vec<MetadataChangeRecord>,
     pub last_current_items: Vec<MetadataCurrentItem>,
+    pub background_client: Option<Arc<IggyClient>>,
+    /// The client of the scenario while a deferred poll runs on it.
+    pub polling_client: Option<Arc<IggyClient>>,
+    /// The operation of a deferred poll whose outcome is unknown.
+    pub lost_operation: Option<u64>,
+    pub deferred_poll_started: Option<Instant>,
+    pub deferred_poll: Option<JoinHandle<Result<PolledMessages, IggyError>>>,
+    pub reader: Option<PartitionReader>,
+    pub consumer: Option<OpenConsumer>,
+}
+
+/// A consumer that a scenario opened, which the world prints by name only.
+pub struct OpenConsumer(pub IggyConsumer);
+
+impl fmt::Debug for OpenConsumer {
+    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+        formatter
+            .debug_tuple("OpenConsumer")
+            .field(&self.0.name())
+            .finish()
+    }
 }
diff --git a/bdd/rust/tests/deferred_polling.rs b/bdd/rust/tests/deferred_polling.rs
new file mode 100644
index 0000000..0fdc537
--- /dev/null
+++ b/bdd/rust/tests/deferred_polling.rs
@@ -0,0 +1,34 @@
+// 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.
+
+pub(crate) mod common;
+pub(crate) mod helpers;
+pub(crate) mod steps;
+
+use crate::common::global_context::GlobalContext;
+use cucumber::World;
+
+#[tokio::main]
+async fn main() {
+    // The scenarios assert wall-clock bounds of parked calls, so they run one
+    // at a time.
+    GlobalContext::cucumber()
+        .max_concurrent_scenarios(1)
+        .fail_on_skipped()
+        .run_and_exit("../../bdd/scenarios/deferred_polling.feature")
+        .await;
+}
diff --git a/bdd/rust/tests/steps/deferred_polling.rs b/bdd/rust/tests/steps/deferred_polling.rs
new file mode 100644
index 0000000..e356d64
--- /dev/null
+++ b/bdd/rust/tests/steps/deferred_polling.rs
@@ -0,0 +1,591 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use crate::common::global_context::{GlobalContext, OpenConsumer};
+use crate::helpers::env::{root_password, root_username};
+use crate::steps::auth::given_authenticated_as_root;
+use cucumber::{given, then, when};
+use futures::StreamExt;
+use iggy::prelude::{
+    AutoCommit, Client, CommitAfter, CommitPolicy, Consumer, Identifier, IggyClient, IggyDuration,
+    IggyError, IggyMessage, MessageClient, Partitioning, PollOptions, PolledMessages,
+    PollingStrategy, StreamClient, StreamCreateOptions, TopicClient, TopicCreateOptions,
+    UserClient,
+};
+use std::sync::Arc;
+use std::time::Duration;
+use tokio::task::JoinError;
+use tokio::time::{Instant, sleep, timeout, timeout_at};
+
+const MESSAGES_COUNT: u32 = 10;
+/// A message that reaches the server before the call answers it at once, so a
+/// start step waits until its call is parked.
+const PARK_TIME: Duration = Duration::from_millis(100);
+/// The wait of a consumer whose step names none.
+const CONSUMER_MAX_WAIT_SECS: u64 = 10;
+
+#[given(
+    regex = r#"^a stream named "([^"]+)" with a topic named "([^"]+)" with (\d+) partitions exists$"#
+)]
+pub async fn given_stream_with_topic(
+    world: &mut GlobalContext,
+    stream_name: String,
+    topic_name: String,
+    partitions: u32,
+) {
+    let client = world.client.as_ref().expect("client");
+    let stream_id = Identifier::named(&stream_name).expect("stream name");
+    if client
+        .get_stream(&stream_id)
+        .await
+        .expect("get stream")
+        .is_some()
+    {
+        client
+            .delete_stream(&stream_id)
+            .await
+            .expect("delete stream");
+    }
+    let stream = client
+        .create_stream(&stream_name, &StreamCreateOptions::default())
+        .await
+        .expect("create stream");
+    let topic = client
+        .create_topic(
+            &stream_id,
+            &topic_name,
+            &TopicCreateOptions {
+                partitions_count: Some(partitions),
+                ..TopicCreateOptions::default()
+            },
+        )
+        .await
+        .expect("create topic");
+    world.last_stream_id = Some(stream.id);
+    world.last_stream_name = Some(stream.name);
+    world.last_topic_id = Some(topic.id);
+    world.last_topic_name = Some(topic.name);
+    world.last_topic_partitions = Some(partitions);
+}
+
+#[when(
+    regex = r"^I start a deferred poll of partition (\d+) from offset (\d+) with a max wait of (\d+) seconds?$"
+)]
+pub async fn when_start_deferred_poll(
+    world: &mut GlobalContext,
+    partition: u32,
+    offset: u64,
+    max_wait_secs: u64,
+) {
+    start_deferred_poll(
+        world,
+        partition,
+        Consumer::default(),
+        PollingStrategy::offset(offset),
+        poll_options(max_wait_secs),
+    )
+    .await;
+}
+
+#[when(
+    regex = r#"^I start a deferred poll of partition (\d+) for consumer "([^"]+)" with the on read commit policy, a min count of (\d+) and a max wait of (\d+) seconds?$"#
+)]
+pub async fn when_start_committing_deferred_poll(
+    world: &mut GlobalContext,
+    partition: u32,
+    consumer_name: String,
+    min_count: u32,
+    max_wait_secs: u64,
+) {
+    start_deferred_poll(
+        world,
+        partition,
+        named_consumer(&consumer_name),
+        PollingStrategy::next(),
+        PollOptions {
+            min_count,
+            commit: CommitPolicy::OnRead,
+            ..poll_options(max_wait_secs)
+        },
+    )
+    .await;
+}
+
+/// The poll runs on the client of the scenario, which a later step disconnects.
+#[when(
+    regex = r#"^I start a deferred poll of partition (\d+) for consumer "([^"]+)" with the on read commit policy and a max wait of (\d+) seconds?$"#
+)]
+pub async fn when_start_committing_poll_on_own_client(
+    world: &mut GlobalContext,
+    partition: u32,
+    consumer_name: String,
+    max_wait_secs: u64,
+) {
+    let client = Arc::new(world.client.take().expect("client"));
+    world.polling_client = Some(Arc::clone(&client));
+    let stream_id = stream_identifier(world);
+    let topic_id = topic_identifier(world);
+    let consumer = named_consumer(&consumer_name);
+    let options = PollOptions {
+        commit: CommitPolicy::OnRead,
+        ..poll_options(max_wait_secs)
+    };
+    // A read that stores nothing routes the partition first, so the committing poll leaves the
+    // client at once. A disconnect before it left would end it as never sent.
+    client
+        .poll_messages(
+            &stream_id,
+            &topic_id,
+            partition,
+            &consumer,
+            &PollingStrategy::offset(0),
+            PollOptions {
+                count: 1,
+                commit: CommitPolicy::Manual,
+                ..PollOptions::default()
+            },
+        )
+        .await
+        .expect("route the partition");
+    world.deferred_poll_started = Some(Instant::now());
+    let poll = tokio::spawn(async move {
+        client
+            .poll_messages(
+                &stream_id,
+                &topic_id,
+                partition,
+                &consumer,
+                &PollingStrategy::next(),
+                options,
+            )
+            .await
+    });
+    sleep(PARK_TIME).await;
+    assert!(!poll.is_finished(), "the deferred poll must park");
+    world.deferred_poll = Some(poll);
+}
+
+/// The SDK's own disconnect, which signs nothing out on the server.
+#[when(regex = r"^the client disconnects before the deferred poll answers$")]
+pub async fn when_client_disconnects(world: &mut GlobalContext) {
+    let client = world.polling_client.as_ref().expect("a deferred poll runs");
+    client.disconnect().await.expect("disconnect");
+}
+
+#[then(regex = r"^the deferred poll should fail as uncertain$")]
+pub async fn then_deferred_poll_fails_as_uncertain(world: &mut GlobalContext) {
+    let poll = world.deferred_poll.take().expect("deferred poll started");
+    let outcome = timeout(Duration::from_secs(CONSUMER_MAX_WAIT_SECS), poll)
+        .await
+        .expect("the deferred poll ends after the disconnect")
+        .expect("the deferred poll task");
+    let Err(IggyError::FetchProgressUncertain(operation)) = outcome else {
+        panic!("the deferred poll must fail as uncertain, not with {outcome:?}");
+    };
+    world.lost_operation = Some(operation);
+}
+
+/// The same client object connects and signs in again, in a new session.
+#[when(regex = r"^the client connects again$")]
+pub async fn when_client_connects_again(world: &mut GlobalContext) {
+    let client = world.polling_client.take().expect("a deferred poll ran");
+    let client = Arc::try_unwrap(client).expect("the ended poll released its client");
+    client.connect().await.expect("connect again");
+    client
+        .login_user(&root_username(), &root_password())
+        .await
+        .expect("sign in again");
+    world.client = Some(client);
+}
+
+#[then(
+    regex = r#"^a poll of partition (\d+) for consumer "([^"]+)" with the next strategy and the on read commit policy should fail as blocked by the deferred poll$"#
+)]
+pub async fn then_on_read_poll_fails_as_blocked(
+    world: &mut GlobalContext,
+    partition: u32,
+    consumer_name: String,
+) {
+    let operation = world
+        .lost_operation
+        .expect("a deferred poll failed as uncertain");
+    let client = world.client.as_ref().expect("client");
+    let polled = client
+        .poll_messages(
+            &stream_identifier(world),
+            &topic_identifier(world),
+            partition,
+            &named_consumer(&consumer_name),
+            &PollingStrategy::next(),
+            PollOptions {
+                count: 1,
+                commit: CommitPolicy::OnRead,
+                ..PollOptions::default()
+            },
+        )
+        .await;
+    assert_eq!(
+        polled.map(drop),
+        Err(IggyError::PartitionLaneBlocked(partition, operation)),
+        "the poll must be blocked by the lost poll"
+    );
+}
+
+#[when(regex = r"^I abandon the operation of the deferred poll$")]
+pub async fn when_abandon_lost_poll(world: &mut GlobalContext) {
+    let operation = world
+        .lost_operation
+        .expect("a deferred poll failed as uncertain");
+    let client = world.client.as_ref().expect("client");
+    client
+        .abandon_operation(operation)
+        .await
+        .expect("abandon the lost poll");
+}
+
+#[when(regex = r#"^I send a message with payload "([^"]+)" to partition (\d+)$"#)]
+pub async fn when_send_to_partition(world: &mut GlobalContext, payload: String, partition: u32) {
+    let client = world.client.as_ref().expect("client");
+    let message = IggyMessage::builder()
+        .payload(payload.into())
+        .build()
+        .expect("message");
+    client
+        .send_messages(
+            &stream_identifier(world),
+            &topic_identifier(world),
+            &Partitioning::partition_id(partition),
+            &mut [message],
+        )
+        .await
+        .expect("send message");
+}
+
+#[then(regex = r#"^the deferred poll should return the payload "([^"]+)" within (\d+) seconds?$"#)]
+pub async fn then_deferred_poll_returns(
+    world: &mut GlobalContext,
+    payload: String,
+    within_secs: u64,
+) {
+    let poll = world.deferred_poll.take().expect("deferred poll started");
+    let polled = timeout(Duration::from_secs(within_secs), poll)
+        .await
+        .unwrap_or_else(|_| panic!("the deferred poll did not return within {within_secs} s"))
+        .expect("deferred poll task")
+        .expect("deferred poll");
+    assert_single_payload(&polled, &payload);
+}
+
+#[then(
+    regex = r"^the deferred poll should return no messages between (\d+) and (\d+) seconds after it started$"
+)]
+pub async fn then_deferred_poll_returns_nothing(
+    world: &mut GlobalContext,
+    from_secs: u64,
+    to_secs: u64,
+) {
+    let started = world.deferred_poll_started.expect("deferred poll started");
+    let poll = world.deferred_poll.take().expect("deferred poll started");
+    let polled = timeout_at(started + Duration::from_secs(to_secs), poll)
+        .await
+        .unwrap_or_else(|_| panic!("the deferred poll did not return within {to_secs} s"))
+        .expect("deferred poll task")
+        .expect("deferred poll");
+    let elapsed = started.elapsed();
+    assert!(
+        polled.messages.is_empty(),
+        "the deferred poll must return no messages: {polled:?}"
+    );
+    assert!(
+        elapsed >= Duration::from_secs(from_secs),
+        "the deferred poll returned after {elapsed:?}, before {from_secs} s"
+    );
+}
+
+#[when(
+    regex = r"^I open a partition reader on partitions (\d+), (\d+) and (\d+) from offset (\d+) with a max wait of (\d+) seconds?$"
+)]
+pub async fn when_open_partition_reader(
+    world: &mut GlobalContext,
+    first: u32,
+    second: u32,
+    third: u32,
+    offset: u64,
+    max_wait_secs: u64,
+) {
+    let client = world.client.as_ref().expect("client");
+    let reader = client
+        .reader(
+            &stream_identifier(world),
+            &topic_identifier(world),
+            &Consumer::default(),
+        )
+        .partitions([first, second, third])
+        .start(PollingStrategy::offset(offset))
+        .options(poll_options(max_wait_secs))
+        .build()
+        .await
+        .expect("build reader");
+    world.reader = Some(reader);
+}
+
+#[then(
+    regex = r#"^the partition reader should return the payload "([^"]+)" from partition (\d+) within (\d+) seconds?$"#
+)]
+pub async fn then_partition_reader_returns(
+    world: &mut GlobalContext,
+    payload: String,
+    partition: u32,
+    within_secs: u64,
+) {
+    let reader = world.reader.as_mut().expect("partition reader open");
+    // The reader never yields an empty batch, so its first batch comes from the first
+    // partition that gets a message.
+    let Ok(read) = timeout(Duration::from_secs(within_secs), reader.next()).await else {
+        panic!("the partition reader did not return within {within_secs} s");
+    };
+    let read = read.expect("partition reader batch");
+    assert_eq!(
+        read.partition_id, partition,
+        "the batch must come from partition {partition}"
+    );
+    assert_single_payload(&read, &payload);
+}
+
+#[when(
+    regex = r#"^I open a consumer in group "([^"]+)" on the topic with a max wait of (\d+) seconds?$"#
+)]
+pub async fn when_open_group_consumer(
+    world: &mut GlobalContext,
+    group_name: String,
+    max_wait_secs: u64,
+) {
+    open_group_consumer(world, &group_name, poll_options(max_wait_secs)).await;
+}
+
+#[when(
+    regex = r#"^I open a consumer in group "([^"]+)" on the topic that commits after processing$"#
+)]
+pub async fn when_open_processing_group_consumer(world: &mut GlobalContext, group_name: String) {
+    let options = PollOptions {
+        commit: CommitPolicy::Auto(AutoCommit {
+            after: CommitAfter::Processing,
+            ..AutoCommit::default()
+        }),
+        ..poll_options(CONSUMER_MAX_WAIT_SECS)
+    };
+    open_group_consumer(world, &group_name, options).await;
+}
+
+#[then(regex = r#"^the consumer should return the payload "([^"]+)" within (\d+) seconds?$"#)]
+pub async fn then_consumer_returns(world: &mut GlobalContext, payload: String, within_secs: u64) {
+    let OpenConsumer(consumer) = world.consumer.as_mut().expect("consumer open");
+    let Ok(received) = timeout(Duration::from_secs(within_secs), consumer.next()).await else {
+        panic!("the consumer did not return within {within_secs} s");
+    };
+    let received = received
+        .expect("the consumer stream ended")
+        .expect("consumed message");
+    assert_eq!(
+        String::from_utf8_lossy(&received.message.payload),
+        payload,
+        "the consumer must return the payload {payload:?}"
+    );
+}
+
+#[when(regex = r"^I close the consumer$")]
+pub async fn when_close_consumer(world: &mut GlobalContext) {
+    let OpenConsumer(mut consumer) = world.consumer.take().expect("consumer open");
+    consumer.shutdown().await.expect("shut down the consumer");
+}
+
+#[when(regex = r"^I cancel the deferred poll after (\d+) milliseconds$")]
+pub async fn when_cancel_deferred_poll(world: &mut GlobalContext, after_ms: u64) {
+    sleep(Duration::from_millis(after_ms)).await;
+    let poll = world.deferred_poll.take().expect("deferred poll started");
+    // Dropping the parked request sends the cancellation (108) on its link.
+    poll.abort();
+    let outcome = poll.await;
+    assert!(
+        outcome.as_ref().is_err_and(JoinError::is_cancelled),
+        "the deferred poll must end as cancelled, not with {outcome:?}"
+    );
+}
+
+#[when(regex = r"^I wait (\d+) seconds?$")]
+pub async fn when_wait(_world: &mut GlobalContext, seconds: u64) {
+    sleep(Duration::from_secs(seconds)).await;
+}
+
+#[then(
+    regex = r#"^a poll of partition (\d+) for consumer "([^"]+)" with the next strategy should return the payload "([^"]+)"$"#
+)]
+pub async fn then_next_poll_returns(
+    world: &mut GlobalContext,
+    partition: u32,
+    consumer_name: String,
+    payload: String,
+) {
+    poll_next_and_expect(
+        world,
+        partition,
+        &consumer_name,
+        CommitPolicy::Manual,
+        &payload,
+    )
+    .await;
+}
+
+#[then(
+    regex = r#"^a poll of partition (\d+) for consumer "([^"]+)" with the next strategy and the on read commit policy should return the payload "([^"]+)"$"#
+)]
+pub async fn then_on_read_poll_returns(
+    world: &mut GlobalContext,
+    partition: u32,
+    consumer_name: String,
+    payload: String,
+) {
+    poll_next_and_expect(
+        world,
+        partition,
+        &consumer_name,
+        CommitPolicy::OnRead,
+        &payload,
+    )
+    .await;
+}
+
+/// Polls one message from `Next`, so a poll that commits stores only the offset of the payload
+/// it returns.
+async fn poll_next_and_expect(
+    world: &GlobalContext,
+    partition: u32,
+    consumer_name: &str,
+    commit: CommitPolicy,
+    payload: &str,
+) {
+    let client = world.client.as_ref().expect("client");
+    let polled = client
+        .poll_messages(
+            &stream_identifier(world),
+            &topic_identifier(world),
+            partition,
+            &named_consumer(consumer_name),
+            &PollingStrategy::next(),
+            PollOptions {
+                count: 1,
+                commit,
+                ..PollOptions::default()
+            },
+        )
+        .await
+        .expect("poll messages");
+    let first = polled
+        .messages
+        .first()
+        .map(|message| String::from_utf8_lossy(&message.payload));
+    assert_eq!(
+        first.as_deref(),
+        Some(payload),
+        "the first message must have payload {payload:?}: {polled:?}"
+    );
+}
+
+async fn open_group_consumer(world: &mut GlobalContext, group_name: &str, options: PollOptions) {
+    let client = world.client.as_ref().expect("client");
+    let stream = world.last_stream_name.as_deref().expect("stream created");
+    let topic = world.last_topic_name.as_deref().expect("topic created");
+    let mut consumer = client
+        .consumer_group(group_name, stream, topic)
+        .expect("consumer group builder")
+        .create_consumer_group_if_not_exists()
+        .poll_options(options)
+        .build();
+    consumer.init().await.expect("init the consumer");
+    world.consumer = Some(OpenConsumer(consumer));
+}
+
+/// The SDK defaults with the count of these steps and the max wait of the step.
+fn poll_options(max_wait_secs: u64) -> PollOptions {
+    PollOptions {
+        count: MESSAGES_COUNT,
+        ..PollOptions::wait(IggyDuration::from(Duration::from_secs(max_wait_secs)))
+    }
+}
+
+async fn start_deferred_poll(
+    world: &mut GlobalContext,
+    partition: u32,
+    consumer: Consumer,
+    strategy: PollingStrategy,
+    options: PollOptions,
+) {
+    let client = background_client(world).await;
+    let stream_id = stream_identifier(world);
+    let topic_id = topic_identifier(world);
+    world.deferred_poll_started = Some(Instant::now());
+    let poll = tokio::spawn(async move {
+        client
+            .poll_messages(
+                &stream_id, &topic_id, partition, &consumer, &strategy, options,
+            )
+            .await
+    });
+    sleep(PARK_TIME).await;
+    assert!(!poll.is_finished(), "the deferred poll must park");
+    world.deferred_poll = Some(poll);
+}
+
+/// A spawned call owns its client, so it gets a connection of its own. The
+/// world keeps that client, so that a cancelled call still has a link for its
+/// cancellation.
+async fn background_client(world: &mut GlobalContext) -> Arc<IggyClient> {
+    if world.background_client.is_none() {
+        let mut background = GlobalContext {
+            server_addr: world.server_addr.clone(),
+            ..GlobalContext::default()
+        };
+        given_authenticated_as_root(&mut background).await;
+        world.background_client = background.client.map(Arc::new);
+    }
+    Arc::clone(world.background_client.as_ref().expect("background client"))
+}
+
+fn assert_single_payload(polled: &PolledMessages, expected: &str) {
+    let payloads: Vec<_> = polled
+        .messages
+        .iter()
+        .map(|message| String::from_utf8_lossy(&message.payload))
+        .collect();
+    assert_eq!(
+        payloads,
+        [expected],
+        "the poll must return one message with payload {expected:?}"
+    );
+}
+
+fn named_consumer(name: &str) -> Consumer {
+    Consumer::new(Identifier::named(name).expect("consumer name"))
+}
+
+fn stream_identifier(world: &GlobalContext) -> Identifier {
+    Identifier::numeric(world.last_stream_id.expect("stream created")).expect("stream id")
+}
+
+fn topic_identifier(world: &GlobalContext) -> Identifier {
+    Identifier::numeric(world.last_topic_id.expect("topic created")).expect("topic id")
+}
diff --git a/bdd/rust/tests/steps/messages.rs b/bdd/rust/tests/steps/messages.rs
index 6d7106d..910f4a3 100644
--- a/bdd/rust/tests/steps/messages.rs
+++ b/bdd/rust/tests/steps/messages.rs
@@ -20,7 +20,8 @@
 use bytes::Bytes;
 use cucumber::{then, when};
 use iggy::prelude::{
-    Consumer, ConsumerKind, Identifier, IggyMessage, MessageClient, Partitioning, PollingStrategy,
+    Consumer, ConsumerKind, Identifier, IggyMessage, MessageClient, Partitioning, PollOptions,
+    PollingStrategy,
 };
 
 #[when(regex = r"^I send (\d+) messages to stream (\d+), topic (\d+), partition (\d+)$")]
@@ -74,11 +75,13 @@
         .poll_messages(
             &Identifier::numeric(stream_id).unwrap(),
             &Identifier::numeric(topic_id).unwrap(),
-            Some(partition_id),
+            partition_id,
             &consumer,
             &PollingStrategy::offset(start_offset),
-            100,
-            false,
+            PollOptions {
+                count: 100,
+                ..PollOptions::default()
+            },
         )
         .await
         .expect("Should be able to poll messages");
diff --git a/bdd/rust/tests/steps/mod.rs b/bdd/rust/tests/steps/mod.rs
index 96355c8..21b1f90 100644
--- a/bdd/rust/tests/steps/mod.rs
+++ b/bdd/rust/tests/steps/mod.rs
@@ -16,6 +16,7 @@
 // under the License.
 
 pub mod auth;
+pub mod deferred_polling;
 pub mod leader_redirection;
 pub mod messages;
 pub mod metadata_changes;
diff --git a/bdd/rust/tests/steps/resource_options.rs b/bdd/rust/tests/steps/resource_options.rs
index de6d6dd..81ae31f 100644
--- a/bdd/rust/tests/steps/resource_options.rs
+++ b/bdd/rust/tests/steps/resource_options.rs
@@ -19,7 +19,7 @@
 use cucumber::{given, then, when};
 use iggy::prelude::{
     Consumer, HeaderKey, HeaderValue, Identifier, IggyExpiry, IggyMessage, MaxTopicSize,
-    MessageClient, OptionKey, OptionSpec, OptionsScope, Partitioning, PollingStrategy,
+    MessageClient, OptionKey, OptionSpec, OptionsScope, Partitioning, PollOptions, PollingStrategy,
     StreamClient, StreamCreateOptions, StreamDetails, StreamEncryptionKey, StreamEncryptionSource,
     StreamUpdateOptions, SystemClient, TopicClient, TopicCreateOptions, TopicDetails,
     TopicUpdateOptions, UserClient, UserCreateOptions, UserStatus, UserUpdateOptions,
@@ -466,11 +466,13 @@
         .poll_messages(
             &stream_identifier(world),
             &topic_identifier(world),
-            Some(PARTITION_ID),
+            PARTITION_ID,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            1,
-            false,
+            PollOptions {
+                count: 1,
+                ..PollOptions::default()
+            },
         )
         .await
         .expect("poll messages");
diff --git a/bdd/scenarios/deferred_polling.feature b/bdd/scenarios/deferred_polling.feature
new file mode 100644
index 0000000..1bdf8c3
--- /dev/null
+++ b/bdd/scenarios/deferred_polling.feature
@@ -0,0 +1,96 @@
+# 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.
+
+@deferred-polling
+Feature: Deferred polling
+  As a developer using Apache Iggy
+  I want a poll, a partition reader and a consumer to wait on the server until messages arrive
+  So that a new message reaches me at once without a polling loop
+
+  Background:
+    Given I have a running Iggy server
+    And I am authenticated as the root user
+
+  Scenario: A parked deferred poll answers when a message arrives
+    Given a stream named "deferred-wake" with a topic named "events" with 3 partitions exists
+    When I start a deferred poll of partition 0 from offset 0 with a max wait of 10 seconds
+    And I send a message with payload "wake-up" to partition 0
+    Then the deferred poll should return the payload "wake-up" within 2 seconds
+
+  Scenario: A deferred poll without messages returns empty after its max wait
+    Given a stream named "deferred-idle" with a topic named "events" with 3 partitions exists
+    When I start a deferred poll of partition 1 from offset 0 with a max wait of 1 second
+    Then the deferred poll should return no messages between 1 and 3 seconds after it started
+
+  Scenario: A poll that commits on read stores the offset of its last message
+    Given a stream named "deferred-on-read" with a topic named "events" with 3 partitions exists
+    When I send a message with payload "one" to partition 0
+    And I send a message with payload "two" to partition 0
+    Then a poll of partition 0 for consumer "on-read" with the next strategy and the on read commit policy should return the payload "one"
+    And a poll of partition 0 for consumer "on-read" with the next strategy should return the payload "two"
+
+  # The poll asks for 2 messages and finds 1, so it stays parked until the cancellation.
+  # Without the cancellation it would expire after 1 second and store the offset of "unseen".
+  Scenario: A cancelled deferred poll does not commit its consumer offset
+    Given a stream named "deferred-cancel" with a topic named "events" with 3 partitions exists
+    When I send a message with payload "unseen" to partition 2
+    And I start a deferred poll of partition 2 for consumer "cancel-check" with the on read commit policy, a min count of 2 and a max wait of 1 second
+    And I cancel the deferred poll after 200 milliseconds
+    And I wait 2 seconds
+    Then a poll of partition 2 for consumer "cancel-check" with the next strategy should return the payload "unseen"
+
+  Scenario: A partition reader returns the partition that holds messages
+    Given a stream named "deferred-reader" with a topic named "events" with 3 partitions exists
+    When I open a partition reader on partitions 0, 1 and 2 from offset 0 with a max wait of 10 seconds
+    And I send a message with payload "ready" to partition 1
+    Then the partition reader should return the payload "ready" from partition 1 within 2 seconds
+
+  # The partitions get messages in descending order, so a consumer that reads partitions in
+  # turn waits on an idle partition in at least one round.
+  Scenario: A group consumer reads every partition that holds messages
+    Given a stream named "deferred-group" with a topic named "events" with 3 partitions exists
+    When I open a consumer in group "readers" on the topic with a max wait of 10 seconds
+    And I send a message with payload "first" to partition 2
+    Then the consumer should return the payload "first" within 2 seconds
+    When I send a message with payload "second" to partition 1
+    Then the consumer should return the payload "second" within 2 seconds
+    When I send a message with payload "third" to partition 0
+    Then the consumer should return the payload "third" within 2 seconds
+
+  # Closing the consumer counts the message it handed over last as processed and stores it.
+  Scenario: A group consumer that commits after processing resumes after its last message
+    Given a stream named "deferred-resume" with a topic named "events" with 3 partitions exists
+    When I send a message with payload "processed" to partition 0
+    And I open a consumer in group "resumers" on the topic that commits after processing
+    Then the consumer should return the payload "processed" within 2 seconds
+    When I close the consumer
+    And I send a message with payload "next" to partition 0
+    And I open a consumer in group "resumers" on the topic that commits after processing
+    Then the consumer should return the payload "next" within 2 seconds
+
+  # The connection drops while a committing poll waits, so its outcome stays unknown, and the
+  # partition takes no committing poll until the caller abandons that poll.
+  Scenario: A committing poll whose reply is lost blocks its partition until abandoned
+    Given a stream named "deferred-lost" with a topic named "events" with 3 partitions exists
+    When I start a deferred poll of partition 0 for consumer "lost-check" with the on read commit policy and a max wait of 10 seconds
+    And the client disconnects before the deferred poll answers
+    Then the deferred poll should fail as uncertain
+    When the client connects again
+    Then a poll of partition 0 for consumer "lost-check" with the next strategy and the on read commit policy should fail as blocked by the deferred poll
+    When I abandon the operation of the deferred poll
+    And I send a message with payload "after" to partition 0
+    Then a poll of partition 0 for consumer "lost-check" with the next strategy and the on read commit policy should return the payload "after"
diff --git a/bdd/swift/.swift-format b/bdd/swift/.swift-format
new file mode 100644
index 0000000..2580675
--- /dev/null
+++ b/bdd/swift/.swift-format
@@ -0,0 +1,22 @@
+{
+  "version": 1,
+  "lineLength": 160,
+  "indentation": { "spaces": 4 },
+  "maximumBlankLines": 1,
+  "respectsExistingLineBreaks": true,
+  "lineBreakBeforeControlFlowKeywords": false,
+  "lineBreakBeforeEachArgument": false,
+  "prioritizeKeepingFunctionOutputTogether": true,
+  "rules": {
+    "AlwaysUseLowerCamelCase": false,
+    "AmbiguousTrailingClosureOverload": true,
+    "NeverForceUnwrap": false,
+    "NeverUseForceTry": false,
+    "NeverUseImplicitlyUnwrappedOptionals": false,
+    "OrderedImports": true,
+    "UseLetInEveryBoundCaseVariable": false,
+    "NoBlockComments": true,
+    "OmitExplicitReturns": false,
+    "ValidateDocumentationComments": false
+  }
+}
diff --git a/bdd/swift/Dockerfile b/bdd/swift/Dockerfile
new file mode 100644
index 0000000..931c3b8
--- /dev/null
+++ b/bdd/swift/Dockerfile
@@ -0,0 +1,30 @@
+# 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.
+
+# Swift BDD runner: builds the Swift SDK and the runner, which parses the
+# mounted feature files and runs their scenarios on the SDK. The toolchain
+# matches .github/actions/utils/setup-swift.
+FROM swift:6.4.0
+
+WORKDIR /app
+COPY foreign/swift ./foreign/swift
+COPY bdd/swift ./bdd/swift
+COPY bdd/scenarios/deferred_polling.feature ./features/
+
+RUN cd bdd/swift/runner && swift build --product iggy-bdd
+
+CMD ["/app/bdd/swift/runner/.build/debug/iggy-bdd", "/app/features"]
diff --git a/bdd/swift/runner/Package.resolved b/bdd/swift/runner/Package.resolved
new file mode 100644
index 0000000..85cf3ef
--- /dev/null
+++ b/bdd/swift/runner/Package.resolved
@@ -0,0 +1,78 @@
+{
+  "originHash" : "2eef30a4b9e0abd3654ca61c734ec545f17b5858f0876dbec3b6318dec8d9bb9",
+  "pins" : [
+    {
+      "identity" : "swift-asn1",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/apple/swift-asn1.git",
+      "state" : {
+        "revision" : "3b6410f7dee09eb33cdd26260c5fd47fda19b0e2",
+        "version" : "1.7.3"
+      }
+    },
+    {
+      "identity" : "swift-atomics",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/apple/swift-atomics.git",
+      "state" : {
+        "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34",
+        "version" : "1.3.1"
+      }
+    },
+    {
+      "identity" : "swift-collections",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/apple/swift-collections.git",
+      "state" : {
+        "revision" : "f96b909e1a9e469620bc5037c1e24e00153f376f",
+        "version" : "1.7.5"
+      }
+    },
+    {
+      "identity" : "swift-crypto",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/apple/swift-crypto.git",
+      "state" : {
+        "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc",
+        "version" : "3.15.1"
+      }
+    },
+    {
+      "identity" : "swift-log",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/apple/swift-log.git",
+      "state" : {
+        "revision" : "c9c6bb9d275ec0d95c3ad16391f3f045dc623104",
+        "version" : "1.16.2"
+      }
+    },
+    {
+      "identity" : "swift-nio",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/apple/swift-nio.git",
+      "state" : {
+        "revision" : "6db9d9abd92ad7399b8a564cafbb87d3507245fa",
+        "version" : "2.104.0"
+      }
+    },
+    {
+      "identity" : "swift-nio-ssl",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/apple/swift-nio-ssl.git",
+      "state" : {
+        "revision" : "322f3c2a4a21df31c84ca416bf65ee5e9059e440",
+        "version" : "2.37.5"
+      }
+    },
+    {
+      "identity" : "swift-system",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/apple/swift-system.git",
+      "state" : {
+        "revision" : "869129b7bf4ecc57b97d0193ad29690ca2134750",
+        "version" : "1.8.1"
+      }
+    }
+  ],
+  "version" : 3
+}
diff --git a/bdd/swift/runner/Package.swift b/bdd/swift/runner/Package.swift
new file mode 100644
index 0000000..e868b79
--- /dev/null
+++ b/bdd/swift/runner/Package.swift
@@ -0,0 +1,35 @@
+// swift-tools-version: 6.0
+// 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.
+
+import PackageDescription
+
+let package = Package(
+    name: "iggy-bdd",
+    platforms: [.macOS(.v13)],
+    dependencies: [
+        .package(path: "../../../foreign/swift")
+    ],
+    targets: [
+        .executableTarget(
+            name: "iggy-bdd",
+            dependencies: [.product(name: "Iggy", package: "swift")],
+            path: "Sources"
+        )
+    ],
+    swiftLanguageModes: [.v6]
+)
diff --git a/bdd/swift/runner/Sources/Gherkin.swift b/bdd/swift/runner/Sources/Gherkin.swift
new file mode 100644
index 0000000..180bea0
--- /dev/null
+++ b/bdd/swift/runner/Sources/Gherkin.swift
@@ -0,0 +1,109 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import Foundation
+
+/// One step of a scenario, without its keyword.
+struct Step: Sendable {
+    let keyword: String
+    let text: String
+    let line: Int
+}
+
+struct Scenario: Sendable {
+    let name: String
+    let line: Int
+    var steps: [Step] = []
+}
+
+struct Feature: Sendable {
+    let path: String
+    var name = ""
+    var background: [Step] = []
+    var scenarios: [Scenario] = []
+}
+
+struct GherkinError: Error, CustomStringConvertible {
+    let description: String
+}
+
+/// Reads the subset of Gherkin the shared features use: tags, comments, a
+/// feature with its description, one background and scenarios of plain
+/// steps. Anything else, such as tables, doc strings or outlines, is refused,
+/// so a feature never runs with a step silently dropped.
+enum Gherkin {
+    private static let stepKeywords = ["Given", "When", "Then", "And", "But"]
+
+    static func parse(_ text: String, path: String) throws -> Feature {
+        var feature = Feature(path: path)
+        var inBackground = false
+        var current: Scenario?
+        for (index, raw) in text.components(separatedBy: .newlines).enumerated() {
+            let line = raw.trimmingCharacters(in: .whitespaces)
+            let number = index + 1
+            if line.isEmpty || line.hasPrefix("#") || line.hasPrefix("@") {
+                continue
+            }
+            if let name = value(of: "Feature:", in: line) {
+                feature.name = name
+            } else if value(of: "Background:", in: line) != nil {
+                inBackground = true
+            } else if let name = value(of: "Scenario:", in: line) {
+                if let current {
+                    feature.scenarios.append(current)
+                }
+                inBackground = false
+                current = Scenario(name: name, line: number)
+            } else if let step = step(line, number: number) {
+                if inBackground {
+                    feature.background.append(step)
+                } else if current != nil {
+                    current?.steps.append(step)
+                } else {
+                    throw GherkinError(description: "\(path):\(number): a step outside a scenario")
+                }
+            } else if line.hasPrefix("|") || line.hasPrefix("\"\"\"") || line.hasPrefix("Scenario Outline:") || line.hasPrefix("Examples:")
+                || line.hasPrefix("Rule:")
+            {
+                throw GherkinError(description: "\(path):\(number): unsupported Gherkin: \(line)")
+            } else if current != nil || inBackground {
+                throw GherkinError(description: "\(path):\(number): not a step: \(line)")
+            }
+        }
+        if let current {
+            feature.scenarios.append(current)
+        }
+        guard !feature.name.isEmpty else {
+            throw GherkinError(description: "\(path): no feature")
+        }
+        return feature
+    }
+
+    private static func value(of keyword: String, in line: String) -> String? {
+        guard line.hasPrefix(keyword) else {
+            return nil
+        }
+        return line.dropFirst(keyword.count).trimmingCharacters(in: .whitespaces)
+    }
+
+    private static func step(_ line: String, number: Int) -> Step? {
+        for keyword in stepKeywords where line.hasPrefix(keyword + " ") {
+            return Step(keyword: keyword, text: String(line.dropFirst(keyword.count + 1)), line: number)
+        }
+        return nil
+    }
+}
diff --git a/bdd/swift/runner/Sources/Steps.swift b/bdd/swift/runner/Sources/Steps.swift
new file mode 100644
index 0000000..8eae773
--- /dev/null
+++ b/bdd/swift/runner/Sources/Steps.swift
@@ -0,0 +1,356 @@
+// 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.
+
+import Foundation
+import Iggy
+
+/// Whether a poll ended, set by the task of the poll.
+final class PollEnd: @unchecked Sendable {
+    private let lock = NSLock()
+    private var endedAt: ContinuousClock.Instant?
+
+    func end() {
+        lock.withLock { endedAt = endedAt ?? .now }
+    }
+
+    var ended: ContinuousClock.Instant? {
+        lock.withLock { endedAt }
+    }
+}
+
+struct StepFailure: Error, CustomStringConvertible {
+    let description: String
+
+    init(_ description: String) {
+        self.description = description
+    }
+}
+
+/// The state of one scenario. Its steps run one after another, never at the
+/// same time, so the state needs no lock.
+final class World: @unchecked Sendable {
+    var client: IggyClient?
+    var stream: Identifier?
+    var topic: Identifier?
+    var poll: Task<PolledMessages, any Error>?
+    var pollStarted = ContinuousClock.now
+    var pollEnd = PollEnd()
+    var reader: PartitionReader?
+    var consumer: IggyConsumer?
+    /// The operation of a committing poll whose outcome is unknown.
+    var lostOperation: UInt128Value?
+
+    func requireClient() throws -> IggyClient {
+        guard let client else {
+            throw StepFailure("no client is signed in")
+        }
+        return client
+    }
+
+    func requireTopic() throws -> (stream: Identifier, topic: Identifier) {
+        guard let stream, let topic else {
+            throw StepFailure("no stream and topic were created")
+        }
+        return (stream, topic)
+    }
+
+    func requirePoll() throws -> Task<PolledMessages, any Error> {
+        guard let poll else {
+            throw StepFailure("no deferred poll was started")
+        }
+        return poll
+    }
+
+    func requireConsumer() throws -> IggyConsumer {
+        guard let consumer else {
+            throw StepFailure("no consumer is open")
+        }
+        return consumer
+    }
+
+    func tearDown() async {
+        poll?.cancel()
+        if let reader {
+            try? await reader.close()
+        }
+        await consumer?.shutdown()
+        await client?.shutdown()
+    }
+}
+
+/// How long a started poll gets to reach the server and park there.
+private let parkTime: Duration = .milliseconds(500)
+
+struct StepDefinition: Sendable {
+    let pattern: String
+    let run: @Sendable (World, [String]) async throws -> Void
+}
+
+/// Runs `body` and fails when it takes longer than `limit`.
+func within<T: Sendable>(_ limit: Duration, _ what: String, _ body: @escaping @Sendable () async throws -> T) async throws -> T {
+    try await withThrowingTaskGroup(of: T?.self) { group in
+        group.addTask { try await body() }
+        group.addTask {
+            try await Task.sleep(for: limit)
+            return nil
+        }
+        defer { group.cancelAll() }
+        guard let value = try await group.next() ?? nil else {
+            throw StepFailure("\(what) took longer than \(limit)")
+        }
+        return value
+    }
+}
+
+private func expect(_ condition: Bool, _ message: @autoclosure () -> String) throws {
+    if !condition {
+        throw StepFailure(message())
+    }
+}
+
+private func seconds(_ text: String) throws -> Duration {
+    guard let value = Int64(text) else {
+        throw StepFailure("\(text) is not a number of seconds")
+    }
+    return .seconds(value)
+}
+
+private func number<T: FixedWidthInteger>(_ text: String) throws -> T {
+    guard let value = T(text) else {
+        throw StepFailure("\(text) is not a number")
+    }
+    return value
+}
+
+private func send(_ world: World, payload: String, to partition: UInt32) async throws {
+    let (stream, topic) = try world.requireTopic()
+    var messages = [try IggyMessage(payload)]
+    try await world.requireClient().sendMessages(streamID: stream, topicID: topic, partitioning: .partition(partition), messages: &messages)
+}
+
+private func poll(_ world: World, partition: UInt32, consumer: String, commit: CommitPolicy) async throws -> PolledMessages {
+    let (stream, topic) = try world.requireTopic()
+    return try await world.requireClient().pollMessages(
+        streamID: stream, topicID: topic, partitionID: partition, consumer: .consumer(try Identifier(named: consumer)), strategy: .next,
+        options: PollOptions(count: 1, commit: commit))
+}
+
+/// The ``IggyError`` that `body` throws, or a failure when it throws
+/// anything else or returns, which names what it returned.
+private func iggyError(_ what: String, _ body: () async throws -> PolledMessages) async throws -> IggyError {
+    let polled: PolledMessages
+    do {
+        polled = try await body()
+    } catch let error as IggyError {
+        return error
+    } catch {
+        throw StepFailure("\(what) failed with \(error)")
+    }
+    throw StepFailure("\(what) did not fail, it returned \(polled.messages.map(\.payloadString)) from partition \(polled.partitionID)")
+}
+
+private func openConsumer(_ world: World, group: String, options: PollOptions) async throws {
+    let (stream, topic) = try world.requireTopic()
+    let consumer = try world.requireClient().consumerGroup(
+        name: group, streamID: stream, topicID: topic, configuration: ConsumerConfiguration(pollOptions: options))
+    try await consumer.initialize()
+    world.consumer = consumer
+}
+
+/// The steps of `bdd/scenarios/deferred_polling.feature` on the Swift SDK.
+let stepDefinitions: [StepDefinition] = [
+    StepDefinition(pattern: #"I have a running Iggy server"#) { world, _ in
+        let address = ProcessInfo.processInfo.environment["IGGY_TCP_ADDRESS"] ?? "127.0.0.1:8090"
+        let client = IggyClient(configuration: ClientConfiguration(address: address))
+        try await client.connect()
+        world.client = client
+    },
+    StepDefinition(pattern: #"I am authenticated as the root user"#) { world, _ in
+        let environment = ProcessInfo.processInfo.environment
+        _ = try await world.requireClient().login(
+            username: environment["IGGY_ROOT_USERNAME"] ?? "iggy", password: environment["IGGY_ROOT_PASSWORD"] ?? "iggy")
+    },
+    StepDefinition(pattern: #"a stream named "([^"]+)" with a topic named "([^"]+)" with (\d+) partitions exists"#) { world, values in
+        let client = try world.requireClient()
+        _ = try await client.createStream(name: values[0])
+        let stream = try Identifier(named: values[0])
+        _ = try await client.createTopic(streamID: stream, name: values[1], options: TopicCreateOptions(partitionsCount: try number(values[2])))
+        world.stream = stream
+        world.topic = try Identifier(named: values[1])
+    },
+    StepDefinition(pattern: #"I send a message with payload "([^"]+)" to partition (\d+)"#) { world, values in
+        try await send(world, payload: values[0], to: try number(values[1]))
+    },
+    StepDefinition(pattern: #"I start a deferred poll of partition (\d+) from offset (\d+) with a max wait of (\d+) seconds?"#) { world, values in
+        let (stream, topic) = try world.requireTopic()
+        let client = try world.requireClient()
+        let partition: UInt32 = try number(values[0])
+        let offset: UInt64 = try number(values[1])
+        let options = PollOptions(count: 10, maxWait: try seconds(values[2]))
+        world.pollStarted = .now
+        world.poll = Task {
+            try await client.pollMessages(streamID: stream, topicID: topic, partitionID: partition, strategy: .offset(offset), options: options)
+        }
+    },
+    StepDefinition(
+        pattern:
+            #"I start a deferred poll of partition (\d+) for consumer "([^"]+)" with the on read commit policy, a min count of (\d+) and a max wait of (\d+) seconds?"#
+    ) { world, values in
+        let (stream, topic) = try world.requireTopic()
+        let client = try world.requireClient()
+        let partition: UInt32 = try number(values[0])
+        let consumer = Consumer.consumer(try Identifier(named: values[1]))
+        let options = PollOptions(count: 10, maxWait: try seconds(values[3]), minCount: try number(values[2]), commit: .onRead)
+        world.pollStarted = .now
+        world.poll = Task {
+            try await client.pollMessages(streamID: stream, topicID: topic, partitionID: partition, consumer: consumer, strategy: .next, options: options)
+        }
+    },
+    StepDefinition(
+        pattern: #"I start a deferred poll of partition (\d+) for consumer "([^"]+)" with the on read commit policy and a max wait of (\d+) seconds?"#
+    ) { world, values in
+        let (stream, topic) = try world.requireTopic()
+        let client = try world.requireClient()
+        let partition: UInt32 = try number(values[0])
+        let consumer = Consumer.consumer(try Identifier(named: values[1]))
+        let options = PollOptions(count: 10, maxWait: try seconds(values[2]), commit: .onRead)
+        // A read that waits for nothing first opens the route and the
+        // connection, so the parked poll only writes its frame.
+        _ = try await client.pollMessages(
+            streamID: stream, topicID: topic, partitionID: partition, consumer: consumer, strategy: .offset(0), options: PollOptions(count: 1, maxWait: .zero))
+        let end = PollEnd()
+        world.pollEnd = end
+        world.pollStarted = .now
+        world.poll = Task {
+            defer { end.end() }
+            return try await client.pollMessages(
+                streamID: stream, topicID: topic, partitionID: partition, consumer: consumer, strategy: .next, options: options)
+        }
+    },
+    StepDefinition(pattern: #"the client disconnects before the deferred poll answers"#) { world, _ in
+        _ = try world.requirePoll()
+        try await Task.sleep(for: parkTime)
+        if let ended = world.pollEnd.ended {
+            throw StepFailure("the deferred poll ended \(ended - world.pollStarted) after it started, before the disconnect")
+        }
+        await (try world.requireClient()).disconnect()
+    },
+    StepDefinition(pattern: #"the deferred poll should fail as uncertain"#) { world, _ in
+        let poll = try world.requirePoll()
+        world.poll = nil
+        let error = try await iggyError("the deferred poll") { try await within(.seconds(5), "the deferred poll") { try await poll.value } }
+        let ended = world.pollEnd.ended.map { "\($0 - world.pollStarted)" } ?? "never"
+        try expect(error.code == .fetchProgressUncertain, "the deferred poll failed with \(error) \(ended) after it started")
+        guard let operation = error.operationID else {
+            throw StepFailure("the uncertain poll names no operation: \(error)")
+        }
+        world.lostOperation = operation
+    },
+    StepDefinition(pattern: #"the client connects again"#) { world, _ in
+        let client = try world.requireClient()
+        try await client.connect()
+        let environment = ProcessInfo.processInfo.environment
+        _ = try await client.login(username: environment["IGGY_ROOT_USERNAME"] ?? "iggy", password: environment["IGGY_ROOT_PASSWORD"] ?? "iggy")
+    },
+    StepDefinition(
+        pattern:
+            #"a poll of partition (\d+) for consumer "([^"]+)" with the next strategy and the on read commit policy should fail as blocked by the deferred poll"#
+    ) { world, values in
+        let partition: UInt32 = try number(values[0])
+        let error = try await iggyError("the poll") { try await poll(world, partition: partition, consumer: values[1], commit: .onRead) }
+        try expect(error.code == .partitionLaneBlocked, "the poll failed with \(error)")
+        try expect(error.operationID == world.lostOperation && error.partitionID == partition, "the poll was blocked by \(error)")
+    },
+    StepDefinition(pattern: #"I abandon the operation of the deferred poll"#) { world, _ in
+        guard let operation = world.lostOperation else {
+            throw StepFailure("no deferred poll failed as uncertain")
+        }
+        try world.requireClient().abandonOperation(operation)
+    },
+    StepDefinition(pattern: #"I cancel the deferred poll after (\d+) milliseconds"#) { world, values in
+        let poll = try world.requirePoll()
+        try await Task.sleep(for: .milliseconds(try number(values[0]) as Int64))
+        poll.cancel()
+        // The server answers a cancelled committing poll once, with nothing
+        // stored when the cancellation won.
+        let result = await poll.result
+        guard case .failure(let error) = result, error is CancellationError else {
+            throw StepFailure("the cancelled poll ended with \(result)")
+        }
+    },
+    StepDefinition(pattern: #"I wait (\d+) seconds?"#) { _, values in
+        try await Task.sleep(for: try seconds(values[0]))
+    },
+    StepDefinition(pattern: #"the deferred poll should return the payload "([^"]+)" within (\d+) seconds?"#) { world, values in
+        let poll = try world.requirePoll()
+        let polled = try await within(try seconds(values[1]), "the deferred poll") { try await poll.value }
+        try expect(polled.messages.map(\.payloadString) == [values[0]], "the deferred poll returned \(polled.messages.map(\.payloadString))")
+    },
+    StepDefinition(pattern: #"the deferred poll should return no messages between (\d+) and (\d+) seconds after it started"#) { world, values in
+        let polled = try await world.requirePoll().value
+        let elapsed = ContinuousClock.now - world.pollStarted
+        try expect(polled.messages.isEmpty, "the deferred poll returned \(polled.messages.map(\.payloadString))")
+        try expect(
+            elapsed >= (try seconds(values[0])) && elapsed <= (try seconds(values[1])), "the deferred poll returned after \(elapsed)")
+    },
+    StepDefinition(
+        pattern: #"a poll of partition (\d+) for consumer "([^"]+)" with the next strategy and the on read commit policy should return the payload "([^"]+)""#
+    ) { world, values in
+        let polled = try await poll(world, partition: try number(values[0]), consumer: values[1], commit: .onRead)
+        try expect(polled.messages.map(\.payloadString) == [values[2]], "the poll returned \(polled.messages.map(\.payloadString))")
+    },
+    StepDefinition(pattern: #"a poll of partition (\d+) for consumer "([^"]+)" with the next strategy should return the payload "([^"]+)""#) {
+        world, values in
+        let polled = try await poll(world, partition: try number(values[0]), consumer: values[1], commit: .manual)
+        try expect(polled.messages.map(\.payloadString) == [values[2]], "the poll returned \(polled.messages.map(\.payloadString))")
+    },
+    StepDefinition(pattern: #"I open a partition reader on partitions ([\d, and]+) from offset (\d+) with a max wait of (\d+) seconds?"#) { world, values in
+        let (stream, topic) = try world.requireTopic()
+        let partitions = values[0].split(whereSeparator: { !$0.isNumber }).compactMap { UInt32($0) }
+        let offset: UInt64 = try number(values[1])
+        world.reader = try await world.requireClient().reader(
+            streamID: stream, topicID: topic, consumer: .consumer(try Identifier(named: "bdd-reader")),
+            configuration: ReaderConfiguration(partitions: partitions, start: .offset(offset), options: .wait(try seconds(values[2]))))
+    },
+    StepDefinition(pattern: #"the partition reader should return the payload "([^"]+)" from partition (\d+) within (\d+) seconds?"#) {
+        world, values in
+        guard let reader = world.reader else {
+            throw StepFailure("no partition reader is open")
+        }
+        let batch = try await within(try seconds(values[2]), "the partition reader") { try await reader.next() }
+        let partition: UInt32 = try number(values[1])
+        try expect(batch?.partitionID == partition, "the reader returned partition \(String(describing: batch?.partitionID))")
+        try expect(batch?.messages.map(\.payloadString) == [values[0]], "the reader returned \(String(describing: batch?.messages.map(\.payloadString)))")
+    },
+    StepDefinition(pattern: #"I open a consumer in group "([^"]+)" on the topic with a max wait of (\d+) seconds?"#) { world, values in
+        try await openConsumer(world, group: values[0], options: .wait(try seconds(values[1])))
+    },
+    StepDefinition(pattern: #"I open a consumer in group "([^"]+)" on the topic that commits after processing"#) { world, values in
+        var options = PollOptions.wait(ReaderConfiguration.defaultMaxWait)
+        options.commit = .auto(AutoCommit(interval: .seconds(5), after: .processing))
+        try await openConsumer(world, group: values[0], options: options)
+    },
+    StepDefinition(pattern: #"the consumer should return the payload "([^"]+)" within (\d+) seconds?"#) { world, values in
+        let consumer = try world.requireConsumer()
+        let received = try await within(try seconds(values[1]), "the consumer") { try await consumer.next() }
+        try expect(received?.message.payloadString == values[0], "the consumer returned \(String(describing: received?.message.payloadString))")
+    },
+    StepDefinition(pattern: #"I close the consumer"#) { world, _ in
+        await (try world.requireConsumer()).shutdown()
+        world.consumer = nil
+    },
+]
diff --git a/bdd/swift/runner/Sources/main.swift b/bdd/swift/runner/Sources/main.swift
new file mode 100644
index 0000000..db31742
--- /dev/null
+++ b/bdd/swift/runner/Sources/main.swift
@@ -0,0 +1,92 @@
+// 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.
+
+import Foundation
+
+// Runs every scenario of the feature files in the directory of the first
+// argument, `/app/features` by default, or only `<BDD_FEATURE>.feature` when
+// BDD_FEATURE names one. A step without a definition fails its scenario.
+// Exits with 1 when any scenario failed.
+
+/// The definition whose pattern matches all of `text`, with its captures.
+func match(_ text: String) throws -> (StepDefinition, [String])? {
+    let range = NSRange(text.startIndex..., in: text)
+    for definition in stepDefinitions {
+        let expression = try NSRegularExpression(pattern: "^" + definition.pattern + "$")
+        guard let result = expression.firstMatch(in: text, range: range) else {
+            continue
+        }
+        let captures = (1..<result.numberOfRanges).compactMap { index in
+            Range(result.range(at: index), in: text).map { String(text[$0]) }
+        }
+        return (definition, captures)
+    }
+    return nil
+}
+
+func run(_ scenario: Scenario, of feature: Feature) async -> Bool {
+    let world = World()
+    defer { print() }
+    print("  Scenario: \(scenario.name)")
+    for step in feature.background + scenario.steps {
+        do {
+            guard let (definition, captures) = try match(step.text) else {
+                print("    ✘ \(step.keyword) \(step.text)\n      undefined step at \(feature.path):\(step.line)")
+                await world.tearDown()
+                return false
+            }
+            try await definition.run(world, captures)
+            print("    ✔ \(step.keyword) \(step.text)")
+        } catch {
+            print("    ✘ \(step.keyword) \(step.text)\n      \(error)")
+            await world.tearDown()
+            return false
+        }
+    }
+    await world.tearDown()
+    return true
+}
+
+let directory = CommandLine.arguments.dropFirst().first ?? "/app/features"
+let selected = ProcessInfo.processInfo.environment["BDD_FEATURE"].flatMap { $0 == "all" || $0.isEmpty ? nil : $0 }
+let paths = try FileManager.default.contentsOfDirectory(atPath: directory)
+    .filter { $0.hasSuffix(".feature") && (selected == nil || $0 == "\(selected ?? "").feature") }
+    .sorted()
+    .map { "\(directory)/\($0)" }
+guard !paths.isEmpty else {
+    print("No feature file to run in \(directory)")
+    exit(1)
+}
+
+var passed = 0
+var failed: [String] = []
+for path in paths {
+    let feature = try Gherkin.parse(try String(contentsOfFile: path, encoding: .utf8), path: path)
+    print("Feature: \(feature.name)\n")
+    for scenario in feature.scenarios {
+        if await run(scenario, of: feature) {
+            passed += 1
+        } else {
+            failed.append("\(path):\(scenario.line) \(scenario.name)")
+        }
+    }
+}
+print("\(passed + failed.count) scenarios: \(passed) passed, \(failed.count) failed")
+for scenario in failed {
+    print("  failed: \(scenario)")
+}
+exit(failed.isEmpty ? 0 : 1)
diff --git a/core/ai/mcp/Cargo.toml b/core/ai/mcp/Cargo.toml
index 03560c6..100f671 100644
--- a/core/ai/mcp/Cargo.toml
+++ b/core/ai/mcp/Cargo.toml
@@ -17,7 +17,7 @@
 
 [package]
 name = "iggy-mcp"
-version = "0.5.1-edge.4"
+version = "0.5.1-edge.5"
 description = "MCP Server for Iggy message streaming platform"
 edition = "2024"
 license = "Apache-2.0"
diff --git a/core/ai/mcp/README.md b/core/ai/mcp/README.md
index 613887f..3c1546c 100644
--- a/core/ai/mcp/README.md
+++ b/core/ai/mcp/README.md
@@ -118,6 +118,12 @@
 `{"operation_id": "123"}` and require at least one of the `create`, `update`, or
 `delete` MCP permissions. A process restart loses this local recovery state.
 
+A poll with auto-commit (`auto_commit = true` or `strategy = "next"`) stores the
+consumer offset, so a lost reply leaves it uncertain too. Its error carries
+`data.operation_id`, and later polls and offset writes of that partition fail
+until `abandon_operation` releases it. `resolve_operation` cannot prove such a
+poll and keeps it uncertain. After the abandon, the stored offset stays unknown.
+
 ## Systemd integration
 
 Build with the `systemd` feature to enable systemd readiness and watchdog notifications:
diff --git a/core/ai/mcp/src/service/mod.rs b/core/ai/mcp/src/service/mod.rs
index 5fbe234..fd088b3 100644
--- a/core/ai/mcp/src/service/mod.rs
+++ b/core/ai/mcp/src/service/mod.rs
@@ -16,12 +16,12 @@
 // under the License.
 
 use iggy::prelude::{
-    Client, ClusterClient, CompressionAlgorithm, Consumer, ConsumerGroupClient,
+    Client, ClusterClient, CommitPolicy, CompressionAlgorithm, Consumer, ConsumerGroupClient,
     ConsumerOffsetClient, Identifier, IggyClient, IggyError, IggyExpiry, IggyMessage,
     IggyTimestamp, MaxTopicSize, MessageClient, OptionKey, PartitionClient, Partitioning,
-    PersonalAccessTokenClient, PollingKind, PollingStrategy, SegmentClient, StreamClient,
-    StreamCreateOptions, StreamUpdateOptions, SystemClient, SystemSnapshotType, TopicClient,
-    TopicCreateOptions, TopicUpdateOptions, UserClient, UserCreateOptions, UserStatus,
+    PersonalAccessTokenClient, PollOptions, PollingKind, PollingStrategy, SegmentClient,
+    StreamClient, StreamCreateOptions, StreamUpdateOptions, SystemClient, SystemSnapshotType,
+    TopicClient, TopicCreateOptions, TopicUpdateOptions, UserClient, UserCreateOptions, UserStatus,
     UserUpdateOptions,
 };
 use requests::*;
@@ -43,6 +43,8 @@
 mod requests;
 
 const OPERATION_RECOVERY_TIMEOUT: Duration = Duration::from_secs(30);
+/// An omitted `partition_id` reads partition 0, as the server does for a standalone consumer.
+const DEFAULT_PARTITION_ID: u32 = 0;
 
 #[derive(Debug, Clone)]
 pub struct IggyService {
@@ -318,20 +320,26 @@
             PollingStrategy::offset(offset)
         };
         let auto_commit = auto_commit.unwrap_or(false) || strategy.kind == PollingKind::Next;
-        if auto_commit {
+        let commit = if auto_commit {
             self.permissions.ensure_update()?;
-        }
+            CommitPolicy::OnRead
+        } else {
+            CommitPolicy::Manual
+        };
 
         request(
             self.client
                 .poll_messages(
                     &id(&stream_id)?,
                     &id(&topic_id)?,
-                    partition_id,
+                    partition_id.unwrap_or(DEFAULT_PARTITION_ID),
                     &self.consumer,
                     &strategy,
-                    count,
-                    auto_commit,
+                    PollOptions {
+                        count,
+                        commit,
+                        ..PollOptions::default()
+                    },
                 )
                 .await,
         )
@@ -405,7 +413,7 @@
     }
 
     #[tool(
-        description = "Resolve a retained write using operation_id from an uncertain or blocked tool error. Success proves the original write committed and releases its lane; it does not return the original response body. An uncertain result keeps the operation available for another resolve. Requires write permission."
+        description = "Resolve a retained write using operation_id from an uncertain or blocked tool error. Success proves the original write committed and releases its lane; it does not return the original response body. An uncertain result keeps the operation available for another resolve. A committing poll (auto_commit=true or strategy=next) cannot be resolved and stays uncertain; use abandon_operation for it. Requires write permission."
     )]
     pub async fn resolve_operation(
         &self,
@@ -423,7 +431,7 @@
     }
 
     #[tool(
-        description = "Explicitly abandon a retained write using operation_id from an uncertain or blocked tool error. Releases local recovery state without undoing the write or establishing its outcome. Requires write permission."
+        description = "Explicitly abandon a retained write or a committing poll using operation_id from an uncertain or blocked tool error. A committing poll (auto_commit=true or strategy=next) with an unknown outcome, such as a lost reply, blocks later polls and offset writes of its partition until it is abandoned. Releases local recovery state without undoing the operation or establishing its outcome, so the stored offset of such a poll stays unknown. Requires write permission."
     )]
     pub async fn abandon_operation(
         &self,
diff --git a/core/ai/mcp/src/service/requests.rs b/core/ai/mcp/src/service/requests.rs
index 05eb380..a61ac4c 100644
--- a/core/ai/mcp/src/service/requests.rs
+++ b/core/ai/mcp/src/service/requests.rs
@@ -206,7 +206,9 @@
     #[schemars(description = "timestamp to start from (optional, microseconds from Unix Epoch)")]
     pub timestamp: Option<u64>,
 
-    #[schemars(description = "count (optional, must be greater than 0)")]
+    #[schemars(
+        description = "count (optional, must be greater than 0); one reply stops at 1 MiB of messages, so it can return fewer than count"
+    )]
     pub count: Option<u32>,
 
     #[schemars(description = "auto commit (optional, boolean)")]
diff --git a/core/bench/Cargo.toml b/core/bench/Cargo.toml
index 629f32a..7370975 100644
--- a/core/bench/Cargo.toml
+++ b/core/bench/Cargo.toml
@@ -17,7 +17,7 @@
 
 [package]
 name = "iggy-bench"
-version = "0.6.1-edge.4"
+version = "0.6.1-edge.5"
 edition = "2024"
 license = "Apache-2.0"
 repository = "https://github.com/apache/iggy"
diff --git a/core/bench/src/actors/consumer/benchmark_consumer.rs b/core/bench/src/actors/consumer/benchmark_consumer.rs
index 8e9884a..571aba1 100644
--- a/core/bench/src/actors/consumer/benchmark_consumer.rs
+++ b/core/bench/src/actors/consumer/benchmark_consumer.rs
@@ -483,7 +483,7 @@
                 messages_per_batch: args.messages_per_batch(),
                 warmup_time: args.warmup_time(),
                 polling_kind: PollingKind::Next,
-                poll_options: DeferredPollOptions::default(),
+                poll_options: args.poll_options(),
                 origin_timestamp_latency_calculation,
                 pretty: false,
             },
diff --git a/core/bench/src/actors/consumer/client/high_level.rs b/core/bench/src/actors/consumer/client/high_level.rs
index 4b08343..83c71ce 100644
--- a/core/bench/src/actors/consumer/client/high_level.rs
+++ b/core/bench/src/actors/consumer/client/high_level.rs
@@ -31,6 +31,7 @@
 use tracing::{error, warn};
 
 const TOPIC_ID: &str = "topic-1";
+const PARTITION_ID: u32 = 0;
 const PREFETCH_BATCHES: u32 = 16;
 
 pub struct HighLevelConsumerClient {
@@ -56,45 +57,35 @@
         }
     }
 
-    fn build_consumer(
-        &self,
-        client: &IggyClient,
-        group_polling_strategy: PollingStrategy,
-    ) -> Result<IggyConsumer, IggyError> {
+    fn build_consumer(&self, client: &IggyClient) -> Result<IggyConsumer, IggyError> {
         let stream_id_str = &self.config.stream_id;
+        let options = PollOptions {
+            count: self.config.messages_per_batch.get(),
+            ..self.config.poll_options
+        };
         let builder = if let Some(cg_id) = self.config.consumer_group_id {
             client
                 .consumer_group(&format!("cg_{cg_id}"), stream_id_str, TOPIC_ID)?
-                .polling_strategy(group_polling_strategy)
-                .auto_commit(AutoCommit::When(AutoCommitWhen::PollingMessages))
+                .polling_strategy(PollingStrategy::next())
+                .poll_options(PollOptions {
+                    commit: CommitPolicy::OnRead,
+                    ..options
+                })
                 .create_consumer_group_if_not_exists()
-                .auto_join_consumer_group()
         } else {
             client
                 .consumer(
                     &format!("hl_consumer_{}", self.config.consumer_id),
                     stream_id_str,
                     TOPIC_ID,
-                    0,
                 )?
+                .partitions([PARTITION_ID])
                 .polling_strategy(PollingStrategy::offset(0))
-                .auto_commit(AutoCommit::Disabled)
+                .poll_options(options)
         };
         Ok(builder
-            .batch_length(self.config.messages_per_batch.get())
-            .poll_options(self.config.poll_options)
-            .prefetch_messages(
-                self.config
-                    .messages_per_batch
-                    .get()
-                    .saturating_mul(PREFETCH_BATCHES),
-            )
-            .prefetch_bytes(
-                self.config
-                    .poll_options
-                    .max_bytes
-                    .saturating_mul(PREFETCH_BATCHES),
-            )
+            .prefetch_messages(options.count.saturating_mul(PREFETCH_BATCHES))
+            .prefetch_bytes(options.max_bytes.saturating_mul(PREFETCH_BATCHES))
             .build())
     }
 }
@@ -109,7 +100,7 @@
 
         // One poll can wait up to its request timeout, so a shorter timeout would restart the
         // batch clock in the middle of a deliberate wait.
-        let poll_timeout = self.config.poll_options.request_timeout.get_duration();
+        let poll_timeout = self.config.poll_options.request_timeout().get_duration();
         let batch_start = Instant::now();
         let mut batch_messages = 0;
         let mut batch_user_bytes = 0;
@@ -162,21 +153,31 @@
         }
     }
 
-    #[expect(
-        clippy::significant_drop_tightening,
-        reason = "The replacement must finish initialization before taking over the consumer slot"
-    )]
     async fn reset_offsets(&mut self) -> Result<(), IggyError> {
         let client = self.client.as_deref().expect("Client not initialized");
+        let consumer = self.consumer.as_mut().expect("Consumer not initialized");
+        let Some(topic) = client
+            .get_topic(consumer.stream(), consumer.topic())
+            .await?
+        else {
+            return Err(IggyError::TopicNameNotFound(
+                TOPIC_ID.to_owned(),
+                self.config.stream_id.clone(),
+            ));
+        };
 
-        let mut replacement = self.build_consumer(client, PollingStrategy::offset(0))?;
-
-        // Dropping the old consumer stops its polling. A bare drop does not leave the
-        // group, so the re-join below is the same member and keeps its partitions.
-        self.consumer = None;
-        replacement.init().await?;
-
-        self.consumer = Some(replacement);
+        // A replacement consumer would leave the group and join it again, and each change
+        // rebalances the members that already measure. The consumer exposes no assignment, so
+        // it seeks every partition, and `seek` refuses one that it does not read.
+        for partition in topic.partitions {
+            match consumer
+                .seek(partition.id, PollingStrategy::offset(0))
+                .await
+            {
+                Ok(()) | Err(IggyError::InvalidCommand) => {}
+                Err(error) => return Err(error),
+            }
+        }
         Ok(())
     }
 }
@@ -188,7 +189,7 @@
     )]
     async fn setup(&mut self) -> Result<(), IggyError> {
         let client = self.client_factory.create_authenticated_client().await?;
-        let mut consumer = self.build_consumer(&client, PollingStrategy::next())?;
+        let mut consumer = self.build_consumer(&client)?;
         consumer.init().await?;
         self.consumer = Some(consumer);
         self.client = Some(Box::new(client));
diff --git a/core/bench/src/actors/consumer/client/interface.rs b/core/bench/src/actors/consumer/client/interface.rs
index 6a0e446..95b29a1 100644
--- a/core/bench/src/actors/consumer/client/interface.rs
+++ b/core/bench/src/actors/consumer/client/interface.rs
@@ -28,7 +28,7 @@
     pub messages_per_batch: BenchmarkNumericParameter,
     pub warmup_time: IggyDuration,
     pub polling_kind: PollingKind,
-    pub poll_options: DeferredPollOptions,
+    pub poll_options: PollOptions,
     pub origin_timestamp_latency_calculation: bool,
     pub pretty: bool,
 }
diff --git a/core/bench/src/actors/consumer/client/low_level.rs b/core/bench/src/actors/consumer/client/low_level.rs
index b9a651c..648a654 100644
--- a/core/bench/src/actors/consumer/client/low_level.rs
+++ b/core/bench/src/actors/consumer/client/low_level.rs
@@ -22,11 +22,11 @@
 use crate::utils::ClientFactory;
 use crate::utils::{batch_total_size_bytes, batch_user_size_bytes};
 use iggy::prelude::*;
-use std::collections::HashMap;
-use std::future::ready;
 use std::sync::Arc;
 use std::time::Duration;
-use tokio::time::Instant;
+use tokio::time::{Instant, timeout};
+
+const PARTITION_ID: u32 = 0;
 
 pub struct LowLevelConsumerClient {
     client_factory: Arc<dyn ClientFactory>,
@@ -35,12 +35,11 @@
     consumer: Option<Consumer>,
     stream_id: Identifier,
     topic_id: Identifier,
-    partition_id: Option<u32>,
+    /// Where the next poll of a standalone consumer starts.
     polling_strategy: PollingStrategy,
-    auto_commit: bool,
-    /// Where offset polling continues in each partition. A group member polls its partitions
-    /// round-robin, so one shared cursor would skip the start of every partition but the first.
-    next_offsets: HashMap<u32, u64>,
+    commit: CommitPolicy,
+    /// A group member owns no fixed partition, so only a reader follows its assignment.
+    reader: Option<PartitionReader>,
 }
 
 impl LowLevelConsumerClient {
@@ -52,52 +51,50 @@
             consumer: None,
             stream_id: Identifier::default(),
             topic_id: Identifier::default(),
-            partition_id: None,
             polling_strategy: PollingStrategy::next(),
-            auto_commit: true,
-            next_offsets: HashMap::new(),
+            commit: CommitPolicy::OnRead,
+            reader: None,
         }
     }
 }
 
 impl ConsumerClient for LowLevelConsumerClient {
     async fn consume_batch(&mut self) -> Result<Option<BatchMetrics>, IggyError> {
-        let client = self.client.as_ref().expect("client not initialized");
-        let consumer = self.consumer.as_ref().expect("consumer not initialized");
-        let messages_to_receive = self.config.messages_per_batch.get();
-
-        let polling_strategy = self.polling_strategy;
-        let next_offsets = &self.next_offsets;
-        let strategy_for = |partition_id: u32| {
-            next_offsets
-                .get(&partition_id)
-                .map_or(polling_strategy, |offset| PollingStrategy::offset(*offset))
-        };
         let before_poll = Instant::now();
-        let polled = client
-            .poll_messages_with_strategy_for_and_options(
-                &self.stream_id,
-                &self.topic_id,
-                self.partition_id,
-                consumer,
-                &strategy_for,
-                messages_to_receive,
-                self.auto_commit,
-                Some(self.config.poll_options),
-            )
-            .await;
+        let polled = if let Some(reader) = self.reader.as_mut() {
+            // `next()` has no time limit, so a member whose partitions are drained would hold up
+            // the end of the run, which waits for every actor.
+            let poll_timeout = self.config.poll_options.request_timeout().get_duration();
+            match timeout(poll_timeout, reader.next()).await {
+                Ok(polled) => polled.map_err(IggyError::from),
+                Err(_) => return Ok(None),
+            }
+        } else {
+            let client = self.client.as_ref().expect("client not initialized");
+            let consumer = self.consumer.as_ref().expect("consumer not initialized");
+            let options = PollOptions {
+                count: self.config.messages_per_batch.get(),
+                commit: self.commit,
+                ..self.config.poll_options
+            };
+            client
+                .poll_messages(
+                    &self.stream_id,
+                    &self.topic_id,
+                    PARTITION_ID,
+                    consumer,
+                    &self.polling_strategy,
+                    options,
+                )
+                .await
+        };
 
         let polled = match polled {
-            Ok(p) => p,
-            Err(e) => {
-                if matches!(
-                    e,
-                    IggyError::TopicIdNotFound(_, _) | IggyError::TransientNotAccepted
-                ) {
-                    return Ok(None);
-                }
-                return Err(e);
+            Ok(polled) => polled,
+            Err(IggyError::TopicIdNotFound(_, _) | IggyError::TransientNotAccepted) => {
+                return Ok(None);
             }
+            Err(error) => return Err(error),
         };
 
         if polled.messages.is_empty() {
@@ -114,11 +111,8 @@
         let user_bytes = batch_user_size_bytes(&polled);
         let total_bytes = batch_total_size_bytes(&polled);
 
-        if self.polling_strategy.kind == PollingKind::Offset
-            && let Some(last) = polled.messages.last()
-        {
-            self.next_offsets
-                .insert(polled.partition_id, last.header.offset + 1);
+        if self.reader.is_none() && self.polling_strategy.kind == PollingKind::Offset {
+            self.polling_strategy = polled.continuation();
         }
 
         Ok(Some(BatchMetrics {
@@ -129,27 +123,26 @@
         }))
     }
 
-    fn reset_offsets(&mut self) -> impl Future<Output = Result<(), IggyError>> {
-        self.next_offsets.clear();
+    async fn reset_offsets(&mut self) -> Result<(), IggyError> {
         self.polling_strategy = PollingStrategy::offset(0);
-        ready(Ok(()))
+        if let Some(reader) = self.reader.as_mut() {
+            // A partition assigned after this starts at the start of the reader instead.
+            for partition in reader.partitions() {
+                reader.seek(partition, PollingStrategy::offset(0)).await?;
+            }
+        }
+        Ok(())
     }
 }
 
 impl BenchmarkInit for LowLevelConsumerClient {
     async fn setup(&mut self) -> Result<(), IggyError> {
         let topic_id_str = "topic-1";
-        let default_partition_id = 0u32;
 
         let client = self.client_factory.create_authenticated_client().await?;
 
         let stream_id: Identifier = self.config.stream_id.as_str().try_into().unwrap();
         let topic_id = topic_id_str.try_into().unwrap();
-        let partition_id = if self.config.consumer_group_id.is_some() {
-            None
-        } else {
-            Some(default_partition_id)
-        };
 
         let consumer = create_consumer(
             &client,
@@ -159,20 +152,39 @@
             self.config.consumer_id,
         )
         .await;
-        let (polling_strategy, auto_commit) = match self.config.polling_kind {
-            PollingKind::Offset => (PollingStrategy::offset(0), false),
-            PollingKind::Next => (PollingStrategy::next(), true),
+        let (polling_strategy, commit) = match self.config.polling_kind {
+            PollingKind::Offset => (PollingStrategy::offset(0), CommitPolicy::Manual),
+            PollingKind::Next => (PollingStrategy::next(), CommitPolicy::OnRead),
             _ => panic!("Unsupported polling kind: {:?}", self.config.polling_kind),
         };
+        if self.config.consumer_group_id.is_some() {
+            // A reader asks for one count, so a range of batch sizes reads up to the largest.
+            let options = PollOptions {
+                count: self.config.messages_per_batch.max(),
+                commit,
+                ..self.config.poll_options
+            };
+            // Each timed `next()` measures one fetch round trip, so the reader holds one request
+            // and no prefetch queue.
+            self.reader = Some(
+                client
+                    .reader(&stream_id, &topic_id, &consumer)
+                    .start(polling_strategy)
+                    .options(options)
+                    .prefetch_messages(options.count)
+                    .prefetch_bytes(options.max_bytes)
+                    .build()
+                    .await?,
+            );
+        }
 
         self.client = Some(client);
         self.consumer = Some(consumer);
 
         self.stream_id = stream_id;
         self.topic_id = topic_id;
-        self.partition_id = partition_id;
         self.polling_strategy = polling_strategy;
-        self.auto_commit = auto_commit;
+        self.commit = commit;
 
         Ok(())
     }
@@ -186,35 +198,48 @@
 
 #[cfg(test)]
 mod tests {
-    use super::{BenchmarkConsumerConfig, ConsumerClient, LowLevelConsumerClient};
+    use super::{BenchmarkConsumerConfig, ConsumerClient, LowLevelConsumerClient, PARTITION_ID};
     use crate::utils::client_factory::TcpClientFactory;
     use bench_report::numeric_parameter::BenchmarkNumericParameter;
     use iggy::prelude::{
-        ClientWrapper, Consumer, DeferredPollOptions, Identifier, IggyClient, IggyDuration,
-        PollingStrategy, TcpClient, TcpClientConfig,
+        ClientWrapper, CommitPolicy, Consumer, Identifier, IggyClient, IggyDuration,
+        PartitionContext, PollOptions, PollingKind, PollingStrategy, TcpClient, TcpClientConfig,
     };
-    use std::{collections::HashMap, sync::Arc};
+    use std::sync::Arc;
 
-    const GROUP_ID: u32 = 1;
-    const WARMUP_OFFSETS: [(u32, u64); 2] = [(0, 12), (1, 34)];
+    const CONSUMER_ID: u32 = 1;
+    const WARMUP_OFFSET: u64 = 12;
+    const WARMUP_HISTORY: PartitionContext = PartitionContext {
+        incarnation: 1,
+        owner_generation: 0,
+        metadata_op: 1,
+    };
 
     #[tokio::test]
-    async fn given_disconnected_group_member_when_resetting_should_rewind_without_offset_requests()
-    {
-        for (polling_strategy, auto_commit) in [
-            (PollingStrategy::next(), true),
-            (PollingStrategy::offset(0), false),
+    async fn given_disconnected_standalone_consumer_when_resetting_should_rewind_without_offset_requests()
+     {
+        for (polling_strategy, commit) in [
+            (PollingStrategy::next(), CommitPolicy::OnRead),
+            (
+                PollingStrategy::continuation(
+                    PARTITION_ID,
+                    PollingKind::Offset,
+                    WARMUP_OFFSET,
+                    WARMUP_HISTORY,
+                ),
+                CommitPolicy::Manual,
+            ),
         ] {
             let mut consumer = LowLevelConsumerClient::new(
                 Arc::new(TcpClientFactory::default()),
                 BenchmarkConsumerConfig {
-                    consumer_id: 1,
-                    consumer_group_id: Some(GROUP_ID),
+                    consumer_id: CONSUMER_ID,
+                    consumer_group_id: None,
                     stream_id: "bench-stream-1".to_owned(),
                     messages_per_batch: BenchmarkNumericParameter::Value(1),
                     warmup_time: IggyDuration::default(),
                     polling_kind: polling_strategy.kind,
-                    poll_options: DeferredPollOptions::default(),
+                    poll_options: PollOptions::default(),
                     origin_timestamp_latency_calculation: false,
                     pretty: false,
                 },
@@ -222,18 +247,14 @@
             consumer.client = Some(IggyClient::new(ClientWrapper::Tcp(
                 TcpClient::create(Arc::new(TcpClientConfig::default())).unwrap(),
             )));
-            consumer.consumer = Some(Consumer::group(Identifier::numeric(GROUP_ID).unwrap()));
+            consumer.consumer = Some(Consumer::new(Identifier::numeric(CONSUMER_ID).unwrap()));
             consumer.polling_strategy = polling_strategy;
-            consumer.auto_commit = auto_commit;
-            if !auto_commit {
-                consumer.next_offsets = HashMap::from(WARMUP_OFFSETS);
-            }
+            consumer.commit = commit;
 
             consumer.reset_offsets().await.unwrap();
 
             assert_eq!(consumer.polling_strategy, PollingStrategy::offset(0));
-            assert!(consumer.next_offsets.is_empty());
-            assert_eq!(consumer.auto_commit, auto_commit);
+            assert_eq!(consumer.commit, commit);
         }
     }
 }
diff --git a/core/bench/src/actors/consumer/typed_benchmark_consumer.rs b/core/bench/src/actors/consumer/typed_benchmark_consumer.rs
index dc16eb3..6bbc890 100644
--- a/core/bench/src/actors/consumer/typed_benchmark_consumer.rs
+++ b/core/bench/src/actors/consumer/typed_benchmark_consumer.rs
@@ -54,7 +54,7 @@
         sampling_time: IggyDuration,
         moving_average_window: u32,
         polling_kind: PollingKind,
-        poll_options: DeferredPollOptions,
+        poll_options: PollOptions,
         limit_bytes_per_second: Option<IggyByteSize>,
         origin_timestamp_latency_calculation: bool,
         pretty: bool,
diff --git a/core/bench/src/actors/producing_consumer/typed_benchmark_producing_consumer.rs b/core/bench/src/actors/producing_consumer/typed_benchmark_producing_consumer.rs
index 93bacc8..f242297 100644
--- a/core/bench/src/actors/producing_consumer/typed_benchmark_producing_consumer.rs
+++ b/core/bench/src/actors/producing_consumer/typed_benchmark_producing_consumer.rs
@@ -62,7 +62,7 @@
         moving_average_window: u32,
         limit_bytes_per_second: Option<IggyByteSize>,
         polling_kind: PollingKind,
-        poll_options: DeferredPollOptions,
+        poll_options: PollOptions,
         origin_timestamp_latency_calculation: bool,
         pretty: bool,
     ) -> Self {
diff --git a/core/bench/src/args/common.rs b/core/bench/src/args/common.rs
index 0af82cd..01594c3 100644
--- a/core/bench/src/args/common.rs
+++ b/core/bench/src/args/common.rs
@@ -30,13 +30,12 @@
 use clap::error::ErrorKind;
 use clap::{CommandFactory, Parser};
 use iggy::prelude::{
-    DEFAULT_POLL_MAX_BYTES, DEFAULT_ROOT_PASSWORD, DEFAULT_ROOT_USERNAME, DeferredPollOptions,
-    IggyByteSize, IggyDuration, IggyError, IggyExpiry, TransportProtocol,
+    DEFAULT_POLL_MAX_BYTES, DEFAULT_ROOT_PASSWORD, DEFAULT_ROOT_USERNAME, IggyByteSize,
+    IggyDuration, IggyError, IggyExpiry, PollOptions, TransportProtocol,
 };
 use iggy_binary_protocol::batch::{BATCH_HEADER_SIZE, BATCH_MESSAGE_HEADER_SIZE};
 use iggy_binary_protocol::requests::messages::{
-    MAX_DEFERRED_POLL_TIMEOUT_US, MAX_DEFERRED_POLL_WAIT_US, MIN_DEFERRED_POLL_MAX_BYTES,
-    MIN_DEFERRED_POLL_REPLY_MARGIN_US,
+    MAX_DEFERRED_POLL_WAIT_US, MIN_DEFERRED_POLL_MAX_BYTES,
 };
 use iggy_binary_protocol::responses::messages::poll_messages::POLL_RESPONSE_HEADER_SIZE;
 use std::num::NonZeroU32;
@@ -72,10 +71,6 @@
     #[arg(long)]
     pub max_bytes: Option<u32>,
 
-    /// Overall budget for routing, readiness, I/O and response handling.
-    #[arg(long, default_value_t = DeferredPollOptions::default().request_timeout)]
-    pub request_timeout: IggyDuration,
-
     /// Number of message batches per actor (producer / consumer / producing consumer).
     /// This argument is mutually exclusive with `total_messages_size`.
     #[arg(long, short = 'b', group = "data_to_process")]
@@ -142,14 +137,17 @@
 }
 
 impl IggyBenchArgs {
-    pub fn poll_options(&self) -> DeferredPollOptions {
-        DeferredPollOptions {
+    /// Each actor sets its own `count` and `commit`. The `count` here is the smallest
+    /// batch, which `--min-count` must fit.
+    pub fn poll_options(&self) -> PollOptions {
+        PollOptions {
+            count: self.messages_per_batch.min(),
             max_wait: self.max_wait,
             min_count: self.min_count,
             max_bytes: self
                 .max_bytes
                 .unwrap_or_else(|| self.batch_reply_bytes().max(DEFAULT_POLL_MAX_BYTES)),
-            request_timeout: self.request_timeout,
+            ..PollOptions::default()
         }
     }
 
@@ -173,12 +171,9 @@
                 self.messages_per_batch.min()
             ),
             IggyError::InvalidCommand => format!(
-                "--max-wait {} must be at most {}, and --request-timeout {} must be at least --max-wait plus {} and at most {}",
+                "--max-wait {} must be at most {}",
                 self.max_wait,
-                duration(MAX_DEFERRED_POLL_WAIT_US),
-                self.request_timeout,
-                duration(MIN_DEFERRED_POLL_REPLY_MARGIN_US),
-                duration(MAX_DEFERRED_POLL_TIMEOUT_US)
+                duration(MAX_DEFERRED_POLL_WAIT_US)
             ),
             IggyError::InvalidSizeBytes => format!(
                 "--max-bytes {} must be at least {MIN_DEFERRED_POLL_MAX_BYTES}",
@@ -196,6 +191,12 @@
         )
     }
 
+    /// The request timeout that the SDK derives from `--max-wait`. Result names keep it, so
+    /// they stay comparable with older results.
+    fn request_timeout(&self) -> IggyDuration {
+        self.poll_options().request_timeout()
+    }
+
     pub fn transport_command(&self) -> &BenchmarkTransportCommand {
         self.benchmark_kind.transport_command()
     }
@@ -216,7 +217,7 @@
     }
 
     pub fn validate(&mut self) {
-        if let Err(error) = self.poll_options().validate(self.messages_per_batch.min()) {
+        if let Err(error) = self.poll_options().validate() {
             Self::command()
                 .error(ErrorKind::ValueValidation, self.poll_options_error(&error))
                 .exit();
@@ -508,7 +509,7 @@
                 self.max_wait,
                 self.min_count,
                 self.poll_options().max_bytes,
-                self.request_timeout
+                self.request_timeout()
             ));
         }
 
@@ -569,7 +570,7 @@
                 self.max_wait,
                 self.min_count,
                 self.poll_options().max_bytes,
-                self.request_timeout
+                self.request_timeout()
             );
         }
 
@@ -598,10 +599,7 @@
 
     fn poll_error(arguments: &[&str]) -> String {
         let args = parse(arguments);
-        let error = args
-            .poll_options()
-            .validate(args.messages_per_batch.min())
-            .unwrap_err();
+        let error = args.poll_options().validate().unwrap_err();
         args.poll_options_error(&error)
     }
 
@@ -621,9 +619,9 @@
     #[test]
     fn poll_option_errors_should_name_the_flag() {
         assert!(poll_error(&["-P", "10", "--min-count", "11"]).starts_with("--min-count 11 "));
-        assert!(
-            poll_error(&["--max-wait", "5s", "--request-timeout", "5s"])
-                .contains("--request-timeout 5s must be at least --max-wait plus 1s")
+        assert_eq!(
+            poll_error(&["--max-wait", "11m"]),
+            "--max-wait 11m must be at most 10m"
         );
         assert!(poll_error(&["--max-bytes", "1"]).starts_with("--max-bytes 1 "));
     }
@@ -636,10 +634,12 @@
 
         let consumer = parse(&["-b", "10"]);
         assert!(consumer.generate_dir_name().contains("_wait1s_min1_"));
+        assert!(consumer.generate_dir_name().contains("_timeout11s"));
         assert!(
             consumer
                 .generate_pretty_name()
                 .contains(", wait 1s, min 1,")
         );
+        assert!(consumer.generate_pretty_name().contains(", timeout 11s"));
     }
 }
diff --git a/core/bench/src/utils/mod.rs b/core/bench/src/utils/mod.rs
index 4fca179..725b20e 100644
--- a/core/bench/src/utils/mod.rs
+++ b/core/bench/src/utils/mod.rs
@@ -249,7 +249,7 @@
         parts.push(format!("--warmup-time \'{}\'", args.warmup_time()));
     }
 
-    let poll_defaults = DeferredPollOptions::default();
+    let poll_defaults = PollOptions::wait(IggyDuration::ONE_SECOND);
     if args.max_wait != poll_defaults.max_wait {
         parts.push(format!("--max-wait \'{}\'", args.max_wait));
     }
@@ -259,9 +259,6 @@
     if let Some(max_bytes) = args.max_bytes {
         parts.push(format!("--max-bytes {max_bytes}"));
     }
-    if args.request_timeout != poll_defaults.request_timeout {
-        parts.push(format!("--request-timeout \'{}\'", args.request_timeout));
-    }
 }
 
 fn add_benchmark_kind_arguments(parts: &mut Vec<String>, args: &IggyBenchArgs) {
@@ -442,8 +439,6 @@
             "100",
             "--max-bytes",
             "65536",
-            "--request-timeout",
-            "2m",
             "pinned-consumer",
             "tcp",
         ])
diff --git a/core/binary_protocol/Cargo.toml b/core/binary_protocol/Cargo.toml
index 316e5e3..40a64f4 100644
--- a/core/binary_protocol/Cargo.toml
+++ b/core/binary_protocol/Cargo.toml
@@ -17,7 +17,7 @@
 
 [package]
 name = "iggy_binary_protocol"
-version = "0.11.1-edge.4"
+version = "0.11.1-edge.5"
 description = "Wire protocol types and codec for the Iggy binary protocol. Shared between server and SDK."
 edition = "2024"
 rust-version.workspace = true
diff --git a/core/binary_protocol/src/codes.rs b/core/binary_protocol/src/codes.rs
index d2ff3ac..5d474a0 100644
--- a/core/binary_protocol/src/codes.rs
+++ b/core/binary_protocol/src/codes.rs
@@ -62,13 +62,15 @@
 /// of that view. Sends, polls and consumer offsets all route with it.
 pub const GET_PARTITION_ROUTE_CODE: u32 = 105;
 pub const POLL_MESSAGES_DEFERRED_CODE: u32 = 106;
-pub const WAIT_FOR_MESSAGES_CODE: u32 = 107;
+// 107 was WAIT_FOR_MESSAGES. Never reuse it: older clients still send it.
 /// Cancels a deferred request on the same authenticated connection.
 ///
 /// The body is empty, `request` identifies the original request, and no reply
 /// is sent. A connection that is not authenticated gets an `Unauthenticated`
 /// deny instead.
 pub const CANCEL_DEFERRED_REQUEST_CODE: u32 = 108;
+/// Waits on many partitions of one topic and reads them into one reply.
+pub const FETCH_MESSAGES_CODE: u32 = 109;
 
 // -- Consumer Offsets --
 pub const GET_CONSUMER_OFFSET_CODE: u32 = 120;
@@ -122,7 +124,7 @@
 mod tests {
     use super::*;
 
-    const RETIRED_CODES: &[u32] = &[14, 35, 102, 103, 104, 123, 124, 204, 205, 304, 305];
+    const RETIRED_CODES: &[u32] = &[14, 35, 102, 103, 104, 107, 123, 124, 204, 205, 304, 305];
 
     const ALL_CODES: &[u32] = &[
         PING_CODE,
@@ -153,8 +155,8 @@
         POLL_MESSAGES_CODE,
         GET_PARTITION_ROUTE_CODE,
         POLL_MESSAGES_DEFERRED_CODE,
-        WAIT_FOR_MESSAGES_CODE,
         CANCEL_DEFERRED_REQUEST_CODE,
+        FETCH_MESSAGES_CODE,
         SEND_MESSAGES_CODE,
         GET_CONSUMER_OFFSET_CODE,
         STORE_CONSUMER_OFFSET_CODE,
diff --git a/core/binary_protocol/src/consensus/request.rs b/core/binary_protocol/src/consensus/request.rs
index b6576db..0069fba 100644
--- a/core/binary_protocol/src/consensus/request.rs
+++ b/core/binary_protocol/src/consensus/request.rs
@@ -16,8 +16,8 @@
 // under the License.
 
 use crate::codes::{
-    CANCEL_DEFERRED_REQUEST_CODE, LOGIN_REGISTER_CODE, LOGIN_REGISTER_WITH_PAT_CODE,
-    LOGOUT_USER_CODE, POLL_MESSAGES_DEFERRED_CODE, WAIT_FOR_MESSAGES_CODE,
+    CANCEL_DEFERRED_REQUEST_CODE, FETCH_MESSAGES_CODE, LOGIN_REGISTER_CODE,
+    LOGIN_REGISTER_WITH_PAT_CODE, LOGOUT_USER_CODE, POLL_MESSAGES_DEFERRED_CODE,
 };
 use crate::consensus::{Command, HEADER_SIZE, Operation, RequestHeader};
 use crate::error::WireError;
@@ -53,7 +53,7 @@
         if self.operation != Operation::NonReplicated
             || !matches!(
                 u32::from_le_bytes(self.reserved),
-                POLL_MESSAGES_DEFERRED_CODE | WAIT_FOR_MESSAGES_CODE
+                POLL_MESSAGES_DEFERRED_CODE | FETCH_MESSAGES_CODE
             )
         {
             return None;
@@ -175,6 +175,25 @@
     }
 
     #[test]
+    fn only_deferred_requests_get_a_cancellation() {
+        for code in [POLL_MESSAGES_DEFERRED_CODE, FETCH_MESSAGES_CODE] {
+            let request = RequestHeader::for_request(code, 1, 2, 3, &[7; 8]).unwrap();
+            let cancel = request.deferred_cancellation().unwrap();
+            assert_eq!(cancel.operation, Operation::NonReplicated);
+            assert_eq!(
+                cancel.reserved[NON_REPLICATED_CODE_RANGE],
+                CANCEL_DEFERRED_REQUEST_CODE.to_le_bytes()
+            );
+            assert_eq!((cancel.client, cancel.request, cancel.session), (1, 2, 3));
+            assert_eq!(cancel.size as usize, HEADER_SIZE);
+        }
+        for code in [PING_CODE, CANCEL_DEFERRED_REQUEST_CODE, SEND_MESSAGES_CODE] {
+            let request = RequestHeader::for_request(code, 1, 2, 3, &[]).unwrap();
+            assert!(request.deferred_cancellation().is_none(), "code {code}");
+        }
+    }
+
+    #[test]
     fn partition_and_non_replicated_requests_leave_the_stamp_zero() {
         let send = RequestHeader::for_request(SEND_MESSAGES_CODE, 1, 2, 3, &[9; 16]).unwrap();
         assert_eq!(send.request_checksum, 0);
diff --git a/core/binary_protocol/src/dispatch.rs b/core/binary_protocol/src/dispatch.rs
index cece30c..733519c 100644
--- a/core/binary_protocol/src/dispatch.rs
+++ b/core/binary_protocol/src/dispatch.rs
@@ -191,17 +191,27 @@
     CommandMeta::non_replicated(GET_METADATA_CHANGES_CODE, "metadata.changes"),
     // Deferred messages
     CommandMeta::non_replicated(POLL_MESSAGES_DEFERRED_CODE, "message.poll_deferred"),
-    CommandMeta::non_replicated(WAIT_FOR_MESSAGES_CODE, "message.wait"),
     CommandMeta::non_replicated(CANCEL_DEFERRED_REQUEST_CODE, "message.cancel_deferred"),
+    CommandMeta::non_replicated(FETCH_MESSAGES_CODE, "message.fetch"),
 ];
 
 /// Lookup command metadata by command code.
 ///
-/// Uses a `match` (compiled to a jump table / binary search) for O(1) lookup
-/// instead of linear scan. The match maps code -> table index, keeping
-/// `COMMAND_TABLE` as the single source of truth for all metadata.
+/// Uses the `match` of [`command_index`] (compiled to a jump table / binary
+/// search) for O(1) lookup instead of linear scan, keeping `COMMAND_TABLE`
+/// as the single source of truth for all metadata.
 #[must_use]
 pub const fn lookup_command(code: u32) -> Option<&'static CommandMeta> {
+    match command_index(code) {
+        Some(idx) => Some(&COMMAND_TABLE[idx]),
+        None => None,
+    }
+}
+
+/// The index of command `code` in `COMMAND_TABLE`, for per-command arrays in
+/// table order.
+#[must_use]
+pub const fn command_index(code: u32) -> Option<usize> {
     // Indices must match the order of entries in COMMAND_TABLE above.
     let idx = match code {
         PING_CODE => 0,
@@ -254,11 +264,11 @@
         UPDATE_RESOURCE_CODE => 47,
         GET_METADATA_CHANGES_CODE => 48,
         POLL_MESSAGES_DEFERRED_CODE => 49,
-        WAIT_FOR_MESSAGES_CODE => 50,
-        CANCEL_DEFERRED_REQUEST_CODE => 51,
+        CANCEL_DEFERRED_REQUEST_CODE => 50,
+        FETCH_MESSAGES_CODE => 51,
         _ => return None,
     };
-    Some(&COMMAND_TABLE[idx])
+    Some(idx)
 }
 
 /// Lookup command metadata by VSR operation.
@@ -362,12 +372,13 @@
         }
     }
 
-    /// Verify that the match indices in `lookup_command` point to entries
+    /// Verify that the match indices in `command_index` point to entries
     /// whose `.code` field actually matches the looked-up code.
     /// Catches table reordering that would silently break the match.
     #[test]
     fn lookup_command_indices_match_table_codes() {
         for (i, entry) in COMMAND_TABLE.iter().enumerate() {
+            assert_eq!(command_index(entry.code), Some(i), "{}", entry.name);
             let looked_up = lookup_command(entry.code)
                 .unwrap_or_else(|| panic!("lookup_command({}) returned None", entry.code));
             assert_eq!(
diff --git a/core/binary_protocol/src/lib.rs b/core/binary_protocol/src/lib.rs
index 7f0fadc..e8f31d8 100644
--- a/core/binary_protocol/src/lib.rs
+++ b/core/binary_protocol/src/lib.rs
@@ -85,7 +85,9 @@
     prepare_identity_checksum_bytes, read_size_field, result_code, result_section_len,
     write_rejection_section,
 };
-pub use dispatch::{COMMAND_TABLE, CommandMeta, lookup_by_operation, lookup_command};
+pub use dispatch::{
+    COMMAND_TABLE, CommandMeta, command_index, lookup_by_operation, lookup_command,
+};
 pub use error::WireError;
 pub use framing::{RequestFrame, ResponseFrame, STATUS_OK};
 pub use primitives::ack_level::AckLevel;
@@ -112,3 +114,19 @@
 
 /// Maximum number of partitions allowed in a single create/delete request.
 pub const MAX_PARTITIONS_PER_REQUEST: u32 = 1000;
+/// The largest payload of one message.
+pub const MAX_MESSAGE_PAYLOAD_SIZE: usize = 64 * 1000 * 1000;
+/// The largest user headers of one message.
+pub const MAX_MESSAGE_USER_HEADERS_SIZE: usize = 100 * 1000;
+/// The largest record of one message in a read reply, with its batch and message headers. A
+/// read returns a first record up to this size even above its `max_bytes`, so every client
+/// reply cap holds it.
+pub const MAX_RECORD_SIZE: usize = batch::BATCH_HEADER_SIZE
+    + batch::BATCH_MESSAGE_HEADER_SIZE
+    + MAX_MESSAGE_PAYLOAD_SIZE
+    + MAX_MESSAGE_USER_HEADERS_SIZE;
+/// The largest frame of a read reply that holds one record of [`MAX_RECORD_SIZE`]: the frame
+/// header, the largest fetch table and the record. A poll reply has a smaller head.
+pub const MAX_RECORD_REPLY_SIZE: usize = consensus::HEADER_SIZE
+    + responses::messages::FetchMessagesResponse::head_size(MAX_PARTITIONS_PER_REQUEST as usize)
+    + MAX_RECORD_SIZE;
diff --git a/core/binary_protocol/src/requests/messages/fetch_messages.rs b/core/binary_protocol/src/requests/messages/fetch_messages.rs
new file mode 100644
index 0000000..54bd5aa
--- /dev/null
+++ b/core/binary_protocol/src/requests/messages/fetch_messages.rs
@@ -0,0 +1,615 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use crate::codec::{WireDecode, WireEncode, read_u8, read_u32_le, read_u64_le};
+use crate::primitives::consumer::WireConsumer;
+use crate::primitives::polling_strategy::WirePollingStrategy;
+use crate::requests::messages::poll_messages::{
+    MAX_DEFERRED_POLL_TIMEOUT_US, MAX_DEFERRED_POLL_WAIT_US, MIN_DEFERRED_POLL_MAX_BYTES,
+    MIN_DEFERRED_POLL_REPLY_MARGIN_US, STRATEGY_SIZE,
+};
+use crate::responses::messages::fetch_messages::FetchMessagesResponse;
+use crate::{MAX_PARTITIONS_PER_REQUEST, WireError, WireIdentifier};
+use bytes::{BufMut, BytesMut};
+use std::collections::HashSet;
+
+/// Offsets in the fields between the topic and the targets: the wait, the
+/// request timeout, `min_count`, `count`, `max_bytes`, `auto_commit` and the
+/// count of targets.
+const MAX_BYTES_OFFSET: usize = 2 * size_of::<u64>() + 2 * size_of::<u32>();
+const TARGETS_COUNT_OFFSET: usize = MAX_BYTES_OFFSET + size_of::<u32>() + size_of::<u8>();
+const LIMITS_SIZE: usize = TARGETS_COUNT_OFFSET + size_of::<u32>();
+const TARGET_SIZE: usize = 2 * size_of::<u32>() + STRATEGY_SIZE + 2 * size_of::<u64>();
+/// A split `count` would loop or misread the other start points, so the client
+/// resolves them with a deferred poll first.
+const FETCH_STRATEGY_KINDS: [u8; 2] = [
+    WirePollingStrategy::offset(0).kind,
+    WirePollingStrategy::next().kind,
+];
+
+/// Waits until the targets hold `min_count` messages, then reads them and
+/// answers with one section per read target.
+///
+/// Wire format:
+/// ```text
+/// [consumer][stream_id][topic_id][wait_us:8 LE][request_timeout_us:8 LE]
+/// [min_count:4 LE][count:4 LE][max_bytes:4 LE][auto_commit:1]
+/// [targets_count:4 LE] targets_count x
+///   [partition_id:4 LE][strategy:9][max_bytes:4 LE][incarnation:8 LE][owner_generation:8 LE]
+/// ```
+///
+/// Partition ids are unique, and there are 1 to `MAX_PARTITIONS_PER_REQUEST`.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct FetchMessagesRequest {
+    pub consumer: WireConsumer,
+    pub stream_id: WireIdentifier,
+    pub topic_id: WireIdentifier,
+    pub wait_us: u64,
+    pub request_timeout_us: u64,
+    /// The wait ends once the targets hold this many messages, 1 to `count`.
+    pub min_count: u32,
+    /// Caps the messages of the whole reply.
+    pub count: u32,
+    /// Caps the records of all sections together.
+    pub max_bytes: u32,
+    pub auto_commit: bool,
+    pub targets: Vec<FetchMessagesTarget>,
+}
+
+/// One partition to read.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct FetchMessagesTarget {
+    pub partition_id: u32,
+    /// An offset, or the next one after the stored offset.
+    pub strategy: WirePollingStrategy,
+    /// Caps the records of this section, at most the request `max_bytes`.
+    pub max_bytes: u32,
+    /// With `owner_generation`, the partition fence that a deferred poll
+    /// carries in its request header.
+    pub incarnation: u64,
+    pub owner_generation: u64,
+}
+
+impl WireEncode for FetchMessagesRequest {
+    fn encoded_size(&self) -> usize {
+        self.consumer.encoded_size()
+            + self.stream_id.encoded_size()
+            + self.topic_id.encoded_size()
+            + LIMITS_SIZE
+            + self.targets.len() * TARGET_SIZE
+    }
+
+    #[allow(clippy::cast_possible_truncation)]
+    fn encode(&self, buf: &mut BytesMut) {
+        self.consumer.encode(buf);
+        self.stream_id.encode(buf);
+        self.topic_id.encode(buf);
+        buf.put_u64_le(self.wait_us);
+        buf.put_u64_le(self.request_timeout_us);
+        buf.put_u32_le(self.min_count);
+        buf.put_u32_le(self.count);
+        buf.put_u32_le(self.max_bytes);
+        buf.put_u8(u8::from(self.auto_commit));
+        buf.put_u32_le(self.targets.len() as u32);
+        for target in &self.targets {
+            buf.put_u32_le(target.partition_id);
+            target.strategy.encode(buf);
+            buf.put_u32_le(target.max_bytes);
+            buf.put_u64_le(target.incarnation);
+            buf.put_u64_le(target.owner_generation);
+        }
+    }
+}
+
+impl WireDecode for FetchMessagesRequest {
+    fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+        let (consumer, mut pos) = WireConsumer::decode(buf)?;
+        let (stream_id, n) = WireIdentifier::decode(&buf[pos..])?;
+        pos += n;
+        let (topic_id, n) = WireIdentifier::decode(&buf[pos..])?;
+        pos += n;
+        let wait_us = read_u64_le(buf, pos)?;
+        pos += size_of::<u64>();
+        let request_timeout_us = read_u64_le(buf, pos)?;
+        pos += size_of::<u64>();
+        let min_count = read_u32_le(buf, pos)?;
+        pos += size_of::<u32>();
+        let count = read_u32_le(buf, pos)?;
+        pos += size_of::<u32>();
+        let max_bytes = read_u32_le(buf, pos)?;
+        pos += size_of::<u32>();
+        let auto_commit = read_u8(buf, pos)? != 0;
+        pos += size_of::<u8>();
+        let targets_count = read_u32_le(buf, pos)?;
+        pos += size_of::<u32>();
+        // Bound the allocation before `validate` sees the targets.
+        if targets_count > MAX_PARTITIONS_PER_REQUEST
+            || buf.len() - pos != targets_count as usize * TARGET_SIZE
+        {
+            return Err(WireError::Validation("invalid fetch targets".into()));
+        }
+        let mut targets = Vec::with_capacity(targets_count as usize);
+        for _ in 0..targets_count {
+            let partition_id = read_u32_le(buf, pos)?;
+            pos += size_of::<u32>();
+            let (strategy, n) = WirePollingStrategy::decode(&buf[pos..])?;
+            pos += n;
+            let max_bytes = read_u32_le(buf, pos)?;
+            pos += size_of::<u32>();
+            let incarnation = read_u64_le(buf, pos)?;
+            pos += size_of::<u64>();
+            let owner_generation = read_u64_le(buf, pos)?;
+            pos += size_of::<u64>();
+            targets.push(FetchMessagesTarget {
+                partition_id,
+                strategy,
+                max_bytes,
+                incarnation,
+                owner_generation,
+            });
+        }
+        let request = Self {
+            consumer,
+            stream_id,
+            topic_id,
+            wait_us,
+            request_timeout_us,
+            min_count,
+            count,
+            max_bytes,
+            auto_commit,
+            targets,
+        };
+        request.validate()?;
+        Ok((request, pos))
+    }
+}
+
+impl FetchMessagesRequest {
+    /// The limits that decode enforces, for a sender that builds the request.
+    ///
+    /// # Errors
+    /// Returns `WireError::Validation` for a wait or request timeout out of
+    /// range, a request timeout less than `MIN_DEFERRED_POLL_REPLY_MARGIN_US`
+    /// after the wait (also for a zero wait), a `min_count` outside 1 to
+    /// `count`, a byte limit below `MIN_DEFERRED_POLL_MAX_BYTES` or a target
+    /// limit above the request limit, no targets, too many targets, a
+    /// duplicate partition, or a start point other than an offset or the next
+    /// one.
+    pub fn validate(&self) -> Result<(), WireError> {
+        if !timers_are_valid(self.wait_us, self.request_timeout_us)
+            || self.min_count == 0
+            || self.min_count > self.count
+            || (self.max_bytes as usize) < MIN_DEFERRED_POLL_MAX_BYTES
+            || self.targets.is_empty()
+            || self.targets.len() > MAX_PARTITIONS_PER_REQUEST as usize
+        {
+            return Err(WireError::Validation(
+                "invalid fetch wait, request timeout, message counts, byte limit or targets".into(),
+            ));
+        }
+        let mut seen = HashSet::with_capacity(self.targets.len());
+        for target in &self.targets {
+            if !FETCH_STRATEGY_KINDS.contains(&target.strategy.kind)
+                || (target.max_bytes as usize) < MIN_DEFERRED_POLL_MAX_BYTES
+                || target.max_bytes > self.max_bytes
+            {
+                return Err(WireError::Validation(
+                    "invalid fetch target start point or byte limit".into(),
+                ));
+            }
+            if !seen.insert(target.partition_id) {
+                return Err(WireError::Validation("duplicate partition in fetch".into()));
+            }
+        }
+        Ok(())
+    }
+
+    /// The largest reply this request can get: the section count, one table
+    /// entry per target and `max_bytes` of records. A client reads no larger
+    /// reply.
+    #[must_use]
+    pub const fn reply_limit(&self) -> usize {
+        reply_limit_of(self.targets.len(), self.max_bytes)
+    }
+
+    /// `request_timeout_us` read from the fields before the targets, without
+    /// allocating identifiers or decoding targets, for a transport that bounds
+    /// its reply wait before the request reaches the server.
+    ///
+    /// `None` for a truncated or malformed prefix, or for timers that
+    /// [`Self::validate`] refuses.
+    #[must_use]
+    pub fn peek_request_timeout_us(body: &[u8]) -> Option<u64> {
+        let pos = limits_position(body)?;
+        let wait_us = read_u64_le(body, pos).ok()?;
+        let request_timeout_us = read_u64_le(body, pos + size_of::<u64>()).ok()?;
+        timers_are_valid(wait_us, request_timeout_us).then_some(request_timeout_us)
+    }
+
+    /// [`Self::reply_limit`] read from the fields before the targets, without
+    /// allocating identifiers or decoding targets, for a client that bounds
+    /// the reply to each request it sends.
+    ///
+    /// `None` for a truncated or malformed prefix, or for a byte limit or a
+    /// target count that [`Self::validate`] refuses.
+    #[must_use]
+    pub fn peek_reply_limit(body: &[u8]) -> Option<usize> {
+        let pos = limits_position(body)?;
+        let max_bytes = read_u32_le(body, pos + MAX_BYTES_OFFSET).ok()?;
+        let targets_count = read_u32_le(body, pos + TARGETS_COUNT_OFFSET).ok()?;
+        if (max_bytes as usize) < MIN_DEFERRED_POLL_MAX_BYTES
+            || !(1..=MAX_PARTITIONS_PER_REQUEST).contains(&targets_count)
+        {
+            return None;
+        }
+        Some(reply_limit_of(targets_count as usize, max_bytes))
+    }
+}
+
+/// Where the fields between the topic and the targets start, found without
+/// allocating the consumer or the identifiers.
+fn limits_position(body: &[u8]) -> Option<usize> {
+    let mut pos = WireConsumer::skip(body).ok()?;
+    pos += WireIdentifier::skip(&body[pos..]).ok()?;
+    pos += WireIdentifier::skip(&body[pos..]).ok()?;
+    Some(pos)
+}
+
+const fn reply_limit_of(targets: usize, max_bytes: u32) -> usize {
+    FetchMessagesResponse::head_size(targets).saturating_add(max_bytes as usize)
+}
+
+/// Unlike 106, a zero wait also needs the reply margin, because the
+/// server ends the partition reads two `FETCH_REPLY_MARGIN_US` before the
+/// request timeout.
+const fn timers_are_valid(wait_us: u64, request_timeout_us: u64) -> bool {
+    wait_us <= MAX_DEFERRED_POLL_WAIT_US
+        && request_timeout_us >= wait_us.saturating_add(MIN_DEFERRED_POLL_REPLY_MARGIN_US)
+        && request_timeout_us <= MAX_DEFERRED_POLL_TIMEOUT_US
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[allow(clippy::cast_possible_truncation)]
+    const MIN_BYTES: u32 = MIN_DEFERRED_POLL_MAX_BYTES as u32;
+    const MAX_BYTES: u32 = 4 * MIN_BYTES;
+
+    fn request(targets: Vec<FetchMessagesTarget>) -> FetchMessagesRequest {
+        FetchMessagesRequest {
+            consumer: WireConsumer::consumer_group(WireIdentifier::named("group").unwrap()),
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::named("topic").unwrap(),
+            wait_us: 1_000,
+            request_timeout_us: 1_000 + MIN_DEFERRED_POLL_REPLY_MARGIN_US,
+            min_count: 1,
+            count: 100,
+            max_bytes: MAX_BYTES,
+            auto_commit: true,
+            targets,
+        }
+    }
+
+    fn target(partition_id: u32) -> FetchMessagesTarget {
+        FetchMessagesTarget {
+            partition_id,
+            strategy: WirePollingStrategy::offset(u64::from(partition_id) * 10),
+            max_bytes: MIN_BYTES,
+            incarnation: u64::from(partition_id) + 1,
+            owner_generation: 7,
+        }
+    }
+
+    fn golden() -> FetchMessagesRequest {
+        FetchMessagesRequest {
+            consumer: WireConsumer::consumer_group(WireIdentifier::numeric(7)),
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::named("orders").unwrap(),
+            wait_us: 5_000_000,
+            request_timeout_us: 6_000_000,
+            min_count: 1,
+            count: 100,
+            max_bytes: 1_048_576,
+            auto_commit: true,
+            targets: vec![
+                FetchMessagesTarget {
+                    partition_id: 1,
+                    strategy: WirePollingStrategy::offset(41),
+                    max_bytes: 524_288,
+                    incarnation: 3,
+                    owner_generation: 9,
+                },
+                FetchMessagesTarget {
+                    partition_id: 2,
+                    strategy: WirePollingStrategy::next(),
+                    max_bytes: 524_288,
+                    incarnation: 3,
+                    owner_generation: 9,
+                },
+            ],
+        }
+    }
+
+    fn accepted(request: &FetchMessagesRequest) -> bool {
+        FetchMessagesRequest::decode(&request.to_bytes()).is_ok()
+    }
+
+    #[test]
+    fn roundtrip_and_reject_truncation() {
+        let request = request(vec![
+            target(0),
+            FetchMessagesTarget {
+                strategy: WirePollingStrategy::next(),
+                ..target(7)
+            },
+        ]);
+        let bytes = request.to_bytes();
+        assert_eq!(bytes.len(), request.encoded_size());
+        assert_eq!(
+            FetchMessagesRequest::decode(&bytes).unwrap(),
+            (request, bytes.len())
+        );
+        for end in 0..bytes.len() {
+            assert!(
+                FetchMessagesRequest::decode(&bytes[..end]).is_err(),
+                "accepted truncation at {end}"
+            );
+        }
+    }
+
+    #[test]
+    fn trailing_bytes_are_rejected() {
+        let mut bytes = BytesMut::from(&request(vec![target(1)]).to_bytes()[..]);
+        bytes.put_u8(0);
+        assert!(FetchMessagesRequest::decode(&bytes).is_err());
+    }
+
+    #[test]
+    fn target_count_limits() {
+        assert!(accepted(&request(
+            (0..MAX_PARTITIONS_PER_REQUEST).map(target).collect()
+        )));
+        for targets in [
+            Vec::new(),
+            (0..=MAX_PARTITIONS_PER_REQUEST).map(target).collect(),
+            vec![target(3), target(4), target(3)],
+        ] {
+            let count = targets.len();
+            assert!(!accepted(&request(targets)), "accepted {count} targets");
+        }
+    }
+
+    #[test]
+    fn only_offset_and_next_targets_are_fetched() {
+        for (strategy, valid) in [
+            (WirePollingStrategy::offset(0), true),
+            (WirePollingStrategy::next(), true),
+            (WirePollingStrategy::timestamp(1), false),
+            (WirePollingStrategy::first(), false),
+            (WirePollingStrategy::last(), false),
+        ] {
+            let request = request(vec![
+                target(1),
+                FetchMessagesTarget {
+                    strategy: strategy.clone(),
+                    ..target(2)
+                },
+            ]);
+            assert_eq!(accepted(&request), valid, "{strategy:?}");
+        }
+    }
+
+    #[test]
+    fn wait_and_timeout_limits() {
+        for (wait_us, request_timeout_us, valid) in [
+            (0, MIN_DEFERRED_POLL_REPLY_MARGIN_US, true),
+            (
+                MAX_DEFERRED_POLL_WAIT_US,
+                MAX_DEFERRED_POLL_TIMEOUT_US,
+                true,
+            ),
+            (0, MIN_DEFERRED_POLL_REPLY_MARGIN_US - 1, false),
+            (0, 0, false),
+            (2, 1, false),
+            (1, 1 + MIN_DEFERRED_POLL_REPLY_MARGIN_US, true),
+            (1, MIN_DEFERRED_POLL_REPLY_MARGIN_US, false),
+            (
+                MAX_DEFERRED_POLL_WAIT_US + 1,
+                MAX_DEFERRED_POLL_TIMEOUT_US,
+                false,
+            ),
+            (1, MAX_DEFERRED_POLL_TIMEOUT_US + 1, false),
+        ] {
+            let request = FetchMessagesRequest {
+                wait_us,
+                request_timeout_us,
+                ..request(vec![target(1)])
+            };
+            assert_eq!(
+                accepted(&request),
+                valid,
+                "wait {wait_us}, timeout {request_timeout_us}"
+            );
+            assert_eq!(
+                FetchMessagesRequest::peek_request_timeout_us(&request.to_bytes()).is_some(),
+                valid,
+                "peek of wait {wait_us}, timeout {request_timeout_us}"
+            );
+        }
+    }
+
+    #[test]
+    fn message_count_limits() {
+        for (min_count, count, valid) in [
+            (1, 1, true),
+            (5, 5, true),
+            (1, u32::MAX, true),
+            (0, 5, false),
+            (6, 5, false),
+            (0, 0, false),
+            (1, 0, false),
+        ] {
+            let request = FetchMessagesRequest {
+                min_count,
+                count,
+                ..request(vec![target(1)])
+            };
+            assert_eq!(
+                accepted(&request),
+                valid,
+                "min_count {min_count}, count {count}"
+            );
+        }
+    }
+
+    #[test]
+    fn byte_limits() {
+        for (max_bytes, target_max_bytes, valid) in [
+            (MIN_BYTES, MIN_BYTES, true),
+            (MAX_BYTES, MAX_BYTES, true),
+            (MIN_BYTES - 1, MIN_BYTES - 1, false),
+            (MAX_BYTES, MIN_BYTES - 1, false),
+            (MAX_BYTES, MAX_BYTES + 1, false),
+        ] {
+            let request = FetchMessagesRequest {
+                max_bytes,
+                ..request(vec![
+                    target(1),
+                    FetchMessagesTarget {
+                        max_bytes: target_max_bytes,
+                        ..target(2)
+                    },
+                ])
+            };
+            assert_eq!(
+                accepted(&request),
+                valid,
+                "max_bytes {max_bytes}, target max_bytes {target_max_bytes}"
+            );
+        }
+    }
+
+    #[test]
+    fn reply_limit_counts_the_table_and_the_records() {
+        let request = request(vec![target(1), target(2)]);
+        assert_eq!(
+            request.reply_limit(),
+            4 + 2 * 56 + MAX_BYTES as usize,
+            "the reply layout is part of the protocol"
+        );
+    }
+
+    #[test]
+    fn peek_reads_the_request_timeout_from_the_prefix() {
+        let request = golden();
+        let bytes = request.to_bytes();
+        let prefix = request.consumer.encoded_size()
+            + request.stream_id.encoded_size()
+            + request.topic_id.encoded_size()
+            + 2 * size_of::<u64>();
+        for end in 0..=bytes.len() {
+            let expected = (end >= prefix).then_some(request.request_timeout_us);
+            assert_eq!(
+                FetchMessagesRequest::peek_request_timeout_us(&bytes[..end]),
+                expected,
+                "peek of the first {end} bytes"
+            );
+        }
+    }
+
+    #[test]
+    fn peek_reads_the_reply_limit_from_the_prefix() {
+        let request = golden();
+        let bytes = request.to_bytes();
+        let prefix = request.consumer.encoded_size()
+            + request.stream_id.encoded_size()
+            + request.topic_id.encoded_size()
+            + LIMITS_SIZE;
+        for end in 0..=bytes.len() {
+            let expected = (end >= prefix).then_some(request.reply_limit());
+            assert_eq!(
+                FetchMessagesRequest::peek_reply_limit(&bytes[..end]),
+                expected,
+                "peek of the first {end} bytes"
+            );
+        }
+    }
+
+    #[test]
+    fn peek_refuses_the_reply_limits_that_validate_refuses() {
+        for request in [
+            request(vec![target(1)]),
+            request((0..MAX_PARTITIONS_PER_REQUEST).map(target).collect()),
+            request(Vec::new()),
+            request((0..=MAX_PARTITIONS_PER_REQUEST).map(target).collect()),
+            FetchMessagesRequest {
+                max_bytes: MIN_BYTES - 1,
+                ..request(vec![target(1)])
+            },
+        ] {
+            assert_eq!(
+                FetchMessagesRequest::peek_reply_limit(&request.to_bytes()),
+                accepted(&request).then_some(request.reply_limit()),
+                "peek of {} targets, max_bytes {}",
+                request.targets.len(),
+                request.max_bytes
+            );
+        }
+    }
+
+    #[test]
+    fn golden_request() {
+        let hex = golden()
+            .to_bytes()
+            .iter()
+            .map(|byte| format!("{byte:02x}"))
+            .collect::<Vec<_>>()
+            .concat();
+        let expected = [
+            "02",
+            "0104",
+            "07000000",
+            "0104",
+            "01000000",
+            "0206",
+            "6f7264657273",
+            "404b4c0000000000",
+            "808d5b0000000000",
+            "01000000",
+            "64000000",
+            "00001000",
+            "01",
+            "02000000",
+            "01000000",
+            "01",
+            "2900000000000000",
+            "00000800",
+            "0300000000000000",
+            "0900000000000000",
+            "02000000",
+            "05",
+            "0000000000000000",
+            "00000800",
+            "0300000000000000",
+            "0900000000000000",
+        ]
+        .concat();
+        assert_eq!(hex, expected);
+    }
+}
diff --git a/core/binary_protocol/src/requests/messages/mod.rs b/core/binary_protocol/src/requests/messages/mod.rs
index befceaa..860d0cc 100644
--- a/core/binary_protocol/src/requests/messages/mod.rs
+++ b/core/binary_protocol/src/requests/messages/mod.rs
@@ -15,16 +15,16 @@
 // specific language governing permissions and limitations
 // under the License.
 
+pub mod fetch_messages;
 pub mod get_partition_route;
 pub mod poll_messages;
 pub mod send_messages;
-pub mod wait_for_messages;
 
+pub use fetch_messages::{FetchMessagesRequest, FetchMessagesTarget};
 pub use get_partition_route::GetPartitionRouteRequest;
 pub use poll_messages::{
-    DeferredPollMessagesRequest, MAX_DEFERRED_POLL_TIMEOUT_US, MAX_DEFERRED_POLL_WAIT_US,
-    MIN_DEFERRED_POLL_MAX_BYTES, MIN_DEFERRED_POLL_REPLY_MARGIN_US, PollMessagesRequest,
-    deferred_timeout_covers_wait,
+    DeferredPollMessagesRequest, FETCH_REPLY_MARGIN_US, MAX_DEFERRED_POLL_TIMEOUT_US,
+    MAX_DEFERRED_POLL_WAIT_US, MIN_DEFERRED_POLL_MAX_BYTES, MIN_DEFERRED_POLL_REPLY_MARGIN_US,
+    PollMessagesRequest, deferred_timeout_covers_wait,
 };
 pub use send_messages::{RawMessage, SendMessagesEncoder, SendMessagesHeader};
-pub use wait_for_messages::{WaitForMessagesRequest, WaitForMessagesTarget};
diff --git a/core/binary_protocol/src/requests/messages/poll_messages.rs b/core/binary_protocol/src/requests/messages/poll_messages.rs
index 57406c0..7f42649 100644
--- a/core/binary_protocol/src/requests/messages/poll_messages.rs
+++ b/core/binary_protocol/src/requests/messages/poll_messages.rs
@@ -60,8 +60,18 @@
 /// Least time a waiting request leaves between the end of its wait and its request timeout.
 ///
 /// The server spends it on the final read and the reply, so an idle wait answers before the
-/// client gives up. A zero wait answers at once and needs no margin.
+/// client gives up. A zero wait of a 106 answers at once and needs no margin. A 109
+/// needs it at any wait.
 pub const MIN_DEFERRED_POLL_REPLY_MARGIN_US: u64 = 1_000_000;
+/// Reply margin of a `FetchMessages` request.
+///
+/// The server gives each partition read of a 109 the deadline `timeout - 2 x margin` and
+/// replies by `timeout - margin`, so a read that committed always has one margin to reach the
+/// reply.
+pub const FETCH_REPLY_MARGIN_US: u64 = 200_000;
+// A 109 timeout leaves the reply margin after the wait, so every read deadline falls after
+// the wait.
+const _: () = assert!(2 * FETCH_REPLY_MARGIN_US < MIN_DEFERRED_POLL_REPLY_MARGIN_US);
 /// Smallest byte limit that can return a message: the reply header, one batch
 /// header and one message header.
 pub const MIN_DEFERRED_POLL_MAX_BYTES: usize = POLL_RESPONSE_HEADER_SIZE
diff --git a/core/binary_protocol/src/requests/messages/wait_for_messages.rs b/core/binary_protocol/src/requests/messages/wait_for_messages.rs
deleted file mode 100644
index 4c6eb63..0000000
--- a/core/binary_protocol/src/requests/messages/wait_for_messages.rs
+++ /dev/null
@@ -1,261 +0,0 @@
-// Licensed to the Apache Software Foundation (ASF) under one
-// or more contributor license agreements.  See the NOTICE file
-// distributed with this work for additional information
-// regarding copyright ownership.  The ASF licenses this file
-// to you under the Apache License, Version 2.0 (the
-// "License"); you may not use this file except in compliance
-// with the License.  You may obtain a copy of the License at
-//
-//   http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing,
-// software distributed under the License is distributed on an
-// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-// KIND, either express or implied.  See the License for the
-// specific language governing permissions and limitations
-// under the License.
-
-use crate::codec::{WireDecode, WireEncode, read_u32_le, read_u64_le};
-use crate::primitives::consumer::WireConsumer;
-use crate::primitives::polling_strategy::WirePollingStrategy;
-use crate::requests::messages::poll_messages::{
-    MAX_DEFERRED_POLL_TIMEOUT_US, MAX_DEFERRED_POLL_WAIT_US, STRATEGY_SIZE,
-    deferred_timeout_covers_wait,
-};
-use crate::{MAX_PARTITIONS_PER_REQUEST, WireError, WireIdentifier};
-use bytes::{BufMut, BytesMut};
-use std::collections::HashSet;
-
-const TARGET_SIZE: usize = size_of::<u32>() + STRATEGY_SIZE;
-
-/// Waits until at least one partition holds a message at its start point.
-/// Reads and commits nothing.
-///
-/// Wire format:
-/// ```text
-/// [consumer][stream_id][topic_id][wait_us:8 LE][request_timeout_us:8 LE]
-/// [partitions_count:4 LE] partitions_count x [partition_id:4 LE][strategy:9]
-/// ```
-///
-/// Partition ids are unique, and there are 1 to `MAX_PARTITIONS_PER_REQUEST`.
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct WaitForMessagesRequest {
-    pub consumer: WireConsumer,
-    pub stream_id: WireIdentifier,
-    pub topic_id: WireIdentifier,
-    pub wait_us: u64,
-    pub request_timeout_us: u64,
-    pub partitions: Vec<WaitForMessagesTarget>,
-}
-
-/// The server accepts the offset and next strategies only.
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct WaitForMessagesTarget {
-    pub partition_id: u32,
-    pub strategy: WirePollingStrategy,
-}
-
-impl WireEncode for WaitForMessagesRequest {
-    fn encoded_size(&self) -> usize {
-        self.consumer.encoded_size()
-            + self.stream_id.encoded_size()
-            + self.topic_id.encoded_size()
-            + 2 * size_of::<u64>()
-            + size_of::<u32>()
-            + self.partitions.len() * TARGET_SIZE
-    }
-
-    #[allow(clippy::cast_possible_truncation)]
-    fn encode(&self, buf: &mut BytesMut) {
-        self.consumer.encode(buf);
-        self.stream_id.encode(buf);
-        self.topic_id.encode(buf);
-        buf.put_u64_le(self.wait_us);
-        buf.put_u64_le(self.request_timeout_us);
-        buf.put_u32_le(self.partitions.len() as u32);
-        for target in &self.partitions {
-            buf.put_u32_le(target.partition_id);
-            target.strategy.encode(buf);
-        }
-    }
-}
-
-impl WireDecode for WaitForMessagesRequest {
-    fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
-        let (consumer, mut pos) = WireConsumer::decode(buf)?;
-        let (stream_id, n) = WireIdentifier::decode(&buf[pos..])?;
-        pos += n;
-        let (topic_id, n) = WireIdentifier::decode(&buf[pos..])?;
-        pos += n;
-        let wait_us = read_u64_le(buf, pos)?;
-        pos += size_of::<u64>();
-        let request_timeout_us = read_u64_le(buf, pos)?;
-        pos += size_of::<u64>();
-        let count = read_u32_le(buf, pos)?;
-        pos += size_of::<u32>();
-        // Bound the allocation before `validate` sees the partitions.
-        if count > MAX_PARTITIONS_PER_REQUEST || buf.len() - pos != count as usize * TARGET_SIZE {
-            return Err(WireError::Validation(
-                "invalid readiness wait partitions".into(),
-            ));
-        }
-        let mut partitions = Vec::with_capacity(count as usize);
-        for _ in 0..count {
-            let partition_id = read_u32_le(buf, pos)?;
-            let (strategy, n) = WirePollingStrategy::decode(&buf[pos + size_of::<u32>()..])?;
-            pos += size_of::<u32>() + n;
-            partitions.push(WaitForMessagesTarget {
-                partition_id,
-                strategy,
-            });
-        }
-        let request = Self {
-            consumer,
-            stream_id,
-            topic_id,
-            wait_us,
-            request_timeout_us,
-            partitions,
-        };
-        request.validate()?;
-        Ok((request, pos))
-    }
-}
-
-impl WaitForMessagesRequest {
-    /// The limits that decode enforces, for a sender that builds the request.
-    ///
-    /// # Errors
-    /// Returns `WireError::Validation` for a wait or request timeout out of
-    /// range, no partitions, too many partitions or a duplicate partition.
-    pub fn validate(&self) -> Result<(), WireError> {
-        if self.wait_us > MAX_DEFERRED_POLL_WAIT_US
-            || self.request_timeout_us == 0
-            || !deferred_timeout_covers_wait(self.wait_us, self.request_timeout_us)
-            || self.request_timeout_us > MAX_DEFERRED_POLL_TIMEOUT_US
-            || self.partitions.is_empty()
-            || self.partitions.len() > MAX_PARTITIONS_PER_REQUEST as usize
-        {
-            return Err(WireError::Validation(
-                "invalid readiness wait, request timeout or partitions".into(),
-            ));
-        }
-        let mut seen = HashSet::with_capacity(self.partitions.len());
-        if !self
-            .partitions
-            .iter()
-            .all(|target| seen.insert(target.partition_id))
-        {
-            return Err(WireError::Validation(
-                "duplicate partition in readiness wait".into(),
-            ));
-        }
-        Ok(())
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-    use crate::requests::messages::poll_messages::MIN_DEFERRED_POLL_REPLY_MARGIN_US;
-
-    fn request(partitions: Vec<WaitForMessagesTarget>) -> WaitForMessagesRequest {
-        WaitForMessagesRequest {
-            consumer: WireConsumer::consumer_group(WireIdentifier::named("group").unwrap()),
-            stream_id: WireIdentifier::numeric(1),
-            topic_id: WireIdentifier::named("topic").unwrap(),
-            wait_us: 1_000,
-            request_timeout_us: 1_000 + MIN_DEFERRED_POLL_REPLY_MARGIN_US,
-            partitions,
-        }
-    }
-
-    fn target(partition_id: u32) -> WaitForMessagesTarget {
-        WaitForMessagesTarget {
-            partition_id,
-            strategy: WirePollingStrategy::offset(u64::from(partition_id) * 10),
-        }
-    }
-
-    #[test]
-    fn roundtrip_and_reject_truncation() {
-        let request = request(vec![
-            target(0),
-            WaitForMessagesTarget {
-                partition_id: 7,
-                strategy: WirePollingStrategy::next(),
-            },
-        ]);
-        let bytes = request.to_bytes();
-        assert_eq!(bytes.len(), request.encoded_size());
-        assert_eq!(
-            WaitForMessagesRequest::decode(&bytes).unwrap(),
-            (request, bytes.len())
-        );
-        for end in 0..bytes.len() {
-            assert!(
-                WaitForMessagesRequest::decode(&bytes[..end]).is_err(),
-                "accepted truncation at {end}"
-            );
-        }
-    }
-
-    #[test]
-    fn partition_count_limits() {
-        let full = request(
-            (0..MAX_PARTITIONS_PER_REQUEST)
-                .map(target)
-                .collect::<Vec<_>>(),
-        );
-        assert!(WaitForMessagesRequest::decode(&full.to_bytes()).is_ok());
-        for partitions in [
-            Vec::new(),
-            (0..=MAX_PARTITIONS_PER_REQUEST).map(target).collect(),
-            vec![target(3), target(3)],
-        ] {
-            let count = partitions.len();
-            assert!(
-                WaitForMessagesRequest::decode(&request(partitions).to_bytes()).is_err(),
-                "accepted {count} partitions"
-            );
-        }
-    }
-
-    #[test]
-    fn wait_and_timeout_limits() {
-        for (wait_us, request_timeout_us, valid) in [
-            (0, 1, true),
-            (
-                MAX_DEFERRED_POLL_WAIT_US,
-                MAX_DEFERRED_POLL_TIMEOUT_US,
-                true,
-            ),
-            (0, 0, false),
-            (2, 1, false),
-            (1, 1 + MIN_DEFERRED_POLL_REPLY_MARGIN_US, true),
-            (1, MIN_DEFERRED_POLL_REPLY_MARGIN_US, false),
-            (
-                MAX_DEFERRED_POLL_WAIT_US + 1,
-                MAX_DEFERRED_POLL_TIMEOUT_US,
-                false,
-            ),
-            (1, MAX_DEFERRED_POLL_TIMEOUT_US + 1, false),
-        ] {
-            let mut request = request(vec![target(1)]);
-            request.wait_us = wait_us;
-            request.request_timeout_us = request_timeout_us;
-            assert_eq!(
-                WaitForMessagesRequest::decode(&request.to_bytes()).is_ok(),
-                valid,
-                "wait {wait_us}, timeout {request_timeout_us}"
-            );
-        }
-    }
-
-    #[test]
-    fn trailing_bytes_are_rejected() {
-        let mut bytes = BytesMut::from(&request(vec![target(1)]).to_bytes()[..]);
-        bytes.put_u8(0);
-        assert!(WaitForMessagesRequest::decode(&bytes).is_err());
-    }
-}
diff --git a/core/binary_protocol/src/responses/messages/fetch_messages.rs b/core/binary_protocol/src/responses/messages/fetch_messages.rs
new file mode 100644
index 0000000..723c48b
--- /dev/null
+++ b/core/binary_protocol/src/responses/messages/fetch_messages.rs
@@ -0,0 +1,655 @@
+// 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.
+
+//! Codec for the `FetchMessages` reply.
+//!
+//! The section table comes first, so a client can send its next fetch before
+//! it decodes any record. The records of each section follow the table in
+//! table order, as stored, in the record format of a `PollMessages` reply.
+
+use crate::codec::{WireDecode, read_u32_le, read_u64_le};
+use crate::framing::STATUS_OK;
+use crate::primitives::partition_history::PartitionContext;
+use crate::{MAX_PARTITIONS_PER_REQUEST, WireError};
+use bytes::{BufMut, Bytes, BytesMut};
+use std::cmp::Ordering;
+
+/// Size of the section count that opens the reply.
+pub const FETCH_SECTIONS_COUNT_SIZE: usize = size_of::<u32>();
+/// Size of one table entry: `partition_id(4) + status(4) + current_offset(8)`,
+/// `messages_count(4) + context(24) + next_offset(8) + records_len(4)`.
+pub const FETCH_SECTION_HEADER_SIZE: usize =
+    4 * size_of::<u32>() + 2 * size_of::<u64>() + PartitionContext::ENCODED_SIZE;
+
+/// One entry of the section table.
+///
+/// `current_offset`, `messages_count` and `context` mean what they mean in a
+/// `PollMessages` reply.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct FetchMessagesSectionHeader {
+    pub partition_id: u32,
+    /// [`STATUS_OK`] when the partition was read. Any other value is the
+    /// `IggyError` code of this target, and the section holds no records.
+    pub status: u32,
+    pub current_offset: u64,
+    pub messages_count: u32,
+    pub context: PartitionContext,
+    /// Where the next fetch of this partition starts.
+    pub next_offset: u64,
+    pub records_len: u32,
+}
+
+impl FetchMessagesSectionHeader {
+    /// The entry bytes, for a reply head that is written in place.
+    #[must_use]
+    pub fn to_le_bytes(&self) -> [u8; FETCH_SECTION_HEADER_SIZE] {
+        let mut bytes = [0; FETCH_SECTION_HEADER_SIZE];
+        let mut entry = &mut bytes[..];
+        entry.put_u32_le(self.partition_id);
+        entry.put_u32_le(self.status);
+        entry.put_u64_le(self.current_offset);
+        entry.put_u32_le(self.messages_count);
+        entry.put_slice(&self.context.to_le_bytes());
+        entry.put_u64_le(self.next_offset);
+        entry.put_u32_le(self.records_len);
+        bytes
+    }
+
+    const fn is_error_with_records(&self) -> bool {
+        self.status != STATUS_OK && self.records_len != 0
+    }
+}
+
+impl WireDecode for FetchMessagesSectionHeader {
+    fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+        let partition_id = read_u32_le(buf, 0)?;
+        let mut pos = size_of::<u32>();
+        let status = read_u32_le(buf, pos)?;
+        pos += size_of::<u32>();
+        let current_offset = read_u64_le(buf, pos)?;
+        pos += size_of::<u64>();
+        let messages_count = read_u32_le(buf, pos)?;
+        pos += size_of::<u32>();
+        let (context, n) = PartitionContext::decode(&buf[pos..])?;
+        pos += n;
+        let next_offset = read_u64_le(buf, pos)?;
+        pos += size_of::<u64>();
+        let records_len = read_u32_le(buf, pos)?;
+        pos += size_of::<u32>();
+        Ok((
+            Self {
+                partition_id,
+                status,
+                current_offset,
+                messages_count,
+                context,
+                next_offset,
+                records_len,
+            },
+            pos,
+        ))
+    }
+}
+
+/// One section of a reply and its records.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct FetchMessagesSection<'a> {
+    pub header: FetchMessagesSectionHeader,
+    /// Batch records as stored, `header.records_len` bytes.
+    pub records: &'a [u8],
+}
+
+/// Borrowed `FetchMessages` reply. Does not own message data.
+///
+/// Wire format:
+/// ```text
+/// [sections_count:4 LE] sections_count x
+///   [partition_id:4 LE][status:4 LE][current_offset:8 LE][messages_count:4 LE]
+///   [context:24][next_offset:8 LE][records_len:4 LE]
+/// [records of section 1][records of section 2]...
+/// ```
+///
+/// A request with `n` targets gets at most `n` sections, so a reply holds at
+/// most `FetchMessagesRequest::reply_limit` bytes.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct FetchMessagesResponse<'a> {
+    pub sections: Vec<FetchMessagesSection<'a>>,
+}
+
+impl<'a> FetchMessagesResponse<'a> {
+    /// Size of the head of a reply with `sections` sections: the count and
+    /// the table.
+    #[must_use]
+    pub const fn head_size(sections: usize) -> usize {
+        FETCH_SECTIONS_COUNT_SIZE + sections * FETCH_SECTION_HEADER_SIZE
+    }
+
+    /// Decode a reply body. Borrows the buffer.
+    /// [`FetchMessagesSectionIterator`] reads the same sections without
+    /// allocating.
+    ///
+    /// # Errors
+    /// The errors of [`FetchMessagesSectionIterator::new`].
+    pub fn decode(buf: &'a [u8]) -> Result<Self, WireError> {
+        Ok(Self {
+            sections: FetchMessagesSectionIterator::new(buf)?.collect(),
+        })
+    }
+
+    /// Writes the count and the table into `head`, so the records can follow
+    /// as stored fragments, in table order and without a copy.
+    ///
+    /// # Errors
+    /// Returns `WireError::Validation` when `head` is not
+    /// [`Self::head_size`] bytes, for more sections than a request has
+    /// targets, or for an error section with records.
+    pub fn write_head(
+        headers: &[FetchMessagesSectionHeader],
+        head: &mut [u8],
+    ) -> Result<(), WireError> {
+        let count = u32::try_from(headers.len())
+            .ok()
+            .filter(|count| *count <= MAX_PARTITIONS_PER_REQUEST);
+        let Some(count) = count else {
+            return Err(WireError::Validation(
+                "fetch reply has more sections than a request has targets".into(),
+            ));
+        };
+        if head.len() != Self::head_size(headers.len()) {
+            return Err(WireError::Validation(
+                "fetch reply head does not match its table".into(),
+            ));
+        }
+        if headers
+            .iter()
+            .any(FetchMessagesSectionHeader::is_error_with_records)
+        {
+            return Err(WireError::Validation(
+                "fetch error section carries records".into(),
+            ));
+        }
+        let (count_bytes, table) = head.split_at_mut(FETCH_SECTIONS_COUNT_SIZE);
+        count_bytes.copy_from_slice(&count.to_le_bytes());
+        let (entries, _) = table.as_chunks_mut::<FETCH_SECTION_HEADER_SIZE>();
+        for (entry, header) in entries.iter_mut().zip(headers) {
+            *entry = header.to_le_bytes();
+        }
+        Ok(())
+    }
+
+    /// The whole reply in one buffer, for tests and fakes. A server writes
+    /// the head with [`Self::write_head`] and sends the records as stored.
+    ///
+    /// # Errors
+    /// The errors of [`Self::write_head`], and a section whose `records_len`
+    /// is not the length of its records.
+    pub fn to_bytes(&self) -> Result<Bytes, WireError> {
+        let headers = self
+            .sections
+            .iter()
+            .map(|section| {
+                if section.header.records_len as usize == section.records.len() {
+                    Ok(section.header)
+                } else {
+                    Err(WireError::Validation(
+                        "fetch section records_len differs from its records".into(),
+                    ))
+                }
+            })
+            .collect::<Result<Vec<_>, _>>()?;
+        let mut buf = BytesMut::zeroed(Self::head_size(headers.len()));
+        Self::write_head(&headers, &mut buf)?;
+        for section in &self.sections {
+            buf.extend_from_slice(section.records);
+        }
+        Ok(buf.freeze())
+    }
+}
+
+/// The sections of a reply in table order, read in place. [`Self::new`]
+/// proves the layout of the whole reply before the first section.
+#[derive(Debug, Clone)]
+pub struct FetchMessagesSectionIterator<'a> {
+    table: std::slice::Iter<'a, [u8; FETCH_SECTION_HEADER_SIZE]>,
+    records: &'a [u8],
+}
+
+impl<'a> FetchMessagesSectionIterator<'a> {
+    /// Walks a reply body without allocating. Borrows the buffer.
+    ///
+    /// # Errors
+    /// Returns `WireError` for a table that is truncated or names more
+    /// sections than a request has targets, an error section with records, or
+    /// records that do not fill the rest of the body exactly.
+    pub fn new(buf: &'a [u8]) -> Result<Self, WireError> {
+        let count = read_u32_le(buf, 0)?;
+        if count > MAX_PARTITIONS_PER_REQUEST {
+            return Err(WireError::Validation(
+                "fetch reply has more sections than a request has targets".into(),
+            ));
+        }
+        let Some((head, records)) =
+            buf.split_at_checked(FetchMessagesResponse::head_size(count as usize))
+        else {
+            return Err(WireError::Validation(
+                "fetch reply ends inside its section table".into(),
+            ));
+        };
+        let (table, _) = head[FETCH_SECTIONS_COUNT_SIZE..].as_chunks::<FETCH_SECTION_HEADER_SIZE>();
+        let mut records_len = 0_usize;
+        for entry in table {
+            let (header, _) = FetchMessagesSectionHeader::decode(entry)?;
+            if header.is_error_with_records() {
+                return Err(WireError::Validation(
+                    "fetch error section carries records".into(),
+                ));
+            }
+            records_len = records_len.saturating_add(header.records_len as usize);
+        }
+        match records_len.cmp(&records.len()) {
+            Ordering::Greater => Err(WireError::Validation(
+                "fetch section records run past the reply".into(),
+            )),
+            Ordering::Less => Err(WireError::Validation(
+                "fetch reply has bytes past its sections".into(),
+            )),
+            Ordering::Equal => Ok(Self {
+                table: table.iter(),
+                records,
+            }),
+        }
+    }
+}
+
+impl<'a> Iterator for FetchMessagesSectionIterator<'a> {
+    type Item = FetchMessagesSection<'a>;
+
+    fn next(&mut self) -> Option<Self::Item> {
+        // `new` decoded every entry and proved that the records fill the body,
+        // so neither step can fail.
+        let (header, _) = FetchMessagesSectionHeader::decode(self.table.next()?).ok()?;
+        let (records, rest) = self.records.split_at_checked(header.records_len as usize)?;
+        self.records = rest;
+        Some(FetchMessagesSection { header, records })
+    }
+
+    fn size_hint(&self) -> (usize, Option<usize>) {
+        (self.table.len(), Some(self.table.len()))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::batch::{
+        BATCH_HEADER_SIZE, BATCH_RESERVED_OFFSET, BatchHeader, calculate_batch_checksum,
+        decode_batch_slice,
+    };
+    use crate::requests::messages::{
+        FetchMessagesRequest, FetchMessagesTarget, MIN_DEFERRED_POLL_MAX_BYTES,
+        MIN_DEFERRED_POLL_REPLY_MARGIN_US, RawMessage, SendMessagesEncoder,
+    };
+    use crate::responses::messages::poll_messages::PolledBatchesIterator;
+    use crate::{WireConsumer, WireIdentifier, WirePartitioning, WirePollingStrategy};
+
+    /// `IggyError::NotPrimary`, the error a backup gives for a read.
+    const NOT_PRIMARY: u32 = 89;
+
+    fn served(partition_id: u32, records: &[u8]) -> FetchMessagesSection<'_> {
+        FetchMessagesSection {
+            header: FetchMessagesSectionHeader {
+                partition_id,
+                status: STATUS_OK,
+                current_offset: 99,
+                messages_count: 2,
+                context: PartitionContext {
+                    incarnation: 3,
+                    owner_generation: 4,
+                    metadata_op: 5,
+                },
+                next_offset: 42,
+                records_len: u32::try_from(records.len()).unwrap(),
+            },
+            records,
+        }
+    }
+
+    fn failed(partition_id: u32, status: u32) -> FetchMessagesSection<'static> {
+        FetchMessagesSection {
+            header: FetchMessagesSectionHeader {
+                status,
+                messages_count: 0,
+                records_len: 0,
+                ..served(partition_id, &[]).header
+            },
+            records: &[],
+        }
+    }
+
+    fn response(sections: Vec<FetchMessagesSection<'_>>) -> FetchMessagesResponse<'_> {
+        FetchMessagesResponse { sections }
+    }
+
+    #[test]
+    fn roundtrip_and_reject_truncation() {
+        let records = [1u8, 2, 3, 4, 5, 6, 7];
+        for sections in [
+            Vec::new(),
+            vec![failed(1, NOT_PRIMARY)],
+            vec![
+                served(4, &records[..3]),
+                failed(5, 5009),
+                served(6, &[]),
+                served(7, &records[3..]),
+            ],
+        ] {
+            let response = response(sections);
+            let bytes = response.to_bytes().unwrap();
+            let records_len: usize = response
+                .sections
+                .iter()
+                .map(|section| section.records.len())
+                .sum();
+            assert_eq!(
+                bytes.len(),
+                FetchMessagesResponse::head_size(response.sections.len()) + records_len
+            );
+            assert_eq!(FetchMessagesResponse::decode(&bytes).unwrap(), response);
+            for end in 0..bytes.len() {
+                assert!(
+                    FetchMessagesResponse::decode(&bytes[..end]).is_err(),
+                    "accepted truncation at {end}"
+                );
+            }
+        }
+    }
+
+    #[test]
+    fn walk_reads_each_section_in_place() {
+        let records = [1u8, 2, 3, 4, 5];
+        let response = response(vec![
+            served(4, &records[..2]),
+            failed(5, NOT_PRIMARY),
+            served(6, &records[2..]),
+        ]);
+        let bytes = response.to_bytes().unwrap();
+        let mut walk = FetchMessagesSectionIterator::new(&bytes).unwrap();
+        assert_eq!(walk.size_hint(), (3, Some(3)));
+
+        let first = walk.next().unwrap();
+        assert_eq!(first, response.sections[0]);
+        assert!(std::ptr::eq(
+            first.records.as_ptr(),
+            bytes[FetchMessagesResponse::head_size(3)..].as_ptr()
+        ));
+        assert_eq!(walk.size_hint(), (2, Some(2)));
+        assert_eq!(walk.by_ref().collect::<Vec<_>>(), response.sections[1..]);
+        assert_eq!(walk.next(), None);
+        assert_eq!(walk.size_hint(), (0, Some(0)));
+    }
+
+    #[test]
+    fn section_header_layout() {
+        let header = served(0x0102_0304, &[0; 9]).header;
+        let bytes = header.to_le_bytes();
+        assert_eq!(bytes.len(), 56, "the table entry is part of the protocol");
+        assert_eq!(&bytes[..4], &0x0102_0304u32.to_le_bytes());
+        assert_eq!(&bytes[4..8], &STATUS_OK.to_le_bytes());
+        assert_eq!(&bytes[8..16], &99u64.to_le_bytes());
+        assert_eq!(&bytes[16..20], &2u32.to_le_bytes());
+        assert_eq!(&bytes[20..44], &header.context.to_le_bytes());
+        assert_eq!(&bytes[44..52], &42u64.to_le_bytes());
+        assert_eq!(&bytes[52..], &9u32.to_le_bytes());
+        assert_eq!(
+            FetchMessagesSectionHeader::decode(&bytes).unwrap(),
+            (header, bytes.len())
+        );
+    }
+
+    #[test]
+    fn records_must_fill_the_body_exactly() {
+        let records = [7u8; 6];
+        let bytes = response(vec![served(1, &records[..2]), served(2, &records[2..])])
+            .to_bytes()
+            .unwrap();
+        let mut trailing = bytes.to_vec();
+        trailing.push(0);
+        assert!(FetchMessagesResponse::decode(&trailing).is_err());
+
+        let last_records_len = FetchMessagesResponse::head_size(2) - size_of::<u32>();
+        for records_len in [5u32, u32::MAX] {
+            let mut past_the_body = bytes.to_vec();
+            past_the_body[last_records_len..last_records_len + size_of::<u32>()]
+                .copy_from_slice(&records_len.to_le_bytes());
+            assert!(
+                FetchMessagesResponse::decode(&past_the_body).is_err(),
+                "accepted records_len {records_len}"
+            );
+        }
+    }
+
+    #[test]
+    fn rejects_more_sections_than_a_request_has_targets() {
+        let mut bytes = BytesMut::new();
+        bytes.put_u32_le(MAX_PARTITIONS_PER_REQUEST + 1);
+        bytes.resize(
+            FetchMessagesResponse::head_size(MAX_PARTITIONS_PER_REQUEST as usize + 1),
+            0,
+        );
+        assert!(FetchMessagesResponse::decode(&bytes).is_err());
+
+        let headers = vec![failed(1, NOT_PRIMARY).header; MAX_PARTITIONS_PER_REQUEST as usize + 1];
+        let mut head = vec![0; FetchMessagesResponse::head_size(headers.len())];
+        assert!(FetchMessagesResponse::write_head(&headers, &mut head).is_err());
+    }
+
+    #[test]
+    fn error_sections_carry_no_records() {
+        let mut header = failed(1, NOT_PRIMARY).header;
+        header.records_len = 1;
+        let mut head = vec![0; FetchMessagesResponse::head_size(1)];
+        assert!(FetchMessagesResponse::write_head(&[header], &mut head).is_err());
+
+        header.status = STATUS_OK;
+        FetchMessagesResponse::write_head(&[header], &mut head).unwrap();
+        head[FETCH_SECTIONS_COUNT_SIZE + size_of::<u32>()..][..size_of::<u32>()]
+            .copy_from_slice(&NOT_PRIMARY.to_le_bytes());
+        head.push(0);
+        assert!(FetchMessagesResponse::decode(&head).is_err());
+    }
+
+    #[test]
+    fn head_is_written_in_place_before_the_records() {
+        let records = [9u8; 5];
+        let response = response(vec![served(1, &records[..2]), served(2, &records[2..])]);
+        let headers: Vec<_> = response
+            .sections
+            .iter()
+            .map(|section| section.header)
+            .collect();
+        let mut head = vec![0; FetchMessagesResponse::head_size(headers.len())];
+        FetchMessagesResponse::write_head(&headers, &mut head).unwrap();
+        let bytes = response.to_bytes().unwrap();
+        assert_eq!(&bytes[..head.len()], head.as_slice());
+        assert_eq!(&bytes[head.len()..], &records);
+
+        for wrong in [head.len() - 1, head.len() + 1] {
+            let mut head = vec![0; wrong];
+            assert!(FetchMessagesResponse::write_head(&headers, &mut head).is_err());
+        }
+    }
+
+    #[test]
+    fn to_bytes_rejects_a_records_len_that_differs_from_the_records() {
+        let mut section = served(1, &[1, 2, 3]);
+        section.header.records_len = 2;
+        assert!(response(vec![section]).to_bytes().is_err());
+    }
+
+    #[test]
+    fn the_largest_reply_fills_the_reply_limit() {
+        let targets = 3;
+        let max_bytes = 2 * MIN_DEFERRED_POLL_MAX_BYTES;
+        let request = FetchMessagesRequest {
+            consumer: WireConsumer::consumer(WireIdentifier::numeric(1)),
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(1),
+            wait_us: 1,
+            request_timeout_us: 1 + MIN_DEFERRED_POLL_REPLY_MARGIN_US,
+            min_count: 1,
+            count: 1,
+            max_bytes: u32::try_from(max_bytes).unwrap(),
+            auto_commit: false,
+            targets: (1..=targets)
+                .map(|partition_id| FetchMessagesTarget {
+                    partition_id,
+                    strategy: WirePollingStrategy::next(),
+                    max_bytes: u32::try_from(max_bytes).unwrap(),
+                    incarnation: 1,
+                    owner_generation: 0,
+                })
+                .collect(),
+        };
+        request.validate().unwrap();
+        let records = vec![0; max_bytes + 1];
+        let largest = |records| {
+            let mut sections = vec![served(1, records)];
+            sections.extend((2..=targets).map(|partition_id| failed(partition_id, NOT_PRIMARY)));
+            response(sections).to_bytes().unwrap().len()
+        };
+        assert_eq!(largest(&records[..max_bytes]), request.reply_limit());
+        assert!(largest(&records) > request.reply_limit());
+    }
+
+    /// One stored batch of partition 1 with the message `hello` at offset 41,
+    /// stamped as the server stores it.
+    fn stored_record() -> Vec<u8> {
+        let mut send = BytesMut::new();
+        SendMessagesEncoder::encode(
+            &mut send,
+            &WireIdentifier::numeric(1),
+            &WireIdentifier::named("orders").unwrap(),
+            &WirePartitioning::PartitionId(1),
+            &[RawMessage {
+                id: 1,
+                origin_timestamp: 1_700_000_000_000_000,
+                headers: None,
+                payload: b"hello",
+            }],
+        )
+        .unwrap();
+        let metadata_len = read_u32_le(&send, 0).unwrap() as usize;
+        let mut record = send[size_of::<u32>() + metadata_len..].to_vec();
+        let mut stored = BatchHeader::decode(&record).unwrap();
+        stored.partition_id = 1;
+        stored.base_offset = 41;
+        stored.base_timestamp = 1_700_000_000_000_100;
+        stored.batch_checksum = calculate_batch_checksum(&stored, &record[BATCH_HEADER_SIZE..]);
+        stored.encode_into(&mut record);
+        decode_batch_slice(&record).unwrap();
+        record
+    }
+
+    #[test]
+    fn golden_reply() {
+        let record = stored_record();
+        let response = response(vec![
+            FetchMessagesSection {
+                header: FetchMessagesSectionHeader {
+                    partition_id: 1,
+                    status: STATUS_OK,
+                    current_offset: 41,
+                    messages_count: 1,
+                    context: PartitionContext {
+                        incarnation: 3,
+                        owner_generation: 9,
+                        metadata_op: 12,
+                    },
+                    next_offset: 42,
+                    records_len: u32::try_from(record.len()).unwrap(),
+                },
+                records: &record,
+            },
+            FetchMessagesSection {
+                header: FetchMessagesSectionHeader {
+                    partition_id: 2,
+                    status: NOT_PRIMARY,
+                    current_offset: 0,
+                    messages_count: 0,
+                    context: PartitionContext::default(),
+                    next_offset: 0,
+                    records_len: 0,
+                },
+                records: &[],
+            },
+        ]);
+        let bytes = response.to_bytes().unwrap();
+        let decoded = FetchMessagesResponse::decode(&bytes).unwrap();
+        let messages = PolledBatchesIterator::new(decoded.sections[0].records)
+            .collect::<Result<Vec<_>, _>>()
+            .unwrap();
+        assert_eq!(messages.len(), 1);
+        assert_eq!(
+            (messages[0].offset, messages[0].payload),
+            (41, &b"hello"[..])
+        );
+        let hex = bytes
+            .iter()
+            .map(|byte| format!("{byte:02x}"))
+            .collect::<Vec<_>>()
+            .concat();
+        let empty_context = "00".repeat(PartitionContext::ENCODED_SIZE);
+        let reserved = "00".repeat(BATCH_HEADER_SIZE - BATCH_RESERVED_OFFSET);
+        let expected = [
+            "02000000",
+            "01000000",
+            "00000000",
+            "2900000000000000",
+            "01000000",
+            "0300000000000000",
+            "0900000000000000",
+            "0c00000000000000",
+            "2a00000000000000",
+            "35010000",
+            "02000000",
+            "59000000",
+            "0000000000000000",
+            "00000000",
+            &empty_context,
+            "0000000000000000",
+            "00000000",
+            "0100000000000000",
+            "2900000000000000",
+            "64401e18240a0600",
+            "00401e18240a0600",
+            "3501000000000000",
+            "bd2d787d3cdb745c",
+            "01000000",
+            &reserved,
+            "8e5ca59edbbdbfb9",
+            "01000000000000000000000000000000",
+            "00000000",
+            "00000000",
+            "00000000",
+            "05000000",
+            "0000000000000000",
+            "68656c6c6f",
+        ]
+        .concat();
+        assert_eq!(hex, expected);
+    }
+}
diff --git a/core/binary_protocol/src/responses/messages/mod.rs b/core/binary_protocol/src/responses/messages/mod.rs
index 4f8337a..14332cd 100644
--- a/core/binary_protocol/src/responses/messages/mod.rs
+++ b/core/binary_protocol/src/responses/messages/mod.rs
@@ -15,12 +15,15 @@
 // specific language governing permissions and limitations
 // under the License.
 
+pub mod fetch_messages;
 pub mod partition_route;
 pub mod poll_messages;
 pub mod send_messages;
-pub mod wait_for_messages;
 
+pub use fetch_messages::{
+    FetchMessagesResponse, FetchMessagesSection, FetchMessagesSectionHeader,
+    FetchMessagesSectionIterator,
+};
 pub use partition_route::PartitionRouteResponse;
 pub use poll_messages::{PollMessagesResponse, PollMessagesResponseHeader};
 pub use send_messages::{SendMessagesConfirmationResponse, SendMessagesResponse};
-pub use wait_for_messages::WaitForMessagesResponse;
diff --git a/core/binary_protocol/src/responses/messages/wait_for_messages.rs b/core/binary_protocol/src/responses/messages/wait_for_messages.rs
deleted file mode 100644
index 06d46bd..0000000
--- a/core/binary_protocol/src/responses/messages/wait_for_messages.rs
+++ /dev/null
@@ -1,89 +0,0 @@
-// Licensed to the Apache Software Foundation (ASF) under one
-// or more contributor license agreements.  See the NOTICE file
-// distributed with this work for additional information
-// regarding copyright ownership.  The ASF licenses this file
-// to you under the Apache License, Version 2.0 (the
-// "License"); you may not use this file except in compliance
-// with the License.  You may obtain a copy of the License at
-//
-//   http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing,
-// software distributed under the License is distributed on an
-// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-// KIND, either express or implied.  See the License for the
-// specific language governing permissions and limitations
-// under the License.
-
-use crate::codec::{WireDecode, WireEncode, read_u32_le};
-use crate::{MAX_PARTITIONS_PER_REQUEST, WireError};
-use bytes::{BufMut, BytesMut};
-
-/// Partitions that hold a message at their start point. An empty list means
-/// that the wait expired.
-///
-/// Wire format: `[partitions_count:4 LE] partitions_count x [partition_id:4 LE]`
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct WaitForMessagesResponse {
-    pub partitions: Vec<u32>,
-}
-
-impl WireEncode for WaitForMessagesResponse {
-    fn encoded_size(&self) -> usize {
-        size_of::<u32>() * (1 + self.partitions.len())
-    }
-
-    #[allow(clippy::cast_possible_truncation)]
-    fn encode(&self, buf: &mut BytesMut) {
-        buf.put_u32_le(self.partitions.len() as u32);
-        for partition_id in &self.partitions {
-            buf.put_u32_le(*partition_id);
-        }
-    }
-}
-
-impl WireDecode for WaitForMessagesResponse {
-    fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
-        let count = read_u32_le(buf, 0)?;
-        if count > MAX_PARTITIONS_PER_REQUEST {
-            return Err(WireError::Validation(
-                "readiness reply names too many partitions".into(),
-            ));
-        }
-        let partitions = (1..=count as usize)
-            .map(|index| read_u32_le(buf, index * size_of::<u32>()))
-            .collect::<Result<Vec<_>, _>>()?;
-        Ok((Self { partitions }, size_of::<u32>() * (1 + count as usize)))
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    #[test]
-    fn roundtrip_and_reject_truncation() {
-        for partitions in [Vec::new(), vec![0, 7, u32::MAX]] {
-            let response = WaitForMessagesResponse { partitions };
-            let bytes = response.to_bytes();
-            assert_eq!(bytes.len(), response.encoded_size());
-            assert_eq!(
-                WaitForMessagesResponse::decode(&bytes).unwrap(),
-                (response, bytes.len())
-            );
-            for end in 0..bytes.len() {
-                assert!(
-                    WaitForMessagesResponse::decode(&bytes[..end]).is_err(),
-                    "accepted truncation at {end}"
-                );
-            }
-        }
-    }
-
-    #[test]
-    fn rejects_counts_above_the_request_limit() {
-        let mut bytes = BytesMut::new();
-        bytes.put_u32_le(MAX_PARTITIONS_PER_REQUEST + 1);
-        assert!(WaitForMessagesResponse::decode(&bytes).is_err());
-    }
-}
diff --git a/core/binary_protocol/src/version.rs b/core/binary_protocol/src/version.rs
index 9dbe038..20e46ce 100644
--- a/core/binary_protocol/src/version.rs
+++ b/core/binary_protocol/src/version.rs
@@ -107,10 +107,11 @@
 /// fields from the topic header (50 to 33 fixed bytes, the values live in the
 /// option blocks), adds derived option blocks to stream and user responses and
 /// a flags byte to each `DescribeOptions` descriptor.
+/// Version 0.12.1 replaces `WaitForMessages` (107) with `FetchMessages` (109).
 /// Finalize this number and the minimum during stable release preparation.
 /// Released servers using protocol 0.11.0 accept every 0.11.x patch, so an
 /// incompatible release needs a new minor version for those servers to reject it.
-pub const IGGY_PROTOCOL_VERSION: u32 = pack_protocol_version(0, 12, 0);
+pub const IGGY_PROTOCOL_VERSION: u32 = pack_protocol_version(0, 12, 1);
 
 /// Oldest protocol version this build accepts at login.
 ///
@@ -120,7 +121,9 @@
 ///
 /// Keep this equal to the current protocol unless compatibility with older
 /// released protocols has been verified. Decide the range per stable release.
-pub const IGGY_PROTOCOL_VERSION_MIN: u32 = pack_protocol_version(0, 12, 0);
+///
+/// Version 0.12.0 clients can send the retired `WaitForMessages` (107) command.
+pub const IGGY_PROTOCOL_VERSION_MIN: u32 = pack_protocol_version(0, 12, 1);
 const _: () =
     assert!(IGGY_PROTOCOL_VERSION_MIN > 0 && IGGY_PROTOCOL_VERSION_MIN <= IGGY_PROTOCOL_VERSION);
 
@@ -263,11 +266,12 @@
     }
 
     #[test]
-    fn login_from_a_per_resource_update_protocol_is_outside_the_compatible_window() {
+    fn login_from_an_older_protocol_is_outside_the_compatible_window() {
         assert!(!is_protocol_compatible(pack_protocol_version(0, 11, 0)));
         assert!(!is_protocol_compatible(pack_protocol_version(0, 11, 1)));
-        assert!(is_protocol_compatible(pack_protocol_version(0, 12, 0)));
-        assert!(!is_protocol_compatible(pack_protocol_version(0, 12, 1)));
+        assert!(!is_protocol_compatible(pack_protocol_version(0, 12, 0)));
+        assert!(is_protocol_compatible(pack_protocol_version(0, 12, 1)));
+        assert!(!is_protocol_compatible(pack_protocol_version(0, 12, 2)));
     }
 
     #[test]
@@ -277,8 +281,8 @@
     }
 
     #[test]
-    fn min_requires_the_generic_update_wire_layout() {
-        assert_eq!(IGGY_PROTOCOL_VERSION_MIN, pack_protocol_version(0, 12, 0));
+    fn min_requires_the_fetch_protocol() {
+        assert_eq!(IGGY_PROTOCOL_VERSION_MIN, pack_protocol_version(0, 12, 1));
     }
 
     #[test]
diff --git a/core/cli/Cargo.toml b/core/cli/Cargo.toml
index 64ac1dd..54b011e 100644
--- a/core/cli/Cargo.toml
+++ b/core/cli/Cargo.toml
@@ -17,7 +17,7 @@
 
 [package]
 name = "iggy-cli"
-version = "0.14.1-edge.4"
+version = "0.14.1-edge.5"
 edition = "2024"
 rust-version.workspace = true
 authors = ["bartosz.ciesla@gmail.com"]
diff --git a/core/cli/src/commands/binary_message/poll_messages.rs b/core/cli/src/commands/binary_message/poll_messages.rs
index a9fe69f..b50841b 100644
--- a/core/cli/src/commands/binary_message/poll_messages.rs
+++ b/core/cli/src/commands/binary_message/poll_messages.rs
@@ -22,16 +22,22 @@
 use iggy_binary_protocol::WireUserHeaders;
 use iggy_common::Client;
 use iggy_common::{
-    Consumer, HeaderKey, HeaderKind, Identifier, IggyByteSize, IggyDuration, IggyMessage,
-    IggyTimestamp, PollMessages, PollingStrategy, Sizeable,
+    CommitPolicy, Consumer, HeaderKey, HeaderKind, Identifier, IggyByteSize, IggyDuration,
+    IggyMessage, IggyTimestamp, PollOptions, PollingStrategy, Sizeable,
     wire_conversions::user_headers_from_wire,
 };
 use std::collections::HashSet;
-use tokio::io::AsyncWriteExt;
+use tokio::fs::{File, OpenOptions};
+use tokio::io::{AsyncWriteExt, BufWriter};
 use tracing::{Level, event};
 
 pub struct PollMessagesCmd {
-    poll_messages: PollMessages,
+    stream_id: Identifier,
+    topic_id: Identifier,
+    partition_id: u32,
+    consumer: Consumer,
+    strategy: PollingStrategy,
+    options: PollOptions,
     show_headers: bool,
     output_file: Option<String>,
 }
@@ -59,21 +65,72 @@
             (None, false, false, true) => PollingStrategy::next(),
             _ => unreachable!("Either offset or first, last or next must be specified"),
         };
+        let commit = if auto_commit {
+            CommitPolicy::OnRead
+        } else {
+            CommitPolicy::Manual
+        };
         Self {
-            poll_messages: PollMessages {
-                consumer: Consumer::new(consumer),
-                stream_id,
-                topic_id,
-                partition_id: Some(partition_id),
-                strategy,
+            stream_id,
+            topic_id,
+            partition_id,
+            consumer: Consumer::new(consumer),
+            strategy,
+            options: PollOptions {
                 count: message_count,
-                auto_commit,
+                commit,
+                ..PollOptions::default()
             },
             show_headers,
             output_file,
         }
     }
 
+    async fn print_batch(
+        &self,
+        messages: &[IggyMessage],
+        elapsed: IggyDuration,
+        output: Option<&mut OutputFile<'_>>,
+    ) -> anyhow::Result<(), anyhow::Error> {
+        let polled_size = IggyByteSize::from(
+            messages
+                .iter()
+                .map(|m| m.get_size_bytes().as_bytes_u64())
+                .sum::<u64>(),
+        );
+
+        let message_count_message = match messages.len() {
+            1 => "1 message".into(),
+            count => format!("{count} messages"),
+        };
+        event!(target: PRINT_TARGET, Level::INFO, "Polled {message_count_message} of total size {polled_size}, it took {}", elapsed.as_human_time_string());
+
+        if let Some(output) = output {
+            let output_file = output.path;
+            event!(target: PRINT_TARGET, Level::INFO, "Storing messages to {output_file} binary file");
+
+            let saved_size = output
+                .store(messages)
+                .await
+                .with_context(|| format!("Problem writing message to file: {output_file}"))?;
+
+            let saved_size_str = saved_size.as_human_string();
+            event!(target: PRINT_TARGET, Level::INFO, "Stored {message_count_message} of total size {saved_size_str} to {output_file} binary file");
+        } else {
+            let message_header_keys = self.create_message_header_keys(messages);
+
+            let mut table = Table::new();
+            let table_header = Self::create_table_header(&message_header_keys);
+            let table_content = Self::create_table_content(messages, &message_header_keys);
+            table.set_header(table_header);
+            table.add_rows(table_content);
+
+            event!(target: PRINT_TARGET, Level::INFO, "{table}");
+        }
+
+        Ok(())
+    }
+
     fn create_message_header_keys(
         &self,
         polled_messages: &[IggyMessage],
@@ -169,86 +226,149 @@
     fn explain(&self) -> String {
         format!(
             "poll messages from topic ID: {} and stream with ID: {}",
-            self.poll_messages.topic_id, self.poll_messages.stream_id
+            self.topic_id, self.stream_id
         )
     }
 
     async fn execute_cmd(&mut self, client: &dyn Client) -> anyhow::Result<(), anyhow::Error> {
-        let start = std::time::Instant::now();
-        let polled_messages = client
-            .poll_messages(
-                &self.poll_messages.stream_id,
-                &self.poll_messages.topic_id,
-                self.poll_messages.partition_id,
-                &self.poll_messages.consumer,
-                &self.poll_messages.strategy,
-                self.poll_messages.count,
-                self.poll_messages.auto_commit,
-            )
-            .await
-            .with_context(|| {
-                format!(
-                    "Problem polling messages to topic with ID: {} and stream with ID: {}",
-                    self.poll_messages.topic_id, self.poll_messages.stream_id
-                )
-            })?;
-        let elapsed = IggyDuration::new(start.elapsed());
-
-        event!(target: PRINT_TARGET, Level::INFO,
-            "Polled messages from topic with ID: {} and stream with ID: {} (from partition with ID: {})",
-            self.poll_messages.topic_id,
-            self.poll_messages.stream_id,
-            polled_messages.partition_id,
-        );
-
-        let polled_size = IggyByteSize::from(
-            polled_messages
-                .messages
-                .iter()
-                .map(|m| m.get_size_bytes().as_bytes_u64())
-                .sum::<u64>(),
-        );
-
-        let message_count_message = match polled_messages.messages.len() {
-            1 => "1 message".into(),
-            count => format!("{count} messages"),
+        // Before the first poll, so a file that does not open fails the command before
+        // `--auto-commit` stores an offset.
+        let mut output = match self.output_file.as_deref() {
+            Some(path) => Some(OutputFile::open(path).await?),
+            None => None,
         };
-        event!(target: PRINT_TARGET, Level::INFO, "Polled {message_count_message} of total size {polled_size}, it took {}", elapsed.as_human_time_string());
-
-        if let Some(output_file) = &self.output_file {
-            event!(target: PRINT_TARGET, Level::INFO, "Storing messages to {output_file} binary file");
-
-            let mut saved_size = IggyByteSize::default();
-            let mut file = tokio::fs::OpenOptions::new()
-                .append(true)
-                .create(true)
-                .open(output_file)
+        let mut strategy = self.strategy;
+        let mut polled_count = 0;
+        // One reply holds at most `max_bytes`, so the command reads on until it has
+        // `--message-count` messages or gets an empty batch.
+        while polled_count < self.options.count {
+            let start = std::time::Instant::now();
+            let polled_messages = client
+                .poll_messages(
+                    &self.stream_id,
+                    &self.topic_id,
+                    self.partition_id,
+                    &self.consumer,
+                    &strategy,
+                    PollOptions {
+                        count: self.options.count - polled_count,
+                        ..self.options
+                    },
+                )
                 .await
-                .with_context(|| format!("Problem opening file for writing: {output_file}"))?;
+                .with_context(|| {
+                    format!(
+                        "Problem polling messages to topic with ID: {} and stream with ID: {}",
+                        self.topic_id, self.stream_id
+                    )
+                })?;
+            let elapsed = IggyDuration::new(start.elapsed());
 
-            for message in polled_messages.messages.iter() {
-                let message = message.to_bytes();
-                saved_size += IggyByteSize::from(message.len() as u64);
-                file.write_all(&message)
-                    .await
-                    .with_context(|| format!("Problem writing message to file: {output_file}"))?;
+            // An empty first batch is still printed, so the operator sees that nothing matched.
+            if polled_count == 0 {
+                event!(target: PRINT_TARGET, Level::INFO,
+                    "Polled messages from topic with ID: {} and stream with ID: {} (from partition with ID: {})",
+                    self.topic_id,
+                    self.stream_id,
+                    polled_messages.partition_id,
+                );
+            } else if polled_messages.messages.is_empty() {
+                break;
             }
-
-            let saved_size_str = saved_size.as_human_string();
-            event!(target: PRINT_TARGET, Level::INFO, "Stored {message_count_message} of total size {saved_size_str} to {output_file} binary file");
-        } else {
-            let message_header_keys = self.create_message_header_keys(&polled_messages.messages);
-
-            let mut table = Table::new();
-            let table_header = Self::create_table_header(&message_header_keys);
-            let table_content =
-                Self::create_table_content(&polled_messages.messages, &message_header_keys);
-            table.set_header(table_header);
-            table.add_rows(table_content);
-
-            event!(target: PRINT_TARGET, Level::INFO, "{table}");
+            self.print_batch(&polled_messages.messages, elapsed, output.as_mut())
+                .await?;
+            if polled_messages.messages.is_empty() {
+                break;
+            }
+            polled_count += polled_messages.messages.len() as u32;
+            strategy = polled_messages.continuation();
         }
 
         Ok(())
     }
 }
+
+/// The `--output-file`, open for the whole command.
+struct OutputFile<'a> {
+    path: &'a str,
+    writer: BufWriter<File>,
+}
+
+impl<'a> OutputFile<'a> {
+    async fn open(path: &'a str) -> anyhow::Result<Self> {
+        let file = OpenOptions::new()
+            .append(true)
+            .create(true)
+            .open(path)
+            .await
+            .with_context(|| format!("Problem opening file for writing: {path}"))?;
+        Ok(Self {
+            path,
+            writer: BufWriter::new(file),
+        })
+    }
+
+    /// Appends `messages` and flushes them. A file reports a failed write only at a later write
+    /// or a flush, so the flush fails the command before it polls again.
+    async fn store(&mut self, messages: &[IggyMessage]) -> std::io::Result<IggyByteSize> {
+        let mut saved_size = IggyByteSize::default();
+        for message in messages {
+            let message = message.to_bytes();
+            saved_size += IggyByteSize::from(message.len() as u64);
+            self.writer.write_all(&message).await?;
+        }
+        self.writer.flush().await?;
+        Ok(saved_size)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn batch(payloads: &[&'static str]) -> Vec<IggyMessage> {
+        payloads
+            .iter()
+            .map(|payload| IggyMessage::from(*payload))
+            .collect()
+    }
+
+    fn stored_bytes(batches: &[&[IggyMessage]]) -> Vec<u8> {
+        batches
+            .iter()
+            .flat_map(|batch| batch.iter())
+            .flat_map(|message| message.to_bytes())
+            .collect()
+    }
+
+    #[tokio::test]
+    async fn given_batches_when_stored_should_be_in_the_file_in_order_before_it_closes() {
+        let dir = tempfile::tempdir().unwrap();
+        let path = dir.path().join("messages.bin");
+        let path = path.to_str().unwrap();
+        let (first, second) = (batch(&["one", "two"]), batch(&["three"]));
+        let mut output = OutputFile::open(path).await.unwrap();
+
+        output.store(&first).await.unwrap();
+        output.store(&second).await.unwrap();
+
+        assert_eq!(
+            tokio::fs::read(path).await.unwrap(),
+            stored_bytes(&[&first, &second]),
+            "each batch is in the file once it is stored"
+        );
+    }
+
+    #[cfg(target_os = "linux")]
+    #[tokio::test]
+    async fn given_a_full_device_when_storing_a_batch_should_fail() {
+        let mut output = OutputFile::open("/dev/full").await.unwrap();
+
+        let error = output
+            .store(&batch(&["lost"]))
+            .await
+            .expect_err("a write that the device refused must fail the command");
+
+        assert_eq!(error.kind(), std::io::ErrorKind::StorageFull);
+    }
+}
diff --git a/core/common/Cargo.toml b/core/common/Cargo.toml
index 02e93e3..ebb201d 100644
--- a/core/common/Cargo.toml
+++ b/core/common/Cargo.toml
@@ -17,7 +17,7 @@
 
 [package]
 name = "iggy_common"
-version = "0.11.1-edge.5"
+version = "0.11.1-edge.6"
 description = "Iggy is the persistent message streaming platform written in Rust, supporting QUIC, TCP and HTTP transport protocols, capable of processing millions of messages per second."
 edition = "2024"
 rust-version.workspace = true
@@ -62,6 +62,7 @@
 uuid = { workspace = true }
 
 [dev-dependencies]
+bytemuck = { workspace = true }
 hex = { workspace = true }
 xaes-256-gcm = { workspace = true }
 
diff --git a/core/common/examples/deferred_polling_fixtures.rs b/core/common/examples/deferred_polling_fixtures.rs
new file mode 100644
index 0000000..735f2c5
--- /dev/null
+++ b/core/common/examples/deferred_polling_fixtures.rs
@@ -0,0 +1,37 @@
+// 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.
+
+#[path = "deferred_polling_fixtures/schema.rs"]
+mod schema;
+
+use schema::{CANONICAL_PATH, FixtureResult, LANGUAGE_COPIES, render};
+use std::fs;
+use std::iter;
+use std::path::Path;
+
+fn main() -> FixtureResult<()> {
+    let json = render()?;
+    let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
+    for relative in iter::once(CANONICAL_PATH).chain(LANGUAGE_COPIES) {
+        let path = manifest.join(relative);
+        if let Some(parent) = path.parent() {
+            fs::create_dir_all(parent)?;
+        }
+        fs::write(&path, &json)?;
+    }
+    Ok(())
+}
diff --git a/core/common/examples/deferred_polling_fixtures/schema.rs b/core/common/examples/deferred_polling_fixtures/schema.rs
new file mode 100644
index 0000000..939eb49
--- /dev/null
+++ b/core/common/examples/deferred_polling_fixtures/schema.rs
@@ -0,0 +1,866 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use bytes::BytesMut;
+use iggy_binary_protocol::batch::{BATCH_HEADER_SIZE, BATCH_MESSAGE_HEADER_SIZE, BatchHeader};
+use iggy_binary_protocol::codes::{
+    CANCEL_DEFERRED_REQUEST_CODE, FETCH_MESSAGES_CODE, POLL_MESSAGES_CODE,
+    POLL_MESSAGES_DEFERRED_CODE,
+};
+use iggy_binary_protocol::requests::messages::{
+    DeferredPollMessagesRequest, FETCH_REPLY_MARGIN_US, FetchMessagesRequest, FetchMessagesTarget,
+    MAX_DEFERRED_POLL_TIMEOUT_US, MAX_DEFERRED_POLL_WAIT_US, MIN_DEFERRED_POLL_MAX_BYTES,
+    MIN_DEFERRED_POLL_REPLY_MARGIN_US, PollMessagesRequest, RawMessage, SendMessagesEncoder,
+};
+use iggy_binary_protocol::responses::messages::fetch_messages::FETCH_SECTION_HEADER_SIZE;
+use iggy_binary_protocol::responses::messages::{
+    FetchMessagesResponse, FetchMessagesSection, FetchMessagesSectionHeader,
+};
+use iggy_binary_protocol::{
+    HEADER_SIZE, KIND_CONSUMER, KIND_CONSUMER_GROUP, MAX_PARTITIONS_PER_REQUEST,
+    MAX_RECORD_REPLY_SIZE, MAX_RECORD_SIZE, PartitionContext, RequestHeader, STATUS_OK,
+    WireConsumer, WireEncode, WireIdentifier, WirePartitioning, WirePollingStrategy,
+};
+use iggy_common::{CommitErrorScope, Identifier as IggyIdentifier, IggyError};
+use serde::Serialize;
+use std::collections::BTreeMap;
+use std::error::Error;
+
+pub const SCHEMA_VERSION: u32 = 1;
+pub const GENERATOR: &str = "core/common/examples/deferred_polling_fixtures.rs";
+/// Relative to the `iggy_common` manifest directory.
+pub const CANONICAL_PATH: &str = "fixtures/deferred_polling.json";
+/// Byte-identical copies each foreign SDK loads from its own test resources.
+pub const LANGUAGE_COPIES: [&str; 8] = [
+    "../../foreign/python/tests/fixtures/deferred_polling.json",
+    "../../foreign/cpp/tests/fixtures/deferred_polling.json",
+    "../../foreign/php/tests/fixtures/deferred_polling.json",
+    "../../foreign/node/src/wire/fixtures/deferred_polling.json",
+    "../../foreign/go/contracts/testdata/deferred_polling.json",
+    "../../foreign/java/java-sdk/src/test/resources/deferred_polling.json",
+    "../../foreign/csharp/Iggy_SDK_Tests/Fixtures/deferred_polling.json",
+    "../../foreign/swift/Tests/IggyTests/Fixtures/deferred_polling.json",
+];
+
+const CLIENT: u64 = 7;
+const SESSION: u64 = 3;
+const ORIGIN_TIMESTAMP: u64 = 1_700_000_000_000_000;
+const BASE_TIMESTAMP: u64 = 1_700_000_000_000_500;
+const INCARNATION: u64 = 11;
+const OWNER_GENERATION: u64 = 4;
+const METADATA_OP: u64 = 42;
+
+pub type FixtureResult<T> = Result<T, Box<dyn Error>>;
+
+#[derive(Debug, Serialize)]
+pub struct DeferredPollingFixture {
+    pub schema_version: u32,
+    pub generated_by: String,
+    pub conventions: BTreeMap<String, String>,
+    pub constants: Constants,
+    pub deferred_polls: Vec<DeferredPollVector>,
+    pub fetches: Vec<FetchVector>,
+    pub cancellations: Vec<CancellationVector>,
+    pub fetch_replies: Vec<FetchReplyVector>,
+    pub refused: RefusedVectors,
+    pub commit_outcomes: CommitOutcomes,
+}
+
+#[derive(Debug, Serialize)]
+pub struct Constants {
+    pub codes: BTreeMap<String, u32>,
+    pub status_ok: u32,
+    pub errors: BTreeMap<String, u32>,
+    pub min_deferred_poll_max_bytes: usize,
+    pub max_deferred_poll_wait_us: u64,
+    pub max_deferred_poll_timeout_us: u64,
+    pub min_deferred_poll_reply_margin_us: u64,
+    pub fetch_reply_margin_us: u64,
+    pub max_partitions_per_request: u32,
+    pub max_record_size: usize,
+    pub max_record_reply_size: usize,
+    pub request_header_size: usize,
+    pub fetch_section_header_size: usize,
+}
+
+#[derive(Debug, Clone, Serialize)]
+#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
+pub enum Identifier {
+    Numeric(u32),
+    Named(String),
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct Consumer {
+    pub kind: u8,
+    pub id: Identifier,
+}
+
+#[derive(Debug, Clone, Copy, Serialize)]
+pub struct Strategy {
+    pub kind: u8,
+    pub value: u64,
+}
+
+/// A deferred poll (106) and its body.
+#[derive(Debug, Serialize)]
+pub struct DeferredPollVector {
+    pub case: String,
+    pub request: DeferredPollFields,
+    pub body_hex: String,
+}
+
+#[derive(Debug, Serialize)]
+pub struct DeferredPollFields {
+    pub consumer: Consumer,
+    pub stream_id: Identifier,
+    pub topic_id: Identifier,
+    pub partition_id: Option<u32>,
+    pub strategy: Strategy,
+    pub count: u32,
+    pub auto_commit: bool,
+    pub wait_us: u64,
+    pub min_count: u32,
+    pub max_bytes: u32,
+    pub request_timeout_us: u64,
+}
+
+/// A fetch (109) and its body.
+#[derive(Debug, Serialize)]
+pub struct FetchVector {
+    pub case: String,
+    pub request: FetchFields,
+    pub body_hex: String,
+}
+
+#[derive(Debug, Serialize)]
+pub struct FetchFields {
+    pub consumer: Consumer,
+    pub stream_id: Identifier,
+    pub topic_id: Identifier,
+    pub wait_us: u64,
+    pub request_timeout_us: u64,
+    pub min_count: u32,
+    pub count: u32,
+    pub max_bytes: u32,
+    pub auto_commit: bool,
+    pub targets: Vec<TargetFields>,
+}
+
+#[derive(Debug, Serialize)]
+pub struct TargetFields {
+    pub partition_id: u32,
+    pub strategy: Strategy,
+    pub max_bytes: u32,
+    pub incarnation: u64,
+    pub owner_generation: u64,
+}
+
+/// The cancellation (108) of a deferred request: a request header without a
+/// body, with the client, request and session of the request it cancels.
+#[derive(Debug, Serialize)]
+pub struct CancellationVector {
+    pub case: String,
+    pub cancels_code: u32,
+    pub client: u64,
+    pub request: u64,
+    pub session: u64,
+    pub header_hex: String,
+}
+
+/// A fetch reply body and what a client decodes from it.
+#[derive(Debug, Serialize)]
+pub struct FetchReplyVector {
+    pub case: String,
+    pub sections: Vec<SectionFields>,
+    pub body_hex: String,
+}
+
+#[derive(Debug, Serialize)]
+pub struct SectionFields {
+    pub partition_id: u32,
+    pub status: u32,
+    pub current_offset: u64,
+    pub messages_count: u32,
+    pub context: ContextFields,
+    pub next_offset: u64,
+    pub records_len: u32,
+    pub messages: Vec<MessageFields>,
+}
+
+#[derive(Debug, Serialize)]
+pub struct ContextFields {
+    pub incarnation: u64,
+    pub owner_generation: u64,
+    pub metadata_op: u64,
+}
+
+#[derive(Debug, Serialize)]
+pub struct MessageFields {
+    pub offset: u64,
+    /// Decimal, because a 128-bit id does not fit a JSON number.
+    pub id: String,
+    /// The batch base timestamp that the server stamped.
+    pub timestamp: u64,
+    pub origin_timestamp: u64,
+    pub payload: String,
+}
+
+#[derive(Debug, Serialize)]
+pub struct RefusedVectors {
+    /// Bodies that fail to decode or to validate.
+    pub fetches: Vec<RefusedVector>,
+    /// Bodies that fail to decode.
+    pub fetch_replies: Vec<RefusedVector>,
+}
+
+#[derive(Debug, Serialize)]
+pub struct RefusedVector {
+    pub case: String,
+    pub body_hex: String,
+}
+
+/// Which error answers of a committing 106 or 109 prove that nothing was stored.
+#[derive(Debug, Serialize)]
+pub struct CommitOutcomes {
+    pub rule: String,
+    /// An error that is the whole reply.
+    pub request: Vec<CommitOutcomeVector>,
+    /// An error status of one fetch section.
+    pub section: Vec<CommitOutcomeVector>,
+}
+
+#[derive(Debug, Serialize)]
+pub struct CommitOutcomeVector {
+    pub status: u32,
+    pub error: String,
+    pub certain: bool,
+}
+
+/// One section of a reply to build: its header fields and its messages as
+/// `(id, payload)`, stored from `first_offset` on.
+struct SectionSpec {
+    partition_id: u32,
+    status: u32,
+    current_offset: u64,
+    next_offset: u64,
+    first_offset: u64,
+    messages: &'static [(u128, &'static str)],
+}
+
+pub fn render() -> FixtureResult<String> {
+    let mut json = serde_json::to_string_pretty(&build()?)?;
+    json.push('\n');
+    Ok(json)
+}
+
+/// The fixture, built from the live codecs, so a codec change shows up as a
+/// stale file in the drift test.
+pub fn build() -> FixtureResult<DeferredPollingFixture> {
+    Ok(DeferredPollingFixture {
+        schema_version: SCHEMA_VERSION,
+        generated_by: GENERATOR.to_string(),
+        conventions: conventions(),
+        constants: constants(),
+        deferred_polls: deferred_polls()?,
+        fetches: fetches()?,
+        cancellations: cancellations()?,
+        fetch_replies: fetch_replies()?,
+        refused: refused()?,
+        commit_outcomes: commit_outcomes(),
+    })
+}
+
+fn conventions() -> BTreeMap<String, String> {
+    [
+        (
+            "body_hex",
+            "Every *_hex field is lowercase hex without separators. body_hex is the command payload without framing.",
+        ),
+        (
+            "header_hex",
+            "header_hex is the whole request header of a cancellation, which has no body, before the transport stamps its checksum and checksum_body.",
+        ),
+        (
+            "strategy",
+            "strategy.kind is the wire kind: 1 offset, 2 timestamp, 3 first, 4 last, 5 next.",
+        ),
+        (
+            "status",
+            "A section status of status_ok was read. Any other status is the error code of that partition alone, and its section holds no records.",
+        ),
+        (
+            "refused",
+            "Every refused fetch fails to decode or to validate. Every refused fetch reply fails to decode.",
+        ),
+        (
+            "oversized",
+            "A reply may carry a first record above max_bytes, up to max_record_size, so a client reply cap is at least max_record_reply_size.",
+        ),
+    ]
+    .into_iter()
+    .map(|(key, value)| (key.to_string(), value.to_string()))
+    .collect()
+}
+
+fn constants() -> Constants {
+    Constants {
+        codes: [
+            ("poll_messages", POLL_MESSAGES_CODE),
+            ("poll_messages_deferred", POLL_MESSAGES_DEFERRED_CODE),
+            ("cancel_deferred_request", CANCEL_DEFERRED_REQUEST_CODE),
+            ("fetch_messages", FETCH_MESSAGES_CODE),
+        ]
+        .into_iter()
+        .map(|(name, code)| (name.to_string(), code))
+        .collect(),
+        status_ok: STATUS_OK,
+        errors: [
+            IggyError::TransientNotCommitted,
+            IggyError::TransientNotAccepted,
+            IggyError::InvalidSizeBytes,
+            IggyError::NotPrimary,
+            IggyError::DeferredPollCapacityExceeded,
+            IggyError::TooManyConsumerOffsets,
+            IggyError::HistoryUnavailable,
+            IggyError::PartitionLaneBlocked(0, 0),
+            IggyError::FetchProgressUncertain(0),
+        ]
+        .into_iter()
+        .map(|error| (<&'static str>::from(&error).to_string(), error.as_code()))
+        .collect(),
+        min_deferred_poll_max_bytes: MIN_DEFERRED_POLL_MAX_BYTES,
+        max_deferred_poll_wait_us: MAX_DEFERRED_POLL_WAIT_US,
+        max_deferred_poll_timeout_us: MAX_DEFERRED_POLL_TIMEOUT_US,
+        min_deferred_poll_reply_margin_us: MIN_DEFERRED_POLL_REPLY_MARGIN_US,
+        fetch_reply_margin_us: FETCH_REPLY_MARGIN_US,
+        max_partitions_per_request: MAX_PARTITIONS_PER_REQUEST,
+        max_record_size: MAX_RECORD_SIZE,
+        max_record_reply_size: MAX_RECORD_REPLY_SIZE,
+        request_header_size: HEADER_SIZE,
+        fetch_section_header_size: FETCH_SECTION_HEADER_SIZE,
+    }
+}
+
+fn commit_outcomes() -> CommitOutcomes {
+    let errors = [
+        IggyError::FeatureUnavailable,
+        IggyError::InvalidIdentifier,
+        IggyError::StaleClient,
+        IggyError::Unauthenticated,
+        IggyError::Unauthorized,
+        IggyError::TransientNotCommitted,
+        IggyError::TransientNotAccepted,
+        IggyError::CannotDecryptData,
+        IggyError::InvalidSizeBytes,
+        IggyError::HistoryUnavailable,
+        IggyError::NotPrimary,
+        IggyError::DeferredPollCapacityExceeded,
+        IggyError::StreamIdNotFound(IggyIdentifier::default()),
+        IggyError::StreamNameNotFound(String::new()),
+        IggyError::TopicIdNotFound(IggyIdentifier::default(), IggyIdentifier::default()),
+        IggyError::TopicNameNotFound(String::new(), String::new()),
+        IggyError::PartitionNotFound(0, IggyIdentifier::default(), IggyIdentifier::default()),
+        IggyError::TooManyConsumerOffsets,
+        IggyError::InvalidOffset(0),
+        IggyError::ConsumerGroupPartitionNotOwned(0, 0),
+        IggyError::ShardCommunicationError,
+    ];
+    let vectors = |scope: CommitErrorScope| {
+        errors
+            .iter()
+            .map(|error| CommitOutcomeVector {
+                status: error.as_code(),
+                error: <&'static str>::from(error).to_string(),
+                certain: scope.proves_nothing_stored(error),
+            })
+            .collect()
+    };
+    CommitOutcomes {
+        rule: "A committing deferred poll (106) or fetch (109) that fails with a certain status stored nothing. Every other status, a status not listed here, a lost reply and a transport failure after the send leave the outcome unknown.".to_string(),
+        request: vectors(CommitErrorScope::Request),
+        section: vectors(CommitErrorScope::Section),
+    }
+}
+
+fn deferred_polls() -> FixtureResult<Vec<DeferredPollVector>> {
+    [
+        (
+            "consumer_offset_without_commit",
+            Consumer {
+                kind: KIND_CONSUMER,
+                id: Identifier::Numeric(1),
+            },
+            Strategy { kind: 1, value: 41 },
+            false,
+        ),
+        (
+            "group_next_with_commit",
+            Consumer {
+                kind: KIND_CONSUMER_GROUP,
+                id: Identifier::Named("orders-group".to_string()),
+            },
+            Strategy { kind: 5, value: 0 },
+            true,
+        ),
+    ]
+    .into_iter()
+    .map(|(case, consumer, strategy, auto_commit)| {
+        let request = DeferredPollFields {
+            consumer,
+            stream_id: Identifier::Numeric(1),
+            topic_id: Identifier::Named("orders".to_string()),
+            partition_id: Some(2),
+            strategy,
+            count: 100,
+            auto_commit,
+            wait_us: 5_000_000,
+            min_count: 1,
+            max_bytes: 1024 * 1024,
+            request_timeout_us: 6_000_000,
+        };
+        let wire = DeferredPollMessagesRequest {
+            poll: PollMessagesRequest {
+                consumer: wire_consumer(&request.consumer)?,
+                stream_id: wire_identifier(&request.stream_id)?,
+                topic_id: wire_identifier(&request.topic_id)?,
+                partition_id: request.partition_id,
+                strategy: wire_strategy(request.strategy),
+                count: request.count,
+                auto_commit: request.auto_commit,
+            },
+            wait_us: request.wait_us,
+            min_count: request.min_count,
+            max_bytes: request.max_bytes,
+            request_timeout_us: request.request_timeout_us,
+        };
+        Ok(DeferredPollVector {
+            case: case.to_string(),
+            body_hex: hex::encode(wire.to_bytes()),
+            request,
+        })
+    })
+    .collect()
+}
+
+fn fetches() -> FixtureResult<Vec<FetchVector>> {
+    [
+        (
+            "consumer_two_targets_without_commit",
+            Consumer {
+                kind: KIND_CONSUMER,
+                id: Identifier::Numeric(1),
+            },
+            false,
+            vec![
+                (1, Strategy { kind: 1, value: 41 }),
+                (3, Strategy { kind: 5, value: 0 }),
+            ],
+        ),
+        (
+            "group_three_targets_with_commit",
+            Consumer {
+                kind: KIND_CONSUMER_GROUP,
+                id: Identifier::Numeric(9),
+            },
+            true,
+            vec![
+                (1, Strategy { kind: 5, value: 0 }),
+                (2, Strategy { kind: 5, value: 0 }),
+                (4, Strategy { kind: 1, value: 7 }),
+            ],
+        ),
+    ]
+    .into_iter()
+    .map(|(case, consumer, auto_commit, targets)| {
+        let request = fetch_fields(consumer, auto_commit, targets);
+        Ok(FetchVector {
+            case: case.to_string(),
+            body_hex: hex::encode(wire_fetch(&request)?.to_bytes()),
+            request,
+        })
+    })
+    .collect()
+}
+
+fn fetch_fields(
+    consumer: Consumer,
+    auto_commit: bool,
+    targets: Vec<(u32, Strategy)>,
+) -> FetchFields {
+    // A group member names the owner generation of its partitions.
+    let owner_generation = if consumer.kind == KIND_CONSUMER_GROUP {
+        OWNER_GENERATION
+    } else {
+        0
+    };
+    FetchFields {
+        consumer,
+        stream_id: Identifier::Numeric(1),
+        topic_id: Identifier::Numeric(2),
+        wait_us: 5_000_000,
+        request_timeout_us: 6_000_000,
+        min_count: 1,
+        count: 1000,
+        max_bytes: 1024 * 1024,
+        auto_commit,
+        targets: targets
+            .into_iter()
+            .map(|(partition_id, strategy)| TargetFields {
+                partition_id,
+                strategy,
+                max_bytes: 512 * 1024,
+                incarnation: INCARNATION,
+                owner_generation,
+            })
+            .collect(),
+    }
+}
+
+fn cancellations() -> FixtureResult<Vec<CancellationVector>> {
+    [
+        ("cancel_deferred_poll", POLL_MESSAGES_DEFERRED_CODE, 21),
+        ("cancel_fetch", FETCH_MESSAGES_CODE, 22),
+    ]
+    .into_iter()
+    .map(|(case, code, request)| {
+        let original = RequestHeader::for_request(code, u128::from(CLIENT), request, SESSION, &[])?;
+        let cancellation = original
+            .deferred_cancellation()
+            .ok_or("a deferred request has a cancellation")?;
+        Ok(CancellationVector {
+            case: case.to_string(),
+            cancels_code: code,
+            client: CLIENT,
+            request,
+            session: SESSION,
+            header_hex: hex::encode(bytemuck::bytes_of(&cancellation)),
+        })
+    })
+    .collect()
+}
+
+fn fetch_replies() -> FixtureResult<Vec<FetchReplyVector>> {
+    let replies: [(&str, Vec<SectionSpec>); 3] = [
+        (
+            "ok_section_and_empty_start_section",
+            vec![
+                SectionSpec {
+                    partition_id: 1,
+                    status: STATUS_OK,
+                    current_offset: 44,
+                    next_offset: 44,
+                    first_offset: 41,
+                    messages: &[(0x0102, "first"), (0x0103, "second"), (0x0104, "third")],
+                },
+                SectionSpec {
+                    partition_id: 3,
+                    status: STATUS_OK,
+                    current_offset: 9,
+                    next_offset: 10,
+                    first_offset: 10,
+                    messages: &[],
+                },
+            ],
+        ),
+        (
+            "ok_section_beside_error_sections",
+            vec![
+                SectionSpec {
+                    partition_id: 1,
+                    status: IggyError::NotPrimary.as_code(),
+                    current_offset: 0,
+                    next_offset: 0,
+                    first_offset: 0,
+                    messages: &[],
+                },
+                SectionSpec {
+                    partition_id: 2,
+                    status: STATUS_OK,
+                    current_offset: 7,
+                    next_offset: 7,
+                    first_offset: 6,
+                    messages: &[(u128::MAX, "only")],
+                },
+                SectionSpec {
+                    partition_id: 4,
+                    status: IggyError::TransientNotAccepted.as_code(),
+                    current_offset: 0,
+                    next_offset: 0,
+                    first_offset: 0,
+                    messages: &[],
+                },
+            ],
+        ),
+        ("no_sections", Vec::new()),
+    ];
+    replies
+        .into_iter()
+        .map(|(case, specs)| {
+            let records = specs
+                .iter()
+                .map(|spec| stored_batch(spec))
+                .collect::<FixtureResult<Vec<_>>>()?;
+            let sections: Vec<SectionFields> = specs
+                .iter()
+                .zip(&records)
+                .map(|(spec, records)| section_fields(spec, records))
+                .collect::<FixtureResult<_>>()?;
+            let response = FetchMessagesResponse {
+                sections: sections
+                    .iter()
+                    .zip(&records)
+                    .map(|(section, records)| FetchMessagesSection {
+                        header: section_header(section),
+                        records,
+                    })
+                    .collect(),
+            };
+            Ok(FetchReplyVector {
+                case: case.to_string(),
+                body_hex: hex::encode(response.to_bytes()?),
+                sections,
+            })
+        })
+        .collect()
+}
+
+fn section_fields(spec: &SectionSpec, records: &[u8]) -> FixtureResult<SectionFields> {
+    Ok(SectionFields {
+        partition_id: spec.partition_id,
+        status: spec.status,
+        current_offset: spec.current_offset,
+        messages_count: u32::try_from(spec.messages.len())?,
+        context: if spec.status == STATUS_OK {
+            ContextFields {
+                incarnation: INCARNATION,
+                owner_generation: OWNER_GENERATION,
+                metadata_op: METADATA_OP,
+            }
+        } else {
+            ContextFields {
+                incarnation: 0,
+                owner_generation: 0,
+                metadata_op: 0,
+            }
+        },
+        next_offset: spec.next_offset,
+        records_len: u32::try_from(records.len())?,
+        messages: spec
+            .messages
+            .iter()
+            .zip(spec.first_offset..)
+            .map(|((id, payload), offset)| MessageFields {
+                offset,
+                id: id.to_string(),
+                timestamp: BASE_TIMESTAMP,
+                origin_timestamp: ORIGIN_TIMESTAMP,
+                payload: (*payload).to_string(),
+            })
+            .collect(),
+    })
+}
+
+/// The records of `spec` as the server stores and serves them: one batch,
+/// stamped with its partition, base offset and base timestamp.
+fn stored_batch(spec: &SectionSpec) -> FixtureResult<Vec<u8>> {
+    if spec.messages.is_empty() {
+        return Ok(Vec::new());
+    }
+    let messages: Vec<RawMessage<'_>> = spec
+        .messages
+        .iter()
+        .map(|(id, payload)| RawMessage {
+            id: *id,
+            origin_timestamp: ORIGIN_TIMESTAMP,
+            headers: None,
+            payload: payload.as_bytes(),
+        })
+        .collect();
+    let mut body = BytesMut::new();
+    SendMessagesEncoder::encode(
+        &mut body,
+        &WireIdentifier::numeric(1),
+        &WireIdentifier::numeric(2),
+        &WirePartitioning::PartitionId(spec.partition_id),
+        &messages,
+    )?;
+    let blob: usize = spec
+        .messages
+        .iter()
+        .map(|(_, payload)| BATCH_MESSAGE_HEADER_SIZE + payload.len())
+        .sum();
+    let mut batch = body[body.len() - BATCH_HEADER_SIZE - blob..].to_vec();
+    let mut header = BatchHeader::decode(&batch)?;
+    header.partition_id = u64::from(spec.partition_id);
+    header.base_offset = spec.first_offset;
+    header.base_timestamp = BASE_TIMESTAMP;
+    header.batch_checksum = header.checksum_for_blob(&batch[BATCH_HEADER_SIZE..]);
+    header.encode_into(&mut batch);
+    Ok(batch)
+}
+
+fn section_header(section: &SectionFields) -> FetchMessagesSectionHeader {
+    FetchMessagesSectionHeader {
+        partition_id: section.partition_id,
+        status: section.status,
+        current_offset: section.current_offset,
+        messages_count: section.messages_count,
+        context: PartitionContext {
+            incarnation: section.context.incarnation,
+            owner_generation: section.context.owner_generation,
+            metadata_op: section.context.metadata_op,
+        },
+        next_offset: section.next_offset,
+        records_len: section.records_len,
+    }
+}
+
+fn refused() -> FixtureResult<RefusedVectors> {
+    let valid = || {
+        fetch_fields(
+            Consumer {
+                kind: KIND_CONSUMER,
+                id: Identifier::Numeric(1),
+            },
+            false,
+            vec![(1, Strategy { kind: 1, value: 0 })],
+        )
+    };
+    let mut no_targets = valid();
+    no_targets.targets.clear();
+    let mut duplicate = valid();
+    duplicate.targets.push(TargetFields {
+        partition_id: 1,
+        strategy: Strategy { kind: 5, value: 0 },
+        max_bytes: 512 * 1024,
+        incarnation: INCARNATION,
+        owner_generation: 0,
+    });
+    let mut first_start = valid();
+    first_start.targets[0].strategy = Strategy { kind: 3, value: 0 };
+    let mut min_count_above_count = valid();
+    min_count_above_count.min_count = min_count_above_count.count + 1;
+    let mut target_above_request_bytes = valid();
+    target_above_request_bytes.targets[0].max_bytes = target_above_request_bytes.max_bytes + 1;
+    let mut timeout_without_margin = valid();
+    timeout_without_margin.request_timeout_us = timeout_without_margin.wait_us;
+    let fetches = [
+        ("no_targets", no_targets),
+        ("duplicate_partition", duplicate),
+        ("first_start_point", first_start),
+        ("min_count_above_count", min_count_above_count),
+        (
+            "target_bytes_above_request_bytes",
+            target_above_request_bytes,
+        ),
+        ("timeout_without_reply_margin", timeout_without_margin),
+    ]
+    .into_iter()
+    .map(|(case, fields)| {
+        Ok(RefusedVector {
+            case: case.to_string(),
+            body_hex: hex::encode(wire_fetch(&fields)?.to_bytes()),
+        })
+    })
+    .collect::<FixtureResult<_>>()?;
+
+    let error_with_records = {
+        let mut head = vec![0; FetchMessagesResponse::head_size(1)];
+        head[..4].copy_from_slice(&1u32.to_le_bytes());
+        let header = FetchMessagesSectionHeader {
+            partition_id: 1,
+            status: IggyError::NotPrimary.as_code(),
+            current_offset: 0,
+            messages_count: 0,
+            context: PartitionContext::default(),
+            next_offset: 0,
+            records_len: 4,
+        };
+        head[4..].copy_from_slice(&header.to_le_bytes());
+        head.extend_from_slice(&[0; 4]);
+        head
+    };
+    let truncated_table = {
+        let mut head = vec![0; FetchMessagesResponse::head_size(2)];
+        head[..4].copy_from_slice(&2u32.to_le_bytes());
+        head.truncate(head.len() - 1);
+        head
+    };
+    let fetch_replies = [
+        ("error_section_with_records", error_with_records),
+        ("truncated_table", truncated_table),
+    ]
+    .into_iter()
+    .map(|(case, body)| RefusedVector {
+        case: case.to_string(),
+        body_hex: hex::encode(body),
+    })
+    .collect();
+    Ok(RefusedVectors {
+        fetches,
+        fetch_replies,
+    })
+}
+
+fn wire_fetch(fields: &FetchFields) -> FixtureResult<FetchMessagesRequest> {
+    Ok(FetchMessagesRequest {
+        consumer: wire_consumer(&fields.consumer)?,
+        stream_id: wire_identifier(&fields.stream_id)?,
+        topic_id: wire_identifier(&fields.topic_id)?,
+        wait_us: fields.wait_us,
+        request_timeout_us: fields.request_timeout_us,
+        min_count: fields.min_count,
+        count: fields.count,
+        max_bytes: fields.max_bytes,
+        auto_commit: fields.auto_commit,
+        targets: fields
+            .targets
+            .iter()
+            .map(|target| FetchMessagesTarget {
+                partition_id: target.partition_id,
+                strategy: wire_strategy(target.strategy),
+                max_bytes: target.max_bytes,
+                incarnation: target.incarnation,
+                owner_generation: target.owner_generation,
+            })
+            .collect(),
+    })
+}
+
+fn wire_consumer(consumer: &Consumer) -> FixtureResult<WireConsumer> {
+    let id = wire_identifier(&consumer.id)?;
+    Ok(if consumer.kind == KIND_CONSUMER_GROUP {
+        WireConsumer::consumer_group(id)
+    } else {
+        WireConsumer::consumer(id)
+    })
+}
+
+fn wire_identifier(identifier: &Identifier) -> FixtureResult<WireIdentifier> {
+    Ok(match identifier {
+        Identifier::Numeric(id) => WireIdentifier::numeric(*id),
+        Identifier::Named(name) => WireIdentifier::named(name.as_str())?,
+    })
+}
+
+const fn wire_strategy(strategy: Strategy) -> WirePollingStrategy {
+    WirePollingStrategy {
+        kind: strategy.kind,
+        value: strategy.value,
+    }
+}
diff --git a/core/common/fixtures/deferred_polling.json b/core/common/fixtures/deferred_polling.json
new file mode 100644
index 0000000..452b461
--- /dev/null
+++ b/core/common/fixtures/deferred_polling.json
@@ -0,0 +1,610 @@
+{
+  "schema_version": 1,
+  "generated_by": "core/common/examples/deferred_polling_fixtures.rs",
+  "conventions": {
+    "body_hex": "Every *_hex field is lowercase hex without separators. body_hex is the command payload without framing.",
+    "header_hex": "header_hex is the whole request header of a cancellation, which has no body, before the transport stamps its checksum and checksum_body.",
+    "oversized": "A reply may carry a first record above max_bytes, up to max_record_size, so a client reply cap is at least max_record_reply_size.",
+    "refused": "Every refused fetch fails to decode or to validate. Every refused fetch reply fails to decode.",
+    "status": "A section status of status_ok was read. Any other status is the error code of that partition alone, and its section holds no records.",
+    "strategy": "strategy.kind is the wire kind: 1 offset, 2 timestamp, 3 first, 4 last, 5 next."
+  },
+  "constants": {
+    "codes": {
+      "cancel_deferred_request": 108,
+      "fetch_messages": 109,
+      "poll_messages": 100,
+      "poll_messages_deferred": 106
+    },
+    "status_ok": 0,
+    "errors": {
+      "deferred_poll_capacity_exceeded": 90,
+      "fetch_progress_uncertain": 14010,
+      "history_unavailable": 87,
+      "invalid_size_bytes": 80,
+      "not_primary": 89,
+      "partition_lane_blocked": 14009,
+      "too_many_consumer_offsets": 3024,
+      "transient_not_accepted": 58,
+      "transient_not_committed": 57
+    },
+    "min_deferred_poll_max_bytes": 344,
+    "max_deferred_poll_wait_us": 600000000,
+    "max_deferred_poll_timeout_us": 630000000,
+    "min_deferred_poll_reply_margin_us": 1000000,
+    "fetch_reply_margin_us": 200000,
+    "max_partitions_per_request": 1000,
+    "max_record_size": 64100304,
+    "max_record_reply_size": 64156564,
+    "request_header_size": 256,
+    "fetch_section_header_size": 56
+  },
+  "deferred_polls": [
+    {
+      "case": "consumer_offset_without_commit",
+      "request": {
+        "consumer": {
+          "kind": 1,
+          "id": {
+            "kind": "numeric",
+            "value": 1
+          }
+        },
+        "stream_id": {
+          "kind": "numeric",
+          "value": 1
+        },
+        "topic_id": {
+          "kind": "named",
+          "value": "orders"
+        },
+        "partition_id": 2,
+        "strategy": {
+          "kind": 1,
+          "value": 41
+        },
+        "count": 100,
+        "auto_commit": false,
+        "wait_us": 5000000,
+        "min_count": 1,
+        "max_bytes": 1048576,
+        "request_timeout_us": 6000000
+      },
+      "body_hex": "0101040100000001040100000002066f726465727301020000000129000000000000006400000000404b4c00000000000100000000001000808d5b0000000000"
+    },
+    {
+      "case": "group_next_with_commit",
+      "request": {
+        "consumer": {
+          "kind": 2,
+          "id": {
+            "kind": "named",
+            "value": "orders-group"
+          }
+        },
+        "stream_id": {
+          "kind": "numeric",
+          "value": 1
+        },
+        "topic_id": {
+          "kind": "named",
+          "value": "orders"
+        },
+        "partition_id": 2,
+        "strategy": {
+          "kind": 5,
+          "value": 0
+        },
+        "count": 100,
+        "auto_commit": true,
+        "wait_us": 5000000,
+        "min_count": 1,
+        "max_bytes": 1048576,
+        "request_timeout_us": 6000000
+      },
+      "body_hex": "02020c6f72646572732d67726f757001040100000002066f726465727301020000000500000000000000006400000001404b4c00000000000100000000001000808d5b0000000000"
+    }
+  ],
+  "fetches": [
+    {
+      "case": "consumer_two_targets_without_commit",
+      "request": {
+        "consumer": {
+          "kind": 1,
+          "id": {
+            "kind": "numeric",
+            "value": 1
+          }
+        },
+        "stream_id": {
+          "kind": "numeric",
+          "value": 1
+        },
+        "topic_id": {
+          "kind": "numeric",
+          "value": 2
+        },
+        "wait_us": 5000000,
+        "request_timeout_us": 6000000,
+        "min_count": 1,
+        "count": 1000,
+        "max_bytes": 1048576,
+        "auto_commit": false,
+        "targets": [
+          {
+            "partition_id": 1,
+            "strategy": {
+              "kind": 1,
+              "value": 41
+            },
+            "max_bytes": 524288,
+            "incarnation": 11,
+            "owner_generation": 0
+          },
+          {
+            "partition_id": 3,
+            "strategy": {
+              "kind": 5,
+              "value": 0
+            },
+            "max_bytes": 524288,
+            "incarnation": 11,
+            "owner_generation": 0
+          }
+        ]
+      },
+      "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b000000000001000000e803000000001000000200000001000000012900000000000000000008000b00000000000000000000000000000003000000050000000000000000000008000b000000000000000000000000000000"
+    },
+    {
+      "case": "group_three_targets_with_commit",
+      "request": {
+        "consumer": {
+          "kind": 2,
+          "id": {
+            "kind": "numeric",
+            "value": 9
+          }
+        },
+        "stream_id": {
+          "kind": "numeric",
+          "value": 1
+        },
+        "topic_id": {
+          "kind": "numeric",
+          "value": 2
+        },
+        "wait_us": 5000000,
+        "request_timeout_us": 6000000,
+        "min_count": 1,
+        "count": 1000,
+        "max_bytes": 1048576,
+        "auto_commit": true,
+        "targets": [
+          {
+            "partition_id": 1,
+            "strategy": {
+              "kind": 5,
+              "value": 0
+            },
+            "max_bytes": 524288,
+            "incarnation": 11,
+            "owner_generation": 4
+          },
+          {
+            "partition_id": 2,
+            "strategy": {
+              "kind": 5,
+              "value": 0
+            },
+            "max_bytes": 524288,
+            "incarnation": 11,
+            "owner_generation": 4
+          },
+          {
+            "partition_id": 4,
+            "strategy": {
+              "kind": 1,
+              "value": 7
+            },
+            "max_bytes": 524288,
+            "incarnation": 11,
+            "owner_generation": 4
+          }
+        ]
+      },
+      "body_hex": "02010409000000010401000000010402000000404b4c0000000000808d5b000000000001000000e803000000001000010300000001000000050000000000000000000008000b00000000000000040000000000000002000000050000000000000000000008000b00000000000000040000000000000004000000010700000000000000000008000b000000000000000400000000000000"
+    }
+  ],
+  "cancellations": [
+    {
+      "case": "cancel_deferred_poll",
+      "cancels_code": 106,
+      "client": 7,
+      "request": 21,
+      "session": 3,
+      "header_hex": "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000050000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007000000000000000000000000000000000000000000000000000000000000000000000000000000150000000000000002000000000000000300000000000000000000006c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
+    },
+    {
+      "case": "cancel_fetch",
+      "cancels_code": 109,
+      "client": 7,
+      "request": 22,
+      "session": 3,
+      "header_hex": "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000050000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007000000000000000000000000000000000000000000000000000000000000000000000000000000160000000000000002000000000000000300000000000000000000006c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
+    }
+  ],
+  "fetch_replies": [
+    {
+      "case": "ok_section_and_empty_start_section",
+      "sections": [
+        {
+          "partition_id": 1,
+          "status": 0,
+          "current_offset": 44,
+          "messages_count": 3,
+          "context": {
+            "incarnation": 11,
+            "owner_generation": 4,
+            "metadata_op": 42
+          },
+          "next_offset": 44,
+          "records_len": 416,
+          "messages": [
+            {
+              "offset": 41,
+              "id": "258",
+              "timestamp": 1700000000000500,
+              "origin_timestamp": 1700000000000000,
+              "payload": "first"
+            },
+            {
+              "offset": 42,
+              "id": "259",
+              "timestamp": 1700000000000500,
+              "origin_timestamp": 1700000000000000,
+              "payload": "second"
+            },
+            {
+              "offset": 43,
+              "id": "260",
+              "timestamp": 1700000000000500,
+              "origin_timestamp": 1700000000000000,
+              "payload": "third"
+            }
+          ]
+        },
+        {
+          "partition_id": 3,
+          "status": 0,
+          "current_offset": 9,
+          "messages_count": 0,
+          "context": {
+            "incarnation": 11,
+            "owner_generation": 4,
+            "metadata_op": 42
+          },
+          "next_offset": 10,
+          "records_len": 0,
+          "messages": []
+        }
+      ],
+      "body_hex": "0200000001000000000000002c00000000000000030000000b0000000000000004000000000000002a000000000000002c00000000000000a001000003000000000000000900000000000000000000000b0000000000000004000000000000002a000000000000000a000000000000000000000001000000000000002900000000000000f4411e18240a060000401e18240a0600a00100000000000088fc3268f2c0e02103000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000bb5b1cd43b71761a02010000000000000000000000000000000000000000000000000000050000000000000000000000666972737453c25c376f98c265030100000000000000000000000000000100000000000000000000000600000000000000000000007365636f6e64a761c18f632fb022040100000000000000000000000000000200000000000000000000000500000000000000000000007468697264"
+    },
+    {
+      "case": "ok_section_beside_error_sections",
+      "sections": [
+        {
+          "partition_id": 1,
+          "status": 89,
+          "current_offset": 0,
+          "messages_count": 0,
+          "context": {
+            "incarnation": 0,
+            "owner_generation": 0,
+            "metadata_op": 0
+          },
+          "next_offset": 0,
+          "records_len": 0,
+          "messages": []
+        },
+        {
+          "partition_id": 2,
+          "status": 0,
+          "current_offset": 7,
+          "messages_count": 1,
+          "context": {
+            "incarnation": 11,
+            "owner_generation": 4,
+            "metadata_op": 42
+          },
+          "next_offset": 7,
+          "records_len": 308,
+          "messages": [
+            {
+              "offset": 6,
+              "id": "340282366920938463463374607431768211455",
+              "timestamp": 1700000000000500,
+              "origin_timestamp": 1700000000000000,
+              "payload": "only"
+            }
+          ]
+        },
+        {
+          "partition_id": 4,
+          "status": 58,
+          "current_offset": 0,
+          "messages_count": 0,
+          "context": {
+            "incarnation": 0,
+            "owner_generation": 0,
+            "metadata_op": 0
+          },
+          "next_offset": 0,
+          "records_len": 0,
+          "messages": []
+        }
+      ],
+      "body_hex": "03000000010000005900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000700000000000000010000000b0000000000000004000000000000002a00000000000000070000000000000034010000040000003a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000600000000000000f4411e18240a060000401e18240a060034010000000000003e89bfefc17b805301000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000fbb4aeb35a85bdaeffffffffffffffffffffffffffffffff0000000000000000000000000400000000000000000000006f6e6c79"
+    },
+    {
+      "case": "no_sections",
+      "sections": [],
+      "body_hex": "00000000"
+    }
+  ],
+  "refused": {
+    "fetches": [
+      {
+        "case": "no_targets",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b000000000001000000e8030000000010000000000000"
+      },
+      {
+        "case": "duplicate_partition",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b000000000001000000e803000000001000000200000001000000010000000000000000000008000b00000000000000000000000000000001000000050000000000000000000008000b000000000000000000000000000000"
+      },
+      {
+        "case": "first_start_point",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b000000000001000000e803000000001000000100000001000000030000000000000000000008000b000000000000000000000000000000"
+      },
+      {
+        "case": "min_count_above_count",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b0000000000e9030000e803000000001000000100000001000000010000000000000000000008000b000000000000000000000000000000"
+      },
+      {
+        "case": "target_bytes_above_request_bytes",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b000000000001000000e803000000001000000100000001000000010000000000000000010010000b000000000000000000000000000000"
+      },
+      {
+        "case": "timeout_without_reply_margin",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000404b4c000000000001000000e803000000001000000100000001000000010000000000000000000008000b000000000000000000000000000000"
+      }
+    ],
+    "fetch_replies": [
+      {
+        "case": "error_section_with_records",
+        "body_hex": "01000000010000005900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000"
+      },
+      {
+        "case": "truncated_table",
+        "body_hex": "02000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
+      }
+    ]
+  },
+  "commit_outcomes": {
+    "rule": "A committing deferred poll (106) or fetch (109) that fails with a certain status stored nothing. Every other status, a status not listed here, a lost reply and a transport failure after the send leave the outcome unknown.",
+    "request": [
+      {
+        "status": 5,
+        "error": "feature_unavailable",
+        "certain": true
+      },
+      {
+        "status": 6,
+        "error": "invalid_identifier",
+        "certain": true
+      },
+      {
+        "status": 30,
+        "error": "stale_client",
+        "certain": false
+      },
+      {
+        "status": 40,
+        "error": "unauthenticated",
+        "certain": false
+      },
+      {
+        "status": 41,
+        "error": "unauthorized",
+        "certain": false
+      },
+      {
+        "status": 57,
+        "error": "transient_not_committed",
+        "certain": false
+      },
+      {
+        "status": 58,
+        "error": "transient_not_accepted",
+        "certain": true
+      },
+      {
+        "status": 72,
+        "error": "cannot_decrypt_data",
+        "certain": false
+      },
+      {
+        "status": 80,
+        "error": "invalid_size_bytes",
+        "certain": true
+      },
+      {
+        "status": 87,
+        "error": "history_unavailable",
+        "certain": true
+      },
+      {
+        "status": 89,
+        "error": "not_primary",
+        "certain": true
+      },
+      {
+        "status": 90,
+        "error": "deferred_poll_capacity_exceeded",
+        "certain": true
+      },
+      {
+        "status": 1009,
+        "error": "stream_id_not_found",
+        "certain": true
+      },
+      {
+        "status": 1010,
+        "error": "stream_name_not_found",
+        "certain": true
+      },
+      {
+        "status": 2010,
+        "error": "topic_id_not_found",
+        "certain": true
+      },
+      {
+        "status": 2011,
+        "error": "topic_name_not_found",
+        "certain": true
+      },
+      {
+        "status": 3007,
+        "error": "partition_not_found",
+        "certain": true
+      },
+      {
+        "status": 3024,
+        "error": "too_many_consumer_offsets",
+        "certain": true
+      },
+      {
+        "status": 4100,
+        "error": "invalid_offset",
+        "certain": true
+      },
+      {
+        "status": 5009,
+        "error": "consumer_group_partition_not_owned",
+        "certain": true
+      },
+      {
+        "status": 11001,
+        "error": "shard_communication_error",
+        "certain": false
+      }
+    ],
+    "section": [
+      {
+        "status": 5,
+        "error": "feature_unavailable",
+        "certain": false
+      },
+      {
+        "status": 6,
+        "error": "invalid_identifier",
+        "certain": false
+      },
+      {
+        "status": 30,
+        "error": "stale_client",
+        "certain": false
+      },
+      {
+        "status": 40,
+        "error": "unauthenticated",
+        "certain": false
+      },
+      {
+        "status": 41,
+        "error": "unauthorized",
+        "certain": false
+      },
+      {
+        "status": 57,
+        "error": "transient_not_committed",
+        "certain": false
+      },
+      {
+        "status": 58,
+        "error": "transient_not_accepted",
+        "certain": true
+      },
+      {
+        "status": 72,
+        "error": "cannot_decrypt_data",
+        "certain": false
+      },
+      {
+        "status": 80,
+        "error": "invalid_size_bytes",
+        "certain": true
+      },
+      {
+        "status": 87,
+        "error": "history_unavailable",
+        "certain": true
+      },
+      {
+        "status": 89,
+        "error": "not_primary",
+        "certain": true
+      },
+      {
+        "status": 90,
+        "error": "deferred_poll_capacity_exceeded",
+        "certain": false
+      },
+      {
+        "status": 1009,
+        "error": "stream_id_not_found",
+        "certain": false
+      },
+      {
+        "status": 1010,
+        "error": "stream_name_not_found",
+        "certain": false
+      },
+      {
+        "status": 2010,
+        "error": "topic_id_not_found",
+        "certain": false
+      },
+      {
+        "status": 2011,
+        "error": "topic_name_not_found",
+        "certain": false
+      },
+      {
+        "status": 3007,
+        "error": "partition_not_found",
+        "certain": true
+      },
+      {
+        "status": 3024,
+        "error": "too_many_consumer_offsets",
+        "certain": true
+      },
+      {
+        "status": 4100,
+        "error": "invalid_offset",
+        "certain": false
+      },
+      {
+        "status": 5009,
+        "error": "consumer_group_partition_not_owned",
+        "certain": true
+      },
+      {
+        "status": 11001,
+        "error": "shard_communication_error",
+        "certain": false
+      }
+    ]
+  }
+}
diff --git a/core/common/fixtures/resource_options.json b/core/common/fixtures/resource_options.json
index c5cc2ac..202888f 100644
--- a/core/common/fixtures/resource_options.json
+++ b/core/common/fixtures/resource_options.json
@@ -16,18 +16,18 @@
   },
   "constants": {
     "protocol_version": {
-      "packed": 12288,
-      "text": "0.12.0",
+      "packed": 12289,
+      "text": "0.12.1",
       "major": 0,
       "minor": 12,
-      "patch": 0
+      "patch": 1
     },
     "protocol_version_min": {
-      "packed": 12288,
-      "text": "0.12.0",
+      "packed": 12289,
+      "text": "0.12.1",
       "major": 0,
       "minor": 12,
-      "patch": 0
+      "patch": 1
     },
     "command_codes": {
       "create_stream": {
diff --git a/core/common/src/consumer_group_client_state.rs b/core/common/src/consumer_group_client_state.rs
index 05d2632..ec9dbfd 100644
--- a/core/common/src/consumer_group_client_state.rs
+++ b/core/common/src/consumer_group_client_state.rs
@@ -18,13 +18,12 @@
 //! Client-side consumer-group + partitioning state for the VSR transport.
 //!
 //! The SDK resolves partition operations locally before encoding their namespace:
-//! - consumer-group polls select the next of the member's assigned partitions
-//!   (round-robin) from the cached assignment synced from the coordinator;
+//! - a consumer-group member reads the assignment synced from the coordinator.
 //! - `Balanced` produce round-robins per topic; `MessagesKey` hashes the key.
 //!
 //! Cursors must persist across calls, so this lives on the long-lived
-//! transport. The coordinator fences stale selections (rebalance), prompting a
-//! re-sync that resets the cursor.
+//! transport. The coordinator fences stale assignments (rebalance), prompting
+//! a re-sync.
 
 use crate::{Identifier, IggyError};
 use iggy_binary_protocol::primitives::partition_history::PartitionContext;
@@ -36,7 +35,6 @@
 struct GroupAssignment {
     partitions: Vec<u32>,
     generation: u64,
-    cursor: usize,
 }
 
 #[derive(Debug, Default)]
@@ -79,12 +77,13 @@
 
 impl GroupState {
     fn set_assignment(&mut self, key: String, generation: u64, partitions: Vec<u32>) {
-        let entry = self.assignments.entry(key).or_default();
-        if entry.generation != generation {
-            entry.cursor = 0;
-        }
-        entry.generation = generation;
-        entry.partitions = partitions;
+        self.assignments.insert(
+            key,
+            GroupAssignment {
+                partitions,
+                generation,
+            },
+        );
     }
 }
 
@@ -141,34 +140,17 @@
             .is_some_and(|assignment| !assignment.partitions.is_empty())
     }
 
-    /// Replace the cached assignment for a group. A generation change (a
-    /// rebalance) resets the round-robin cursor so selection restarts cleanly.
+    /// Replace the cached assignment for a group.
     pub fn set_assignment(&self, key: String, generation: u64, partitions: Vec<u32>) {
         self.groups_mut()
             .set_assignment(key, generation, partitions);
     }
 
-    /// Drop a group's cached assignment (after a fence rejection) so the next
-    /// poll re-syncs.
+    /// Drop a group's cached assignment, so it must be synced again.
     pub fn invalidate_assignment(&self, key: &str) {
         self.groups_mut().assignments.remove(key);
     }
 
-    /// The next assigned partition for a group poll, advancing the cursor.
-    /// `None` when nothing is cached / the assignment is empty.
-    #[must_use]
-    pub fn next_group_partition(&self, key: &str) -> Option<u32> {
-        let mut groups = self.groups();
-        let map = &mut groups.assignments;
-        let assignment = map.get_mut(key)?;
-        if assignment.partitions.is_empty() {
-            return None;
-        }
-        let index = assignment.cursor % assignment.partitions.len();
-        assignment.cursor = assignment.cursor.wrapping_add(1);
-        Some(assignment.partitions[index])
-    }
-
     /// The next `Balanced` produce partition for a topic, advancing the cursor.
     #[must_use]
     #[allow(clippy::cast_possible_truncation)]
@@ -427,13 +409,13 @@
 
         state.set_assignment(KEY.to_owned(), 1, vec![0, 1]);
         assert!(changed(&state));
-        assert_eq!(state.next_group_partition(KEY), Some(0));
+        assert_eq!(state.assignment_snapshot(KEY).1, [0, 1]);
         let version = AssignmentVersion {
             session: 0,
             generation: 1,
         };
         assert!(!state.is_current_assignment(KEY, version, 0));
-        assert!(!changed(&state), "reads and cursor moves keep the count");
+        assert!(!changed(&state), "reads keep the count");
         state.register_group(
             KEY.to_owned(),
             identifier.clone(),
@@ -454,27 +436,6 @@
     }
 
     #[test]
-    fn group_partition_round_robins_then_wraps() {
-        let state = ConsumerGroupClientState::new();
-        state.set_assignment("s|t|g".to_owned(), 1, vec![0, 1, 2]);
-        let picks: Vec<u32> = (0..4)
-            .map(|_| state.next_group_partition("s|t|g").unwrap())
-            .collect();
-        assert_eq!(picks, vec![0, 1, 2, 0]);
-    }
-
-    #[test]
-    fn generation_change_resets_cursor() {
-        let state = ConsumerGroupClientState::new();
-        state.set_assignment("s|t|g".to_owned(), 1, vec![0, 1, 2]);
-        assert_eq!(state.next_group_partition("s|t|g"), Some(0));
-        assert_eq!(state.next_group_partition("s|t|g"), Some(1));
-        // Rebalance: new generation, one partition, cursor reset.
-        state.set_assignment("s|t|g".to_owned(), 2, vec![5]);
-        assert_eq!(state.next_group_partition("s|t|g"), Some(5));
-    }
-
-    #[test]
     fn balanced_round_robins() {
         let state = ConsumerGroupClientState::new();
         let picks: Vec<u32> = (0..4)
@@ -514,7 +475,7 @@
     fn missing_assignment_yields_none() {
         let state = ConsumerGroupClientState::new();
         assert!(!state.has_assignment("s|t|g"));
-        assert_eq!(state.next_group_partition("s|t|g"), None);
+        assert!(state.assignment_snapshot("s|t|g").1.is_empty());
     }
 
     #[test]
diff --git a/core/common/src/error/iggy_error.rs b/core/common/src/error/iggy_error.rs
index 33d0dc8..be13e4a 100644
--- a/core/common/src/error/iggy_error.rs
+++ b/core/common/src/error/iggy_error.rs
@@ -639,6 +639,17 @@
     LaneBlocked(u64) = 14007,
     #[error("Operation {0} is not retained")]
     OperationNotRetained(u64) = 14008,
+    /// Names the blocked partition of a multi-target request, or of any progress
+    /// call prevented by the retained unknown outcome of a committing read: a
+    /// fetch or a poll with `CommitPolicy::OnRead`.
+    /// Field order: `(partition_id, operation_id)`.
+    #[error("Lane of partition {0} blocked by operation {1} with an unknown outcome")]
+    PartitionLaneBlocked(u32, u64) = 14009,
+    /// A committing read, a fetch or a poll with `CommitPolicy::OnRead`,
+    /// has no replayable receipt. Its local recovery ID survives session
+    /// replacement. Only explicit abandonment frees progress.
+    #[error("Read {0} has unknown progress, explicit abandonment is required")]
+    FetchProgressUncertain(u64) = 14010,
 }
 
 impl IggyError {
@@ -652,13 +663,16 @@
         self.into()
     }
 
-    /// The retained operation that an `OperationUncertain`, `LaneBlocked` or
-    /// `OperationNotRetained` error names, to resolve or abandon it.
+    /// The retained operation that an `OperationUncertain`, `LaneBlocked`,
+    /// `OperationNotRetained`, `PartitionLaneBlocked` or `FetchProgressUncertain`
+    /// error names. A fetch identity supports abandonment only, never replay.
     pub fn operation_id(&self) -> Option<u64> {
         match self {
             IggyError::OperationUncertain(operation_id)
             | IggyError::LaneBlocked(operation_id)
-            | IggyError::OperationNotRetained(operation_id) => Some(*operation_id),
+            | IggyError::OperationNotRetained(operation_id)
+            | IggyError::FetchProgressUncertain(operation_id)
+            | IggyError::PartitionLaneBlocked(_, operation_id) => Some(*operation_id),
             _ => None,
         }
     }
@@ -686,6 +700,7 @@
 
     const GROUP_NAME_ERROR_CODE: u32 = 5005;
     const RETAINED_OPERATION: u64 = 7;
+    const BLOCKED_PARTITION: u32 = 3;
 
     #[cfg(target_arch = "x86_64")]
     #[test]
@@ -790,6 +805,16 @@
                 14008,
                 "operation_not_retained",
             ),
+            (
+                IggyError::PartitionLaneBlocked(BLOCKED_PARTITION, RETAINED_OPERATION),
+                14009,
+                "partition_lane_blocked",
+            ),
+            (
+                IggyError::FetchProgressUncertain(RETAINED_OPERATION),
+                14010,
+                "fetch_progress_uncertain",
+            ),
         ];
         for (error, code, name) in cases {
             assert_eq!(error.as_code(), code);
@@ -815,6 +840,14 @@
             IggyError::from_code(14008),
             IggyError::OperationNotRetained(0)
         ));
+        assert!(matches!(
+            IggyError::from_code(14009),
+            IggyError::PartitionLaneBlocked(0, 0)
+        ));
+        assert!(matches!(
+            IggyError::from_code(14010),
+            IggyError::FetchProgressUncertain(0)
+        ));
     }
 
     #[test]
@@ -826,6 +859,14 @@
             ),
             (IggyError::LaneBlocked(u64::MAX), Some(u64::MAX)),
             (IggyError::OperationNotRetained(0), Some(0)),
+            (
+                IggyError::FetchProgressUncertain(RETAINED_OPERATION),
+                Some(RETAINED_OPERATION),
+            ),
+            (
+                IggyError::PartitionLaneBlocked(BLOCKED_PARTITION, RETAINED_OPERATION),
+                Some(RETAINED_OPERATION),
+            ),
             (IggyError::NotPrimary, None),
         ];
         for (error, operation_id) in cases {
@@ -834,6 +875,20 @@
     }
 
     #[test]
+    fn given_a_blocked_partition_lane_when_displayed_should_name_the_partition_and_the_operation() {
+        let message =
+            IggyError::PartitionLaneBlocked(BLOCKED_PARTITION, RETAINED_OPERATION).to_string();
+        assert!(
+            message.contains(&format!("partition {BLOCKED_PARTITION}")),
+            "{message}"
+        );
+        assert!(
+            message.contains(&format!("operation {RETAINED_OPERATION}")),
+            "{message}"
+        );
+    }
+
+    #[test]
     fn given_too_many_consumer_groups_when_displayed_should_name_the_per_topic_limit() {
         let message = IggyError::TooManyConsumerGroups.to_string();
         assert!(message.contains("topic"), "{message}");
diff --git a/core/common/src/lib.rs b/core/common/src/lib.rs
index 41ee390..e1db4cb 100644
--- a/core/common/src/lib.rs
+++ b/core/common/src/lib.rs
@@ -42,10 +42,10 @@
 pub const RESYNC_REQUIRED_PARTITION_SENTINEL: u32 = u32::MAX;
 
 /// Client-side `partition_id` of an empty poll reply for a consumer-group
-/// member that currently holds no partitions. The server never sends it: the
-/// transport fills it in when the synced assignment is empty, so a caller can
-/// tell "nothing assigned" from a genuine empty poll, which echoes the real
-/// partition id. Same value as the Go and Node SDKs use for that case.
+/// member that currently holds no partitions, as the Go and Swift SDKs fill it
+/// in. The server never sends it, and the Rust SDK never fills it in, because
+/// its polls always name a partition. It stays as the source of the Swift
+/// golden vectors.
 pub const NO_ASSIGNED_PARTITION: u32 = u32::MAX - 1;
 
 /// Frozen ceiling on the widest batch record any admission path can persist.
@@ -107,9 +107,8 @@
 pub use traits::topic_client::TopicClient;
 pub use traits::user_client::UserClient;
 pub use traits::validatable::Validatable;
-pub use traits::wait_for_messages_request;
 #[doc(hidden)]
-pub use traits::{BinarySendPartitioning, send_binary_messages};
+pub use traits::{BinarySendPartitioning, fetch_binary_messages, send_binary_messages};
 pub use traits::{binary_sign_in, rust_sdk_version_info, sign_in_request};
 pub use traits::{decode_send_confirmations, group_cache_key, sync_group_assignment};
 pub use types::args::*;
diff --git a/core/common/src/traits/binary_impls/consumer_offsets.rs b/core/common/src/traits/binary_impls/consumer_offsets.rs
index fb9034c..037d37a 100644
--- a/core/common/src/traits/binary_impls/consumer_offsets.rs
+++ b/core/common/src/traits/binary_impls/consumer_offsets.rs
@@ -18,8 +18,8 @@
 use crate::traits::binary_auth::fail_if_not_authenticated;
 use crate::wire_conversions::{consumer_to_wire, identifier_to_wire};
 use crate::{
-    BinaryClient, Consumer, ConsumerOffsetClient, ConsumerOffsetInfo, ConsumerPosition, Identifier,
-    IggyError,
+    BinaryClient, Checkpoint, Consumer, ConsumerOffsetClient, ConsumerOffsetInfo, Identifier,
+    IggyError, PartitionContext,
 };
 use iggy_binary_protocol::AckLevel;
 use iggy_binary_protocol::codec::WireEncode;
@@ -38,7 +38,7 @@
         consumer: &Consumer,
         stream_id: &Identifier,
         topic_id: &Identifier,
-        position: ConsumerPosition,
+        checkpoint: &Checkpoint,
     ) -> Result<(), IggyError> {
         fail_if_not_authenticated(self).await?;
         self.send_offset_write_with_response(
@@ -47,12 +47,12 @@
                 consumer: consumer_to_wire(consumer)?,
                 stream_id: identifier_to_wire(stream_id)?,
                 topic_id: identifier_to_wire(topic_id)?,
-                partition_id: Some(position.partition_id),
-                offset: position.offset,
+                partition_id: Some(checkpoint.partition_id()),
+                offset: checkpoint.offset(),
                 ack: AckLevel::Quorum,
             }
             .to_bytes(),
-            Some(position.context),
+            Some(checkpoint.context()),
         )
         .await?;
         Ok(())
@@ -124,23 +124,50 @@
         topic_id: &Identifier,
         partition_id: Option<u32>,
     ) -> Result<(), IggyError> {
-        fail_if_not_authenticated(self).await?;
-        let wire_consumer = consumer_to_wire(consumer)?;
-        let wire_stream_id = identifier_to_wire(stream_id)?;
-        let wire_topic_id = identifier_to_wire(topic_id)?;
-        self.send_offset_write_with_response(
+        delete_offset(self, consumer, stream_id, topic_id, partition_id, None).await
+    }
+
+    async fn delete_consumer_position(
+        &self,
+        consumer: &Consumer,
+        stream_id: &Identifier,
+        topic_id: &Identifier,
+        checkpoint: &Checkpoint,
+    ) -> Result<(), IggyError> {
+        delete_offset(
+            self,
+            consumer,
+            stream_id,
+            topic_id,
+            Some(checkpoint.partition_id()),
+            Some(checkpoint.context()),
+        )
+        .await
+    }
+}
+
+async fn delete_offset<B: BinaryClient>(
+    client: &B,
+    consumer: &Consumer,
+    stream_id: &Identifier,
+    topic_id: &Identifier,
+    partition_id: Option<u32>,
+    context: Option<PartitionContext>,
+) -> Result<(), IggyError> {
+    fail_if_not_authenticated(client).await?;
+    client
+        .send_offset_write_with_response(
             DELETE_CONSUMER_OFFSET_CODE,
             DeleteConsumerOffsetRequest {
-                consumer: wire_consumer,
-                stream_id: wire_stream_id,
-                topic_id: wire_topic_id,
+                consumer: consumer_to_wire(consumer)?,
+                stream_id: identifier_to_wire(stream_id)?,
+                topic_id: identifier_to_wire(topic_id)?,
                 partition_id,
                 ack: AckLevel::Quorum,
             }
             .to_bytes(),
-            None,
+            context,
         )
         .await?;
-        Ok(())
-    }
+    Ok(())
 }
diff --git a/core/common/src/traits/binary_impls/messages.rs b/core/common/src/traits/binary_impls/messages.rs
index 736e5ad..160ec69 100644
--- a/core/common/src/traits/binary_impls/messages.rs
+++ b/core/common/src/traits/binary_impls/messages.rs
@@ -21,12 +21,13 @@
     consumer_to_wire, identifier_to_wire, partitioning_to_wire, polling_strategy_to_wire,
 };
 use crate::{
-    Consumer, Identifier, IggyError, IggyMessage, MessageClient, Partitioning, PolledMessages,
+    CommitPolicy, Consumer, DecodeError, DeferredPollOptions, FetchedSection, Identifier,
+    IggyError, IggyMessage, MessageClient, Partitioning, PollOptions, PolledMessages,
     PollingStrategy, SendMessagesResponse,
 };
 use crate::{
-    ConsumerKind, PartitioningKind, TopicClient, binary_operation_deadline, calculate_32,
-    with_binary_deadline, with_binary_lineage,
+    PartitioningKind, TopicClient, binary_operation_deadline, calculate_32, with_binary_deadline,
+    with_binary_lineage,
 };
 use bytes::{Bytes, BytesMut};
 use iggy_binary_protocol::codec::WireDecode;
@@ -35,17 +36,11 @@
 use iggy_binary_protocol::codes::SYNC_CONSUMER_GROUP_CODE;
 use iggy_binary_protocol::requests::consumer_groups::SyncConsumerGroupRequest;
 use iggy_binary_protocol::requests::messages::{
-    PollMessagesRequest, RawMessage, SendMessagesEncoder, WaitForMessagesRequest,
-    WaitForMessagesTarget,
+    FetchMessagesRequest, FetchMessagesTarget, PollMessagesRequest, RawMessage, SendMessagesEncoder,
 };
 use iggy_binary_protocol::responses::consumer_groups::SyncConsumerGroupResponse;
-use iggy_binary_protocol::responses::messages::WaitForMessagesResponse;
 use std::borrow::Cow;
 
-/// Max attempts to resolve a fenced consumer-group poll: one re-sync after the
-/// coordinator rejects a stale assignment, then retry once.
-const GROUP_POLL_MAX_ATTEMPTS: usize = 2;
-
 /// The key of a group in [`crate::ConsumerGroupClientState`].
 #[doc(hidden)]
 pub fn group_cache_key(
@@ -261,121 +256,118 @@
     Ok(Partitioning::partition_id(partition))
 }
 
-/// Poll a consumer group: select one of the member's assigned partitions
-/// (round-robin), ask `strategy_for` where to read it from and send an
-/// explicit-partition poll. A coordinator fence rejection (stale assignment
-/// after a rebalance) triggers one re-sync + retry.
+/// Read up to `count` messages from several partitions in one request to their primary, and
+/// return one section per answered target, in reply order.
+///
+/// The targets are one readiness group: partitions with one primary, as the SDK groups them.
+/// Each start point is [`PollingStrategy::offset`] or [`PollingStrategy::next`] of `consumer`,
+/// and each partition appears once. A continuation fences its partition with its context and
+/// must belong to its target. A target without a context takes the context of its route. The
+/// server waits up to `options.max_wait` until the targets hold `options.min_count` messages.
+/// `count` bounds the messages of the whole reply. `options.max_bytes` bounds the records of
+/// the whole reply and of each section. The section table adds 4 bytes and 56 bytes per
+/// target. `options.request_timeout` must exceed `max_wait` by
+/// [`MIN_DEFERRED_POLL_REPLY_MARGIN_US`](crate::MIN_DEFERRED_POLL_REPLY_MARGIN_US), also for a
+/// zero wait.
+///
+/// A section serves messages and the offset where the next fetch of its partition starts, or
+/// holds the refusal of its partition, such as `NotPrimary` after a move. A target without a
+/// section keeps its position.
+///
+/// A fetch with `auto_commit` or a `next` target waits for the offset reads, stores and
+/// deletes of the client on its partitions. A store or delete with an unknown outcome blocks
+/// it with [`IggyError::PartitionLaneBlocked`], which names the partition. A committing fetch
+/// with an unknown outcome returns [`IggyError::FetchProgressUncertain`] and keeps all
+/// affected progress lanes blocked until explicit `Client::abandon_operation`. The local
+/// recovery identity survives reconnect and session replacement. Resolution never replays
+/// the fetch, and abandonment accepts uncertainty without stopping the server request.
+/// GetOffset and stateless reads remain usable. A partial reply keeps healthy sections and
+/// attaches the recovery error to uncertain sections. Record decoding and decryption failures
+/// of known healthy sections retain their ordinary rewind behavior. A noncommitting `next`
+/// fetch still ends with `TransientNotCommitted` without retaining a progress block.
+///
+/// Do not drop the future of a fetch with `auto_commit`. TCP and WebSocket then send a cancel,
+/// but a cancel cannot undo a fetch that the server already served: serving it committed the
+/// offsets, so the client loses the served messages. Await such a fetch until it ends, also at
+/// shutdown. It ends within `options.request_timeout`.
+/// Dropping it after possible send retains the block, and the next progress call names its
+/// partition and recovery identity with `PartitionLaneBlocked`.
+///
+/// # Errors
+/// The errors of [`DeferredPollOptions::validate_fetch`], [`IggyError::InvalidCommand`] for an
+/// invalid target list or a continuation of another partition, and the errors of the send.
+#[doc(hidden)]
 #[allow(clippy::too_many_arguments)]
-async fn poll_group_messages<B: BinaryClient>(
+pub async fn fetch_binary_messages<B: BinaryClient>(
     client: &B,
     stream_id: &Identifier,
     topic_id: &Identifier,
+    targets: &[(u32, PollingStrategy)],
     consumer: &Consumer,
-    strategy_for: &(dyn Fn(u32) -> PollingStrategy + Send + Sync),
     count: u32,
     auto_commit: bool,
-    options: Option<crate::DeferredPollOptions>,
-) -> Result<PolledMessages, IggyError> {
-    let started = std::time::Instant::now();
-    let key = group_cache_key(stream_id, topic_id, &consumer.id);
-    if !client.consumer_group_state().has_assignment(&key) {
-        sync_group_assignment(client, stream_id, topic_id, &consumer.id).await?;
-    }
-    for _ in 0..GROUP_POLL_MAX_ATTEMPTS {
-        let Some(partition_id) = client.consumer_group_state().next_group_partition(&key) else {
-            // Nothing to poll, but the two causes need opposite handling and
-            // only membership tells them apart: a real member can legitimately
-            // hold zero partitions, while a non-member must surface 5006 or
-            // `IggyConsumer` polls an empty assignment forever instead of
-            // rejoining. The client id is not known client-side.
-            if !client.consumer_group_state().is_registered(&key) {
-                return Err(IggyError::ConsumerGroupMemberNotFound(
-                    0,
-                    consumer.id.clone(),
-                    topic_id.clone(),
-                ));
-            }
-            return Ok(PolledMessages {
-                partition_id: crate::NO_ASSIGNED_PARTITION,
-                ..PolledMessages::empty()
-            });
-        };
-        // Resolved per attempt: a fence retry can land on another partition.
-        let strategy = strategy_for(partition_id);
-        let request = PollMessagesRequest {
-            consumer: consumer_to_wire(consumer)?,
-            stream_id: identifier_to_wire(stream_id)?,
-            topic_id: identifier_to_wire(topic_id)?,
-            partition_id: Some(partition_id),
-            strategy: polling_strategy_to_wire(&strategy),
-            count,
-            auto_commit,
-        };
-        let remaining = options
-            .map(|options| options.remaining(started.elapsed()))
-            .transpose()?;
-        match client
-            .send_poll_with_response_and_options(&request, strategy.context, remaining)
-            .await
-        {
-            Ok(response) => {
-                let polled = PolledMessages::from_bytes(response)?;
-                // The coordinator can't yet signal a generation fence as a typed
-                // error (no reply-header status), so it rides the empty-poll body
-                // as a sentinel partition id. Re-sync and retry, same as the
-                // typed error below; a genuine empty poll echoes the real id.
-                if polled.messages.is_empty()
-                    && polled.partition_id == crate::RESYNC_REQUIRED_PARTITION_SENTINEL
-                {
-                    client.consumer_group_state().invalidate_assignment(&key);
-                    sync_group_assignment(client, stream_id, topic_id, &consumer.id).await?;
-                    continue;
-                }
-                return Ok(polled);
-            }
-            Err(error @ IggyError::ConsumerGroupPartitionNotOwned(..)) => {
-                client.consumer_group_state().invalidate_assignment(&key);
-                sync_group_assignment(client, stream_id, topic_id, &consumer.id).await?;
-                if strategy.context.is_some() {
-                    return Err(error);
-                }
-            }
-            Err(error) => return Err(error),
-        }
-    }
-    // Exhausted the retry budget on back-to-back fences (a rebalance landed on
-    // every attempt) -- rare, and the cursor is already re-synced. Surface an
-    // empty poll rather than `ConsumerGroupPartitionNotOwned(0, 0)`: the (0, 0)
-    // ids are fabricated and a normal rebalance must not look like a hard error
-    // to a CG app that doesn't special-case 5009. The caller just re-polls.
-    if options.is_some() {
-        return Err(IggyError::TransientNotAccepted);
-    }
-    Ok(PolledMessages::empty())
+    options: DeferredPollOptions,
+) -> Result<Vec<FetchedSection>, IggyError> {
+    options.validate_fetch(count)?;
+    let request = fetch_messages_request(
+        stream_id,
+        topic_id,
+        targets,
+        consumer,
+        count,
+        auto_commit,
+        options,
+    )?;
+    let deadline = tokio::time::Instant::now()
+        .checked_add(options.request_timeout.get_duration())
+        .ok_or(IggyError::InvalidCommand)?;
+    with_binary_lineage(
+        client.operation_lineage(),
+        with_binary_deadline(deadline, async {
+            fail_if_not_authenticated(client).await?;
+            client.send_fetch_sections(&request).await
+        }),
+    )
+    .await
 }
 
-/// The wire form of a readiness wait. The SDK also routes it by partition.
-#[doc(hidden)]
-pub fn wait_for_messages_request(
+/// The wire form of a fetch. A target without a context gets incarnation
+/// zero, which the transport replaces with the incarnation of its route.
+fn fetch_messages_request(
     stream_id: &Identifier,
     topic_id: &Identifier,
-    partitions: &[(u32, PollingStrategy)],
+    targets: &[(u32, PollingStrategy)],
     consumer: &Consumer,
-    options: crate::DeferredPollOptions,
-) -> Result<WaitForMessagesRequest, IggyError> {
-    let request = WaitForMessagesRequest {
+    count: u32,
+    auto_commit: bool,
+    options: DeferredPollOptions,
+) -> Result<FetchMessagesRequest, IggyError> {
+    let request = FetchMessagesRequest {
         consumer: consumer_to_wire(consumer)?,
         stream_id: identifier_to_wire(stream_id)?,
         topic_id: identifier_to_wire(topic_id)?,
         wait_us: options.max_wait.as_micros(),
         request_timeout_us: options.request_timeout.as_micros(),
-        partitions: partitions
+        min_count: options.min_count,
+        count,
+        max_bytes: options.max_bytes,
+        auto_commit,
+        targets: targets
             .iter()
-            .map(|(partition_id, strategy)| WaitForMessagesTarget {
-                partition_id: *partition_id,
-                strategy: polling_strategy_to_wire(strategy),
+            .map(|(partition_id, strategy)| {
+                strategy.validate_partition(*partition_id)?;
+                let context = strategy.context().unwrap_or_default();
+                Ok(FetchMessagesTarget {
+                    partition_id: *partition_id,
+                    strategy: polling_strategy_to_wire(strategy),
+                    // The server allots the budget by the bytes that it knows
+                    // a target holds, and a smaller cap would slice batches.
+                    max_bytes: options.max_bytes,
+                    incarnation: context.incarnation,
+                    owner_generation: context.owner_generation,
+                })
             })
-            .collect(),
+            .collect::<Result<_, IggyError>>()?,
     };
     request.validate().map_err(|_| IggyError::InvalidCommand)?;
     Ok(request)
@@ -418,131 +410,73 @@
         &self,
         stream_id: &Identifier,
         topic_id: &Identifier,
-        partition_id: Option<u32>,
+        partition_id: u32,
         consumer: &Consumer,
         strategy: &PollingStrategy,
-        count: u32,
-        auto_commit: bool,
+        options: PollOptions,
     ) -> Result<PolledMessages, IggyError> {
-        self.poll_messages_with_strategy_for(
+        self.poll_messages_raw(
             stream_id,
             topic_id,
             partition_id,
             consumer,
-            &|_: u32| *strategy,
-            count,
-            auto_commit,
+            strategy,
+            options,
         )
         .await
+        .map_err(|error| error.error)
     }
 
-    async fn poll_messages_with_strategy_for(
+    async fn poll_messages_raw(
         &self,
         stream_id: &Identifier,
         topic_id: &Identifier,
-        partition_id: Option<u32>,
+        partition_id: u32,
         consumer: &Consumer,
-        strategy_for: &(dyn Fn(u32) -> PollingStrategy + Send + Sync),
-        count: u32,
-        auto_commit: bool,
-    ) -> Result<PolledMessages, IggyError> {
-        self.poll_messages_with_strategy_for_and_options(
-            stream_id,
-            topic_id,
-            partition_id,
-            consumer,
-            strategy_for,
-            count,
-            auto_commit,
-            None,
-        )
-        .await
-    }
-
-    async fn poll_messages_with_strategy_for_and_options(
-        &self,
-        stream_id: &Identifier,
-        topic_id: &Identifier,
-        partition_id: Option<u32>,
-        consumer: &Consumer,
-        strategy_for: &(dyn Fn(u32) -> PollingStrategy + Send + Sync),
-        count: u32,
-        auto_commit: bool,
-        options: Option<crate::DeferredPollOptions>,
-    ) -> Result<PolledMessages, IggyError> {
-        if let Some(options) = options {
-            options.validate(count)?;
-        }
-        let deadline = if let Some(options) = options {
-            tokio::time::Instant::now()
-                .checked_add(options.request_timeout.get_duration())
-                .ok_or(IggyError::InvalidCommand)?
-        } else {
-            binary_operation_deadline()
+        strategy: &PollingStrategy,
+        options: PollOptions,
+    ) -> Result<PolledMessages, DecodeError> {
+        let auto_commit = match options.commit {
+            CommitPolicy::Manual => false,
+            CommitPolicy::OnRead => true,
+            CommitPolicy::Auto(_) => {
+                return Err(DecodeError::without_record(IggyError::InvalidConfiguration));
+            }
         };
-        with_binary_lineage(
+        options.validate().map_err(DecodeError::without_record)?;
+        strategy
+            .validate_partition(partition_id)
+            .map_err(DecodeError::without_record)?;
+        let deadline = tokio::time::Instant::now()
+            .checked_add(options.request_timeout().get_duration())
+            .ok_or(IggyError::InvalidCommand)
+            .map_err(DecodeError::without_record)?;
+        let response = with_binary_lineage(
             self.operation_lineage(),
             with_binary_deadline(deadline, async {
                 fail_if_not_authenticated(self).await?;
-                // VSR: a consumer-group poll without an explicit partition is resolved
-                // client-side from the member's cached assignment (the broker routes
-                // explicit partitions only).
-                if consumer.kind == ConsumerKind::ConsumerGroup && partition_id.is_none() {
-                    return poll_group_messages(
-                        self,
-                        stream_id,
-                        topic_id,
-                        consumer,
-                        strategy_for,
-                        count,
-                        auto_commit,
-                        options,
-                    )
-                    .await;
-                }
-                let strategy = strategy_for(partition_id.unwrap_or(0));
-                let req = PollMessagesRequest {
+                let request = PollMessagesRequest {
                     consumer: consumer_to_wire(consumer)?,
                     stream_id: identifier_to_wire(stream_id)?,
                     topic_id: identifier_to_wire(topic_id)?,
-                    partition_id,
-                    strategy: polling_strategy_to_wire(&strategy),
-                    count,
+                    partition_id: Some(partition_id),
+                    strategy: polling_strategy_to_wire(strategy),
+                    count: options.count,
                     auto_commit,
                 };
                 let response = self
-                    .send_poll_with_response_and_options(&req, strategy.context, options)
+                    .send_poll_with_response_and_options(
+                        &request,
+                        strategy.context(),
+                        Some(options.into()),
+                    )
                     .await?;
-                PolledMessages::from_bytes(response)
+                Ok::<Bytes, IggyError>(response)
             }),
         )
         .await
-    }
-
-    async fn wait_for_messages(
-        &self,
-        stream_id: &Identifier,
-        topic_id: &Identifier,
-        partitions: &[(u32, PollingStrategy)],
-        consumer: &Consumer,
-        options: crate::DeferredPollOptions,
-    ) -> Result<Vec<u32>, IggyError> {
-        let request =
-            wait_for_messages_request(stream_id, topic_id, partitions, consumer, options)?;
-        let deadline = tokio::time::Instant::now()
-            .checked_add(options.request_timeout.get_duration())
-            .ok_or(IggyError::InvalidCommand)?;
-        with_binary_lineage(
-            self.operation_lineage(),
-            with_binary_deadline(deadline, async {
-                fail_if_not_authenticated(self).await?;
-                let response = self.send_wait_with_response(&request).await?;
-                Ok(WaitForMessagesResponse::decode_from(&response)
-                    .map_err(|_| IggyError::InvalidCommand)?
-                    .partitions)
-            }),
-        )
-        .await
+        .map_err(DecodeError::without_record)?;
+        PolledMessages::decode(response, strategy)
     }
 
     async fn send_messages(
diff --git a/core/common/src/traits/binary_impls/mod.rs b/core/common/src/traits/binary_impls/mod.rs
index 4826cd1..4cd3606 100644
--- a/core/common/src/traits/binary_impls/mod.rs
+++ b/core/common/src/traits/binary_impls/mod.rs
@@ -27,8 +27,10 @@
 mod topics;
 mod users;
 
-pub use messages::{BinarySendPartitioning, decode_send_confirmations, send_binary_messages};
-pub use messages::{group_cache_key, sync_group_assignment, wait_for_messages_request};
+pub use messages::{
+    BinarySendPartitioning, decode_send_confirmations, fetch_binary_messages, send_binary_messages,
+};
+pub use messages::{group_cache_key, sync_group_assignment};
 
 use crate::IggyError;
 use crate::OptionsPatch;
diff --git a/core/common/src/traits/binary_transport.rs b/core/common/src/traits/binary_transport.rs
index aeb52e4..a74de78 100644
--- a/core/common/src/traits/binary_transport.rs
+++ b/core/common/src/traits/binary_transport.rs
@@ -26,7 +26,7 @@
 use iggy_binary_protocol::codes::{
     DELETE_CONSUMER_OFFSET_CODE, POLL_MESSAGES_CODE, STORE_CONSUMER_OFFSET_CODE,
 };
-use iggy_binary_protocol::requests::messages::{PollMessagesRequest, WaitForMessagesRequest};
+use iggy_binary_protocol::requests::messages::{FetchMessagesRequest, PollMessagesRequest};
 use iggy_binary_protocol::requests::system::{BindSessionRequest, SessionIdentity};
 use iggy_binary_protocol::requests::users::login_register::BindSecret;
 use iggy_binary_protocol::responses::users::LoginRegisterResponse;
@@ -218,15 +218,31 @@
         }
     }
 
-    /// A readiness wait shares a multiplexed connection, like a positive poll wait.
-    /// A client runs at most `MAX_DEFERRED_REQUESTS` deferred requests at once.
-    async fn send_wait_with_response(
+    /// A fetch is a deferred request to the primary of its targets. A target
+    /// with incarnation zero takes the incarnation of its route, because the
+    /// server refuses that incarnation.
+    /// A raw partial reply preserves wire statuses. If a committing section's
+    /// progress is unknown, a later progress call on that target returns
+    /// `PartitionLaneBlocked` with the local ID to abandon. Typed callers use
+    /// [`Self::send_fetch_sections`] to receive that ID in the section error.
+    #[doc(hidden)]
+    async fn send_fetch_with_response(
         &self,
-        _request: &WaitForMessagesRequest,
+        _request: &FetchMessagesRequest,
     ) -> Result<Bytes, IggyError> {
         Err(IggyError::FeatureUnavailable)
     }
 
+    /// Typed fetch sections retain local recovery identities for unknown
+    /// committing progress. Raw callers retain the original wire statuses.
+    #[doc(hidden)]
+    async fn send_fetch_sections(
+        &self,
+        request: &FetchMessagesRequest,
+    ) -> Result<Vec<crate::FetchedSection>, IggyError> {
+        crate::FetchedSection::from_reply(&self.send_fetch_with_response(request).await?)
+    }
+
     fn get_heartbeat_interval(&self) -> NonZeroIggyDuration;
 
     /// Per-transport consumer-group + partitioning cache used to resolve
@@ -387,18 +403,27 @@
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::{Client, Consumer, ConsumerGroupClientState, MessageClient, PollingStrategy};
+    use crate::{
+        AutoCommit, Client, CommitPolicy, Consumer, ConsumerGroupClientState, DeferredPollOptions,
+        MIN_DEFERRED_POLL_REPLY_MARGIN_US, MessageClient, PollOptions, PollingKind,
+        PollingStrategy, fetch_binary_messages, sync_group_assignment,
+    };
     use bytes::BytesMut;
     use iggy_binary_protocol::codes::SYNC_CONSUMER_GROUP_CODE;
     use iggy_binary_protocol::codes::{GET_PARTITION_ROUTE_CODE, PING_CODE, SEND_MESSAGES_CODE};
     use iggy_binary_protocol::requests::consumer_offsets::{
         DeleteConsumerOffsetRequest, StoreConsumerOffsetRequest,
     };
-    use iggy_binary_protocol::requests::messages::{RawMessage, SendMessagesEncoder};
+    use iggy_binary_protocol::requests::messages::{
+        FetchMessagesTarget, RawMessage, SendMessagesEncoder,
+    };
     use iggy_binary_protocol::responses::consumer_groups::SyncConsumerGroupResponse;
-    use iggy_binary_protocol::responses::messages::PollMessagesResponseHeader;
+    use iggy_binary_protocol::responses::messages::{
+        FetchMessagesResponse, FetchMessagesSection, FetchMessagesSectionHeader,
+        PollMessagesResponseHeader,
+    };
     use iggy_binary_protocol::{
-        AckLevel, WireConsumer, WireIdentifier, WirePartitioning, WirePollingStrategy,
+        AckLevel, STATUS_OK, WireConsumer, WireIdentifier, WirePartitioning, WirePollingStrategy,
     };
     use std::sync::Mutex;
 
@@ -410,7 +435,13 @@
     enum Call {
         Raw(u32, Bytes, PartitionContext),
         Poll(PollMessagesRequest, Option<PartitionContext>),
+        DeferredPoll(
+            PollMessagesRequest,
+            Option<PartitionContext>,
+            DeferredPollOptions,
+        ),
         OffsetWrite(u32, Bytes, Option<PartitionContext>),
+        Fetch(FetchMessagesRequest),
     }
 
     /// Keeps the provided `send_raw_with_response`, the behavior under test.
@@ -460,7 +491,7 @@
                 let scenario = scenario.as_mut().unwrap();
                 if scenario.replaced {
                     assert!(self.state.is_registered("1|orders|3"));
-                    assert_eq!(self.state.next_group_partition("1|orders|3"), Some(7));
+                    assert_eq!(self.state.assignment_snapshot("1|orders|3").1, [7]);
                     if scenario.caller_changed {
                         return Err(IggyError::Unauthenticated);
                     }
@@ -511,18 +542,59 @@
             context: Option<PartitionContext>,
         ) -> Result<Bytes, IggyError> {
             self.record(Call::Poll(request.clone(), context));
-            if self.sync.lock().unwrap().is_some() {
-                return Ok(PollMessagesResponseHeader {
-                    partition_id: request.partition_id.unwrap(),
-                    current_offset: 0,
-                    messages_count: 0,
-                    context: Default::default(),
-                }
-                .to_bytes());
-            }
             Ok(Bytes::from_static(RECEIPT))
         }
 
+        /// Answers a deferred poll with an empty reply of its partition.
+        async fn send_poll_with_response_and_options(
+            &self,
+            request: &PollMessagesRequest,
+            context: Option<PartitionContext>,
+            options: Option<DeferredPollOptions>,
+        ) -> Result<Bytes, IggyError> {
+            let Some(options) = options else {
+                return self.send_poll_with_response(request, context).await;
+            };
+            self.record(Call::DeferredPoll(request.clone(), context, options));
+            Ok(PollMessagesResponseHeader {
+                partition_id: request.partition_id.unwrap(),
+                current_offset: 0,
+                messages_count: 0,
+                context: Default::default(),
+            }
+            .to_bytes())
+        }
+
+        /// Serves an empty section of `PARTITION` and refuses the other
+        /// targets as moved.
+        async fn send_fetch_with_response(
+            &self,
+            request: &FetchMessagesRequest,
+        ) -> Result<Bytes, IggyError> {
+            self.record(Call::Fetch(request.clone()));
+            let sections = request
+                .targets
+                .iter()
+                .map(|target| FetchMessagesSection {
+                    header: FetchMessagesSectionHeader {
+                        partition_id: target.partition_id,
+                        status: if target.partition_id == PARTITION {
+                            STATUS_OK
+                        } else {
+                            IggyError::NotPrimary.as_code()
+                        },
+                        current_offset: 0,
+                        messages_count: 0,
+                        context: PartitionContext::default(),
+                        next_offset: 0,
+                        records_len: 0,
+                    },
+                    records: &[],
+                })
+                .collect();
+            Ok(FetchMessagesResponse { sections }.to_bytes().unwrap())
+        }
+
         fn get_heartbeat_interval(&self) -> NonZeroIggyDuration {
             NonZeroIggyDuration::ONE_SECOND
         }
@@ -582,17 +654,13 @@
             })),
             ..Default::default()
         };
-        let result = transport
-            .poll_messages(
-                &Identifier::numeric(STREAM).unwrap(),
-                &Identifier::named(TOPIC).unwrap(),
-                None,
-                &Consumer::group(Identifier::numeric(3).unwrap()),
-                &PollingStrategy::offset(0),
-                1,
-                false,
-            )
-            .await;
+        let result = sync_group_assignment(
+            &transport,
+            &Identifier::numeric(STREAM).unwrap(),
+            &Identifier::named(TOPIC).unwrap(),
+            &Identifier::numeric(3).unwrap(),
+        )
+        .await;
         if caller_changed {
             assert_eq!(result.unwrap_err(), IggyError::Unauthenticated);
         } else {
@@ -602,21 +670,14 @@
             );
         }
         assert!(transport.state.is_registered("1|orders|3"));
-        assert_eq!(transport.state.next_group_partition("1|orders|3"), Some(7));
+        assert_eq!(transport.state.assignment_snapshot("1|orders|3").1, [7]);
         let calls = transport.take_calls();
-        assert_eq!(
+        assert_eq!(calls.len(), 2, "{calls:?}");
+        assert!(
             calls
                 .iter()
-                .filter(|call| matches!(call, Call::Raw(SYNC_CONSUMER_GROUP_CODE, ..)))
-                .count(),
-            2
-        );
-        assert_eq!(
-            calls
-                .iter()
-                .filter(|call| matches!(call, Call::Poll(..)))
-                .count(),
-            usize::from(!caller_changed)
+                .all(|call| matches!(call, Call::Raw(SYNC_CONSUMER_GROUP_CODE, ..))),
+            "{calls:?}"
         );
     }
 
@@ -684,6 +745,281 @@
     }
 
     #[tokio::test]
+    async fn given_a_fetch_when_sent_should_encode_every_target_and_return_a_section_of_each() {
+        const MOVED: u32 = 5;
+        const COUNT: u32 = 10;
+        const OFFSET: u64 = 7;
+        let fence = PartitionContext {
+            incarnation: 4,
+            owner_generation: 2,
+            metadata_op: 9,
+        };
+        let options = DeferredPollOptions::default();
+        let transport = Recorder::default();
+
+        let sections = fetch_binary_messages(
+            &transport,
+            &Identifier::numeric(STREAM).unwrap(),
+            &Identifier::named(TOPIC).unwrap(),
+            &[
+                (PARTITION, PollingStrategy::next()),
+                (
+                    MOVED,
+                    PollingStrategy::continuation(MOVED, PollingKind::Offset, OFFSET, fence),
+                ),
+            ],
+            &Consumer::new(Identifier::numeric(1).unwrap()),
+            COUNT,
+            true,
+            options,
+        )
+        .await
+        .unwrap();
+
+        let calls = transport.take_calls();
+        let [Call::Fetch(request)] = calls.as_slice() else {
+            panic!("expected one fetch: {calls:?}");
+        };
+        assert_eq!(
+            (&request.consumer, &request.stream_id, &request.topic_id),
+            (&consumer(), &WireIdentifier::numeric(STREAM), &wire_topic())
+        );
+        assert_eq!(
+            (
+                request.wait_us,
+                request.request_timeout_us,
+                request.min_count,
+                request.count,
+                request.max_bytes,
+                request.auto_commit
+            ),
+            (
+                options.max_wait.as_micros(),
+                options.request_timeout.as_micros(),
+                options.min_count,
+                COUNT,
+                options.max_bytes,
+                true
+            )
+        );
+        assert_eq!(
+            request.targets,
+            [
+                FetchMessagesTarget {
+                    partition_id: PARTITION,
+                    strategy: WirePollingStrategy::next(),
+                    max_bytes: options.max_bytes,
+                    incarnation: 0,
+                    owner_generation: 0,
+                },
+                FetchMessagesTarget {
+                    partition_id: MOVED,
+                    strategy: WirePollingStrategy::offset(OFFSET),
+                    max_bytes: options.max_bytes,
+                    incarnation: fence.incarnation,
+                    owner_generation: fence.owner_generation,
+                },
+            ]
+        );
+        let statuses: Vec<_> = sections
+            .iter()
+            .map(|section| (section.partition_id, section.status.clone()))
+            .collect();
+        assert_eq!(
+            statuses,
+            [(PARTITION, Ok(())), (MOVED, Err(IggyError::NotPrimary))]
+        );
+    }
+
+    #[tokio::test]
+    async fn given_an_invalid_fetch_when_sent_should_fail_before_the_transport() {
+        let transport = Recorder::default();
+        let next = (PARTITION, PollingStrategy::next());
+        let other_partition = (
+            PARTITION,
+            PollingStrategy::continuation(
+                PARTITION + 1,
+                PollingKind::Offset,
+                0,
+                PartitionContext::default(),
+            ),
+        );
+        let options = DeferredPollOptions::default();
+        // Unlike a deferred poll, a fetch needs the reply margin also
+        // without a wait.
+        let no_margin = DeferredPollOptions {
+            max_wait: Duration::ZERO.into(),
+            request_timeout: Duration::from_micros(MIN_DEFERRED_POLL_REPLY_MARGIN_US - 1).into(),
+            ..options
+        };
+        let cases = [
+            (vec![], 1, options, IggyError::InvalidCommand),
+            (vec![next, next], 1, options, IggyError::InvalidCommand),
+            (
+                vec![(PARTITION, PollingStrategy::first())],
+                1,
+                options,
+                IggyError::InvalidCommand,
+            ),
+            (vec![other_partition], 1, options, IggyError::InvalidCommand),
+            (vec![next], 0, options, IggyError::InvalidMessagesCount),
+            (vec![next], 1, no_margin, IggyError::InvalidCommand),
+            (
+                vec![next],
+                1,
+                DeferredPollOptions {
+                    request_timeout: Duration::from_micros(MIN_DEFERRED_POLL_REPLY_MARGIN_US)
+                        .into(),
+                    ..no_margin
+                },
+                IggyError::InvalidCommand,
+            ),
+            (
+                vec![next],
+                1,
+                DeferredPollOptions {
+                    request_timeout: (Duration::from_micros(MIN_DEFERRED_POLL_REPLY_MARGIN_US)
+                        + Duration::from_nanos(999))
+                    .into(),
+                    ..no_margin
+                },
+                IggyError::InvalidCommand,
+            ),
+        ];
+
+        for (targets, count, options, error) in cases {
+            let fetched = fetch_binary_messages(
+                &transport,
+                &Identifier::numeric(STREAM).unwrap(),
+                &Identifier::named(TOPIC).unwrap(),
+                &targets,
+                &Consumer::new(Identifier::numeric(1).unwrap()),
+                count,
+                false,
+                options,
+            )
+            .await;
+            assert_eq!(fetched.unwrap_err(), error, "{targets:?}");
+        }
+        assert!(transport.take_calls().is_empty());
+    }
+
+    #[tokio::test]
+    async fn given_auto_commit_when_polling_once_should_refuse_it_before_sending() {
+        let transport = Recorder::default();
+        let options = PollOptions {
+            commit: CommitPolicy::Auto(AutoCommit::default()),
+            ..PollOptions::default()
+        };
+
+        let polled = transport
+            .poll_messages(
+                &Identifier::numeric(STREAM).unwrap(),
+                &Identifier::named(TOPIC).unwrap(),
+                PARTITION,
+                &Consumer::new(Identifier::numeric(1).unwrap()),
+                &PollingStrategy::next(),
+                options,
+            )
+            .await;
+
+        assert_eq!(polled.map(|_| ()), Err(IggyError::InvalidConfiguration));
+        assert!(transport.take_calls().is_empty(), "nothing is sent");
+    }
+
+    #[tokio::test]
+    async fn given_a_poll_when_sent_should_send_one_deferred_poll_of_its_partition() {
+        const COUNT: u32 = 5;
+        const OFFSET: u64 = 7;
+        let context = PartitionContext {
+            incarnation: 4,
+            owner_generation: 2,
+            metadata_op: 9,
+        };
+        let options = PollOptions {
+            count: COUNT,
+            commit: CommitPolicy::OnRead,
+            ..PollOptions::default()
+        };
+        let transport = Recorder::default();
+
+        let polled = transport
+            .poll_messages(
+                &Identifier::numeric(STREAM).unwrap(),
+                &Identifier::named(TOPIC).unwrap(),
+                PARTITION,
+                &Consumer::new(Identifier::numeric(1).unwrap()),
+                &PollingStrategy::continuation(PARTITION, PollingKind::Offset, OFFSET, context),
+                options,
+            )
+            .await
+            .unwrap();
+
+        assert_eq!(
+            transport.take_calls(),
+            vec![Call::DeferredPoll(
+                PollMessagesRequest {
+                    consumer: consumer(),
+                    stream_id: WireIdentifier::numeric(STREAM),
+                    topic_id: wire_topic(),
+                    partition_id: Some(PARTITION),
+                    strategy: WirePollingStrategy::offset(OFFSET),
+                    count: COUNT,
+                    auto_commit: true,
+                },
+                Some(context),
+                DeferredPollOptions::from(options),
+            )]
+        );
+        assert_eq!(
+            polled.continuation(),
+            PollingStrategy::continuation(PARTITION, PollingKind::Offset, OFFSET, context),
+            "an empty reply keeps the start of its request"
+        );
+    }
+
+    #[tokio::test]
+    async fn given_an_invalid_poll_when_sent_should_fail_before_the_transport() {
+        let transport = Recorder::default();
+        let other_partition = PollingStrategy::continuation(
+            PARTITION + 1,
+            PollingKind::Offset,
+            0,
+            PartitionContext::default(),
+        );
+        let cases = [
+            (
+                PollingStrategy::next(),
+                PollOptions {
+                    count: 0,
+                    ..PollOptions::default()
+                },
+                IggyError::InvalidMessagesCount,
+            ),
+            (
+                other_partition,
+                PollOptions::default(),
+                IggyError::InvalidCommand,
+            ),
+        ];
+
+        for (strategy, options, error) in cases {
+            let polled = transport
+                .poll_messages(
+                    &Identifier::numeric(STREAM).unwrap(),
+                    &Identifier::named(TOPIC).unwrap(),
+                    PARTITION,
+                    &Consumer::new(Identifier::numeric(1).unwrap()),
+                    &strategy,
+                    options,
+                )
+                .await;
+            assert_eq!(polled.unwrap_err(), error, "{strategy:?}");
+        }
+        assert!(transport.take_calls().is_empty());
+    }
+
+    #[tokio::test]
     async fn given_raw_send_to_partition_id_when_sent_should_leave_routing_to_transport() {
         let transport = Recorder::default();
         let payload = send_payload(&WirePartitioning::PartitionId(PARTITION));
diff --git a/core/common/src/traits/client.rs b/core/common/src/traits/client.rs
index be51523..88915a5 100644
--- a/core/common/src/traits/client.rs
+++ b/core/common/src/traits/client.rs
@@ -94,6 +94,9 @@
     /// `OperationUncertain(op)` keeps the original frame and lane:
     /// a later denial without committed-result proof cannot settle an earlier send.
     /// TCP, QUIC and WebSocket support recovery; HTTP returns `FeatureUnavailable`.
+    /// A retained committing fetch instead returns `FetchProgressUncertain(op)`
+    /// without I/O. It has no replayable receipt and remains blocked until
+    /// [`Self::abandon_operation`] explicitly accepts its unknown outcome.
     ///
     /// The client keeps the proved outcomes of its last
     /// [`max_operations`](crate::PendingOperationsConfig::max_operations) replays,
@@ -113,6 +116,9 @@
     /// keeps the outcome it proves for [`Self::resolve_operation`]. An operation
     /// that is not retained gives `OperationNotRetained(op)`. A client that
     /// retains no operations returns `FeatureUnavailable`.
+    /// For `FetchProgressUncertain`, this frees every affected progress lane,
+    /// including after reconnect or session replacement. It does not stop an
+    /// old server request. A later offset inspection cannot prove that it stopped.
     async fn abandon_operation(&self, _op: u64) -> Result<(), IggyError> {
         Err(IggyError::FeatureUnavailable)
     }
diff --git a/core/common/src/traits/consumer_offset_client.rs b/core/common/src/traits/consumer_offset_client.rs
index 0689356..2562e06 100644
--- a/core/common/src/traits/consumer_offset_client.rs
+++ b/core/common/src/traits/consumer_offset_client.rs
@@ -15,7 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
-use crate::{Consumer, ConsumerOffsetInfo, ConsumerPosition, Identifier, IggyError};
+use crate::{Checkpoint, Consumer, ConsumerOffsetInfo, Identifier, IggyError};
 use async_trait::async_trait;
 
 /// This trait defines the methods to interact with the consumer offset module.
@@ -29,11 +29,11 @@
 /// ([`Self::store_consumer_offset`], [`Self::delete_consumer_offset`]) can fail once with
 /// [`IggyError::HistoryUnavailable`] (87), or with [`IggyError::ConsumerGroupPartitionNotOwned`]
 /// (5009) for a consumer group. The failed route is dropped, so the next call routes again and
-/// takes the new context. [`Self::store_consumer_position`] carries the caller's context, so it
-/// keeps getting the refusal for that context.
+/// takes the new context. [`Self::store_consumer_position`] and [`Self::delete_consumer_position`]
+/// carry the caller's context, so they keep getting the refusal for that context.
 #[async_trait]
 pub trait ConsumerOffsetClient {
-    /// Commit a captured position without substituting a newer incarnation or owner.
+    /// Commit a checkpoint without substituting a newer incarnation or owner.
     /// Authentication and permission to poll messages are required.
     /// Returns [`IggyError::HistoryUnavailable`] (87) for a deleted or replaced incarnation,
     /// [`IggyError::ConsumerGroupPartitionNotOwned`] (5009) for a lost owner,
@@ -43,7 +43,7 @@
         consumer: &Consumer,
         stream_id: &Identifier,
         topic_id: &Identifier,
-        position: ConsumerPosition,
+        checkpoint: &Checkpoint,
     ) -> Result<(), IggyError>;
 
     /// Store the consumer offset for a specific consumer or consumer group for the given stream and topic by unique IDs or names.
@@ -87,4 +87,20 @@
         topic_id: &Identifier,
         partition_id: Option<u32>,
     ) -> Result<(), IggyError>;
+
+    /// Delete the offset of the partition of a checkpoint without substituting a newer
+    /// incarnation or owner. The offset of the checkpoint is not used.
+    /// Authentication and permission to poll messages are required.
+    /// Returns [`IggyError::HistoryUnavailable`] (87) for a deleted or replaced incarnation,
+    /// [`IggyError::ConsumerGroupPartitionNotOwned`] (5009) for a lost owner,
+    /// or [`IggyError::ConsumerOffsetNotFound`] (3021) for a missing key.
+    /// HTTP cannot send a context with a delete, so there the server deletes the offset of
+    /// the incarnation that it holds.
+    async fn delete_consumer_position(
+        &self,
+        consumer: &Consumer,
+        stream_id: &Identifier,
+        topic_id: &Identifier,
+        checkpoint: &Checkpoint,
+    ) -> Result<(), IggyError>;
 }
diff --git a/core/common/src/traits/message_client.rs b/core/common/src/traits/message_client.rs
index ac773b5..ba4abf9 100644
--- a/core/common/src/traits/message_client.rs
+++ b/core/common/src/traits/message_client.rs
@@ -16,197 +16,111 @@
 // under the License.
 
 use crate::{
-    Consumer, ConsumerKind, Identifier, IggyError, IggyMessage, Partitioning, PolledMessages,
-    PollingStrategy, SendMessagesResponse,
+    Consumer, DecodeError, Identifier, IggyError, IggyMessage, Partitioning, PollOptions,
+    PolledMessages, PollingStrategy, SendMessagesResponse,
 };
 use async_trait::async_trait;
 
 /// This trait defines the methods to interact with the messaging module.
 #[async_trait]
 pub trait MessageClient {
-    /// Poll given amount of messages using the specified consumer and strategy from the specified stream and topic by unique IDs or names.
+    /// Read up to `options.count` messages of one partition, from `strategy`.
     ///
     /// Authentication is required, and the permission to poll the messages.
     ///
-    /// Polling a consumer group the client is not (or no longer) a member of fails with `ConsumerGroupMemberNotFound` rather than returning an empty batch, so the caller can rejoin.
-    /// A member that holds no partitions gets an empty batch whose `partition_id` is [`NO_ASSIGNED_PARTITION`](crate::NO_ASSIGNED_PARTITION).
-    /// A [`ConsumerKind::ExternalGroup`] consumer only holds offsets, so polling with it returns [`IggyError::FeatureUnavailable`].
+    /// The server waits up to `options.max_wait` until the partition holds `options.min_count`
+    /// messages, and a zero wait answers at once. `options.max_bytes` bounds the reply, and a
+    /// first message larger than it comes alone in the reply. The request timeout is `max_wait`
+    /// plus 10 s. Invalid options fail before anything is sent, with the errors of
+    /// [`PollOptions::validate`], and [`CommitPolicy::Auto`](crate::CommitPolicy::Auto) with
+    /// [`IggyError::InvalidConfiguration`]: one call has nothing to count, so it takes
+    /// [`CommitPolicy::Manual`](crate::CommitPolicy::Manual) or
+    /// [`CommitPolicy::OnRead`](crate::CommitPolicy::OnRead).
     ///
-    /// Binary clients cache the route to a partition with the partition context it reported.
-    /// After another client deletes and recreates the partition, a poll without a caller context
-    /// ([`PollingStrategy::with_context`]) can fail once with `HistoryUnavailable` (87), or with
-    /// `ConsumerGroupPartitionNotOwned` (5009) for a consumer group. The failed route is dropped,
-    /// so the next call routes again and takes the new context. A poll with a caller context keeps
-    /// getting the refusal for that context.
+    /// One call has one deadline for admission, route refreshes and retries. If the deadline
+    /// passes before the request leaves the client, the call returns
+    /// [`IggyError::TransientNotAccepted`], and a retry is safe.
     ///
-    /// With automatic commits enabled, a new consumer offset key can be
-    /// rejected with `TooManyConsumerOffsets` at the partition's configured
-    /// limit. That poll returns no messages. Existing keys remain writable,
-    /// and polling without automatic commits does not allocate a stored offset.
-    /// A refused automatic commit submission returns `TransientNotAccepted` with no
-    /// messages and may be retried. A capacity error requires capacity to be freed.
-    /// Local cursors without a committed offset can be evicted at the limit.
-    /// Their next `Next` poll resumes from the earliest retained messages,
-    /// which can redeliver messages from earlier polls.
+    /// # Continuations and checkpoints
     ///
-    /// # Recovering from a missing response
+    /// To read on, pass [`PolledMessages::continuation`] as the next `strategy`. A continuation
+    /// belongs to the partition of its batch, so a poll of another partition refuses it with
+    /// [`IggyError::InvalidCommand`]. Serde drops that partition, so a deserialized continuation
+    /// fits every partition. It carries the partition history of its batch. After
+    /// another client deletes and recreates the partition, a poll from it keeps getting
+    /// [`IggyError::HistoryUnavailable`] (87), or `ConsumerGroupPartitionNotOwned` (5009) for a
+    /// consumer group. A strategy from [`PollingStrategy::offset`] or the other constructors
+    /// has no history, so binary clients use the history of their cached route. Such a poll
+    /// can fail once with the same errors, and the next call routes again and takes the new
+    /// history.
     ///
-    /// A timeout or communication failure does not establish that the server
-    /// rejected the poll. With `auto_commit` enabled, the server can advance its
-    /// consumer cursor before the caller receives the messages. Retrying with
-    /// [`PollingStrategy::next()`] can then skip messages from the missing response.
-    /// Even a successful poll response does not acknowledge a durable offset commit.
+    /// To commit, pass [`PolledMessages::checkpoint`] to
+    /// [`store_consumer_position`](crate::ConsumerOffsetClient::store_consumer_position).
+    /// A [`ConsumerKind::ExternalGroup`](crate::ConsumerKind::ExternalGroup) consumer only holds
+    /// offsets, so polling with it returns [`IggyError::FeatureUnavailable`].
     ///
-    /// Keep a checkpoint for each partition: one past the last message successfully
-    /// processed in order, or the intended starting offset if none was processed.
-    /// Recover with [`PollingStrategy::offset(checkpoint)`](PollingStrategy::offset)
-    /// and advance the checkpoint only after processing the returned messages.
-    /// Use each message's header offset, not [`PolledMessages::current_offset`],
-    /// which reports the partition's current offset rather than the last message
-    /// returned. Persist checkpoints if recovery must survive a client restart.
+    /// # Server commits and lost replies
     ///
-    /// Continue using explicit offsets until all messages through the server's
-    /// cursor have been processed: replay does not rewind an advanced cursor.
-    /// For a consumer group, recover each partition with its own checkpoint and
-    /// respect the current assignment. [`Self::poll_messages_with_strategy_for`]
-    /// selects the strategy after the partition is known.
-    /// Recovery assumes the same partition message history and requires the
-    /// messages to remain retained. It can repeat messages already processed.
-    #[allow(clippy::too_many_arguments)]
+    /// Without [`CommitPolicy::OnRead`](crate::CommitPolicy::OnRead), the server
+    /// stores no offset. If a [`PollingStrategy::next`] request left the client and its reply
+    /// does not arrive, the call returns [`IggyError::TransientNotCommitted`]. The server stored
+    /// nothing, so a retry is safe.
+    ///
+    /// With `OnRead`, the server stores the offset of the last message of a read as part of the
+    /// read, in one round trip. While the primary stays up a message is delivered at most once.
+    /// A failover before the store replicates can deliver the last batch again. A new offset key
+    /// can be refused with `TooManyConsumerOffsets` at the configured limit of the partition,
+    /// and that poll returns no messages.
+    ///
+    /// An `OnRead` call whose outcome is unknown fails with
+    /// [`IggyError::FetchProgressUncertain`] naming its operation, never with
+    /// `TransientNotCommitted`: its request left the client and no reply proves what the server
+    /// stored, also when the client disconnects while the call waits. Every later poll of that
+    /// partition with `OnRead` or from `Next`, and every offset store or delete of it, on the
+    /// same client then fails with [`IggyError::PartitionLaneBlocked`] naming that operation,
+    /// until [`abandon_operation`](crate::Client::abandon_operation) or a reply proves the
+    /// outcome, because the server can still store the offset after the client gives up. The
+    /// block survives a reconnect and a new session of the same client, and it names the
+    /// partition by its incarnation, so another spelling of its stream or topic meets it too. An
+    /// `OnRead` call dropped after its request left keeps its reply slot and sends a cancel over
+    /// TCP, QUIC and WebSocket, and its block lasts until that one reply proves the outcome. An
+    /// `OnRead` poll is never sent again after an unknown outcome.
+    ///
+    /// HTTP has no cancel and no connection to close, so a dropped call keeps its block until
+    /// `abandon_operation`, and `disconnect` leaves a call in flight to its reply. An HTTP block
+    /// covers the stream and the topic as the lost call spelled them, and any spelling for a
+    /// call that carries a context of the same partition incarnation.
     async fn poll_messages(
         &self,
         stream_id: &Identifier,
         topic_id: &Identifier,
-        partition_id: Option<u32>,
+        partition_id: u32,
         consumer: &Consumer,
         strategy: &PollingStrategy,
-        count: u32,
-        auto_commit: bool,
+        options: PollOptions,
     ) -> Result<PolledMessages, IggyError>;
 
-    /// [`poll_messages`](Self::poll_messages) whose strategy is chosen once the partition is
-    /// known. A consumer-group poll without a partition picks one of the member's assigned
-    /// partitions first and then asks `strategy_for` for it, so a caller can continue every
-    /// partition from its own position. Any other poll has its partition up front and asks
-    /// `strategy_for` for that one, or for `0` when none was given, which is the partition the
-    /// server reads then.
-    ///
-    /// The default implementation is for transports that cannot pick a partition client-side:
-    /// a consumer-group poll without a partition fails with `FeatureUnavailable`.
-    #[allow(clippy::too_many_arguments)]
-    async fn poll_messages_with_strategy_for(
+    /// [`Self::poll_messages`], keeping the record of a reply that does not decode.
+    #[doc(hidden)]
+    async fn poll_messages_raw(
         &self,
         stream_id: &Identifier,
         topic_id: &Identifier,
-        partition_id: Option<u32>,
+        partition_id: u32,
         consumer: &Consumer,
-        strategy_for: &(dyn Fn(u32) -> PollingStrategy + Send + Sync),
-        count: u32,
-        auto_commit: bool,
-    ) -> Result<PolledMessages, IggyError> {
-        if consumer.kind == ConsumerKind::ConsumerGroup && partition_id.is_none() {
-            return Err(IggyError::FeatureUnavailable);
-        }
-        let strategy = strategy_for(partition_id.unwrap_or(0));
+        strategy: &PollingStrategy,
+        options: PollOptions,
+    ) -> Result<PolledMessages, DecodeError> {
         self.poll_messages(
             stream_id,
             topic_id,
             partition_id,
             consumer,
-            &strategy,
-            count,
-            auto_commit,
+            strategy,
+            options,
         )
         .await
-    }
-
-    /// Wait for the readiness minimum and return up to `count` messages.
-    /// Expiry returns an available partial; response bytes are independently bounded.
-    /// A lost response can follow progress admission; see `poll_messages`.
-    ///
-    /// A consumer group poll without a partition waits on one assigned partition, chosen
-    /// round-robin, so messages on the other partitions wait up to `max_wait`. To wait on the
-    /// whole assignment, call `wait_for_messages` first and poll a partition that it lists.
-    #[allow(clippy::too_many_arguments)]
-    async fn poll_messages_deferred(
-        &self,
-        stream_id: &Identifier,
-        topic_id: &Identifier,
-        partition_id: Option<u32>,
-        consumer: &Consumer,
-        strategy: &PollingStrategy,
-        count: u32,
-        auto_commit: bool,
-        options: crate::DeferredPollOptions,
-    ) -> Result<PolledMessages, IggyError> {
-        self.poll_messages_with_strategy_for_and_options(
-            stream_id,
-            topic_id,
-            partition_id,
-            consumer,
-            &|_| *strategy,
-            count,
-            auto_commit,
-            Some(options),
-        )
-        .await
-    }
-
-    /// Deferred polling with the strategy resolved after choosing a partition.
-    /// Custom transports must opt in; positive waits never silently downgrade.
-    #[allow(clippy::too_many_arguments)]
-    async fn poll_messages_with_strategy_for_and_options(
-        &self,
-        stream_id: &Identifier,
-        topic_id: &Identifier,
-        partition_id: Option<u32>,
-        consumer: &Consumer,
-        strategy_for: &(dyn Fn(u32) -> PollingStrategy + Send + Sync),
-        count: u32,
-        auto_commit: bool,
-        options: Option<crate::DeferredPollOptions>,
-    ) -> Result<PolledMessages, IggyError> {
-        if options.is_some() {
-            return Err(IggyError::FeatureUnavailable);
-        }
-        self.poll_messages_with_strategy_for(
-            stream_id,
-            topic_id,
-            partition_id,
-            consumer,
-            strategy_for,
-            count,
-            auto_commit,
-        )
-        .await
-    }
-
-    /// Wait until at least one of `partitions` holds a message at its start point, and return
-    /// the ids of the partitions that do. An empty list means that `options.max_wait` expired.
-    /// The wait reads and commits no message. Only `max_wait` and `request_timeout` apply.
-    ///
-    /// Each start point is [`PollingStrategy::offset`] or [`PollingStrategy::next`] of
-    /// `consumer`, and each partition appears once. A reported partition returns at least one
-    /// message to a poll with the same consumer and strategy, unless a purge or retention
-    /// removed the messages, or another commit moved `next` past them, in the meantime. A
-    /// reply can leave out a `next` partition whose stored offset moved back during the wait.
-    ///
-    /// The wait runs on the primary of each partition. If the partitions have more than one
-    /// primary, the call waits on each of them and returns the first reply that lists a
-    /// partition. It then cancels the other waits on their shared connections.
-    ///
-    /// A consumer group member that does not own one of the partitions gets
-    /// `ConsumerGroupPartitionNotOwned`. The default implementation is for transports without
-    /// the command, such as HTTP: it fails with `FeatureUnavailable`.
-    async fn wait_for_messages(
-        &self,
-        _stream_id: &Identifier,
-        _topic_id: &Identifier,
-        _partitions: &[(u32, PollingStrategy)],
-        _consumer: &Consumer,
-        _options: crate::DeferredPollOptions,
-    ) -> Result<Vec<u32>, IggyError> {
-        Err(IggyError::FeatureUnavailable)
+        .map_err(DecodeError::without_record)
     }
 
     /// Send messages using specified partitioning strategy to the given stream and topic by unique IDs or names.
diff --git a/core/common/src/traits/mod.rs b/core/common/src/traits/mod.rs
index a5ae3db..db7fab3 100644
--- a/core/common/src/traits/mod.rs
+++ b/core/common/src/traits/mod.rs
@@ -35,6 +35,8 @@
 pub(crate) mod user_client;
 pub(crate) mod validatable;
 
-pub use binary_impls::{BinarySendPartitioning, decode_send_confirmations, send_binary_messages};
+pub use binary_impls::{
+    BinarySendPartitioning, decode_send_confirmations, fetch_binary_messages, send_binary_messages,
+};
 pub use binary_impls::{binary_sign_in, rust_sdk_version_info, sign_in_request};
-pub use binary_impls::{group_cache_key, sync_group_assignment, wait_for_messages_request};
+pub use binary_impls::{group_cache_key, sync_group_assignment};
diff --git a/core/common/src/types/configuration/pending_operations_config.rs b/core/common/src/types/configuration/pending_operations_config.rs
index fbe16aa..057c86b 100644
--- a/core/common/src/types/configuration/pending_operations_config.rs
+++ b/core/common/src/types/configuration/pending_operations_config.rs
@@ -19,6 +19,9 @@
 
 /// Client-wide limits for active and retained replicated mutations.
 /// Queued writers keep their frame bytes charged after completion or abandonment.
+/// Committing fetches use a separate pool with these same limits for active
+/// reservations and local unknown-progress blocks. That pool survives sessions
+/// and charges target metadata only, never reply bodies or replay frames.
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 pub struct PendingOperationsConfig {
     /// Active or retained mutations sharing this client session.
diff --git a/core/common/src/types/configuration/quic_config/quic_client_config.rs b/core/common/src/types/configuration/quic_config/quic_client_config.rs
index 4b57162..d52ec76 100644
--- a/core/common/src/types/configuration/quic_config/quic_client_config.rs
+++ b/core/common/src/types/configuration/quic_config/quic_client_config.rs
@@ -19,6 +19,7 @@
     AutoLogin, ConnectionString, ConnectionStringOptions, NonZeroIggyDuration,
     PendingOperationsConfig, QuicClientReconnectionConfig, QuicConnectionStringOptions,
 };
+use iggy_binary_protocol::MAX_RECORD_REPLY_SIZE;
 use std::str::FromStr;
 
 /// Configuration for the QUIC client.
@@ -68,7 +69,7 @@
             pending_operations: PendingOperationsConfig::default(),
             heartbeat_interval: NonZeroIggyDuration::from_str("5s").unwrap(),
             reconnection: QuicClientReconnectionConfig::default(),
-            response_buffer_size: 1000 * 1000 * 10,
+            response_buffer_size: MAX_RECORD_REPLY_SIZE as u64,
             max_concurrent_bidi_streams: 10000,
             datagram_send_buffer_size: 100_000,
             initial_mtu: 1200,
diff --git a/core/common/src/types/configuration/quic_config/quic_client_config_builder.rs b/core/common/src/types/configuration/quic_config/quic_client_config_builder.rs
index 8c8f6b6..cc80c24 100644
--- a/core/common/src/types/configuration/quic_config/quic_client_config_builder.rs
+++ b/core/common/src/types/configuration/quic_config/quic_client_config_builder.rs
@@ -28,7 +28,7 @@
 /// - `server_name`: Default is "localhost".
 /// - `auto_login`: Default is AutoLogin::Disabled.
 /// - `reconnection`: Default is enabled unlimited retries and 1 second interval.
-/// - `response_buffer_size`: Default is 10MB (10,000,000 bytes).
+/// - `response_buffer_size`: Default is a reply that holds the largest message, about 64 MB.
 /// - `max_concurrent_bidi_streams`: Default is 10,000 streams.
 /// - `datagram_send_buffer_size`: Default is 100,000 bytes.
 /// - `initial_mtu`: Default is 1200 bytes.
diff --git a/core/common/src/types/configuration/quic_config/quic_connection_string_options.rs b/core/common/src/types/configuration/quic_config/quic_connection_string_options.rs
index a17d2be..1f37895 100644
--- a/core/common/src/types/configuration/quic_config/quic_connection_string_options.rs
+++ b/core/common/src/types/configuration/quic_config/quic_connection_string_options.rs
@@ -19,6 +19,7 @@
     ConnectionStringOptions, IggyDuration, IggyError, NonZeroIggyDuration, PendingOperationsConfig,
     QuicClientReconnectionConfig,
 };
+use iggy_binary_protocol::MAX_RECORD_REPLY_SIZE;
 use std::str::FromStr;
 
 #[derive(Debug)]
@@ -95,7 +96,7 @@
     fn parse_options(options: &str) -> Result<QuicConnectionStringOptions, IggyError> {
         let options = options.split('&').collect::<Vec<&str>>();
         let mut pending_operations = PendingOperationsConfig::default();
-        let mut response_buffer_size = 1000 * 1000 * 10;
+        let mut response_buffer_size = MAX_RECORD_REPLY_SIZE as u64;
         let mut max_concurrent_bidi_streams = 10000;
         let mut datagram_send_buffer_size = 100_000;
         let mut initial_mtu = 1200;
@@ -287,7 +288,7 @@
         QuicConnectionStringOptions {
             pending_operations: PendingOperationsConfig::default(),
             reconnection: Default::default(),
-            response_buffer_size: 1000 * 1000 * 10,
+            response_buffer_size: MAX_RECORD_REPLY_SIZE as u64,
             max_concurrent_bidi_streams: 10000,
             datagram_send_buffer_size: 100_000,
             initial_mtu: 1200,
diff --git a/core/common/src/types/configuration/websocket_config/websocket_client_config.rs b/core/common/src/types/configuration/websocket_config/websocket_client_config.rs
index 5c80619..9fbdec7 100644
--- a/core/common/src/types/configuration/websocket_config/websocket_client_config.rs
+++ b/core/common/src/types/configuration/websocket_config/websocket_client_config.rs
@@ -20,6 +20,7 @@
 use crate::{
     AutoLogin, NonZeroIggyDuration, PendingOperationsConfig, WebSocketClientReconnectionConfig,
 };
+use iggy_binary_protocol::MAX_RECORD_REPLY_SIZE;
 use std::fmt::{Display, Formatter};
 use std::str::FromStr;
 use tungstenite::protocol::WebSocketConfig as TungsteniteConfig;
@@ -59,13 +60,14 @@
     pub write_buffer_size: Option<usize>,
     /// Maximum write buffer size.
     pub max_write_buffer_size: Option<usize>,
-    /// Maximum message size, or `None` for no limit. Defaults to tungstenite's
-    /// own limit rather than `None`, so `None` always means the limit was lifted
-    /// deliberately.
+    /// Maximum message size, or `None` for no limit. Defaults to the larger of
+    /// tungstenite's own limit and a reply that holds the largest message, so
+    /// `None` always means the limit was lifted deliberately.
     pub max_message_size: Option<usize>,
-    /// Maximum frame size, or `None` for no limit. Defaults to tungstenite's own
-    /// limit rather than `None`, so `None` always means the limit was lifted
-    /// deliberately.
+    /// Maximum frame size, or `None` for no limit. The server sends each reply as
+    /// one frame, so it defaults to the larger of tungstenite's own limit and a
+    /// reply that holds the largest message. `None` always means the limit was
+    /// lifted deliberately.
     pub max_frame_size: Option<usize>,
     /// Accept unmasked frames (client should typically keep as false).
     pub accept_unmasked_frames: bool,
@@ -96,8 +98,12 @@
             read_buffer_size: Some(tungstenite_config.read_buffer_size),
             write_buffer_size: Some(tungstenite_config.write_buffer_size),
             max_write_buffer_size: Some(tungstenite_config.max_write_buffer_size),
-            max_message_size: tungstenite_config.max_message_size,
-            max_frame_size: tungstenite_config.max_frame_size,
+            max_message_size: tungstenite_config
+                .max_message_size
+                .map(|size| size.max(MAX_RECORD_REPLY_SIZE)),
+            max_frame_size: tungstenite_config
+                .max_frame_size
+                .map(|size| size.max(MAX_RECORD_REPLY_SIZE)),
             accept_unmasked_frames: false,
         }
     }
@@ -205,14 +211,17 @@
     use super::*;
 
     #[test]
-    fn default_should_carry_tungstenites_own_size_limits() {
+    fn given_no_size_limits_when_defaulted_should_hold_the_largest_record_reply() {
         let config = WebSocketConfig::default();
         let tungstenite = TungsteniteConfig::default();
 
-        assert_eq!(config.max_message_size, tungstenite.max_message_size);
-        assert_eq!(config.max_frame_size, tungstenite.max_frame_size);
-        assert!(config.max_message_size.is_some());
-        assert!(config.max_frame_size.is_some());
+        assert_eq!(
+            config.max_message_size,
+            tungstenite
+                .max_message_size
+                .map(|size| size.max(MAX_RECORD_REPLY_SIZE))
+        );
+        assert_eq!(config.max_frame_size, Some(MAX_RECORD_REPLY_SIZE));
     }
 
     #[test]
@@ -231,11 +240,11 @@
 
     #[test]
     fn default_size_limits_should_reach_tungstenite_unchanged() {
-        let tungstenite = WebSocketConfig::default().to_tungstenite_config();
-        let expected = TungsteniteConfig::default();
+        let config = WebSocketConfig::default();
+        let tungstenite = config.to_tungstenite_config();
 
-        assert_eq!(tungstenite.max_message_size, expected.max_message_size);
-        assert_eq!(tungstenite.max_frame_size, expected.max_frame_size);
+        assert_eq!(tungstenite.max_message_size, config.max_message_size);
+        assert_eq!(tungstenite.max_frame_size, config.max_frame_size);
     }
 
     #[test]
diff --git a/core/common/src/types/consumer/consumer_offset_info.rs b/core/common/src/types/consumer/consumer_offset_info.rs
index b8b4cf5..cc95f3d 100644
--- a/core/common/src/types/consumer/consumer_offset_info.rs
+++ b/core/common/src/types/consumer/consumer_offset_info.rs
@@ -33,13 +33,70 @@
     pub stored_offset: u64,
 }
 
-/// A checkpoint retains the partition incarnation and owner that delivered its messages.
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
-pub struct ConsumerPosition {
-    /// Partition from which the message was consumed.
-    pub partition_id: u32,
-    /// Inclusive message offset to commit.
-    pub offset: u64,
-    /// Partition incarnation and owner captured by the poll; retain both for a delayed commit.
-    pub context: PartitionContext,
+/// The position after one delivered message. A commit stores its offset, so it covers all
+/// earlier messages of the partition. A checkpoint retains the partition incarnation and owner
+/// that delivered the message, so a commit after a change of the partition history fails. It
+/// serializes for logs but never deserializes: only the reader that delivered the message can
+/// commit it, so a stored checkpoint could never be committed. Store its offset instead.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
+pub struct Checkpoint {
+    partition_id: u32,
+    offset: u64,
+    context: PartitionContext,
+    #[serde(skip)]
+    stamp: Option<ReaderStamp>,
+}
+
+/// The reader and the partition generation that delivered a message. The SDK reader refuses a
+/// commit of a checkpoint that another reader delivered, and skips one of an older generation.
+#[doc(hidden)]
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct ReaderStamp {
+    pub reader: u64,
+    pub generation: u64,
+}
+
+impl Checkpoint {
+    /// The checkpoint after the message at `offset`, delivered under `context`.
+    #[doc(hidden)]
+    pub const fn new(partition_id: u32, offset: u64, context: PartitionContext) -> Self {
+        Self::stamped(partition_id, offset, context, None)
+    }
+
+    /// The checkpoint after the message at `offset`, delivered by the reader of `stamp`.
+    #[doc(hidden)]
+    pub const fn stamped(
+        partition_id: u32,
+        offset: u64,
+        context: PartitionContext,
+        stamp: Option<ReaderStamp>,
+    ) -> Self {
+        Self {
+            partition_id,
+            offset,
+            context,
+            stamp,
+        }
+    }
+
+    /// The partition of the delivered message.
+    pub const fn partition_id(&self) -> u32 {
+        self.partition_id
+    }
+
+    /// The offset of the delivered message, which a commit stores.
+    pub const fn offset(&self) -> u64 {
+        self.offset
+    }
+
+    /// Partition incarnation and owner captured by the read. A delayed commit keeps both.
+    #[doc(hidden)]
+    pub const fn context(&self) -> PartitionContext {
+        self.context
+    }
+
+    #[doc(hidden)]
+    pub const fn stamp(&self) -> Option<ReaderStamp> {
+        self.stamp
+    }
 }
diff --git a/core/common/src/types/message/commit_outcome.rs b/core/common/src/types/message/commit_outcome.rs
new file mode 100644
index 0000000..74e64bb
--- /dev/null
+++ b/core/common/src/types/message/commit_outcome.rs
@@ -0,0 +1,106 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use crate::IggyError;
+
+/// Where a committing deferred poll (106) or fetch (109) reported an error.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum CommitErrorScope {
+    /// The whole reply is the error.
+    Request,
+    /// One section of a fetch reply carries the error status.
+    Section,
+}
+
+impl CommitErrorScope {
+    /// Whether `error`, reported in this scope, proves that the server stored no offset. Any
+    /// other error, a lost reply and a transport failure after the send leave the outcome
+    /// unknown.
+    pub fn proves_nothing_stored(self, error: &IggyError) -> bool {
+        match self {
+            Self::Request => matches!(
+                error,
+                IggyError::FeatureUnavailable
+                    | IggyError::InvalidIdentifier
+                    | IggyError::TransientNotAccepted
+                    | IggyError::HistoryUnavailable
+                    | IggyError::NotPrimary
+                    | IggyError::DeferredPollCapacityExceeded
+                    // The server refuses a first message larger than `max_bytes` before it
+                    // stores the offset.
+                    | IggyError::InvalidSizeBytes
+                    | IggyError::StreamIdNotFound(..)
+                    | IggyError::StreamNameNotFound(..)
+                    | IggyError::TopicIdNotFound(..)
+                    | IggyError::TopicNameNotFound(..)
+                    | IggyError::PartitionNotFound(..)
+                    | IggyError::InvalidOffset(..)
+                    | IggyError::ConsumerGroupPartitionNotOwned(..)
+                    // A read that needs a new offset key above the limit returns no messages.
+                    | IggyError::TooManyConsumerOffsets
+            ),
+            // None of these leaves an offset stored in the history that the request named.
+            Self::Section => matches!(
+                error,
+                IggyError::TransientNotAccepted
+                    | IggyError::InvalidSizeBytes
+                    | IggyError::HistoryUnavailable
+                    | IggyError::NotPrimary
+                    | IggyError::ConsumerGroupPartitionNotOwned(..)
+                    | IggyError::PartitionNotFound(..)
+                    | IggyError::TooManyConsumerOffsets
+            ),
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn given_transient_not_accepted_section_when_classifying_then_should_prove_nothing_stored() {
+        assert!(CommitErrorScope::Section.proves_nothing_stored(&IggyError::TransientNotAccepted));
+    }
+
+    #[test]
+    fn given_lost_session_when_classifying_then_should_leave_outcome_unknown_in_both_scopes() {
+        for error in [
+            IggyError::Unauthenticated,
+            IggyError::Unauthorized,
+            IggyError::StaleClient,
+            IggyError::TransientNotCommitted,
+            IggyError::CannotDecryptData,
+            IggyError::ShardCommunicationError,
+        ] {
+            assert!(!CommitErrorScope::Request.proves_nothing_stored(&error));
+            assert!(!CommitErrorScope::Section.proves_nothing_stored(&error));
+        }
+    }
+
+    #[test]
+    fn given_caller_condition_in_a_section_when_classifying_then_should_leave_outcome_unknown() {
+        for error in [
+            IggyError::FeatureUnavailable,
+            IggyError::DeferredPollCapacityExceeded,
+            IggyError::InvalidOffset(0),
+        ] {
+            assert!(CommitErrorScope::Request.proves_nothing_stored(&error));
+            assert!(!CommitErrorScope::Section.proves_nothing_stored(&error));
+        }
+    }
+}
diff --git a/core/common/src/types/message/deferred_poll_options.rs b/core/common/src/types/message/deferred_poll_options.rs
index da983f7..2bd839c 100644
--- a/core/common/src/types/message/deferred_poll_options.rs
+++ b/core/common/src/types/message/deferred_poll_options.rs
@@ -17,12 +17,13 @@
 
 use std::time::Duration;
 
+use iggy_binary_protocol::requests::messages::{
+    DeferredPollMessagesRequest, FetchMessagesRequest, deferred_timeout_covers_wait,
+};
+
 use crate::{
     IggyDuration, IggyError, MAX_DEFERRED_POLL_TIMEOUT_US, MAX_DEFERRED_POLL_WAIT_US,
-    MIN_DEFERRED_POLL_MAX_BYTES,
-};
-use iggy_binary_protocol::requests::messages::{
-    DeferredPollMessagesRequest, deferred_timeout_covers_wait,
+    MIN_DEFERRED_POLL_MAX_BYTES, MIN_DEFERRED_POLL_REPLY_MARGIN_US,
 };
 
 pub const DEFAULT_POLL_MAX_BYTES: u32 = 1024 * 1024;
@@ -35,6 +36,7 @@
 /// timeout for the final read and the reply. Byte limits include the binary
 /// response metadata and batch framing, including for HTTP's equivalent
 /// selection.
+#[doc(hidden)]
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 pub struct DeferredPollOptions {
     pub max_wait: IggyDuration,
@@ -78,6 +80,21 @@
         Ok(())
     }
 
+    /// Validates the options before starting a fetch, including an immediate one.
+    /// The timeout must exceed the reply margin at wire precision, because
+    /// routing and admission consume part of it before the first send.
+    ///
+    /// # Errors
+    /// Returns the errors from [`Self::validate`], or [`IggyError::InvalidCommand`]
+    /// when the timeout leaves no budget beyond the fetch reply margin.
+    pub fn validate_fetch(&self, count: u32) -> Result<(), IggyError> {
+        self.validate(count)?;
+        if self.request_timeout.as_micros() <= MIN_DEFERRED_POLL_REPLY_MARGIN_US {
+            return Err(IggyError::InvalidCommand);
+        }
+        Ok(())
+    }
+
     /// Preserve one client budget across assignment refreshes and routing retries.
     /// Both limits shrink by `elapsed`, so a wait keeps its reply margin.
     ///
@@ -85,18 +102,28 @@
     /// Returns `TransientNotAccepted` once the budget is spent, because this
     /// attempt never left the client.
     pub fn remaining(self, elapsed: Duration) -> Result<Self, IggyError> {
-        let request_timeout = self.request_timeout.get_duration().saturating_sub(elapsed);
+        // The wire carries whole microseconds. Without their fractions, both
+        // limits lose the same whole microseconds to `elapsed`, so the gap that
+        // `validate` checked stays on the wire.
+        let request_timeout =
+            whole_micros(self.request_timeout.get_duration()).saturating_sub(elapsed);
         if request_timeout.as_micros() == 0 {
             return Err(IggyError::TransientNotAccepted);
         }
         Ok(Self {
-            max_wait: self.max_wait.get_duration().saturating_sub(elapsed).into(),
+            max_wait: whole_micros(self.max_wait.get_duration())
+                .saturating_sub(elapsed)
+                .into(),
             request_timeout: request_timeout.into(),
             ..self
         })
     }
 }
 
+fn whole_micros(duration: Duration) -> Duration {
+    Duration::from_micros(u64::try_from(duration.as_micros()).unwrap_or(u64::MAX))
+}
+
 impl From<&DeferredPollMessagesRequest> for DeferredPollOptions {
     fn from(request: &DeferredPollMessagesRequest) -> Self {
         Self {
@@ -108,10 +135,20 @@
     }
 }
 
+impl From<&FetchMessagesRequest> for DeferredPollOptions {
+    fn from(request: &FetchMessagesRequest) -> Self {
+        Self {
+            max_wait: request.wait_us.into(),
+            min_count: request.min_count,
+            max_bytes: request.max_bytes,
+            request_timeout: request.request_timeout_us.into(),
+        }
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::MIN_DEFERRED_POLL_REPLY_MARGIN_US;
 
     fn options(max_wait: Duration, request_timeout: Duration) -> DeferredPollOptions {
         DeferredPollOptions {
@@ -156,4 +193,39 @@
             Err(IggyError::TransientNotAccepted)
         ));
     }
+
+    #[test]
+    fn given_fetch_options_when_validating_should_leave_budget_beyond_the_reply_margin() {
+        let margin = Duration::from_micros(MIN_DEFERRED_POLL_REPLY_MARGIN_US);
+        for timeout in [
+            Duration::from_micros(1),
+            margin,
+            margin + Duration::from_nanos(999),
+        ] {
+            let options = options(Duration::ZERO, timeout);
+            assert_eq!(options.validate(1), Ok(()));
+            assert_eq!(options.validate_fetch(1), Err(IggyError::InvalidCommand));
+        }
+        assert_eq!(
+            options(Duration::ZERO, margin + Duration::from_micros(1)).validate_fetch(1),
+            Ok(())
+        );
+        assert_eq!(options(margin, margin * 2).validate_fetch(1), Ok(()));
+    }
+
+    #[test]
+    fn given_a_wait_with_a_fraction_of_a_microsecond_when_the_budget_shrinks_should_keep_the_reply_margin()
+     {
+        let margin = Duration::from_micros(MIN_DEFERRED_POLL_REPLY_MARGIN_US);
+        let wait = Duration::from_secs(1);
+        let options = options(wait + Duration::from_nanos(999), wait + margin);
+        assert!(options.validate(1).is_ok());
+
+        let remaining = options.remaining(Duration::from_nanos(500)).unwrap();
+
+        assert!(
+            remaining.validate(1).is_ok(),
+            "lost the margin: {remaining:?}"
+        );
+    }
 }
diff --git a/core/common/src/types/message/fetched_section.rs b/core/common/src/types/message/fetched_section.rs
new file mode 100644
index 0000000..e9818d8
--- /dev/null
+++ b/core/common/src/types/message/fetched_section.rs
@@ -0,0 +1,401 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use super::polled_messages::{DecodeError, messages_from_batches, record_at};
+use crate::{EncryptorKind, IggyError, PolledMessages, PollingStrategy};
+use bytes::Bytes;
+use iggy_binary_protocol::STATUS_OK;
+use iggy_binary_protocol::primitives::partition_history::PartitionContext;
+use iggy_binary_protocol::responses::messages::{
+    FetchMessagesSectionHeader, FetchMessagesSectionIterator,
+};
+use std::sync::Arc;
+
+/// The outcome of one target of a `FetchMessages` request: the served
+/// messages, still encoded, or the refusal of its partition. The table fields
+/// are known before [`Self::decode`] walks the records.
+#[doc(hidden)]
+#[derive(Debug, Clone)]
+pub struct FetchedSection {
+    pub partition_id: u32,
+    /// The refusal of this partition, such as `NotPrimary` after a move. A
+    /// refused section has no records.
+    pub status: Result<(), IggyError>,
+    /// The current offset of the partition.
+    pub current_offset: u64,
+    pub messages_count: u32,
+    /// The partition history that served the section. A later fetch fences
+    /// the partition with it through the [continuation](PolledMessages::continuation)
+    /// of the decoded section.
+    pub context: PartitionContext,
+    /// Where the next fetch of the partition starts, also after an empty
+    /// section.
+    pub next_offset: u64,
+    /// The batch records of the served messages.
+    pub records: Bytes,
+    encryptor: Option<Arc<EncryptorKind>>,
+}
+
+impl FetchedSection {
+    /// The sections of a fetch reply body, in reply order. Their records
+    /// share the buffer of `body`.
+    ///
+    /// # Errors
+    /// [`IggyError::InvalidFormat`] for a body that is not a fetch reply.
+    pub fn from_reply(body: &Bytes) -> Result<Vec<Self>, IggyError> {
+        let sections =
+            FetchMessagesSectionIterator::new(body).map_err(|_| IggyError::InvalidFormat)?;
+        Ok(sections
+            .map(|section| Self::new(section.header, body.slice_ref(section.records)))
+            .collect())
+    }
+
+    /// [`Self::decode`] then decrypts the messages with `encryptor`.
+    pub fn with_encryptor(mut self, encryptor: Option<Arc<EncryptorKind>>) -> Self {
+        self.encryptor = encryptor;
+        self
+    }
+
+    /// The messages of a served section. Its continuation starts at
+    /// `next_offset`, which is also the resolved start of an empty section.
+    ///
+    /// # Errors
+    /// The refusal of a refused section, a decode error of
+    /// [`PolledMessages::from_bytes`], or a decryption error.
+    pub fn decode(&self) -> Result<PolledMessages, IggyError> {
+        self.decode_records().map_err(|error| error.error)
+    }
+
+    /// [`Self::decode`], keeping the record of the message that does not decode or decrypt.
+    pub fn decode_records(&self) -> Result<PolledMessages, DecodeError> {
+        self.status.clone().map_err(DecodeError::without_record)?;
+        let mut messages = messages_from_batches(self.records.clone(), self.messages_count)?;
+        if let Some(encryptor) = &self.encryptor {
+            for (index, message) in messages.iter_mut().enumerate() {
+                encryptor
+                    .decrypt_message(message)
+                    .map_err(|error| DecodeError {
+                        error,
+                        record: record_at(&self.records, index).map(Box::new),
+                    })?;
+            }
+        }
+        let mut polled = PolledMessages::with_records(
+            self.context,
+            self.partition_id,
+            self.current_offset,
+            self.messages_count,
+            messages,
+            self.records.clone(),
+        );
+        polled.set_request_start(&PollingStrategy::offset(self.next_offset));
+        Ok(polled)
+    }
+
+    fn new(header: FetchMessagesSectionHeader, records: Bytes) -> Self {
+        Self {
+            partition_id: header.partition_id,
+            status: match header.status {
+                STATUS_OK => Ok(()),
+                status => Err(IggyError::from_code(status)),
+            },
+            current_offset: header.current_offset,
+            messages_count: header.messages_count,
+            context: header.context,
+            next_offset: header.next_offset,
+            records,
+            encryptor: None,
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::{Aes256GcmEncryptor, PollingKind, UndecodedRecord};
+    use bytes::BytesMut;
+    use iggy_binary_protocol::batch::{BATCH_HEADER_SIZE, BATCH_MESSAGE_HEADER_SIZE, BatchHeader};
+    use iggy_binary_protocol::requests::messages::{RawMessage, SendMessagesEncoder};
+    use iggy_binary_protocol::responses::messages::{FetchMessagesResponse, FetchMessagesSection};
+    use iggy_binary_protocol::{WireIdentifier, WirePartitioning};
+
+    const SERVED_PARTITION: u32 = 1;
+    const MOVED_PARTITION: u32 = 2;
+    const BASE_OFFSET: u64 = 41;
+    const NEXT_OFFSET: u64 = 43;
+    const CONTEXT: PartitionContext = PartitionContext {
+        incarnation: 3,
+        owner_generation: 9,
+        metadata_op: 12,
+    };
+    const PAYLOADS: [&[u8]; 2] = [b"first", b"second"];
+    const KEY: [u8; 32] = [7; 32];
+
+    /// One batch of `payloads` from `BASE_OFFSET`, as a section carries it.
+    fn records(payloads: &[&[u8]]) -> Bytes {
+        let messages: Vec<_> = (1..)
+            .zip(payloads.iter().copied())
+            .map(|(id, payload)| RawMessage {
+                id,
+                origin_timestamp: 0,
+                headers: None,
+                payload,
+            })
+            .collect();
+        let mut send = BytesMut::new();
+        SendMessagesEncoder::encode(
+            &mut send,
+            &WireIdentifier::numeric(1),
+            &WireIdentifier::numeric(1),
+            &WirePartitioning::PartitionId(SERVED_PARTITION),
+            &messages,
+        )
+        .unwrap();
+        let metadata_len = u32::from_le_bytes(send[..size_of::<u32>()].try_into().unwrap());
+        let mut record = send.split_off(size_of::<u32>() + metadata_len as usize);
+        let mut header = BatchHeader::decode(&record).unwrap();
+        header.base_offset = BASE_OFFSET;
+        header.encode_into(&mut record);
+        record.freeze()
+    }
+
+    fn reply(records: &[u8], messages_count: u32) -> Bytes {
+        FetchMessagesResponse {
+            sections: vec![
+                FetchMessagesSection {
+                    header: FetchMessagesSectionHeader {
+                        partition_id: SERVED_PARTITION,
+                        status: STATUS_OK,
+                        current_offset: NEXT_OFFSET - 1,
+                        messages_count,
+                        context: CONTEXT,
+                        next_offset: NEXT_OFFSET,
+                        records_len: u32::try_from(records.len()).unwrap(),
+                    },
+                    records,
+                },
+                FetchMessagesSection {
+                    header: FetchMessagesSectionHeader {
+                        partition_id: MOVED_PARTITION,
+                        status: IggyError::NotPrimary.as_code(),
+                        current_offset: 0,
+                        messages_count: 0,
+                        context: PartitionContext::default(),
+                        next_offset: 0,
+                        records_len: 0,
+                    },
+                    records: &[],
+                },
+            ],
+        }
+        .to_bytes()
+        .unwrap()
+    }
+
+    fn payloads(polled: &PolledMessages) -> Vec<&[u8]> {
+        polled
+            .messages
+            .iter()
+            .map(|message| &message.payload[..])
+            .collect()
+    }
+
+    #[test]
+    fn given_a_fetch_reply_when_read_should_keep_the_table_and_decode_each_section() {
+        let records = records(&PAYLOADS);
+        let sections = FetchedSection::from_reply(&reply(&records, 2)).unwrap();
+
+        let [served, moved] = sections.as_slice() else {
+            panic!("expected two sections: {sections:?}");
+        };
+        assert_eq!(
+            (
+                served.partition_id,
+                &served.status,
+                served.next_offset,
+                served.messages_count
+            ),
+            (SERVED_PARTITION, &Ok(()), NEXT_OFFSET, 2)
+        );
+        assert_eq!(served.records, records);
+        let polled = served.decode().unwrap();
+        assert_eq!(
+            (polled.partition_id, polled.current_offset, polled.context()),
+            (SERVED_PARTITION, NEXT_OFFSET - 1, CONTEXT)
+        );
+        assert_eq!(payloads(&polled), PAYLOADS);
+        assert_eq!(
+            polled
+                .messages
+                .iter()
+                .map(|message| message.header.offset)
+                .collect::<Vec<_>>(),
+            [BASE_OFFSET, BASE_OFFSET + 1]
+        );
+
+        assert_eq!(moved.partition_id, MOVED_PARTITION);
+        assert!(matches!(moved.status, Err(IggyError::NotPrimary)));
+        assert!(moved.records.is_empty());
+        assert!(matches!(moved.decode(), Err(IggyError::NotPrimary)));
+    }
+
+    #[test]
+    fn given_a_served_section_when_decoded_should_continue_at_its_next_offset() {
+        let expected = PollingStrategy::continuation(
+            SERVED_PARTITION,
+            PollingKind::Offset,
+            NEXT_OFFSET,
+            CONTEXT,
+        );
+        for (records, messages_count) in [(records(&PAYLOADS), 2), (Bytes::new(), 0)] {
+            let sections = FetchedSection::from_reply(&reply(&records, messages_count)).unwrap();
+
+            let polled = sections[0].decode().unwrap();
+
+            assert_eq!(polled.continuation(), expected, "{messages_count} messages");
+        }
+    }
+
+    #[test]
+    fn given_a_section_whose_count_differs_from_its_records_when_decoded_should_fail() {
+        let records = records(&PAYLOADS);
+        let sections = FetchedSection::from_reply(&reply(&records, 1)).unwrap();
+
+        assert!(matches!(
+            sections[0].decode(),
+            Err(IggyError::InvalidMessagesCount)
+        ));
+    }
+
+    #[test]
+    fn given_a_truncated_reply_when_read_should_fail() {
+        let reply = reply(&records(&PAYLOADS), 2);
+
+        for end in 0..reply.len() {
+            assert!(
+                matches!(
+                    FetchedSection::from_reply(&reply.slice(..end)),
+                    Err(IggyError::InvalidFormat)
+                ),
+                "accepted truncation at {end}"
+            );
+        }
+    }
+
+    #[test]
+    fn given_an_encryptor_when_decoding_should_return_the_plaintext() {
+        let encryptor = Arc::new(EncryptorKind::Aes256Gcm(
+            Aes256GcmEncryptor::new(&KEY).unwrap(),
+        ));
+        let encrypted: Vec<_> = PAYLOADS
+            .iter()
+            .map(|payload| encryptor.encrypt(payload).unwrap())
+            .collect();
+        let encrypted: Vec<&[u8]> = encrypted.iter().map(Vec::as_slice).collect();
+        let records = records(&encrypted);
+        let section = FetchedSection::from_reply(&reply(&records, 2))
+            .unwrap()
+            .remove(0);
+
+        let undecrypted = section.decode().unwrap();
+        let decrypted = section.with_encryptor(Some(encryptor)).decode().unwrap();
+
+        assert_eq!(payloads(&undecrypted), encrypted);
+        assert_eq!(payloads(&decrypted), PAYLOADS);
+        assert_eq!(
+            decrypted.messages[1].header.payload_length as usize,
+            PAYLOADS[1].len()
+        );
+    }
+
+    #[test]
+    fn given_a_message_that_does_not_decrypt_when_decoded_should_carry_its_record_as_served() {
+        let encryptor = Arc::new(EncryptorKind::Aes256Gcm(
+            Aes256GcmEncryptor::new(&KEY).unwrap(),
+        ));
+        let other = Arc::new(EncryptorKind::Aes256Gcm(
+            Aes256GcmEncryptor::new(&[8; 32]).unwrap(),
+        ));
+        let encrypted: Vec<_> = PAYLOADS
+            .iter()
+            .map(|payload| encryptor.encrypt(payload).unwrap())
+            .collect();
+        let encrypted: Vec<&[u8]> = encrypted.iter().map(Vec::as_slice).collect();
+        let records = records(&encrypted);
+        let section = FetchedSection::from_reply(&reply(&records, 2))
+            .unwrap()
+            .remove(0)
+            .with_encryptor(Some(other));
+
+        let error = section.decode_records().unwrap_err();
+
+        let first_frame =
+            BATCH_HEADER_SIZE..BATCH_HEADER_SIZE + BATCH_MESSAGE_HEADER_SIZE + encrypted[0].len();
+        assert_eq!(error.error, IggyError::CannotDecryptData);
+        assert_eq!(
+            error.record,
+            Some(Box::new(UndecodedRecord {
+                offset: Some(BASE_OFFSET),
+                bytes: records.slice(first_frame),
+            })),
+            "the record is the ciphertext frame as served"
+        );
+    }
+
+    #[test]
+    fn given_malformed_framing_when_decoded_should_carry_the_rest_without_an_offset() {
+        let mut records = records(&PAYLOADS).to_vec();
+        // The second frame claims a payload past the end of its batch.
+        let second_frame = BATCH_HEADER_SIZE + BATCH_MESSAGE_HEADER_SIZE + PAYLOADS[0].len();
+        let payload_length = second_frame + 36;
+        records[payload_length..payload_length + 4].copy_from_slice(&u32::MAX.to_le_bytes());
+        let records = Bytes::from(records);
+        let section = FetchedSection::from_reply(&reply(&records, 2))
+            .unwrap()
+            .remove(0);
+
+        let error = section.decode_records().unwrap_err();
+
+        assert_eq!(error.error, IggyError::InvalidMessagePayloadLength);
+        let record = error.record.unwrap();
+        assert_eq!(
+            record.offset,
+            Some(BASE_OFFSET + 1),
+            "the frame decoded, so its offset is known"
+        );
+        assert_eq!(record.bytes, records.slice(second_frame..));
+
+        let mut records = self::records(&PAYLOADS).to_vec();
+        // A reserved byte of the batch header, so the header does not decode.
+        records[BATCH_HEADER_SIZE - 1] = 1;
+        let records = Bytes::from(records);
+        let section = FetchedSection::from_reply(&reply(&records, 2))
+            .unwrap()
+            .remove(0);
+
+        let error = section.decode_records().unwrap_err();
+
+        assert_eq!(
+            error.record,
+            Some(Box::new(UndecodedRecord {
+                offset: None,
+                bytes: records,
+            })),
+            "malformed framing isolates no record"
+        );
+    }
+}
diff --git a/core/common/src/types/message/mod.rs b/core/common/src/types/message/mod.rs
index 1ec9250..5a4de21 100644
--- a/core/common/src/types/message/mod.rs
+++ b/core/common/src/types/message/mod.rs
@@ -15,7 +15,9 @@
 // specific language governing permissions and limitations
 // under the License.
 
+mod commit_outcome;
 mod deferred_poll_options;
+mod fetched_section;
 mod iggy_message;
 mod index;
 mod index_view;
@@ -29,6 +31,7 @@
 pub mod partitioning;
 pub mod partitioning_kind;
 mod poll_metadata;
+mod poll_options;
 pub mod polled_messages;
 pub mod polling_kind;
 pub mod polling_strategy;
@@ -36,7 +39,9 @@
 
 pub const INDEX_SIZE: usize = 16;
 
+pub use commit_outcome::CommitErrorScope;
 pub use deferred_poll_options::{DEFAULT_POLL_MAX_BYTES, DeferredPollOptions};
+pub use fetched_section::FetchedSection;
 
 pub use crate::http::messages::poll_messages::PollMessages;
 pub use crate::http::messages::send_messages::{
@@ -61,7 +66,8 @@
 pub use partitioning::Partitioning;
 pub use partitioning_kind::PartitioningKind;
 pub use poll_metadata::IggyPollMetadata;
-pub use polled_messages::PolledMessages;
+pub use poll_options::{AutoCommit, CommitAfter, CommitEvery, CommitPolicy, PollOptions};
+pub use polled_messages::{DecodeError, PolledMessages, UndecodedRecord, record_at};
 pub use polling_kind::PollingKind;
 pub use polling_strategy::PollingStrategy;
 pub use user_headers::{
diff --git a/core/common/src/types/message/poll_options.rs b/core/common/src/types/message/poll_options.rs
new file mode 100644
index 0000000..3a94967
--- /dev/null
+++ b/core/common/src/types/message/poll_options.rs
@@ -0,0 +1,335 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::num::NonZeroU32;
+use std::time::Duration;
+
+use crate::{
+    DEFAULT_POLL_MAX_BYTES, DeferredPollOptions, IggyDuration, IggyError, NonZeroIggyDuration,
+};
+
+const DEFAULT_POLL_COUNT: u32 = 1000;
+const DEFAULT_POLL_MIN_COUNT: u32 = 1;
+/// Keeps the 1 s reply gap of the protocol and today's 11 s default (1 s wait plus 10 s).
+const POLL_REQUEST_TIMEOUT_MARGIN: Duration = Duration::from_secs(10);
+const DEFAULT_AUTO_COMMIT_INTERVAL: Duration = Duration::from_secs(5);
+const DEFAULT_AUTO_COMMIT_MESSAGES: NonZeroU32 = NonZeroU32::MIN.saturating_add(9_999);
+
+/// When the offsets of the messages that a read returns are stored.
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
+pub enum CommitPolicy {
+    /// Nothing is stored. The application commits checkpoints.
+    #[default]
+    Manual,
+    /// The server stores the offset of the last message of each read as part of the read, in one
+    /// round trip. While the primary stays up a message is delivered at most once. A failover
+    /// before the store replicates can deliver the last batch again.
+    OnRead,
+    /// The client stores the latest counted position, at least once with
+    /// [`CommitAfter::Processing`]. A one-shot poll refuses it with
+    /// [`IggyError::InvalidConfiguration`], because it has nothing to count. A partition reader
+    /// and a consumer accept it.
+    Auto(AutoCommit),
+}
+
+/// When [`CommitPolicy::Auto`] stores. It needs `interval` or `every`. It also stores on close,
+/// on shutdown and before a rebalance takes a partition away. A store only moves forward, and a
+/// position of an older generation of its partition, after a seek or a new assignment, is
+/// dropped.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct AutoCommit {
+    /// Store the latest counted position at this interval, when it moved.
+    pub interval: Option<NonZeroIggyDuration>,
+    /// Also store it on this event.
+    pub every: Option<CommitEvery>,
+    /// When a message counts.
+    pub after: CommitAfter,
+}
+
+/// The event of [`AutoCommit::every`].
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum CommitEvery {
+    /// Each counted message, or each counted batch of a partition reader.
+    Message,
+    /// This many counted messages of a partition since its last store.
+    Messages(NonZeroU32),
+    /// The last message of each batch.
+    Batch,
+}
+
+/// When a message counts for [`CommitPolicy::Auto`].
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
+pub enum CommitAfter {
+    /// Once the application asks for the next message or batch, returns from its handler with
+    /// success, or closes the reader. A handler that fails leaves its message uncounted.
+    #[default]
+    Processing,
+    /// When the message is handed over.
+    Delivery,
+    /// Only when the application marks its checkpoint, so messages processed concurrently count
+    /// once they are done.
+    Marked,
+}
+
+/// The limits of one read: a poll of one partition, or a fetch that a partition reader sends
+/// for several partitions of one primary. A fetch applies `count`, `min_count` and `max_bytes`
+/// to all of its partitions together. A reader runs several fetches at once, each within these
+/// limits, for partitions with more messages waiting.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct PollOptions {
+    /// Most messages in one read, over all partitions of a fetch. Default 1000.
+    pub count: u32,
+    /// Zero answers at once. At most 600 s.
+    pub max_wait: IggyDuration,
+    /// At least 1 and at most `count`, over all partitions of a fetch. Default 1.
+    pub min_count: u32,
+    /// Default 1 MiB, over all partitions of a fetch. A first message larger than `max_bytes`
+    /// comes alone in the reply, so a read always makes progress.
+    pub max_bytes: u32,
+    /// Default [`CommitPolicy::Manual`].
+    pub commit: CommitPolicy,
+}
+
+impl Default for PollOptions {
+    fn default() -> Self {
+        Self {
+            count: DEFAULT_POLL_COUNT,
+            max_wait: Duration::ZERO.into(),
+            min_count: DEFAULT_POLL_MIN_COUNT,
+            max_bytes: DEFAULT_POLL_MAX_BYTES,
+            commit: CommitPolicy::Manual,
+        }
+    }
+}
+
+/// Every 10,000 messages or 5 s, whichever comes first, after [`CommitAfter::Processing`].
+impl Default for AutoCommit {
+    fn default() -> Self {
+        Self {
+            interval: NonZeroIggyDuration::new(DEFAULT_AUTO_COMMIT_INTERVAL).ok(),
+            every: Some(CommitEvery::Messages(DEFAULT_AUTO_COMMIT_MESSAGES)),
+            after: CommitAfter::Processing,
+        }
+    }
+}
+
+impl PollOptions {
+    /// The defaults with a wait of up to `max_wait` for `min_count` messages.
+    pub fn wait(max_wait: IggyDuration) -> Self {
+        Self {
+            max_wait,
+            ..Self::default()
+        }
+    }
+
+    /// # Errors
+    /// [`IggyError::InvalidMessagesCount`] for a zero `count` or a `min_count` outside
+    /// `1..=count`, [`IggyError::InvalidCommand`] for a `max_wait` above
+    /// [`MAX_DEFERRED_POLL_WAIT_US`](crate::MAX_DEFERRED_POLL_WAIT_US),
+    /// [`IggyError::InvalidSizeBytes`] for a `max_bytes` below
+    /// [`MIN_DEFERRED_POLL_MAX_BYTES`](crate::MIN_DEFERRED_POLL_MAX_BYTES), and
+    /// [`IggyError::InvalidConfiguration`] for [`CommitPolicy::Auto`] without an `interval` or an
+    /// `every`, which would store only on close.
+    pub fn validate(&self) -> Result<(), IggyError> {
+        if let CommitPolicy::Auto(AutoCommit {
+            interval: None,
+            every: None,
+            ..
+        }) = self.commit
+        {
+            return Err(IggyError::InvalidConfiguration);
+        }
+        DeferredPollOptions::from(*self).validate(self.count)
+    }
+
+    /// The limit of the whole request: `max_wait` plus 10 s.
+    pub fn request_timeout(&self) -> IggyDuration {
+        self.max_wait
+            .get_duration()
+            .saturating_add(POLL_REQUEST_TIMEOUT_MARGIN)
+            .into()
+    }
+}
+
+/// The wire limits of a poll.
+#[doc(hidden)]
+impl From<PollOptions> for DeferredPollOptions {
+    fn from(options: PollOptions) -> Self {
+        Self {
+            max_wait: options.max_wait,
+            min_count: options.min_count,
+            max_bytes: options.max_bytes,
+            request_timeout: options.request_timeout(),
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::{MAX_DEFERRED_POLL_WAIT_US, MIN_DEFERRED_POLL_MAX_BYTES};
+
+    const MAX_WAIT: Duration = Duration::from_micros(MAX_DEFERRED_POLL_WAIT_US);
+    const MIN_COUNT: u32 = 2;
+
+    fn min_max_bytes() -> u32 {
+        u32::try_from(MIN_DEFERRED_POLL_MAX_BYTES).unwrap()
+    }
+
+    #[test]
+    fn given_no_options_when_defaulted_should_answer_at_once_and_commit_nothing() {
+        let options = PollOptions::default();
+
+        assert_eq!(
+            options,
+            PollOptions {
+                count: 1000,
+                max_wait: Duration::ZERO.into(),
+                min_count: 1,
+                max_bytes: 1024 * 1024,
+                commit: CommitPolicy::Manual,
+            }
+        );
+        assert_eq!(options.validate(), Ok(()));
+    }
+
+    #[test]
+    fn given_no_auto_commit_settings_when_defaulted_should_store_every_10000_messages_or_5_seconds_after_processing()
+     {
+        assert_eq!(
+            AutoCommit::default(),
+            AutoCommit {
+                interval: Some(NonZeroIggyDuration::new(Duration::from_secs(5)).unwrap()),
+                every: Some(CommitEvery::Messages(NonZeroU32::new(10_000).unwrap())),
+                after: CommitAfter::Processing,
+            }
+        );
+    }
+
+    #[test]
+    fn given_auto_commit_without_an_interval_or_an_event_when_validating_should_refuse_it() {
+        let auto = |interval, every| PollOptions {
+            commit: CommitPolicy::Auto(AutoCommit {
+                interval,
+                every,
+                after: CommitAfter::Delivery,
+            }),
+            ..PollOptions::default()
+        };
+
+        assert_eq!(
+            auto(None, None).validate(),
+            Err(IggyError::InvalidConfiguration)
+        );
+        assert_eq!(
+            auto(Some(NonZeroIggyDuration::ONE_SECOND), None).validate(),
+            Ok(())
+        );
+        assert_eq!(auto(None, Some(CommitEvery::Batch)).validate(), Ok(()));
+    }
+
+    #[test]
+    fn given_a_wait_when_building_options_should_keep_the_other_defaults() {
+        let max_wait = IggyDuration::ONE_SECOND;
+
+        assert_eq!(
+            PollOptions::wait(max_wait),
+            PollOptions {
+                max_wait,
+                ..PollOptions::default()
+            }
+        );
+    }
+
+    #[test]
+    fn given_invalid_options_when_validating_should_fail_like_the_wire_options() {
+        let valid = PollOptions::wait(MAX_WAIT.into());
+        assert_eq!(valid.validate(), Ok(()));
+        let cases = [
+            (
+                PollOptions { count: 0, ..valid },
+                IggyError::InvalidMessagesCount,
+            ),
+            (
+                PollOptions {
+                    min_count: 0,
+                    ..valid
+                },
+                IggyError::InvalidMessagesCount,
+            ),
+            (
+                PollOptions {
+                    min_count: valid.count + 1,
+                    ..valid
+                },
+                IggyError::InvalidMessagesCount,
+            ),
+            (
+                PollOptions::wait((MAX_WAIT + Duration::from_micros(1)).into()),
+                IggyError::InvalidCommand,
+            ),
+            (
+                PollOptions::wait(Duration::MAX.into()),
+                IggyError::InvalidCommand,
+            ),
+            (
+                PollOptions {
+                    max_bytes: min_max_bytes() - 1,
+                    ..valid
+                },
+                IggyError::InvalidSizeBytes,
+            ),
+        ];
+
+        for (options, error) in cases {
+            assert_eq!(options.validate(), Err(error), "{options:?}");
+        }
+        let smallest = PollOptions {
+            max_bytes: min_max_bytes(),
+            ..valid
+        };
+        assert_eq!(smallest.validate(), Ok(()));
+    }
+
+    #[test]
+    fn given_options_when_converted_to_the_wire_should_allow_ten_seconds_beyond_the_wait() {
+        for (max_wait, request_timeout) in [
+            (Duration::ZERO, Duration::from_secs(10)),
+            (MAX_WAIT, MAX_WAIT + Duration::from_secs(10)),
+        ] {
+            let options = PollOptions {
+                min_count: MIN_COUNT,
+                max_bytes: min_max_bytes(),
+                ..PollOptions::wait(max_wait.into())
+            };
+
+            let wire = DeferredPollOptions::from(options);
+
+            assert_eq!(options.request_timeout(), request_timeout.into());
+            assert_eq!(
+                wire,
+                DeferredPollOptions {
+                    max_wait: max_wait.into(),
+                    min_count: MIN_COUNT,
+                    max_bytes: min_max_bytes(),
+                    request_timeout: request_timeout.into(),
+                }
+            );
+            assert_eq!(wire.validate(options.count), Ok(()));
+        }
+    }
+}
diff --git a/core/common/src/types/message/polled_messages.rs b/core/common/src/types/message/polled_messages.rs
index 5fb0e26..e682968 100644
--- a/core/common/src/types/message/polled_messages.rs
+++ b/core/common/src/types/message/polled_messages.rs
@@ -15,7 +15,10 @@
 // specific language governing permissions and limitations
 // under the License.
 
-use crate::{IggyMessage, IggyMessageHeader, error::IggyError};
+use crate::{
+    Checkpoint, IggyMessage, IggyMessageHeader, PollingKind, PollingStrategy, ReaderStamp,
+    error::IggyError,
+};
 use bytes::Bytes;
 use iggy_binary_protocol::WireDecode;
 use iggy_binary_protocol::batch::{
@@ -24,6 +27,7 @@
 use iggy_binary_protocol::primitives::partition_history::PartitionContext;
 use iggy_binary_protocol::responses::messages::PollMessagesResponseHeader;
 use serde::{Deserialize, Serialize};
+use std::ops::Range;
 use tracing::error;
 
 /// The wrapper on top of the collection of messages that are polled from the partition.
@@ -35,12 +39,10 @@
 #[derive(Debug, Serialize, Deserialize)]
 pub struct PolledMessages {
     /// Partition incarnation and owner captured by the accepted poll.
-    pub context: PartitionContext,
-    /// The identifier of the partition. An empty reply can carry a sentinel instead of a real
-    /// id: [`NO_ASSIGNED_PARTITION`](crate::NO_ASSIGNED_PARTITION) for a consumer-group member
-    /// that holds no partitions, or
-    /// [`RESYNC_REQUIRED_PARTITION_SENTINEL`](crate::RESYNC_REQUIRED_PARTITION_SENTINEL) when
-    /// the server fenced a stale group assignment.
+    context: PartitionContext,
+    /// The identifier of the partition. An empty reply can carry
+    /// [`RESYNC_REQUIRED_PARTITION_SENTINEL`](crate::RESYNC_REQUIRED_PARTITION_SENTINEL) instead
+    /// of a real id when the server fenced a stale group assignment.
     pub partition_id: u32,
     /// The current offset of the partition.
     pub current_offset: u64,
@@ -48,39 +50,171 @@
     pub count: u32,
     /// The collection of messages.
     pub messages: Vec<IggyMessage>,
+    /// The start of the request that read the batch, for the continuation of an empty batch.
+    /// The HTTP JSON form has no such field.
+    #[serde(skip)]
+    request_start: Option<PollingStrategy>,
+    /// Set by the SDK reader, and copied into each checkpoint of the batch.
+    #[serde(skip)]
+    stamp: Option<ReaderStamp>,
+    /// The served records a binary reply carried, kept for the record of a message that does
+    /// not decrypt. A batch decoded from JSON has none.
+    #[serde(skip)]
+    records: Option<Bytes>,
 }
 
 impl PolledMessages {
     pub fn empty() -> Self {
+        Self::new(PartitionContext::default(), 0, 0, 0, Vec::new())
+    }
+
+    /// A batch that no request read, so it has no request start.
+    #[doc(hidden)]
+    pub fn new(
+        context: PartitionContext,
+        partition_id: u32,
+        current_offset: u64,
+        count: u32,
+        messages: Vec<IggyMessage>,
+    ) -> Self {
         Self {
-            context: PartitionContext::default(),
-            partition_id: 0,
-            current_offset: 0,
-            count: 0,
-            messages: Vec::new(),
+            context,
+            partition_id,
+            current_offset,
+            count,
+            messages,
+            request_start: None,
+            stamp: None,
+            records: None,
         }
     }
+
+    /// [`Self::new`] with the served records the batch was decoded from.
+    #[doc(hidden)]
+    pub fn with_records(
+        context: PartitionContext,
+        partition_id: u32,
+        current_offset: u64,
+        count: u32,
+        messages: Vec<IggyMessage>,
+        records: Bytes,
+    ) -> Self {
+        Self {
+            records: Some(records),
+            ..Self::new(context, partition_id, current_offset, count, messages)
+        }
+    }
+
+    #[doc(hidden)]
+    pub fn context(&self) -> PartitionContext {
+        self.context
+    }
+
+    /// Records the start of the request that read a batch decoded from JSON.
+    #[doc(hidden)]
+    pub fn set_request_start(&mut self, start: &PollingStrategy) {
+        self.request_start = Some(*start);
+    }
+
+    #[doc(hidden)]
+    pub fn stamp(&self) -> Option<ReaderStamp> {
+        self.stamp
+    }
+
+    #[doc(hidden)]
+    pub fn set_stamp(&mut self, stamp: ReaderStamp) {
+        self.stamp = Some(stamp);
+    }
+
+    /// The position after the last message, to commit the whole batch. `None` for an empty
+    /// batch.
+    pub fn checkpoint(&self) -> Option<Checkpoint> {
+        self.messages
+            .last()
+            .map(|message| self.checkpoint_of(message))
+    }
+
+    /// The position after `messages[index]`, to commit a processed part of the batch. `None`
+    /// for an index out of range.
+    pub fn checkpoint_at(&self, index: usize) -> Option<Checkpoint> {
+        self.messages
+            .get(index)
+            .map(|message| self.checkpoint_of(message))
+    }
+
+    /// The position after the batch, for the next read of its partition.
+    ///
+    /// After the last message, the continuation is `Offset(last + 1)`. An empty batch keeps the
+    /// start of its request, so a start can take more than one read:
+    /// - `Offset(n)` stays `Offset(n)`.
+    /// - `First` becomes `Offset(0)`, because the server reads both from offset 0.
+    /// - `Timestamp(t)` keeps `t`.
+    /// - `Last` stays `Last`.
+    /// - `Next` stays `Next`, and the next read resolves it again from the stored offset.
+    ///
+    /// An empty batch without a recorded start, such as a batch that the caller deserialized,
+    /// continues with `Next`.
+    ///
+    /// The continuation belongs to the partition of the batch, and a read of another partition
+    /// refuses it. Serde drops that partition, so a deserialized continuation fits every
+    /// partition: read it only from the partition of its batch. It carries the context of the
+    /// batch, so a read after a change of the partition history returns
+    /// [`IggyError::HistoryUnavailable`].
+    ///
+    /// An empty batch with a zero context is the reply of a partition that could not serve the
+    /// read, and it names no partition history. Its continuation takes nothing from the reply:
+    /// it is the start of the request as it was, or `Next` without a recorded start.
+    pub fn continuation(&self) -> PollingStrategy {
+        let start = match (self.messages.last(), self.request_start) {
+            (Some(last), _) => PollingStrategy::offset(last.header.offset.saturating_add(1)),
+            // The server refuses a read that carries the zero context with `HistoryUnavailable`.
+            (None, start) if self.context == PartitionContext::default() => {
+                return start.unwrap_or_else(PollingStrategy::next);
+            }
+            (None, Some(start)) if start.kind == PollingKind::First => PollingStrategy::offset(0),
+            (None, Some(start)) => start,
+            (None, None) => PollingStrategy::next(),
+        };
+        PollingStrategy::continuation(self.partition_id, start.kind, start.value, self.context)
+    }
+
+    fn checkpoint_of(&self, message: &IggyMessage) -> Checkpoint {
+        Checkpoint::stamped(
+            self.partition_id,
+            message.header.offset,
+            self.context,
+            self.stamp,
+        )
+    }
 }
 
 impl PolledMessages {
     /// Decode a `PollMessages` response body: the 40-byte prefix followed by
     /// the served batch records (`[256B batch header][frames]`, deltas
-    /// resolved against the stamped bases).
+    /// resolved against the stamped bases). `strategy` is the start of the
+    /// request, which the [continuation](Self::continuation) of an empty batch keeps.
     ///
     /// # Errors
     /// [`IggyError::InvalidNumberEncoding`] on a short prefix;
     /// [`IggyError::InvalidMessagePayloadLength`] on a malformed record;
     /// [`IggyError::InvalidMessagesCount`] when the records hold another count than the prefix.
-    pub fn from_bytes(bytes: Bytes) -> Result<Self, IggyError> {
+    pub fn from_bytes(bytes: Bytes, strategy: &PollingStrategy) -> Result<Self, IggyError> {
+        Self::decode(bytes, strategy).map_err(|error| error.error)
+    }
+
+    /// [`Self::from_bytes`], keeping the records of a reply that does not decode.
+    #[doc(hidden)]
+    pub fn decode(bytes: Bytes, strategy: &PollingStrategy) -> Result<Self, DecodeError> {
         let (header, consumed) = PollMessagesResponseHeader::decode(&bytes)
-            .map_err(|_| IggyError::InvalidNumberEncoding)?;
+            .map_err(|_| DecodeError::without_record(IggyError::InvalidNumberEncoding))?;
         let PollMessagesResponseHeader {
             partition_id,
             current_offset,
             messages_count: count,
             context,
         } = header;
-        let messages = messages_from_batches(bytes.slice(consumed..), count)?;
+        let records = bytes.slice(consumed..);
+        let messages = messages_from_batches(records.clone(), count)?;
 
         Ok(Self {
             context,
@@ -88,46 +222,172 @@
             current_offset,
             count,
             messages,
+            request_start: Some(*strategy),
+            stamp: None,
+            records: Some(records),
         })
     }
+
+    /// The record of the message at `index` as the reply carried it, for a message that does
+    /// not decrypt. `None` for a batch decoded from JSON, which carries no records.
+    #[doc(hidden)]
+    #[must_use]
+    pub fn undecoded_record(&self, index: usize) -> Option<UndecodedRecord> {
+        record_at(self.records.as_ref()?, index)
+    }
+}
+
+/// A record of a reply that does not decode, with what the reply carried for it.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct UndecodedRecord {
+    /// The offset of the record, known when its frame decoded and a later step failed, such as
+    /// a payload past the batch, a checksum or a decryption. `None` when the framing itself is
+    /// malformed, so no record can be isolated.
+    pub offset: Option<u64>,
+    /// The record as the reply carried it: its frame header, payload and user headers, before
+    /// any decryption. For malformed framing, the rest of the records from where decoding
+    /// stopped. For an HTTP reply that is not JSON, the response body.
+    pub bytes: Bytes,
+}
+
+/// A reply that does not decode, with its undecoded record when the reply carried one.
+#[doc(hidden)]
+#[derive(Debug, Clone, PartialEq)]
+pub struct DecodeError {
+    pub error: IggyError,
+    /// Boxed, so a result of the decode stays small.
+    pub record: Option<Box<UndecodedRecord>>,
+}
+
+impl DecodeError {
+    /// A failure before any record was read, or of a reply that carries none.
+    #[must_use]
+    pub const fn without_record(error: IggyError) -> Self {
+        Self {
+            error,
+            record: None,
+        }
+    }
+}
+
+/// The record of the message at `index` of the served `records`, as the reply carried it.
+#[doc(hidden)]
+#[must_use]
+pub fn record_at(records: &Bytes, index: usize) -> Option<UndecodedRecord> {
+    let record = Records::new(records).nth(index)?.ok()?;
+    Some(UndecodedRecord {
+        offset: Some(record.header.offset),
+        bytes: records.slice(record.frame),
+    })
 }
 
 /// Walk the served batch records, resolving each frame's deltas to absolute
 /// values. Payload and user-header `Bytes` are zero-copy slices of the
 /// response buffer.
-fn messages_from_batches(buffer: Bytes, count: u32) -> Result<Vec<IggyMessage>, IggyError> {
+pub(super) fn messages_from_batches(
+    buffer: Bytes,
+    count: u32,
+) -> Result<Vec<IggyMessage>, DecodeError> {
+    let whole = |error| DecodeError {
+        error,
+        record: Some(Box::new(UndecodedRecord {
+            offset: None,
+            bytes: buffer.clone(),
+        })),
+    };
     if count as usize > buffer.len() / BATCH_MESSAGE_HEADER_SIZE {
-        return Err(IggyError::InvalidMessagesCount);
+        return Err(whole(IggyError::InvalidMessagesCount));
     }
     let mut messages = Vec::with_capacity(count as usize);
-    let mut position = 0usize;
-    while position < buffer.len() {
-        let batch = BatchHeader::decode(&buffer[position..]).map_err(|decode_error| {
-            error!("Failed to decode polled batch header: {decode_error}");
-            IggyError::InvalidMessagePayloadLength
-        })?;
-        let batch_end = position
-            .checked_add(batch.total_size())
-            .filter(|end| *end <= buffer.len())
-            .ok_or(IggyError::InvalidMessagePayloadLength)?;
-        let mut cursor = position + BATCH_HEADER_SIZE;
-        while cursor < batch_end {
-            let frame =
-                BatchMessageHeader::decode(&buffer[cursor..batch_end]).map_err(|decode_error| {
-                    error!("Failed to decode polled message frame: {decode_error}");
-                    IggyError::InvalidMessagePayloadLength
-                })?;
-            let payload_start = cursor + iggy_binary_protocol::batch::BATCH_MESSAGE_HEADER_SIZE;
-            let payload_end = payload_start + frame.payload_length as usize;
-            let user_headers_end = payload_end + frame.user_headers_length as usize;
-            if user_headers_end > batch_end {
-                return Err(IggyError::InvalidMessagePayloadLength);
-            }
+    for record in Records::new(&buffer) {
+        let record = record?;
+        if messages.len() == count as usize {
+            return Err(DecodeError {
+                error: IggyError::InvalidMessagesCount,
+                record: Some(Box::new(UndecodedRecord {
+                    offset: Some(record.header.offset),
+                    bytes: buffer.slice(record.frame),
+                })),
+            });
+        }
+        let user_headers = record.user_headers.map(|range| buffer.slice(range));
+        messages.push(IggyMessage {
+            header: record.header,
+            payload: buffer.slice(record.payload),
+            user_headers,
+        });
+    }
+    if messages.len() != count as usize {
+        return Err(whole(IggyError::InvalidMessagesCount));
+    }
+    Ok(messages)
+}
 
-            let header = IggyMessageHeader {
+/// One record of a reply: its resolved header and where its parts lie in the buffer.
+struct Record {
+    header: IggyMessageHeader,
+    frame: Range<usize>,
+    payload: Range<usize>,
+    user_headers: Option<Range<usize>>,
+}
+
+/// The records of a reply, batch by batch. Ends at the first record that does not decode,
+/// with the bytes from there on.
+struct Records<'a> {
+    buffer: &'a Bytes,
+    position: usize,
+    /// The batch being walked and where it ends.
+    batch: Option<(BatchHeader, usize)>,
+    cursor: usize,
+}
+
+impl<'a> Records<'a> {
+    const fn new(buffer: &'a Bytes) -> Self {
+        Self {
+            buffer,
+            position: 0,
+            batch: None,
+            cursor: 0,
+        }
+    }
+
+    fn malformed(&mut self, from: usize, offset: Option<u64>) -> DecodeError {
+        self.position = self.buffer.len();
+        self.batch = None;
+        DecodeError {
+            error: IggyError::InvalidMessagePayloadLength,
+            record: Some(Box::new(UndecodedRecord {
+                offset,
+                bytes: self.buffer.slice(from..),
+            })),
+        }
+    }
+
+    fn frame(&mut self, batch: BatchHeader, batch_end: usize) -> Result<Record, DecodeError> {
+        let cursor = self.cursor;
+        let frame = BatchMessageHeader::decode(&self.buffer[cursor..batch_end]).map_err(
+            |decode_error| {
+                error!("Failed to decode polled message frame: {decode_error}");
+                self.malformed(cursor, None)
+            },
+        )?;
+        let offset = batch.base_offset + u64::from(frame.offset_delta);
+        let payload_start = cursor + BATCH_MESSAGE_HEADER_SIZE;
+        let payload_end = payload_start + frame.payload_length as usize;
+        let user_headers_end = payload_end + frame.user_headers_length as usize;
+        if user_headers_end > batch_end {
+            let mut error = self.malformed(cursor, Some(offset));
+            if let Some(record) = &mut error.record {
+                record.bytes = self.buffer.slice(cursor..batch_end);
+            }
+            return Err(error);
+        }
+        self.cursor = user_headers_end;
+        Ok(Record {
+            header: IggyMessageHeader {
                 checksum: frame.checksum,
                 id: frame.id,
-                offset: batch.base_offset + u64::from(frame.offset_delta),
+                offset,
                 // Broker append time is stamped once per batch; the
                 // per-message delta applies to `origin_timestamp` only.
                 timestamp: batch.base_timestamp,
@@ -135,37 +395,229 @@
                 user_headers_length: frame.user_headers_length,
                 payload_length: frame.payload_length,
                 reserved: 0,
-            };
-            let payload = buffer.slice(payload_start..payload_end);
-            let user_headers = if frame.user_headers_length > 0 {
-                Some(buffer.slice(payload_end..user_headers_end))
-            } else {
-                None
-            };
-            if messages.len() == count as usize {
-                return Err(IggyError::InvalidMessagesCount);
-            }
-            messages.push(IggyMessage {
-                header,
-                payload,
-                user_headers,
-            });
-            cursor = user_headers_end;
-        }
-        position = batch_end;
+            },
+            frame: cursor..user_headers_end,
+            payload: payload_start..payload_end,
+            user_headers: (frame.user_headers_length > 0).then_some(payload_end..user_headers_end),
+        })
     }
+}
 
-    if messages.len() != count as usize {
-        return Err(IggyError::InvalidMessagesCount);
+impl Iterator for Records<'_> {
+    type Item = Result<Record, DecodeError>;
+
+    fn next(&mut self) -> Option<Self::Item> {
+        loop {
+            if let Some((batch, batch_end)) = self.batch {
+                if self.cursor < batch_end {
+                    return Some(self.frame(batch, batch_end));
+                }
+                self.batch = None;
+                self.position = batch_end;
+            }
+            if self.position >= self.buffer.len() {
+                return None;
+            }
+            let position = self.position;
+            let batch = match BatchHeader::decode(&self.buffer[position..]) {
+                Ok(batch) => batch,
+                Err(decode_error) => {
+                    error!("Failed to decode polled batch header: {decode_error}");
+                    return Some(Err(self.malformed(position, None)));
+                }
+            };
+            let Some(batch_end) = position
+                .checked_add(batch.total_size())
+                .filter(|end| *end <= self.buffer.len())
+            else {
+                return Some(Err(self.malformed(position, None)));
+            };
+            self.cursor = position + BATCH_HEADER_SIZE;
+            self.batch = Some((batch, batch_end));
+        }
     }
-    Ok(messages)
 }
 
 #[cfg(test)]
 mod tests {
     use super::*;
+    use crate::{IggyTimestamp, RESYNC_REQUIRED_PARTITION_SENTINEL};
     use iggy_binary_protocol::WireEncode;
 
+    const PARTITION: u32 = 1;
+    const CURRENT_OFFSET: u64 = 5;
+    const CONTEXT: PartitionContext = PartitionContext {
+        incarnation: 3,
+        owner_generation: 9,
+        metadata_op: 12,
+    };
+    /// Today's JSON form of an empty batch of `PARTITION` under `CONTEXT`.
+    const EMPTY_BATCH_JSON: &str = r#"{"context":{"incarnation":3,"owner_generation":9,"metadata_op":12},"partition_id":1,"current_offset":5,"count":0,"messages":[]}"#;
+
+    fn message_at(offset: u64) -> IggyMessage {
+        IggyMessage {
+            header: IggyMessageHeader {
+                offset,
+                ..Default::default()
+            },
+            payload: Bytes::new(),
+            user_headers: None,
+        }
+    }
+
+    fn batch(offsets: &[u64]) -> PolledMessages {
+        PolledMessages::new(
+            CONTEXT,
+            PARTITION,
+            CURRENT_OFFSET,
+            u32::try_from(offsets.len()).unwrap(),
+            offsets.iter().copied().map(message_at).collect(),
+        )
+    }
+
+    fn empty_reply(start: &PollingStrategy) -> PolledMessages {
+        let header = PollMessagesResponseHeader {
+            partition_id: PARTITION,
+            current_offset: CURRENT_OFFSET,
+            messages_count: 0,
+            context: CONTEXT,
+        };
+        PolledMessages::from_bytes(header.to_bytes(), start).unwrap()
+    }
+
+    fn continuation(kind: PollingKind, value: u64) -> PollingStrategy {
+        PollingStrategy::continuation(PARTITION, kind, value, CONTEXT)
+    }
+
+    #[test]
+    fn given_an_empty_reply_when_continuing_should_keep_the_start_of_its_request() {
+        const OFFSET: u64 = 7;
+        const TIMESTAMP: u64 = 1_000;
+        let cases = [
+            (
+                PollingStrategy::offset(OFFSET),
+                continuation(PollingKind::Offset, OFFSET),
+            ),
+            (
+                PollingStrategy::first(),
+                continuation(PollingKind::Offset, 0),
+            ),
+            (
+                PollingStrategy::timestamp(IggyTimestamp::from(TIMESTAMP)),
+                continuation(PollingKind::Timestamp, TIMESTAMP),
+            ),
+            (PollingStrategy::last(), continuation(PollingKind::Last, 0)),
+            (PollingStrategy::next(), continuation(PollingKind::Next, 0)),
+            (
+                continuation(PollingKind::Offset, OFFSET),
+                continuation(PollingKind::Offset, OFFSET),
+            ),
+        ];
+
+        for (start, expected) in cases {
+            assert_eq!(empty_reply(&start).continuation(), expected, "{start:?}");
+        }
+    }
+
+    #[test]
+    fn given_a_batch_with_messages_when_continuing_should_start_after_the_last_message() {
+        let mut polled = batch(&[4, 5]);
+        polled.set_request_start(&PollingStrategy::last());
+
+        let next = polled.continuation();
+
+        assert_eq!(next, continuation(PollingKind::Offset, 6));
+        assert_eq!(next.partition_id(), Some(PARTITION));
+        assert_eq!(next.context(), Some(CONTEXT));
+    }
+
+    #[test]
+    fn given_an_empty_batch_without_a_start_when_continuing_should_continue_with_next() {
+        assert_eq!(
+            batch(&[]).continuation(),
+            continuation(PollingKind::Next, 0)
+        );
+    }
+
+    /// The server answers a partition that cannot serve the read with an empty reply and a zero
+    /// context, under the re-sync sentinel for a fenced group member.
+    #[test]
+    fn given_a_fail_fast_empty_reply_when_continuing_should_repeat_the_start_of_its_request() {
+        const OFFSET: u64 = 7;
+        let fail_fast = |partition_id, start: &PollingStrategy| {
+            let header = PollMessagesResponseHeader {
+                partition_id,
+                current_offset: 0,
+                messages_count: 0,
+                context: PartitionContext::default(),
+            };
+            PolledMessages::from_bytes(header.to_bytes(), start).unwrap()
+        };
+
+        for start in [
+            PollingStrategy::offset(OFFSET),
+            PollingStrategy::first(),
+            PollingStrategy::last(),
+            PollingStrategy::next(),
+            continuation(PollingKind::Offset, OFFSET),
+        ] {
+            for partition_id in [PARTITION, RESYNC_REQUIRED_PARTITION_SENTINEL] {
+                assert_eq!(
+                    fail_fast(partition_id, &start).continuation(),
+                    start,
+                    "{start:?} from partition {partition_id}"
+                );
+            }
+        }
+        assert_eq!(
+            PolledMessages::empty().continuation(),
+            PollingStrategy::next(),
+            "a batch without a start"
+        );
+    }
+
+    #[test]
+    fn given_a_batch_when_taking_checkpoints_should_follow_the_delivered_messages() {
+        let polled = batch(&[4, 5]);
+        let after = |offset| Some(Checkpoint::new(PARTITION, offset, CONTEXT));
+
+        assert_eq!(polled.checkpoint(), after(5));
+        assert_eq!(polled.checkpoint_at(0), after(4));
+        assert_eq!(polled.checkpoint_at(1), after(5));
+        assert_eq!(polled.checkpoint_at(2), None);
+        let empty = batch(&[]);
+        assert_eq!(empty.checkpoint(), None);
+        assert_eq!(empty.checkpoint_at(0), None);
+    }
+
+    #[test]
+    fn given_a_batch_when_serialized_should_keep_the_json_form_without_its_start() {
+        let decoded = empty_reply(&PollingStrategy::offset(CURRENT_OFFSET));
+        assert_eq!(serde_json::to_string(&decoded).unwrap(), EMPTY_BATCH_JSON);
+
+        let deserialized: PolledMessages = serde_json::from_str(EMPTY_BATCH_JSON).unwrap();
+
+        assert_eq!(
+            (
+                deserialized.context(),
+                deserialized.partition_id,
+                deserialized.current_offset,
+                deserialized.count,
+                deserialized.messages.len()
+            ),
+            (CONTEXT, PARTITION, CURRENT_OFFSET, 0, 0)
+        );
+        assert_eq!(
+            deserialized.continuation(),
+            continuation(PollingKind::Next, 0),
+            "a deserialized batch has no start"
+        );
+        assert_eq!(
+            serde_json::to_string(&deserialized).unwrap(),
+            EMPTY_BATCH_JSON
+        );
+    }
+
     #[test]
     fn rejects_impossible_count_before_allocating_messages() {
         let prefix = PollMessagesResponseHeader {
@@ -176,7 +628,7 @@
         }
         .to_bytes();
         assert!(matches!(
-            PolledMessages::from_bytes(prefix),
+            PolledMessages::from_bytes(prefix, &PollingStrategy::next()),
             Err(IggyError::InvalidMessagesCount)
         ));
     }
diff --git a/core/common/src/types/message/polling_strategy.rs b/core/common/src/types/message/polling_strategy.rs
index 63ed838..ce84bc5 100644
--- a/core/common/src/types/message/polling_strategy.rs
+++ b/core/common/src/types/message/polling_strategy.rs
@@ -15,6 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
+use crate::error::IggyError;
 use crate::types::message::polling_kind::PollingKind;
 use crate::utils::timestamp::IggyTimestamp;
 use iggy_binary_protocol::primitives::partition_history::PartitionContext;
@@ -47,13 +48,18 @@
     /// context rejects the poll instead of interpreting that offset in another incarnation.
     #[serde_as(as = "Option<JsonString>")]
     #[serde(default, skip_serializing_if = "Option::is_none")]
-    pub context: Option<PartitionContext>,
+    context: Option<PartitionContext>,
+    /// The partition of a continuation, because a context names no partition. Not on the
+    /// wire: every request names its partition in a field of its own.
+    #[serde(skip)]
+    partition_id: Option<u32>,
 }
 
 impl Default for PollingStrategy {
     fn default() -> Self {
         Self {
             context: None,
+            partition_id: None,
             kind: PollingKind::Offset,
             value: 0,
         }
@@ -71,22 +77,17 @@
     pub fn offset(value: u64) -> Self {
         Self {
             context: None,
+            partition_id: None,
             kind: PollingKind::Offset,
             value,
         }
     }
 
-    /// Continue at an offset under the incarnation and owner that produced it.
-    #[must_use]
-    pub fn with_context(mut self, context: PartitionContext) -> Self {
-        self.context = Some(context);
-        self
-    }
-
     /// Poll messages from the specified timestamp.
     pub fn timestamp(value: IggyTimestamp) -> Self {
         Self {
             context: None,
+            partition_id: None,
             kind: PollingKind::Timestamp,
             value: value.into(),
         }
@@ -96,6 +97,7 @@
     pub fn first() -> Self {
         Self {
             context: None,
+            partition_id: None,
             kind: PollingKind::First,
             value: 0,
         }
@@ -105,6 +107,7 @@
     pub fn last() -> Self {
         Self {
             context: None,
+            partition_id: None,
             kind: PollingKind::Last,
             value: 0,
         }
@@ -119,6 +122,7 @@
     pub fn next() -> Self {
         Self {
             context: None,
+            partition_id: None,
             kind: PollingKind::Next,
             value: 0,
         }
@@ -140,4 +144,132 @@
     pub fn default_kind() -> PollingKind {
         PollingKind::default()
     }
+
+    /// A continuation of `partition_id` from `kind` and `value`, under the `context` that
+    /// served the batch before it.
+    #[doc(hidden)]
+    pub fn continuation(
+        partition_id: u32,
+        kind: PollingKind,
+        value: u64,
+        context: PartitionContext,
+    ) -> Self {
+        Self {
+            kind,
+            value,
+            context: Some(context),
+            partition_id: Some(partition_id),
+        }
+    }
+
+    #[doc(hidden)]
+    pub fn context(&self) -> Option<PartitionContext> {
+        self.context
+    }
+
+    /// The partition of a continuation. `None` for a strategy of any partition.
+    #[doc(hidden)]
+    pub fn partition_id(&self) -> Option<u32> {
+        self.partition_id
+    }
+
+    /// Refuses a continuation of another partition, which would read `partition_id` at an
+    /// offset of its own partition. A strategy without a partition fits every partition.
+    ///
+    /// # Errors
+    /// [`IggyError::InvalidCommand`] for a continuation of another partition.
+    #[doc(hidden)]
+    pub fn validate_partition(&self, partition_id: u32) -> Result<(), IggyError> {
+        match self.partition_id {
+            Some(bound) if bound != partition_id => Err(IggyError::InvalidCommand),
+            _ => Ok(()),
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use serde::de::value::{Error as ValueError, MapDeserializer};
+
+    const PARTITION: u32 = 3;
+    const OFFSET: u64 = 5;
+    const CONTEXT: PartitionContext = PartitionContext {
+        incarnation: 1,
+        owner_generation: 2,
+        metadata_op: 3,
+    };
+    /// The JSON and HTTP query forms of `Offset(OFFSET)` under `CONTEXT`, as of `5c252f08d`.
+    const JSON: &str = r#"{"kind":"offset","value":"5","context":"{\"incarnation\":1,\"owner_generation\":2,\"metadata_op\":3}"}"#;
+    const QUERY: &str = "kind=offset&value=5&context=%7B%22incarnation%22%3A1%2C%22owner_generation%22%3A2%2C%22metadata_op%22%3A3%7D";
+
+    /// The HTTP poll query flattens its strategy, as the server reads it.
+    #[derive(Deserialize)]
+    struct PollQuery {
+        #[serde(flatten)]
+        strategy: PollingStrategy,
+    }
+
+    fn from_query(query: &str) -> PollingStrategy {
+        let pairs = url::form_urlencoded::parse(query.as_bytes());
+        PollQuery::deserialize(MapDeserializer::<_, ValueError>::new(pairs))
+            .unwrap()
+            .strategy
+    }
+
+    #[test]
+    fn given_a_continuation_when_validating_a_partition_should_refuse_another_partition() {
+        let bound = PollingStrategy::continuation(PARTITION, PollingKind::Offset, OFFSET, CONTEXT);
+
+        assert_eq!(bound.validate_partition(PARTITION), Ok(()));
+        assert_eq!(
+            bound.validate_partition(PARTITION + 1),
+            Err(IggyError::InvalidCommand)
+        );
+        for unbound in [PollingStrategy::offset(OFFSET), PollingStrategy::next()] {
+            assert_eq!(unbound.partition_id(), None);
+            assert_eq!(unbound.validate_partition(PARTITION + 1), Ok(()));
+        }
+    }
+
+    #[test]
+    fn given_a_strategy_when_serialized_should_keep_the_json_form_without_its_partition() {
+        assert_eq!(
+            serde_json::to_string(&PollingStrategy::offset(OFFSET)).unwrap(),
+            r#"{"kind":"offset","value":"5"}"#
+        );
+        let bound = PollingStrategy::continuation(PARTITION, PollingKind::Offset, OFFSET, CONTEXT);
+        assert_eq!(serde_json::to_string(&bound).unwrap(), JSON);
+
+        let deserialized: PollingStrategy = serde_json::from_str(JSON).unwrap();
+
+        assert_eq!(
+            (
+                deserialized.kind,
+                deserialized.value,
+                deserialized.context(),
+                deserialized.partition_id()
+            ),
+            (PollingKind::Offset, OFFSET, Some(CONTEXT), None)
+        );
+    }
+
+    #[test]
+    fn given_a_poll_query_when_read_should_take_the_strategy_and_its_context() {
+        let strategy = from_query(QUERY);
+        assert_eq!(
+            (
+                strategy.kind,
+                strategy.value,
+                strategy.context(),
+                strategy.partition_id()
+            ),
+            (PollingKind::Offset, OFFSET, Some(CONTEXT), None)
+        );
+        assert_eq!(
+            from_query("kind=next"),
+            PollingStrategy::next(),
+            "a query without a context reads a strategy without one"
+        );
+    }
 }
diff --git a/core/common/src/utils/crypto.rs b/core/common/src/utils/crypto.rs
index 051362e..7c69890 100644
--- a/core/common/src/utils/crypto.rs
+++ b/core/common/src/utils/crypto.rs
@@ -15,12 +15,13 @@
 // specific language governing permissions and limitations
 // under the License.
 
-use crate::IggyError;
 use crate::text;
+use crate::{IggyError, IggyMessage};
 use aes_gcm::aead::{Aead, Generate};
 use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
 use aes_gcm_siv::aead::AeadInOut;
 use aes_gcm_siv::{Aes256GcmSiv, Nonce as SivNonce, Tag as SivTag};
+use bytes::Bytes;
 use std::fmt::Debug;
 
 const AES_GCM_NONCE_SIZE: usize = 12;
@@ -47,6 +48,19 @@
             EncryptorKind::Aes256GcmSiv(e) => e.decrypt(data),
         }
     }
+
+    /// Replaces the payload and the user headers of a polled `message` with
+    /// their plaintext.
+    pub fn decrypt_message(&self, message: &mut IggyMessage) -> Result<(), IggyError> {
+        message.payload = Bytes::from(self.decrypt(&message.payload)?);
+        message.header.payload_length = message.payload.len() as u32;
+        if let Some(user_headers) = &message.user_headers {
+            let user_headers = self.decrypt(user_headers)?;
+            message.header.user_headers_length = user_headers.len() as u32;
+            message.user_headers = Some(Bytes::from(user_headers));
+        }
+        Ok(())
+    }
 }
 
 pub trait Encryptor {
diff --git a/core/common/tests/deferred_polling_fixtures.rs b/core/common/tests/deferred_polling_fixtures.rs
new file mode 100644
index 0000000..0202b3e
--- /dev/null
+++ b/core/common/tests/deferred_polling_fixtures.rs
@@ -0,0 +1,195 @@
+// 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.
+
+#[path = "../examples/deferred_polling_fixtures/schema.rs"]
+mod schema;
+
+use iggy_binary_protocol::batch::{BATCH_HEADER_SIZE, BatchHeader, BatchRef};
+use iggy_binary_protocol::requests::messages::{DeferredPollMessagesRequest, FetchMessagesRequest};
+use iggy_binary_protocol::responses::messages::FetchMessagesResponse;
+use iggy_binary_protocol::{HEADER_SIZE, WireDecode};
+use schema::{CANONICAL_PATH, LANGUAGE_COPIES, build, render};
+use std::fs;
+use std::path::Path;
+
+#[test]
+fn given_live_codecs_when_rendering_fixture_should_match_the_canonical_file() {
+    let rendered = render().expect("fixture builds");
+    assert!(
+        read_manifest_file(CANONICAL_PATH) == rendered.as_bytes(),
+        "{CANONICAL_PATH} is stale, rerun: cargo run -p iggy_common --example deferred_polling_fixtures"
+    );
+}
+
+#[test]
+fn given_language_copies_when_comparing_should_match_canonical_bytes() {
+    let canonical = read_manifest_file(CANONICAL_PATH);
+    for copy in LANGUAGE_COPIES {
+        assert!(
+            read_manifest_file(copy) == canonical,
+            "{copy} differs from {CANONICAL_PATH}, rerun the deferred_polling_fixtures example"
+        );
+    }
+}
+
+#[test]
+fn given_request_vectors_when_decoding_should_reach_their_fields() {
+    let fixture = build().expect("fixture builds");
+    for vector in &fixture.deferred_polls {
+        let bytes = hex::decode(&vector.body_hex).expect("hex");
+        let request = DeferredPollMessagesRequest::decode_from(&bytes).expect("poll decodes");
+        assert_eq!(
+            request.poll.partition_id, vector.request.partition_id,
+            "{}",
+            vector.case
+        );
+        assert_eq!(
+            request.poll.strategy.kind, vector.request.strategy.kind,
+            "{}",
+            vector.case
+        );
+        assert_eq!(
+            request.poll.auto_commit, vector.request.auto_commit,
+            "{}",
+            vector.case
+        );
+        assert_eq!(
+            request.max_bytes, vector.request.max_bytes,
+            "{}",
+            vector.case
+        );
+    }
+    for vector in &fixture.fetches {
+        let bytes = hex::decode(&vector.body_hex).expect("hex");
+        let request = FetchMessagesRequest::decode_from(&bytes).expect("fetch decodes");
+        request.validate().expect("a fetch vector is valid");
+        let targets: Vec<_> = request
+            .targets
+            .iter()
+            .map(|target| {
+                (
+                    target.partition_id,
+                    target.strategy.kind,
+                    target.owner_generation,
+                )
+            })
+            .collect();
+        let expected: Vec<_> = vector
+            .request
+            .targets
+            .iter()
+            .map(|target| {
+                (
+                    target.partition_id,
+                    target.strategy.kind,
+                    target.owner_generation,
+                )
+            })
+            .collect();
+        assert_eq!(targets, expected, "{}", vector.case);
+        assert_eq!(
+            request.auto_commit, vector.request.auto_commit,
+            "{}",
+            vector.case
+        );
+    }
+    for vector in &fixture.cancellations {
+        assert_eq!(
+            hex::decode(&vector.header_hex).expect("hex").len(),
+            HEADER_SIZE
+        );
+    }
+}
+
+#[test]
+fn given_reply_vectors_when_decoding_should_reach_their_sections_and_messages() {
+    let fixture = build().expect("fixture builds");
+    for vector in &fixture.fetch_replies {
+        let bytes = hex::decode(&vector.body_hex).expect("hex");
+        let reply = FetchMessagesResponse::decode(&bytes).expect("reply decodes");
+        assert_eq!(
+            reply.sections.len(),
+            vector.sections.len(),
+            "{}",
+            vector.case
+        );
+        for (section, expected) in reply.sections.iter().zip(&vector.sections) {
+            assert_eq!(
+                (
+                    section.header.partition_id,
+                    section.header.status,
+                    section.header.messages_count,
+                    section.header.next_offset,
+                ),
+                (
+                    expected.partition_id,
+                    expected.status,
+                    expected.messages_count,
+                    expected.next_offset,
+                ),
+                "{}",
+                vector.case
+            );
+            let messages: Vec<_> = if section.records.is_empty() {
+                Vec::new()
+            } else {
+                let header = BatchHeader::decode(section.records).expect("batch header decodes");
+                BatchRef::new(header, &section.records[BATCH_HEADER_SIZE..])
+                    .iter_with_offsets()
+                    .map(|record| {
+                        (
+                            header.base_offset + u64::from(record.message.header.offset_delta),
+                            record.message.header.id.to_string(),
+                            String::from_utf8(record.message.payload.to_vec()).expect("utf8"),
+                        )
+                    })
+                    .collect()
+            };
+            let expected: Vec<_> = expected
+                .messages
+                .iter()
+                .map(|message| (message.offset, message.id.clone(), message.payload.clone()))
+                .collect();
+            assert_eq!(messages, expected, "{}", vector.case);
+        }
+    }
+}
+
+#[test]
+fn given_refused_vectors_when_decoding_or_validating_should_refuse_each() {
+    let fixture = build().expect("fixture builds");
+    for vector in &fixture.refused.fetches {
+        let bytes = hex::decode(&vector.body_hex).expect("hex");
+        let refused = FetchMessagesRequest::decode_from(&bytes)
+            .and_then(|request| request.validate())
+            .is_err();
+        assert!(refused, "{}", vector.case);
+    }
+    for vector in &fixture.refused.fetch_replies {
+        let bytes = hex::decode(&vector.body_hex).expect("hex");
+        assert!(
+            FetchMessagesResponse::decode(&bytes).is_err(),
+            "{}",
+            vector.case
+        );
+    }
+}
+
+fn read_manifest_file(relative: &str) -> Vec<u8> {
+    let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(relative);
+    fs::read(&path).unwrap_or_else(|error| panic!("read {}: {error}", path.display()))
+}
diff --git a/core/common/tests/resource_options_fixtures.rs b/core/common/tests/resource_options_fixtures.rs
index dbf5d0c..dcb6fbc 100644
--- a/core/common/tests/resource_options_fixtures.rs
+++ b/core/common/tests/resource_options_fixtures.rs
@@ -65,9 +65,9 @@
     );
     assert_eq!(
         constants.protocol_version.packed,
-        pack_protocol_version(0, 12, 0)
+        pack_protocol_version(0, 12, 1)
     );
-    assert_eq!(constants.protocol_version.text, "0.12.0");
+    assert_eq!(constants.protocol_version.text, "0.12.1");
     assert_eq!(
         constants.command_codes["update_resource"],
         CommandCode {
diff --git a/core/connectors/runtime/Cargo.toml b/core/connectors/runtime/Cargo.toml
index ef5b855..f2defb2 100644
--- a/core/connectors/runtime/Cargo.toml
+++ b/core/connectors/runtime/Cargo.toml
@@ -17,7 +17,7 @@
 
 [package]
 name = "iggy-connectors"
-version = "0.5.1-edge.4"
+version = "0.5.1-edge.5"
 description = "Connectors runtime for Iggy message streaming platform"
 edition = "2024"
 license = "Apache-2.0"
diff --git a/core/connectors/runtime/README.md b/core/connectors/runtime/README.md
index d7c2f26..4a40e18 100644
--- a/core/connectors/runtime/README.md
+++ b/core/connectors/runtime/README.md
@@ -63,7 +63,7 @@
 `[[streams]]` entry sets `poll_interval`, that sink fails to start. The server
 commits the offset of each sink poll when it answers the poll, before the
 plugin consumes the messages. Sinks need a server that supports deferred polls
-(binary commands 106 and 107).
+(binary commands 106 and 109 in protocol 0.12.1).
 
 At each start, a sink connects an Iggy client of its own. After the last
 consumer of that start stops, the sink logs the client out and shuts it down.
diff --git a/core/connectors/runtime/src/main.rs b/core/connectors/runtime/src/main.rs
index ad91890..91c00f6 100644
--- a/core/connectors/runtime/src/main.rs
+++ b/core/connectors/runtime/src/main.rs
@@ -60,6 +60,7 @@
 mod log;
 mod manager;
 pub(crate) mod metrics;
+mod plugin_instance;
 mod sink;
 mod source;
 mod state;
diff --git a/core/connectors/runtime/src/manager/sink.rs b/core/connectors/runtime/src/manager/sink.rs
index 74a4ead..6a64688 100644
--- a/core/connectors/runtime/src/manager/sink.rs
+++ b/core/connectors/runtime/src/manager/sink.rs
@@ -21,12 +21,16 @@
 use crate::context::RuntimeContext;
 use crate::error::RuntimeError;
 use crate::metrics::Metrics;
+use crate::plugin_instance::PluginInstanceGuard;
 use crate::sink;
 use dashmap::DashMap;
 use dlopen2::wrapper::Container;
+use futures::future::join_all;
+use iggy_common::{BINARY_OPERATION_TIMEOUT, IggyDuration, PollOptions};
 use iggy_connector_sdk::api::{ConnectorError, ConnectorStatus};
 use std::collections::HashMap;
 use std::fmt;
+use std::str::FromStr;
 use std::sync::Arc;
 use std::sync::atomic::Ordering;
 use std::time::Duration;
@@ -141,13 +145,14 @@
             .map(|e| e.value().clone())
             .ok_or_else(|| RuntimeError::SinkNotFound(key.to_string()))?;
 
-        let (shutdown_tx, task_handles, plugin_id, container) = {
+        let (shutdown_tx, task_handles, plugin_id, container, budget) = {
             let mut details = details.lock().await;
             (
                 details.shutdown_tx.take(),
                 std::mem::take(&mut details.task_handles),
                 details.info.id,
                 details.container.clone(),
+                close_budget(&details.config),
             )
         };
 
@@ -155,9 +160,8 @@
             let _ = tx.send(());
         }
 
-        for handle in task_handles {
-            let _ = tokio::time::timeout(Duration::from_secs(5), handle).await;
-        }
+        // The consumers close at the same time, so one budget covers them all.
+        let _ = tokio::time::timeout(budget, join_all(task_handles)).await;
 
         if let Some(container) = &container {
             info!("Closing sink connector with ID: {plugin_id} for plugin: {key}");
@@ -203,40 +207,45 @@
         // Connected before the plugin starts, so a refused connection leaves no
         // plugin instance behind.
         let client = context.iggy_clients.connect_sink_client().await?;
-        let setup = async {
-            sink::init_sink(
-                &container,
-                &config.plugin_config.clone().unwrap_or_default(),
-                plugin_id,
-            )?;
-            info!(
-                "Sink connector with ID: {plugin_id} for plugin: {key} initialized successfully."
-            );
-            sink::setup_sink_consumers(key, config, &client).await
-        };
-        let consumers = match setup.await {
+        if let Err(error) = sink::init_sink(
+            &container,
+            &config.plugin_config.clone().unwrap_or_default(),
+            plugin_id,
+        ) {
+            sink::release_sink_client(client, plugin_id).await;
+            return Err(error);
+        }
+        info!("Sink connector with ID: {plugin_id} for plugin: {key} initialized successfully.");
+        // Armed from here until the id is recorded below. `PluginInstanceGuard`
+        // carries why that window strands the instance.
+        let instance_guard = PluginInstanceGuard::for_sink(container.clone(), plugin_id, key);
+
+        let consumers = match sink::setup_sink_consumers(key, config, &client).await {
             Ok(consumers) => consumers,
             Err(error) => {
                 sink::release_sink_client(client, plugin_id).await;
+                instance_guard.close().await;
                 return Err(error);
             }
         };
 
         let callback = container.iggy_sink_consume;
-        let (shutdown_tx, task_handles) = sink::spawn_consume_tasks(
-            plugin_id,
-            key,
-            consumers,
-            client,
-            callback,
-            config.verbose,
-            config.benchmark,
-            metrics,
-            context.clone(),
-        );
-
+        // Locked before the spawn, so no await sits between starting the tasks
+        // and recording the id: a cancellation there would leave the tasks
+        // consuming into an instance the guard has just closed.
         {
             let mut details = details.lock().await;
+            let (shutdown_tx, task_handles) = sink::spawn_consume_tasks(
+                plugin_id,
+                key,
+                consumers,
+                client,
+                callback,
+                config.verbose,
+                config.benchmark,
+                metrics,
+                context.clone(),
+            );
             details.info.id = plugin_id;
             details.info.status = ConnectorStatus::Running;
             details.info.last_error = None;
@@ -245,6 +254,8 @@
             details.task_handles = task_handles;
             metrics.increment_sinks_running();
         }
+        // `details.info.id` now names this instance, so a later stop reaches it.
+        instance_guard.disarm();
 
         Ok(())
     }
@@ -317,10 +328,32 @@
     }
 }
 
+/// The longest that the consumers of `config` take to close: a committing read, which the close
+/// cancels but a server that stopped answering ends only at its deadline, then the rewind of the
+/// offsets and the leave of the group, each within one operation timeout. A stream without
+/// `max_wait` waits one second.
+fn close_budget(config: &SinkConfig) -> Duration {
+    let read = config
+        .streams
+        .iter()
+        .map(|stream| {
+            stream
+                .max_wait
+                .as_deref()
+                .and_then(|max_wait| IggyDuration::from_str(max_wait).ok())
+                .unwrap_or(IggyDuration::ONE_SECOND)
+        })
+        .map(|max_wait| PollOptions::wait(max_wait).request_timeout().get_duration())
+        .max()
+        .unwrap_or_default();
+    read.saturating_add(BINARY_OPERATION_TIMEOUT.saturating_mul(2))
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::configs::connectors::SinkConfig;
+    use crate::configs::connectors::{SinkConfig, StreamConsumerConfig};
+    use std::sync::atomic::AtomicBool;
 
     fn create_test_sink_info(key: &str, id: u32) -> SinkInfo {
         SinkInfo {
@@ -516,6 +549,39 @@
         assert!(details.task_handles.is_empty());
     }
 
+    /// A consumer close can wait for a committing read and then store its offset and leave its
+    /// group, which takes longer than a fixed few seconds.
+    #[tokio::test]
+    async fn given_a_consumer_close_longer_than_a_few_seconds_when_stopping_should_wait_for_it() {
+        const CLOSE: Duration = Duration::from_secs(6);
+        let metrics = Arc::new(Metrics::init());
+        let (shutdown_tx, mut shutdown_rx) = watch::channel(());
+        let closed = Arc::new(AtomicBool::new(false));
+        let handle = tokio::spawn({
+            let closed = Arc::clone(&closed);
+            async move {
+                let _ = shutdown_rx.changed().await;
+                tokio::time::sleep(CLOSE).await;
+                closed.store(true, Ordering::SeqCst);
+            }
+        });
+        let mut details = create_test_sink_details("es", 1);
+        details.config.streams = vec![StreamConsumerConfig {
+            max_wait: Some("10s".to_owned()),
+            ..Default::default()
+        }];
+        details.shutdown_tx = Some(shutdown_tx);
+        details.task_handles = vec![handle];
+        let manager = SinkManager::new(vec![details]);
+
+        manager.stop_connector("es", &metrics).await.unwrap();
+
+        assert!(
+            closed.load(Ordering::SeqCst),
+            "the stop waits for the close of its consumer"
+        );
+    }
+
     #[tokio::test]
     async fn stop_should_work_without_container() {
         let metrics = Arc::new(Metrics::init());
diff --git a/core/connectors/runtime/src/manager/source.rs b/core/connectors/runtime/src/manager/source.rs
index db24df2..dd1d14f 100644
--- a/core/connectors/runtime/src/manager/source.rs
+++ b/core/connectors/runtime/src/manager/source.rs
@@ -21,6 +21,7 @@
 use crate::context::RuntimeContext;
 use crate::error::RuntimeError;
 use crate::metrics::Metrics;
+use crate::plugin_instance::PluginInstanceGuard;
 use crate::source;
 use dashmap::DashMap;
 use dlopen2::wrapper::Container;
@@ -261,10 +262,9 @@
             state,
         )?;
         info!("Source connector with ID: {plugin_id} for plugin: {key} initialized successfully.");
-        // Armed from here until the id is recorded below. `SourceInstanceGuard`
+        // Armed from here until the id is recorded below. `PluginInstanceGuard`
         // carries why that window strands the instance.
-        let instance_guard =
-            source::SourceInstanceGuard::for_container(container.clone(), plugin_id, key);
+        let instance_guard = PluginInstanceGuard::for_source(container.clone(), plugin_id, key);
 
         let (producer, transforms) =
             match source::setup_source_producer(key, config, iggy_client).await {
diff --git a/core/connectors/runtime/src/plugin_instance.rs b/core/connectors/runtime/src/plugin_instance.rs
new file mode 100644
index 0000000..65b4e98
--- /dev/null
+++ b/core/connectors/runtime/src/plugin_instance.rs
@@ -0,0 +1,324 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use crate::metrics::ConnectorType;
+use crate::{SinkApi, SourceApi, close_plugin_instance};
+use dlopen2::wrapper::Container;
+use std::sync::Arc;
+use tokio::runtime::Handle;
+use tracing::warn;
+
+/// A plugin's `iggy_source_close` or `iggy_sink_close` together with whatever
+/// keeps the library that exports it mapped, so the call stays valid once it
+/// is deferred off the calling thread.
+pub(crate) type PluginClose = Arc<dyn Fn(u32) -> i32 + Send + Sync>;
+
+/// Closes a plugin instance that `iggy_source_open` or `iggy_sink_open`
+/// created and nothing else will ever reach.
+///
+/// Between the open succeeding and the plugin id reaching the connector
+/// details, the instance exists inside the plugin and nothing outside it knows
+/// the id: `stop_connector` closes whatever `details.info.id` holds, which is
+/// still the previous instance. An early return there stranded the new one for
+/// the life of the process. A guard rather than a cleanup branch per fallible
+/// call, because the window is those two statements rather than whichever call
+/// between them is fallible today, so a `?` added inside it stays correct.
+/// Source startup and both restarts hand off through it.
+///
+/// Teardown runs two ways and they are not interchangeable. [`Self::close`]
+/// awaits, so an error returned after it means the instance is already gone
+/// and an immediate retry has nothing to collide with. `Drop` cannot await, so
+/// it hands the work to the blocking pool, and the closure carries the container,
+/// which is what keeps the library mapped until the call returns.
+#[must_use = "dropping an armed guard closes the plugin instance"]
+pub(crate) struct PluginInstanceGuard {
+    /// `Some` while this guard owns the instance, `None` once something else
+    /// does. One representation rather than a close plus a flag that had to
+    /// agree with it, and taking it is what lets both teardown paths run
+    /// without cloning the callback.
+    close: Option<PluginClose>,
+    kind: ConnectorType,
+    plugin_id: u32,
+    key: String,
+}
+
+impl PluginInstanceGuard {
+    /// Arms a guard over a source instance the caller has just opened through
+    /// `container`, which it captures rather than borrows for the reason the
+    /// type documents.
+    pub(crate) fn for_source(
+        container: Arc<Container<SourceApi>>,
+        plugin_id: u32,
+        key: &str,
+    ) -> Self {
+        Self::new(
+            Arc::new(move |id| (container.iggy_source_close)(id)),
+            ConnectorType::Source,
+            plugin_id,
+            key,
+        )
+    }
+
+    /// [`Self::for_source`] for a sink instance.
+    pub(crate) fn for_sink(container: Arc<Container<SinkApi>>, plugin_id: u32, key: &str) -> Self {
+        Self::new(
+            Arc::new(move |id| (container.iggy_sink_close)(id)),
+            ConnectorType::Sink,
+            plugin_id,
+            key,
+        )
+    }
+
+    /// Kept behind `for_source` and `for_sink` so no production caller can
+    /// build a guard that holds a close pointer without its library. Tests
+    /// pass a closure.
+    fn new(close: PluginClose, kind: ConnectorType, plugin_id: u32, key: &str) -> Self {
+        Self {
+            close: Some(close),
+            kind,
+            plugin_id,
+            key: key.to_owned(),
+        }
+    }
+
+    /// Hands ownership of the instance to the caller, once something else can
+    /// close it. Call only after the plugin id is recorded on the connector
+    /// details.
+    pub(crate) fn disarm(mut self) {
+        self.close = None;
+    }
+
+    /// The awaited half of the teardown the type documents. Error arms call it
+    /// rather than leaving the work to `Drop`, which cannot offer the ordering.
+    pub(crate) async fn close(mut self) {
+        let Some(close) = self.close.take() else {
+            return;
+        };
+        let kind = self.kind.clone();
+        let plugin_id = self.plugin_id;
+        let key = std::mem::take(&mut self.key);
+        if tokio::task::spawn_blocking(move || {
+            close_plugin_instance(close.as_ref(), kind, plugin_id, &key)
+        })
+        .await
+        .is_err()
+        {
+            let kind = self.kind.as_label();
+            warn!(
+                "Teardown of failed {kind} connector with ID: {plugin_id} did not run to completion."
+            );
+        }
+    }
+}
+
+impl Drop for PluginInstanceGuard {
+    fn drop(&mut self) {
+        let Some(close) = self.close.take() else {
+            return;
+        };
+
+        let kind = self.kind.clone();
+        let plugin_id = self.plugin_id;
+        let key = std::mem::take(&mut self.key);
+        // Both containers drive the plugin's own `close()` under `block_on`
+        // and run for as long as the plugin takes, so it goes to the blocking
+        // pool where blocking is what the thread is for.
+        match Handle::try_current() {
+            Ok(handle) => {
+                handle.spawn_blocking(move || {
+                    close_plugin_instance(close.as_ref(), kind, plugin_id, &key)
+                });
+            }
+            // No runtime to hand it to, and no worker to protect either.
+            Err(_) => close_plugin_instance(close.as_ref(), kind, plugin_id, &key),
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::error::RuntimeError;
+    use std::sync::Mutex;
+    use std::time::Duration;
+
+    const PLUGIN_ID: u32 = 7;
+
+    /// A close that records the ids it was handed and answers `result`.
+    ///
+    /// The guard takes its close as a closure, so each test owns its recorder
+    /// and nothing is shared between tests. An `extern "C" fn` cannot capture,
+    /// which is what used to force this through statics.
+    fn recording_close(result: i32) -> (PluginClose, Arc<Mutex<Vec<u32>>>) {
+        let closed = Arc::new(Mutex::new(Vec::new()));
+        let recorded = closed.clone();
+        (
+            Arc::new(move |id| {
+                recorded.lock().expect("close recorder").push(id);
+                result
+            }),
+            closed,
+        )
+    }
+
+    /// The shape `start_connector` has: a guard armed over an instance nothing
+    /// else knows about, then a fallible step whose `?` returns before anything
+    /// records the id.
+    fn start_with_fallible_step(
+        close: PluginClose,
+        step: Result<(), RuntimeError>,
+    ) -> Result<(), RuntimeError> {
+        let instance_guard =
+            PluginInstanceGuard::new(close, ConnectorType::Sink, PLUGIN_ID, "stdout");
+        step?;
+        instance_guard.disarm();
+        Ok(())
+    }
+
+    #[test]
+    fn given_armed_guard_when_dropped_should_close_the_instance() {
+        // The leak this exists for: the open has created the instance and
+        // nothing outside the plugin knows its id yet, so an early return here
+        // would strand it for the life of the process.
+        let (close, closed) = recording_close(0);
+
+        drop(PluginInstanceGuard::new(
+            close,
+            ConnectorType::Source,
+            PLUGIN_ID,
+            "random",
+        ));
+
+        assert_eq!(
+            *closed.lock().expect("close recorder"),
+            vec![PLUGIN_ID],
+            "a guard still armed owns the instance and must close exactly it"
+        );
+    }
+
+    #[test]
+    fn given_fallible_step_when_it_returns_early_should_close_the_instance() {
+        // The shape dropping or disarming inline cannot show, and the one the
+        // guard is there for: the `?` leaves with the guard still armed and
+        // never reaches `disarm`. The error arms call `close()` directly now,
+        // so this is the net under a `?` added inside the window later.
+        let (close, closed) = recording_close(0);
+
+        let result = start_with_fallible_step(
+            close,
+            Err(RuntimeError::InvalidConfiguration("injected".to_string())),
+        );
+
+        assert!(result.is_err(), "the injected failure has to propagate");
+        assert_eq!(
+            *closed.lock().expect("close recorder"),
+            vec![PLUGIN_ID],
+            "a `?` must not strand the instance it left behind"
+        );
+    }
+
+    #[test]
+    fn given_fallible_step_when_it_succeeds_should_leave_the_instance_open() {
+        // The other half of the same helper: reaching `disarm` hands the
+        // instance on rather than closing it.
+        let (close, closed) = recording_close(0);
+
+        let result = start_with_fallible_step(close, Ok(()));
+
+        assert!(result.is_ok());
+        assert!(
+            closed.lock().expect("close recorder").is_empty(),
+            "a step that succeeded leaves the instance for the manager to close"
+        );
+    }
+
+    #[tokio::test]
+    async fn given_armed_guard_when_dropped_in_runtime_should_close_off_the_worker() {
+        // `drop` cannot await, and the containers drive the plugin's own
+        // teardown under `block_on`, so closing here would hold a worker for
+        // however long the plugin takes. It goes to the blocking pool instead,
+        // which is what the differing thread asserts. The close still has to
+        // happen.
+        let dropping_thread = std::thread::current().id();
+        let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
+
+        drop(PluginInstanceGuard::new(
+            Arc::new(move |id| {
+                let _ = sender.send((id, std::thread::current().id()));
+                0
+            }),
+            ConnectorType::Source,
+            PLUGIN_ID,
+            "random",
+        ));
+
+        let (closed_id, closing_thread) =
+            tokio::time::timeout(Duration::from_secs(5), receiver.recv())
+                .await
+                .expect("the deferred close should run")
+                .expect("the deferred close should report the instance");
+        assert_eq!(
+            closed_id, PLUGIN_ID,
+            "the deferred close must reach the instance the guard was armed over"
+        );
+        assert_ne!(
+            closing_thread, dropping_thread,
+            "closing on the dropping thread holds it for the plugin's teardown"
+        );
+    }
+
+    #[tokio::test]
+    async fn given_armed_guard_when_closed_should_finish_before_returning() {
+        // What the error arms rely on: once `close()` has returned the instance
+        // is gone, so the error they return cannot reach an operator who then
+        // retries into a collision with it.
+        let (close, closed) = recording_close(0);
+
+        PluginInstanceGuard::new(close, ConnectorType::Sink, PLUGIN_ID, "stdout")
+            .close()
+            .await;
+
+        assert_eq!(
+            *closed.lock().expect("close recorder"),
+            vec![PLUGIN_ID],
+            "close() has to await the teardown, and the drop after it must not repeat it"
+        );
+    }
+
+    #[test]
+    fn given_refused_close_when_guard_drops_should_close_once_and_swallow_refusal() {
+        // The plugin answers -1 for an id it does not know. Both callers are
+        // already returning an error of their own, so the refusal is reported
+        // and not propagated: unwinding out of `drop` would be worse than the
+        // leak it is cleaning up after. The harness gives no-panic for free, so
+        // what this asserts is the single call.
+        let (close, closed) = recording_close(-1);
+
+        drop(PluginInstanceGuard::new(
+            close,
+            ConnectorType::Source,
+            PLUGIN_ID,
+            "random",
+        ));
+
+        assert_eq!(
+            *closed.lock().expect("close recorder"),
+            vec![PLUGIN_ID],
+            "a refusal must not become a retry or a second close"
+        );
+    }
+}
diff --git a/core/connectors/runtime/src/sink.rs b/core/connectors/runtime/src/sink.rs
index 6da5560..5f2cd38 100644
--- a/core/connectors/runtime/src/sink.rs
+++ b/core/connectors/runtime/src/sink.rs
@@ -29,8 +29,8 @@
 use dlopen2::wrapper::Container;
 use futures::{Stream, StreamExt};
 use iggy::prelude::{
-    AutoCommit, AutoCommitWhen, Client, DeferredPollOptions, IggyClient, IggyConsumer, IggyError,
-    IggyMessage, NonZeroIggyDuration, PollingStrategy, UserClient,
+    Client, CommitPolicy, IggyClient, IggyConsumer, IggyDuration, IggyError, IggyMessage,
+    NonZeroIggyDuration, PollOptions, PollingStrategy, ReadError, UserClient,
 };
 use iggy_connector_sdk::decoders::avro::{AvroConfig, AvroStreamDecoder};
 use iggy_connector_sdk::{
@@ -39,14 +39,18 @@
 };
 use std::{
     collections::HashMap,
+    pin::Pin,
     str::FromStr,
     sync::{Arc, atomic::Ordering},
+    task::{Context, Poll},
     time::{Duration, Instant},
 };
 use tokio::sync::watch;
 use tokio::task::JoinHandle;
 use tracing::{debug, error, info, warn};
 
+const DEFAULT_BATCH_LENGTH: u32 = 1000;
+
 /// Initializes all enabled sink connectors.
 ///
 /// Per-connector failures (path resolution, dlopen, plugin init, client
@@ -309,7 +313,10 @@
                 callback,
                 transforms,
                 topic_metadata,
-                &mut consumer,
+                &mut ConsumerMessages {
+                    consumer: &mut consumer,
+                    client: &client,
+                },
                 verbose,
                 benchmark,
                 &plugin_key,
@@ -360,6 +367,15 @@
     }
 }
 
+/// The reads of a sink task, and the calls that resume a partition that its reader stopped.
+pub(crate) trait SinkMessages:
+    Stream<Item = Result<iggy::prelude::ReceivedMessage, ReadError>> + Unpin
+{
+    async fn abandon_operation(&mut self, operation: u64) -> Result<(), IggyError>;
+
+    async fn seek(&mut self, partition_id: u32, start: PollingStrategy) -> Result<(), IggyError>;
+}
+
 #[allow(clippy::too_many_arguments)]
 pub(crate) async fn consume_messages(
     plugin_id: u32,
@@ -368,7 +384,7 @@
     consume: ConsumeCallback,
     transforms: Vec<Arc<dyn Transform>>,
     topic_metadata: TopicMetadata,
-    mut messages: impl Stream<Item = Result<iggy::prelude::ReceivedMessage, IggyError>> + Unpin,
+    messages: &mut impl SinkMessages,
     verbose: bool,
     benchmark: bool,
     plugin_key: &str,
@@ -397,12 +413,9 @@
                 };
                 match message {
                     Some(Ok(message)) => Some(message),
-                    Some(Err(_)) => {
-                        error!(
-                            "Failed to receive message for sink connector with ID: {plugin_id} from stream: {}, topic: {}",
-                            topic_metadata.stream, topic_metadata.topic
-                        );
+                    Some(Err(error)) => {
                         metrics.inc_errors_with_labels(&labels.counter);
+                        handle_read_error(messages, error, plugin_id, &topic_metadata).await;
                         continue;
                     }
                     None => None,
@@ -527,6 +540,91 @@
     Ok(())
 }
 
+/// The reader stops a partition after a read with an unknown outcome, a message that does not
+/// decode, or a new partition history. Each would wait for a restart, so the sink resumes the
+/// partition. After an unknown outcome it resumes after the stored offset. The abandoned request
+/// can still store its offset later, so the sink can skip the messages of that request. The
+/// reader retries any other error by itself.
+async fn handle_read_error(
+    messages: &mut impl SinkMessages,
+    error: ReadError,
+    plugin_id: u32,
+    topic_metadata: &TopicMetadata,
+) {
+    let TopicMetadata { stream, topic } = topic_metadata;
+    let (partition_id, start) = match (error.partition_id, &error.error, error.skip) {
+        (
+            Some(partition_id),
+            IggyError::FetchProgressUncertain(operation)
+            | IggyError::PartitionLaneBlocked(_, operation),
+            _,
+        ) => {
+            warn!(
+                "Abandoning operation: {operation} with an unknown outcome, which blocks partition: {partition_id} of sink connector with ID: {plugin_id} from stream: {stream}, topic: {topic}. The partition resumes after its stored offset. {}",
+                error.error
+            );
+            match messages.abandon_operation(*operation).await {
+                // The reader reports a lost fetch for each of its partitions, and the first
+                // abandon frees the operation for all of them.
+                Ok(()) | Err(IggyError::OperationNotRetained(_)) => {}
+                Err(abandon_error) => warn!(
+                    "Failed to abandon operation: {operation} of sink connector with ID: {plugin_id}: {abandon_error}"
+                ),
+            }
+            (partition_id, PollingStrategy::next())
+        }
+        (Some(partition_id), _, Some(skip)) => {
+            warn!(
+                "Skipping a message that does not decode in partition: {partition_id} of sink connector with ID: {plugin_id} from stream: {stream}, topic: {topic}. {}",
+                error.error
+            );
+            (partition_id, skip)
+        }
+        (Some(partition_id), IggyError::HistoryUnavailable, None) => {
+            warn!(
+                "Partition: {partition_id} of sink connector with ID: {plugin_id} from stream: {stream}, topic: {topic} has a new history. It resumes after its stored offset. {}",
+                error.error
+            );
+            (partition_id, PollingStrategy::next())
+        }
+        _ => {
+            error!(
+                "Failed to receive message for sink connector with ID: {plugin_id} from stream: {stream}, topic: {topic}"
+            );
+            return;
+        }
+    };
+    if let Err(seek_error) = messages.seek(partition_id, start).await {
+        error!(
+            "Failed to resume partition: {partition_id} of sink connector with ID: {plugin_id} from stream: {stream}, topic: {topic}: {seek_error}"
+        );
+    }
+}
+
+/// A sink consumer with the client that holds the operations of its reads.
+struct ConsumerMessages<'a> {
+    consumer: &'a mut IggyConsumer,
+    client: &'a IggyClient,
+}
+
+impl Stream for ConsumerMessages<'_> {
+    type Item = Result<iggy::prelude::ReceivedMessage, ReadError>;
+
+    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
+        self.consumer.poll_next_unpin(cx)
+    }
+}
+
+impl SinkMessages for ConsumerMessages<'_> {
+    async fn abandon_operation(&mut self, operation: u64) -> Result<(), IggyError> {
+        self.client.abandon_operation(operation).await
+    }
+
+    async fn seek(&mut self, partition_id: u32, start: PollingStrategy) -> Result<(), IggyError> {
+        self.consumer.seek(partition_id, start).await
+    }
+}
+
 fn get_plugin_version(container: &Container<SinkApi>) -> String {
     unsafe {
         let version_ptr = (container.iggy_sink_version)();
@@ -590,15 +688,11 @@
             .consumer_group
             .as_deref()
             .unwrap_or(&default_consumer_group);
-        let batch_length = stream.batch_length.unwrap_or(1000);
         for topic in stream.topics.iter() {
             let mut consumer = iggy_client
                 .consumer_group(consumer_group, &stream.stream, topic)?
-                .auto_commit(AutoCommit::When(AutoCommitWhen::PollingMessages))
                 .create_consumer_group_if_not_exists()
-                .auto_join_consumer_group()
                 .polling_strategy(PollingStrategy::next())
-                .batch_length(batch_length)
                 .poll_options(poll_options)
                 .build();
             consumer.init().await?;
@@ -618,26 +712,30 @@
                 }
                 other => other.decoder(),
             };
-            consumers.push((consumer, decoder, batch_length, transforms.clone()));
+            consumers.push((consumer, decoder, poll_options.count, transforms.clone()));
         }
     }
     Ok(consumers)
 }
 
-/// A configured wait extends the default request timeout by its length, so the
-/// final read and the reply keep the default time after the wait.
-fn poll_options(stream: &StreamConsumerConfig) -> Result<DeferredPollOptions, RuntimeError> {
+/// The server stores the offset of each reply as part of the read. While the
+/// primary stays up the plugin gets each message at most once, and a failover
+/// before the store replicates can deliver the last batch again. A stream
+/// without `max_wait` waits one second.
+fn poll_options(stream: &StreamConsumerConfig) -> Result<PollOptions, RuntimeError> {
     if stream.poll_interval.is_some() {
         return Err(RuntimeError::UnsupportedPollInterval(stream.stream.clone()));
     }
-    let mut options = DeferredPollOptions::default();
+    let mut options = PollOptions {
+        count: stream.batch_length.unwrap_or(DEFAULT_BATCH_LENGTH),
+        commit: CommitPolicy::OnRead,
+        ..PollOptions::wait(IggyDuration::ONE_SECOND)
+    };
     if let Some(max_wait) = stream.max_wait.as_deref() {
         // A zero wait answers at once, so an idle topic would be polled constantly.
         let max_wait = NonZeroIggyDuration::from_str(max_wait).map_err(|error| {
             RuntimeError::InvalidConfiguration(format!("Invalid max wait: {error}"))
         })?;
-        options.request_timeout =
-            (options.request_timeout.get_duration() + max_wait.get_duration()).into();
         options.max_wait = max_wait.into();
     }
     if let Some(min_count) = stream.min_count {
@@ -941,7 +1039,11 @@
     use iggy_connector_sdk::{Error, Payload};
     use prost::Message as _;
     use std::sync::LazyLock;
-    use std::sync::atomic::AtomicU32;
+    use std::sync::atomic::{AtomicU32, AtomicUsize};
+    use tracing::{Level, Subscriber};
+    use tracing_subscriber::Layer;
+    use tracing_subscriber::layer::{Context as LayerContext, SubscriberExt};
+    use tracing_subscriber::registry::Registry;
 
     /// One entry per FFI call the stub sink received, keyed by plugin id so
     /// tests sharing the binary do not read each other's batches.
@@ -1210,6 +1312,106 @@
             .unwrap_or_default()
     }
 
+    /// A recovery call of the sink loop.
+    #[derive(Debug, PartialEq)]
+    enum Recovery {
+        Abandon(u64),
+        Seek(u32, PollingStrategy),
+    }
+
+    /// Counts the warnings logged under its subscriber.
+    struct Warnings(Arc<AtomicUsize>);
+
+    impl<S: Subscriber> Layer<S> for Warnings {
+        fn on_event(&self, event: &tracing::Event<'_>, _: LayerContext<'_, S>) {
+            if *event.metadata().level() == Level::WARN {
+                self.0.fetch_add(1, Ordering::Relaxed);
+            }
+        }
+    }
+
+    /// Hands reads to the sink loop and records its recovery calls in order.
+    struct FakeMessages<S> {
+        reads: S,
+        abandon: Result<(), IggyError>,
+        calls: Vec<Recovery>,
+    }
+
+    impl<S> FakeMessages<S> {
+        fn new(reads: S) -> Self {
+            Self {
+                reads,
+                abandon: Ok(()),
+                calls: Vec::new(),
+            }
+        }
+    }
+
+    impl<S> Stream for FakeMessages<S>
+    where
+        S: Stream<Item = Result<iggy::prelude::ReceivedMessage, ReadError>> + Unpin,
+    {
+        type Item = S::Item;
+
+        fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
+            self.reads.poll_next_unpin(cx)
+        }
+    }
+
+    impl<S> SinkMessages for FakeMessages<S>
+    where
+        S: Stream<Item = Result<iggy::prelude::ReceivedMessage, ReadError>> + Unpin,
+    {
+        /// As the client does, the first abandon frees `operation`.
+        async fn abandon_operation(&mut self, operation: u64) -> Result<(), IggyError> {
+            let abandoned = self.calls.contains(&Recovery::Abandon(operation));
+            self.calls.push(Recovery::Abandon(operation));
+            if abandoned {
+                return Err(IggyError::OperationNotRetained(operation));
+            }
+            self.abandon.clone()
+        }
+
+        async fn seek(
+            &mut self,
+            partition_id: u32,
+            start: PollingStrategy,
+        ) -> Result<(), IggyError> {
+            self.calls.push(Recovery::Seek(partition_id, start));
+            Ok(())
+        }
+    }
+
+    /// Runs the sink loop until `messages` end, into the capturing stub sink.
+    async fn consume_reads(
+        plugin_id: u32,
+        messages: &mut impl SinkMessages,
+        metrics: &Arc<Metrics>,
+    ) {
+        const BATCH_SIZE: u32 = 16;
+        let (_shutdown, shutdown_rx) = watch::channel(());
+        consume_messages(
+            plugin_id,
+            Schema::Json.decoder(),
+            BATCH_SIZE,
+            capturing_consume,
+            Vec::new(),
+            TopicMetadata {
+                stream: "test_stream".to_owned(),
+                topic: "test_topic".to_owned(),
+            },
+            messages,
+            false,
+            false,
+            "test_sink",
+            metrics,
+            &SinkLabels::new("test_sink"),
+            shutdown_rx,
+        )
+        .await
+        .expect("consuming the stream should succeed");
+    }
+
     #[tokio::test]
     async fn given_an_avro_stream_when_batch_is_tagged_should_use_the_decoded_payload_schema() {
         let plugin_id = next_plugin_id();
@@ -1266,12 +1468,12 @@
         const BATCH_SIZE: u32 = 16;
         let plugin_id = next_plugin_id();
         let received = |partition_id, offset, current_offset| {
-            Ok(iggy::prelude::ReceivedMessage {
-                context: iggy::prelude::PartitionContext::default(),
-                message: test_message(offset, br#"{"id":1}"#.to_vec()),
+            iggy::prelude::ReceivedMessage::new(
+                test_message(offset, br#"{"id":1}"#.to_vec()),
                 current_offset,
                 partition_id,
-            })
+                iggy::prelude::PartitionContext::default(),
+            )
         };
         // Byte-limited replies stop each partition short of its head.
         let messages = futures::stream::iter([
@@ -1280,7 +1482,8 @@
             received(2, 0, 1),
             received(1, 2, 2),
             received(2, 1, 1),
-        ]);
+        ])
+        .map(Ok);
         let (_shutdown, shutdown_rx) = watch::channel(());
 
         consume_messages(
@@ -1293,7 +1496,7 @@
                 stream: "test_stream".to_owned(),
                 topic: "test_topic".to_owned(),
             },
-            messages,
+            &mut FakeMessages::new(messages),
             false,
             false,
             "test_sink",
@@ -1318,26 +1521,29 @@
     async fn given_a_batch_short_of_the_partition_head_when_shutdown_arrives_should_still_send_it()
     {
         const BATCH_SIZE: u32 = 16;
+        const PARTITION_ID: u32 = 1;
         let plugin_id = next_plugin_id();
         let (shutdown_tx, shutdown_rx) = watch::channel(());
         let received = |offset| {
-            Ok(iggy::prelude::ReceivedMessage {
-                context: iggy::prelude::PartitionContext::default(),
-                message: test_message(offset, br#"{"id":1}"#.to_vec()),
-                current_offset: TEST_CURRENT_OFFSET,
-                partition_id: 1,
-            })
+            iggy::prelude::ReceivedMessage::new(
+                test_message(offset, br#"{"id":1}"#.to_vec()),
+                TEST_CURRENT_OFFSET,
+                PARTITION_ID,
+                iggy::prelude::PartitionContext::default(),
+            )
         };
         // A byte-limited reply ends short of the head, so the loop asks for
         // more. That poll parks, and the shutdown arrives while it waits.
-        let messages = Box::pin(futures::stream::iter([received(0), received(1)]).chain(
-            futures::stream::once(async move {
-                shutdown_tx
-                    .send(())
-                    .expect("the sink loop should still listen for shutdown");
-                std::future::pending().await
-            }),
-        ));
+        let messages = Box::pin(
+            futures::stream::iter([received(0), received(1)])
+                .map(Ok)
+                .chain(futures::stream::once(async move {
+                    shutdown_tx
+                        .send(())
+                        .expect("the sink loop should still listen for shutdown");
+                    std::future::pending().await
+                })),
+        );
 
         consume_messages(
             plugin_id,
@@ -1349,7 +1555,7 @@
                 stream: "test_stream".to_owned(),
                 topic: "test_topic".to_owned(),
             },
-            messages,
+            &mut FakeMessages::new(messages),
             false,
             false,
             "test_sink",
@@ -1366,12 +1572,162 @@
             .collect();
         assert_eq!(
             batches,
-            [(1, vec![0, 1])],
+            [(PARTITION_ID, vec![0, 1])],
             "the poll committed this batch, so a shutdown must not drop it"
         );
     }
 
     #[tokio::test]
+    async fn given_reads_that_stop_a_partition_when_consumed_should_resume_each_partition() {
+        const UNCERTAIN_OPERATION: u64 = 7;
+        const BLOCKING_OPERATION: u64 = 8;
+        const UNDECODED_OFFSET: u64 = 5;
+        let plugin_id = next_plugin_id();
+        let metrics = Arc::new(Metrics::init());
+        let skip = PollingStrategy::offset(UNDECODED_OFFSET + 1);
+        let stopped = |partition_id, error, skip| ReadError {
+            partition_id: Some(partition_id),
+            error,
+            skip,
+            offset: None,
+            record: None,
+        };
+        let mut messages = FakeMessages::new(futures::stream::iter([
+            Err(stopped(
+                1,
+                IggyError::FetchProgressUncertain(UNCERTAIN_OPERATION),
+                None,
+            )),
+            Err(stopped(
+                2,
+                IggyError::PartitionLaneBlocked(2, BLOCKING_OPERATION),
+                None,
+            )),
+            Err(stopped(3, IggyError::CannotDecryptData, Some(skip))),
+            Err(stopped(4, IggyError::HistoryUnavailable, None)),
+            Ok(iggy::prelude::ReceivedMessage::new(
+                test_message(0, br#"{"id":1}"#.to_vec()),
+                0,
+                1,
+                iggy::prelude::PartitionContext::default(),
+            )),
+        ]));
+
+        consume_reads(plugin_id, &mut messages, &metrics).await;
+
+        assert_eq!(
+            messages.calls,
+            [
+                Recovery::Abandon(UNCERTAIN_OPERATION),
+                Recovery::Seek(1, PollingStrategy::next()),
+                Recovery::Abandon(BLOCKING_OPERATION),
+                Recovery::Seek(2, PollingStrategy::next()),
+                Recovery::Seek(3, skip),
+                Recovery::Seek(4, PollingStrategy::next()),
+            ]
+        );
+        assert_eq!(metrics.get_errors("test_sink", ConnectorType::Sink), 4);
+        let batches: Vec<_> = captured(plugin_id)
+            .into_iter()
+            .map(|batch| (batch.partition_id, batch.offsets))
+            .collect();
+        assert_eq!(
+            batches,
+            [(1, vec![0])],
+            "the loop reads on after it resumes a partition"
+        );
+    }
+
+    #[tokio::test]
+    async fn given_a_read_error_that_the_reader_retries_when_consumed_should_only_count_it() {
+        let plugin_id = next_plugin_id();
+        let metrics = Arc::new(Metrics::init());
+        let mut messages = FakeMessages::new(futures::stream::iter([
+            Err(ReadError {
+                partition_id: None,
+                error: IggyError::Disconnected,
+                skip: None,
+                offset: None,
+                record: None,
+            }),
+            Err(ReadError {
+                partition_id: Some(1),
+                error: IggyError::TransientNotAccepted,
+                skip: None,
+                offset: None,
+                record: None,
+            }),
+        ]));
+
+        consume_reads(plugin_id, &mut messages, &metrics).await;
+
+        assert_eq!(messages.calls, []);
+        assert_eq!(metrics.get_errors("test_sink", ConnectorType::Sink), 2);
+    }
+
+    #[tokio::test]
+    async fn given_an_abandon_that_fails_when_resuming_should_still_seek_the_partition() {
+        const OPERATION: u64 = 7;
+        let plugin_id = next_plugin_id();
+        let mut messages = FakeMessages {
+            abandon: Err(IggyError::FeatureUnavailable),
+            ..FakeMessages::new(futures::stream::iter([Err(ReadError {
+                partition_id: Some(1),
+                error: IggyError::FetchProgressUncertain(OPERATION),
+                skip: None,
+                offset: None,
+                record: None,
+            })]))
+        };
+
+        consume_reads(plugin_id, &mut messages, &Arc::new(Metrics::init())).await;
+
+        assert_eq!(
+            messages.calls,
+            [
+                Recovery::Abandon(OPERATION),
+                Recovery::Seek(1, PollingStrategy::next())
+            ],
+            "the seek still tries to resume the partition"
+        );
+    }
+
+    #[tokio::test]
+    async fn given_a_lost_fetch_of_two_partitions_when_resuming_should_warn_once_per_partition() {
+        const OPERATION: u64 = 7;
+        let plugin_id = next_plugin_id();
+        let lost = |partition_id| ReadError {
+            partition_id: Some(partition_id),
+            error: IggyError::FetchProgressUncertain(OPERATION),
+            skip: None,
+            offset: None,
+            record: None,
+        };
+        let mut messages = FakeMessages::new(futures::stream::iter([Err(lost(1)), Err(lost(2))]));
+        let warnings = Arc::new(AtomicUsize::new(0));
+        let _subscriber = tracing::subscriber::set_default(
+            Registry::default().with(Warnings(Arc::clone(&warnings))),
+        );
+
+        consume_reads(plugin_id, &mut messages, &Arc::new(Metrics::init())).await;
+
+        assert_eq!(
+            messages.calls,
+            [
+                Recovery::Abandon(OPERATION),
+                Recovery::Seek(1, PollingStrategy::next()),
+                Recovery::Abandon(OPERATION),
+                Recovery::Seek(2, PollingStrategy::next()),
+            ]
+        );
+        assert_eq!(
+            warnings.load(Ordering::Relaxed),
+            2,
+            "the second abandon finds the operation freed by the first, which is no failure"
+        );
+    }
+
+    #[tokio::test]
     async fn given_mixed_payload_variants_when_batch_is_processed_should_split_into_runs() {
         let plugin_id = next_plugin_id();
         let (transform, messages) = split_batch();
@@ -1565,24 +1921,29 @@
 
     #[test]
     fn given_max_wait_and_min_count_when_building_poll_options_should_wait_for_a_batch() {
-        let default = DeferredPollOptions::default();
         let unset = poll_options(&StreamConsumerConfig::default()).expect("defaults are valid");
-        assert_eq!(unset, default);
+        assert_eq!(
+            unset,
+            PollOptions {
+                count: DEFAULT_BATCH_LENGTH,
+                commit: CommitPolicy::OnRead,
+                ..PollOptions::wait(IggyDuration::ONE_SECOND)
+            }
+        );
 
         let stream = StreamConsumerConfig {
+            batch_length: Some(2000),
             max_wait: Some("30s".to_owned()),
             min_count: Some(500),
             ..StreamConsumerConfig::default()
         };
         let options = poll_options(&stream).expect("a 30 s wait is valid");
 
+        assert_eq!(options.count, 2000);
         assert_eq!(options.max_wait.get_duration(), Duration::from_secs(30));
         assert_eq!(options.min_count, 500);
-        assert_eq!(
-            options.request_timeout.get_duration(),
-            default.request_timeout.get_duration() + Duration::from_secs(30)
-        );
-        assert_eq!(options.validate(1000), Ok(()));
+        assert_eq!(options.commit, CommitPolicy::OnRead);
+        assert_eq!(options.validate(), Ok(()));
     }
 
     #[test]
diff --git a/core/connectors/runtime/src/source.rs b/core/connectors/runtime/src/source.rs
index 85bd678..560d867 100644
--- a/core/connectors/runtime/src/source.rs
+++ b/core/connectors/runtime/src/source.rs
@@ -48,17 +48,16 @@
 use crate::context::RuntimeContext;
 use crate::log::LOG_CALLBACK;
 use crate::manager::source::StopReport;
-use crate::metrics::ConnectorType;
 use crate::metrics::SourceLabels;
+use crate::plugin_instance::PluginInstanceGuard;
 use crate::{
     FailedPlugin, PLUGIN_ID, RuntimeError, SourceApi, SourceConnector, SourceConnectorPlugin,
-    SourceConnectorProducer, SourceConnectorWrapper, close_plugin_instance, resolve_plugin_path,
+    SourceConnectorProducer, SourceConnectorWrapper, resolve_plugin_path,
     state::{StateStorage, StateStorageFactory},
     transform,
 };
 use iggy_connector_sdk::api::ConnectorStatus;
 use prometheus_client::metrics::counter::Counter;
-use tokio::runtime::Handle;
 use tokio::sync::mpsc;
 use tokio::task::JoinHandle;
 use tokio::time::timeout;
@@ -252,7 +251,7 @@
             let connector = source_connectors
                 .get_mut(&path)
                 .expect("source connector was inserted above");
-            SourceInstanceGuard::for_container(connector.container.clone(), plugin_id, &key)
+            PluginInstanceGuard::for_source(connector.container.clone(), plugin_id, &key)
         };
 
         match setup_source_producer(&key, &config, iggy_client).await {
@@ -308,7 +307,9 @@
     id: u32,
     state: Option<ConnectorState>,
 ) -> Result<(), RuntimeError> {
-    trace!("Initializing source plugin with config: {plugin_config:?} (ID: {id})");
+    // The configuration holds credentials, and the runtime cannot tell which
+    // fields they are.
+    trace!("Initializing source plugin (ID: {id})");
     let plugin_config =
         serde_json::to_string(plugin_config).expect("Invalid source plugin config.");
     let state_ptr = state.as_ref().map_or(std::ptr::null(), |s| s.0.as_ptr());
@@ -330,115 +331,6 @@
     }
 }
 
-/// A plugin's `iggy_source_close` together with whatever keeps the library that
-/// exports it mapped, so the call stays valid once it is deferred off the
-/// calling thread.
-pub(crate) type SourceClose = Arc<dyn Fn(u32) -> i32 + Send + Sync>;
-
-/// Closes a source instance that `iggy_source_open` created and nothing else
-/// will ever reach.
-///
-/// Between `init_source` succeeding and the plugin id reaching `SourceDetails`,
-/// the instance exists inside the plugin and nothing outside it knows the id:
-/// `stop_connector` closes whatever `details.info.id` holds, which is still the
-/// previous instance. An early return there stranded the new one for the life
-/// of the process. A guard rather than a cleanup branch per fallible call,
-/// because the window is those two statements rather than whichever call
-/// between them is fallible today, so a `?` added inside it stays correct.
-/// Startup and restart both hand off through it.
-///
-/// Teardown runs two ways and they are not interchangeable. [`Self::close`]
-/// awaits, so an error returned after it means the instance is already gone
-/// and an immediate retry has nothing to collide with. `Drop` cannot await, so
-/// it hands the work to the blocking pool; the closure carries the container,
-/// which is what keeps the library mapped until the call returns.
-#[must_use = "dropping an armed guard closes the source instance"]
-pub(crate) struct SourceInstanceGuard {
-    /// `Some` while this guard owns the instance, `None` once something else
-    /// does. One representation rather than a close plus a flag that had to
-    /// agree with it, and taking it is what lets both teardown paths run
-    /// without cloning the callback.
-    close: Option<SourceClose>,
-    plugin_id: u32,
-    key: String,
-}
-
-impl SourceInstanceGuard {
-    /// Arms a guard over an instance the caller has just opened through
-    /// `container`, which it captures rather than borrows for the reason the
-    /// type documents.
-    pub(crate) fn for_container(
-        container: Arc<Container<SourceApi>>,
-        plugin_id: u32,
-        key: &str,
-    ) -> Self {
-        Self::new(
-            Arc::new(move |id| (container.iggy_source_close)(id)),
-            plugin_id,
-            key,
-        )
-    }
-
-    /// Kept behind `for_container` so no production caller can build a guard
-    /// that holds a close pointer without its library. Tests pass a closure.
-    fn new(close: SourceClose, plugin_id: u32, key: &str) -> Self {
-        Self {
-            close: Some(close),
-            plugin_id,
-            key: key.to_owned(),
-        }
-    }
-
-    /// Hands ownership of the instance to the caller, once something else can
-    /// close it. Call only after the plugin id is recorded on `SourceDetails`.
-    pub(crate) fn disarm(mut self) {
-        self.close = None;
-    }
-
-    /// The awaited half of the teardown the type documents. Error arms call it
-    /// rather than leaving the work to `Drop`, which cannot offer the ordering.
-    pub(crate) async fn close(mut self) {
-        let Some(close) = self.close.take() else {
-            return;
-        };
-        let plugin_id = self.plugin_id;
-        let key = std::mem::take(&mut self.key);
-        if tokio::task::spawn_blocking(move || {
-            close_plugin_instance(close.as_ref(), ConnectorType::Source, plugin_id, &key)
-        })
-        .await
-        .is_err()
-        {
-            warn!(
-                "Teardown of failed source connector with ID: {plugin_id} did not run to completion."
-            );
-        }
-    }
-}
-
-impl Drop for SourceInstanceGuard {
-    fn drop(&mut self) {
-        let Some(close) = self.close.take() else {
-            return;
-        };
-
-        let plugin_id = self.plugin_id;
-        let key = std::mem::take(&mut self.key);
-        // `SourceContainer::close` drives the plugin's own `close()` under
-        // `block_on` and runs for as long as the plugin takes, so it goes to
-        // the blocking pool where blocking is what the thread is for.
-        match Handle::try_current() {
-            Ok(handle) => {
-                handle.spawn_blocking(move || {
-                    close_plugin_instance(close.as_ref(), ConnectorType::Source, plugin_id, &key)
-                });
-            }
-            // No runtime to hand it to, and no worker to protect either.
-            Err(_) => close_plugin_instance(close.as_ref(), ConnectorType::Source, plugin_id, &key),
-        }
-    }
-}
-
 /// Returns the last stream's producer with its encoder, and the transforms.
 pub(crate) async fn setup_source_producer(
     key: &str,
@@ -1268,7 +1160,6 @@
     use std::cell::Cell;
     use std::collections::VecDeque;
     use std::future::{Ready, ready};
-    use std::sync::Mutex;
     use std::sync::atomic::{AtomicU32, AtomicU64, AtomicUsize, Ordering};
     use std::time::Duration;
 
@@ -1467,166 +1358,6 @@
         messages.iter().map(|message| message.payload[0]).collect()
     }
 
-    /// A close that records the ids it was handed and answers `result`.
-    ///
-    /// The guard takes its close as a closure, so each test owns its recorder
-    /// and nothing is shared between tests. An `extern "C" fn` cannot capture,
-    /// which is what used to force this through statics.
-    fn recording_close(result: i32) -> (SourceClose, Arc<Mutex<Vec<u32>>>) {
-        let closed = Arc::new(Mutex::new(Vec::new()));
-        let recorded = closed.clone();
-        (
-            Arc::new(move |id| {
-                recorded.lock().expect("close recorder").push(id);
-                result
-            }),
-            closed,
-        )
-    }
-
-    /// The shape `start_connector` has: a guard armed over an instance nothing
-    /// else knows about, then a fallible step whose `?` returns before anything
-    /// records the id.
-    fn start_with_fallible_step(
-        close: SourceClose,
-        plugin_id: u32,
-        step: Result<(), RuntimeError>,
-    ) -> Result<(), RuntimeError> {
-        let instance_guard = SourceInstanceGuard::new(close, plugin_id, "random");
-        step?;
-        instance_guard.disarm();
-        Ok(())
-    }
-
-    #[test]
-    fn given_armed_guard_when_dropped_should_close_the_instance() {
-        // The leak this exists for: `init_source` has created the instance and
-        // nothing outside the plugin knows its id yet, so an early return here
-        // would strand it for the life of the process.
-        let plugin_id = next_plugin_id();
-        let (close, closed) = recording_close(0);
-
-        drop(SourceInstanceGuard::new(close, plugin_id, "random"));
-
-        assert_eq!(
-            *closed.lock().expect("close recorder"),
-            vec![plugin_id],
-            "a guard still armed owns the instance and must close exactly it"
-        );
-    }
-
-    #[test]
-    fn given_fallible_step_when_it_returns_early_should_close_the_instance() {
-        // The shape dropping or disarming inline cannot show, and the one the
-        // guard is there for: the `?` leaves with the guard still armed and
-        // never reaches `disarm`. The error arms call `close()` directly now,
-        // so this is the net under a `?` added inside the window later.
-        let plugin_id = next_plugin_id();
-        let (close, closed) = recording_close(0);
-
-        let result = start_with_fallible_step(
-            close,
-            plugin_id,
-            Err(RuntimeError::InvalidConfiguration("injected".to_string())),
-        );
-
-        assert!(result.is_err(), "the injected failure has to propagate");
-        assert_eq!(
-            *closed.lock().expect("close recorder"),
-            vec![plugin_id],
-            "a `?` must not strand the instance it left behind"
-        );
-    }
-
-    #[test]
-    fn given_fallible_step_when_it_succeeds_should_leave_the_instance_open() {
-        // The other half of the same helper: reaching `disarm` hands the
-        // instance on rather than closing it.
-        let plugin_id = next_plugin_id();
-        let (close, closed) = recording_close(0);
-
-        let result = start_with_fallible_step(close, plugin_id, Ok(()));
-
-        assert!(result.is_ok());
-        assert!(
-            closed.lock().expect("close recorder").is_empty(),
-            "a step that succeeded leaves the instance for the manager to close"
-        );
-    }
-
-    #[tokio::test]
-    async fn given_armed_guard_when_dropped_in_runtime_should_close_off_the_worker() {
-        // `drop` cannot await, and `SourceContainer::close` drives the plugin's
-        // own teardown under `block_on`, so closing here would hold a worker for
-        // however long the plugin takes. It goes to the blocking pool instead,
-        // which is what the differing thread asserts. The close still has to
-        // happen.
-        let plugin_id = next_plugin_id();
-        let dropping_thread = std::thread::current().id();
-        let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
-
-        drop(SourceInstanceGuard::new(
-            Arc::new(move |id| {
-                let _ = sender.send((id, std::thread::current().id()));
-                0
-            }),
-            plugin_id,
-            "random",
-        ));
-
-        let (closed_id, closing_thread) =
-            tokio::time::timeout(Duration::from_secs(5), receiver.recv())
-                .await
-                .expect("the deferred close should run")
-                .expect("the deferred close should report the instance");
-        assert_eq!(
-            closed_id, plugin_id,
-            "the deferred close must reach the instance the guard was armed over"
-        );
-        assert_ne!(
-            closing_thread, dropping_thread,
-            "closing on the dropping thread holds it for the plugin's teardown"
-        );
-    }
-
-    #[tokio::test]
-    async fn given_armed_guard_when_closed_should_finish_before_returning() {
-        // What the error arms rely on: once `close()` has returned the instance
-        // is gone, so the error they return cannot reach an operator who then
-        // retries into a collision with it.
-        let plugin_id = next_plugin_id();
-        let (close, closed) = recording_close(0);
-
-        SourceInstanceGuard::new(close, plugin_id, "random")
-            .close()
-            .await;
-
-        assert_eq!(
-            *closed.lock().expect("close recorder"),
-            vec![plugin_id],
-            "close() has to await the teardown, and the drop after it must not repeat it"
-        );
-    }
-
-    #[test]
-    fn given_refused_close_when_guard_drops_should_close_once_and_swallow_refusal() {
-        // The plugin answers -1 for an id it does not know. Both callers are
-        // already returning an error of their own, so the refusal is reported
-        // and not propagated: unwinding out of `drop` would be worse than the
-        // leak it is cleaning up after. The harness gives no-panic for free, so
-        // what this asserts is the single call.
-        let plugin_id = next_plugin_id();
-        let (close, closed) = recording_close(-1);
-
-        drop(SourceInstanceGuard::new(close, plugin_id, "random"));
-
-        assert_eq!(
-            *closed.lock().expect("close recorder"),
-            vec![plugin_id],
-            "a refusal must not become a retry or a second close"
-        );
-    }
-
     #[test]
     fn given_serialized_batch_when_callback_runs_should_forward_batch_id() {
         let plugin_id = next_plugin_id();
diff --git a/core/integration/src/harness/helpers.rs b/core/integration/src/harness/helpers.rs
index eb60fb7..15a0723 100644
--- a/core/integration/src/harness/helpers.rs
+++ b/core/integration/src/harness/helpers.rs
@@ -18,15 +18,28 @@
 use crate::harness::{TestBinary, TestHarness};
 use iggy::prelude::UserCreateOptions;
 use iggy::prelude::{
-    ConsumerGroupClient, ConsumerGroupDetails, DEFAULT_ROOT_PASSWORD, DEFAULT_ROOT_USERNAME,
-    GlobalPermissions, Identifier, IdentityInfo, Permissions, StreamClient, TopicClient,
-    TopicDetails, UserClient, UserStatus,
+    ClientWrapper, CommitPolicy, Consumer, ConsumerGroupClient, ConsumerGroupDetails,
+    DEFAULT_ROOT_PASSWORD, DEFAULT_ROOT_USERNAME, GlobalPermissions, Identifier, IdentityInfo,
+    IggyClient, IggyError, MessageClient, Permissions, PollOptions, PolledMessages,
+    PollingStrategy, StreamClient, TopicClient, TopicDetails, UserClient, UserStatus,
 };
+use iggy_binary_protocol::codes::{GET_PARTITION_ROUTE_CODE, SYNC_CONSUMER_GROUP_CODE};
+use iggy_binary_protocol::requests::consumer_groups::SyncConsumerGroupRequest;
+use iggy_binary_protocol::requests::messages::GetPartitionRouteRequest;
+use iggy_binary_protocol::responses::consumer_groups::SyncConsumerGroupResponse;
+use iggy_binary_protocol::responses::messages::PartitionRouteResponse;
+use iggy_binary_protocol::{WireDecode, WireEncode};
+use iggy_common::locking::IggyRwLockFn;
+use iggy_common::wire_conversions::identifier_to_wire;
+use iggy_common::{FetchedSection, fetch_binary_messages};
 use std::collections::HashSet;
 use std::time::Duration;
 use tokio::time::{Instant, sleep};
 
 pub const USER_PASSWORD: &str = "secret";
+/// Each attempt of [`GroupPoller::poll`] syncs the assignment again, so the
+/// second one covers an assignment that moved between the sync and the poll.
+const GROUP_POLL_ATTEMPTS: usize = 2;
 const ASSIGNMENT_POLL_INTERVAL: Duration = Duration::from_millis(100);
 const TOPIC_STATS_POLL_INTERVAL: Duration = Duration::from_millis(50);
 /// On an idle cluster a backup learns a commit only from the commit broadcast.
@@ -84,6 +97,194 @@
     }
 }
 
+/// The group poll that the SDK removed, as test code: a round robin over the
+/// partitions that the member may poll.
+///
+/// Each attempt syncs the assignment first, so the poller follows a rebalance
+/// at once and never polls a partition that waits for revocation.
+#[derive(Debug)]
+pub struct GroupPoller {
+    consumer: Consumer,
+    cursor: usize,
+}
+
+impl GroupPoller {
+    pub fn new(group: Identifier) -> Self {
+        Self {
+            consumer: Consumer::group(group),
+            cursor: 0,
+        }
+    }
+
+    /// Poll the next partition of the member's assignment. A
+    /// `ConsumerGroupPartitionNotOwned` reply starts a new attempt, at most
+    /// `GROUP_POLL_ATTEMPTS`.
+    ///
+    /// A member without partitions, or one whose every attempt is refused,
+    /// gets [`PolledMessages::empty`]. Its `partition_id` is 0, so do not read
+    /// it.
+    ///
+    /// # Errors
+    /// `ConsumerGroupMemberNotFound` if the client is not a member, and the
+    /// other errors of [`group_partitions`] and of the poll.
+    pub async fn poll(
+        &mut self,
+        client: &IggyClient,
+        stream_id: &Identifier,
+        topic_id: &Identifier,
+        strategy: &PollingStrategy,
+        options: PollOptions,
+    ) -> Result<PolledMessages, IggyError> {
+        for _ in 0..GROUP_POLL_ATTEMPTS {
+            let partitions = group_partitions(client, stream_id, topic_id, &self.consumer.id)
+                .await?
+                .ok_or_else(|| {
+                    IggyError::ConsumerGroupMemberNotFound(
+                        0,
+                        self.consumer.id.clone(),
+                        topic_id.clone(),
+                    )
+                })?;
+            if partitions.is_empty() {
+                return Ok(PolledMessages::empty());
+            }
+            let partition_id = partitions[self.cursor % partitions.len()];
+            self.cursor = self.cursor.wrapping_add(1);
+            match client
+                .poll_messages(
+                    stream_id,
+                    topic_id,
+                    partition_id,
+                    &self.consumer,
+                    strategy,
+                    options,
+                )
+                .await
+            {
+                Err(IggyError::ConsumerGroupPartitionNotOwned(..)) => {}
+                outcome => return outcome,
+            }
+        }
+        Ok(PolledMessages::empty())
+    }
+}
+
+/// The caller's assignment from a raw `SyncConsumerGroup` frame: the
+/// partitions it may poll, without those that wait for revocation. `None` when
+/// the reply is empty: the client is not a member, or the stream, topic or
+/// group does not resolve.
+///
+/// # Errors
+/// The errors of the send, and `InvalidFormat` for a reply that does not
+/// decode.
+pub async fn group_partitions(
+    client: &IggyClient,
+    stream_id: &Identifier,
+    topic_id: &Identifier,
+    group_id: &Identifier,
+) -> Result<Option<Vec<u32>>, IggyError> {
+    let request = SyncConsumerGroupRequest {
+        stream_id: identifier_to_wire(stream_id)?,
+        topic_id: identifier_to_wire(topic_id)?,
+        group_id: identifier_to_wire(group_id)?,
+    };
+    let reply = client
+        .send_binary_request(SYNC_CONSUMER_GROUP_CODE, request.to_bytes())
+        .await?;
+    if reply.is_empty() {
+        return Ok(None);
+    }
+    SyncConsumerGroupResponse::decode_from(&reply)
+        .map(|assignment| Some(assignment.partitions))
+        .map_err(|_| IggyError::InvalidFormat)
+}
+
+/// One `FetchMessages` frame, routed by its first target, so all targets need
+/// one primary. The sections are not decrypted.
+///
+/// # Errors
+/// `FeatureUnavailable` on HTTP and on a nested client, and the errors of the
+/// fetch.
+pub async fn fetch_sections(
+    client: &IggyClient,
+    stream_id: &Identifier,
+    topic_id: &Identifier,
+    targets: &[(u32, PollingStrategy)],
+    consumer: &Consumer,
+    options: PollOptions,
+) -> Result<Vec<FetchedSection>, IggyError> {
+    let auto_commit = options.commit == CommitPolicy::OnRead;
+    let transport = client.client();
+    let transport = transport.read().await;
+    match &*transport {
+        ClientWrapper::Tcp(inner) => {
+            fetch_binary_messages(
+                inner,
+                stream_id,
+                topic_id,
+                targets,
+                consumer,
+                options.count,
+                auto_commit,
+                options.into(),
+            )
+            .await
+        }
+        ClientWrapper::Quic(inner) => {
+            fetch_binary_messages(
+                inner,
+                stream_id,
+                topic_id,
+                targets,
+                consumer,
+                options.count,
+                auto_commit,
+                options.into(),
+            )
+            .await
+        }
+        ClientWrapper::WebSocket(inner) => {
+            fetch_binary_messages(
+                inner,
+                stream_id,
+                topic_id,
+                targets,
+                consumer,
+                options.count,
+                auto_commit,
+                options.into(),
+            )
+            .await
+        }
+        ClientWrapper::Http(_) | ClientWrapper::Iggy(_) => Err(IggyError::FeatureUnavailable),
+    }
+}
+
+/// The replica id of the partition's primary, from a raw `GetPartitionRoute`
+/// frame.
+///
+/// # Errors
+/// The errors of the send, and `InvalidFormat` for a reply that does not
+/// decode.
+pub async fn partition_primary(
+    client: &IggyClient,
+    stream_id: &Identifier,
+    topic_id: &Identifier,
+    partition_id: u32,
+) -> Result<u8, IggyError> {
+    let request = GetPartitionRouteRequest {
+        stream_id: identifier_to_wire(stream_id)?,
+        topic_id: identifier_to_wire(topic_id)?,
+        partition_id,
+    };
+    let reply = client
+        .send_binary_request(GET_PARTITION_ROUTE_CODE, request.to_bytes())
+        .await?;
+    PartitionRouteResponse::decode_from(&reply)
+        .map(|route| route.primary_replica)
+        .map_err(|_| IggyError::InvalidFormat)
+}
+
 /// Wait until `get_topic` counts `expected` messages in the topic.
 pub async fn wait_for_topic_messages_count(
     client: &(impl TopicClient + ?Sized),
diff --git a/core/integration/src/harness/mod.rs b/core/integration/src/harness/mod.rs
index f023fd2..243135e 100644
--- a/core/integration/src/harness/mod.rs
+++ b/core/integration/src/harness/mod.rs
@@ -69,8 +69,9 @@
 pub use traits::{IggyServerDependent, Restartable, TestBinary};
 
 pub use helpers::{
-    USER_PASSWORD, assert_clean_system, backup_commit_budget, create_user, delete_user, login_root,
-    login_user, wait_for_consumer_group_assignment, wait_for_topic, wait_for_topic_messages_count,
+    GroupPoller, USER_PASSWORD, assert_clean_system, backup_commit_budget, create_user,
+    delete_user, fetch_sections, group_partitions, login_root, login_user, partition_primary,
+    wait_for_consumer_group_assignment, wait_for_topic, wait_for_topic_messages_count,
 };
 
 pub use fixture::TestFixture;
diff --git a/core/integration/tests/cli/message/test_message_send_command.rs b/core/integration/tests/cli/message/test_message_send_command.rs
index ed708b6..dabacba 100644
--- a/core/integration/tests/cli/message/test_message_send_command.rs
+++ b/core/integration/tests/cli/message/test_message_send_command.rs
@@ -235,11 +235,13 @@
                         .poll_messages(
                             &self.actual_stream_id.unwrap().try_into().unwrap(),
                             &self.actual_topic_id.unwrap().try_into().unwrap(),
-                            Some(partition_id),
+                            partition_id,
                             &Consumer::default(),
                             &PollingStrategy::offset(0),
-                            self.messages.len() as u32,
-                            false,
+                            PollOptions {
+                                count: self.messages.len() as u32,
+                                ..PollOptions::default()
+                            },
                         )
                         .await;
                     if let Ok(polled) = polled {
@@ -254,11 +256,13 @@
                     .poll_messages(
                         &self.actual_stream_id.unwrap().try_into().unwrap(),
                         &self.actual_topic_id.unwrap().try_into().unwrap(),
-                        Some(self.get_partition_id()),
+                        self.get_partition_id(),
                         &Consumer::default(),
                         &PollingStrategy::offset(0),
-                        self.messages.len() as u32,
-                        false,
+                        PollOptions {
+                            count: self.messages.len() as u32,
+                            ..PollOptions::default()
+                        },
                     )
                     .await;
 
diff --git a/core/integration/tests/cli/message/test_message_send_from_file_command.rs b/core/integration/tests/cli/message/test_message_send_from_file_command.rs
index 9a025b0..6073b49 100644
--- a/core/integration/tests/cli/message/test_message_send_from_file_command.rs
+++ b/core/integration/tests/cli/message/test_message_send_from_file_command.rs
@@ -211,11 +211,14 @@
                 .poll_messages(
                     &stream_id.try_into().unwrap(),
                     &topic_id.try_into().unwrap(),
-                    Some(0),
+                    0,
                     &Consumer::new(Identifier::default()),
                     &PollingStrategy::offset(0),
-                    self.message_count as u32 * 2,
-                    true,
+                    PollOptions {
+                        count: self.message_count as u32 * 2,
+                        commit: CommitPolicy::OnRead,
+                        ..PollOptions::default()
+                    },
                 )
                 .await;
             assert!(messages.is_ok());
diff --git a/core/integration/tests/cluster/chaos_monkey.rs b/core/integration/tests/cluster/chaos_monkey.rs
index 17edd16..b4137e8 100644
--- a/core/integration/tests/cluster/chaos_monkey.rs
+++ b/core/integration/tests/cluster/chaos_monkey.rs
@@ -258,8 +258,8 @@
 use iggy::prelude::*;
 use integration::harness::handle::common::SIGTERM_TIMEOUT;
 use integration::harness::{
-    ServerHandle, StopOutcome, TestBinary, TestBinaryError, TestHarness, TestServerConfig, disk,
-    resolve_config_paths,
+    GroupPoller, ServerHandle, StopOutcome, TestBinary, TestBinaryError, TestHarness,
+    TestServerConfig, disk, resolve_config_paths,
 };
 use rand::rngs::StdRng;
 use rand::{RngExt, SeedableRng};
@@ -1604,13 +1604,9 @@
         }
         let client = dialer.connect().await;
         let builder = client
-            .consumer(
-                STANDALONE_ICONSUMER_NAME,
-                STREAM_NAME,
-                TOPIC_NAME,
-                STANDALONE_ICONSUMER_PARTITION,
-            )
-            .expect("standalone IggyConsumer builder");
+            .consumer(STANDALONE_ICONSUMER_NAME, STREAM_NAME, TOPIC_NAME)
+            .expect("standalone IggyConsumer builder")
+            .partitions([STANDALONE_ICONSUMER_PARTITION]);
         let reader = Reader::new(
             format!("consumer {STANDALONE_ICONSUMER_NAME}"),
             Consumer::new(Identifier::named(STANDALONE_ICONSUMER_NAME).expect("valid name")),
@@ -1707,11 +1703,6 @@
         };
 
         println!("{} drain over, readers stop", stamp(started));
-        // The group's `IggyConsumer` stops before the members leave. Its
-        // shutdown flush stores the last offset it consumed in every partition
-        // it ever held, unless it stored that offset already. A leave can move
-        // such a partition back to it, and the fence then admits the old
-        // offset, so the group offset moves back.
         group_iconsumer_phase.send_replace(ConsumerPhase::Stop);
         let mut consumer_outcomes = Vec::with_capacity(consumers.len() + 1);
         consumer_outcomes.push(group_iconsumer.await.expect("consumer task panicked"));
@@ -2314,24 +2305,38 @@
     }
 }
 
-/// Polls the reader's next batch and stores its last offset.
+/// Polls the reader's next batch and stores its last offset. The group
+/// reader polls through the member's `GroupPoller`.
 async fn poll_and_commit(
     client: &IggyClient,
     reader: &Reader,
+    group_poller: Option<&mut GroupPoller>,
     outcome: &mut ConsumerOutcome,
     context: &TaskContext,
 ) -> Result<(), Failure> {
     let target = &context.target;
-    let polled = call(client.poll_messages(
-        &target.stream,
-        &target.topic,
-        reader.partition,
-        &reader.consumer,
-        &PollingStrategy::next(),
-        POLL_BATCH,
-        false,
-    ))
-    .await?;
+    let strategy = PollingStrategy::next();
+    let options = PollOptions {
+        count: POLL_BATCH,
+        ..PollOptions::default()
+    };
+    let polled = match reader.partition {
+        Some(partition) => {
+            call(client.poll_messages(
+                &target.stream,
+                &target.topic,
+                partition,
+                &reader.consumer,
+                &strategy,
+                options,
+            ))
+            .await?
+        }
+        None => {
+            let poller = group_poller.expect("a group member polls through its GroupPoller");
+            call(poller.poll(client, &target.stream, &target.topic, &strategy, options)).await?
+        }
+    };
     outcome.errors.succeeded();
     let Some(last) = polled.messages.last().map(|message| message.header.offset) else {
         sleep(POLL_PAUSE).await;
@@ -2377,6 +2382,7 @@
             continue;
         }
         churn.joins += 1;
+        let mut poller = GroupPoller::new(target.group.clone());
         let session_end =
             Instant::now() + Duration::from_millis(rng.random_range(MEMBER_SESSION_MS));
         loop {
@@ -2386,7 +2392,9 @@
             {
                 break;
             }
-            if let Err(failure) = poll_and_commit(&client, &group, &mut outcome, &context).await {
+            if let Err(failure) =
+                poll_and_commit(&client, &group, Some(&mut poller), &mut outcome, &context).await
+            {
                 let lost_membership = matches!(
                     failure,
                     Failure::Iggy(IggyError::ConsumerGroupMemberNotFound(..))
@@ -2421,7 +2429,8 @@
 ) -> ConsumerOutcome {
     let mut outcome = ConsumerOutcome::new(reader.name.clone(), context.started);
     while *phase.borrow() != ConsumerPhase::Stop {
-        if let Err(failure) = poll_and_commit(&client, &reader, &mut outcome, &context).await {
+        if let Err(failure) = poll_and_commit(&client, &reader, None, &mut outcome, &context).await
+        {
             outcome.errors.record(&failure);
             sleep(RETRY_PAUSE).await;
         }
@@ -2433,17 +2442,17 @@
     let commit_interval =
         NonZeroIggyDuration::new(ICONSUMER_COMMIT_INTERVAL).expect("non-zero commit interval");
     let mut consumer = builder
-        // Commits only what was handed over. The default commits with the
-        // poll and can skip a batch whose reply was lost, which would break
-        // the no-loss check.
-        .auto_commit(AutoCommit::IntervalOrWhen(
-            commit_interval,
-            AutoCommitWhen::ConsumingAllMessages,
-        ))
-        .batch_length(POLL_BATCH)
-        .poll_options(DeferredPollOptions {
-            max_wait: IggyDuration::from(POLL_PAUSE),
-            ..Default::default()
+        // Commits only what was handed over. `OnRead` commits with the read
+        // and can skip a batch whose reply was lost, which would break the
+        // no-loss check.
+        .poll_options(PollOptions {
+            count: POLL_BATCH,
+            commit: CommitPolicy::Auto(AutoCommit {
+                interval: Some(commit_interval),
+                every: Some(CommitEvery::Batch),
+                after: CommitAfter::Delivery,
+            }),
+            ..PollOptions::wait(IggyDuration::from(POLL_PAUSE))
         })
         .build();
     consumer.init().await.expect("init the IggyConsumer");
@@ -2479,7 +2488,7 @@
                         &context,
                     );
                 }
-                Some(Err(error)) => outcome.errors.record(&Failure::Iggy(error)),
+                Some(Err(error)) => outcome.errors.record(&Failure::Iggy(error.into())),
                 None => break,
             },
         }
@@ -3452,11 +3461,13 @@
             let polled = call(client.poll_messages(
                 &target.stream,
                 &target.topic,
-                Some(partition),
+                partition,
                 &consumer,
                 &PollingStrategy::offset(next),
-                VERIFY_BATCH,
-                false,
+                PollOptions {
+                    count: VERIFY_BATCH,
+                    ..PollOptions::default()
+                },
             ))
             .await?;
             if polled.messages.is_empty() {
diff --git a/core/integration/tests/cluster/client_table_restart.rs b/core/integration/tests/cluster/client_table_restart.rs
index 009e58e..55ffe4a 100644
--- a/core/integration/tests/cluster/client_table_restart.rs
+++ b/core/integration/tests/cluster/client_table_restart.rs
@@ -1031,42 +1031,47 @@
             .partitions,
         vec![0]
     );
+    let options = PollOptions {
+        count: 1,
+        ..PollOptions::default()
+    };
+    let refusal = replacement
+        .poll_messages(
+            &stream,
+            &topic,
+            0,
+            &consumer,
+            &PollingStrategy::next(),
+            options,
+        )
+        .await;
     assert!(
-        replacement
-            .poll_messages(
-                &stream,
-                &topic,
-                None,
-                &consumer,
-                &PollingStrategy::next(),
-                1,
-                false
-            )
-            .await
-            .unwrap()
-            .messages
-            .is_empty(),
-        "replacement must wait while the original owns the only partition"
+        matches!(refusal, Err(IggyError::ConsumerGroupPartitionNotOwned(..))),
+        "replacement must wait while the original owns the only partition: {refusal:?}"
     );
     drop(member);
     let deadline = Instant::now() + RESUME_BUDGET;
     loop {
-        let recovered = replacement
+        match replacement
             .poll_messages(
                 &stream,
                 &topic,
-                None,
+                0,
                 &consumer,
                 &PollingStrategy::next(),
-                1,
-                false,
+                options,
             )
             .await
-            .unwrap();
-        if let Some(message) = recovered.messages.first() {
-            assert_eq!(message.header.offset, 1);
-            assert_eq!(message.payload, messages[1].payload);
-            break;
+        {
+            Ok(recovered) => {
+                if let Some(message) = recovered.messages.first() {
+                    assert_eq!(message.header.offset, 1);
+                    assert_eq!(message.payload, messages[1].payload);
+                    break;
+                }
+            }
+            Err(IggyError::ConsumerGroupPartitionNotOwned(..)) => {}
+            Err(error) => panic!("replacement poll failed: {error}"),
         }
         assert!(
             Instant::now() < deadline,
diff --git a/core/integration/tests/cluster/consumer_offset_quota.rs b/core/integration/tests/cluster/consumer_offset_quota.rs
index 831efe3..f02343c 100644
--- a/core/integration/tests/cluster/consumer_offset_quota.rs
+++ b/core/integration/tests/cluster/consumer_offset_quota.rs
@@ -150,11 +150,14 @@
         .poll_messages(
             &stream,
             &topic,
-            Some(PARTITION_ID),
+            PARTITION_ID,
             &auto_commit_consumer,
             &PollingStrategy::first(),
-            1,
-            true,
+            PollOptions {
+                count: 1,
+                commit: CommitPolicy::OnRead,
+                ..PollOptions::default()
+            },
         )
         .await
         .expect("auto-commit poll within limit");
diff --git a/core/integration/tests/cluster/crash_durability.rs b/core/integration/tests/cluster/crash_durability.rs
index 1ed2a2b..57a56d7 100644
--- a/core/integration/tests/cluster/crash_durability.rs
+++ b/core/integration/tests/cluster/crash_durability.rs
@@ -151,11 +151,13 @@
             .poll_messages(
                 &stream,
                 &topic,
-                Some(PARTITION_ID),
+                PARTITION_ID,
                 &Consumer::default(),
                 &PollingStrategy::offset(0),
-                want,
-                false,
+                PollOptions {
+                    count: want,
+                    ..PollOptions::default()
+                },
             )
             .await
         {
@@ -864,11 +866,13 @@
             .poll_messages(
                 &stream,
                 &topic,
-                Some(0),
+                0,
                 &Consumer::default(),
                 &PollingStrategy::offset(offset),
-                1,
-                false,
+                PollOptions {
+                    count: 1,
+                    ..PollOptions::default()
+                },
             )
             .await
             .unwrap();
@@ -963,11 +967,13 @@
             .poll_messages(
                 &stream,
                 &topic,
-                Some(0),
+                0,
                 &Consumer::default(),
                 &PollingStrategy::offset(0),
-                1,
-                false,
+                PollOptions {
+                    count: 1,
+                    ..PollOptions::default()
+                },
             )
             .await
             .is_ok_and(|polled| {
diff --git a/core/integration/tests/cluster/crash_offset_reuse.rs b/core/integration/tests/cluster/crash_offset_reuse.rs
index b5c7610..a57b1bb 100644
--- a/core/integration/tests/cluster/crash_offset_reuse.rs
+++ b/core/integration/tests/cluster/crash_offset_reuse.rs
@@ -394,11 +394,13 @@
         .poll_messages(
             &stream,
             &topic,
-            Some(PARTITION_ID),
+            PARTITION_ID,
             &consumer,
             &PollingStrategy::next(),
-            1,
-            false,
+            PollOptions {
+                count: 1,
+                ..PollOptions::default()
+            },
         )
         .await
         .expect("poll forward from the stored position");
diff --git a/core/integration/tests/cluster/crash_recovery_corruption.rs b/core/integration/tests/cluster/crash_recovery_corruption.rs
index f892dba..e985ba7 100644
--- a/core/integration/tests/cluster/crash_recovery_corruption.rs
+++ b/core/integration/tests/cluster/crash_recovery_corruption.rs
@@ -201,11 +201,13 @@
             .poll_messages(
                 &stream,
                 &topic,
-                Some(PARTITION_ID),
+                PARTITION_ID,
                 &Consumer::default(),
                 &PollingStrategy::offset(0),
-                want,
-                false,
+                PollOptions {
+                    count: want,
+                    ..PollOptions::default()
+                },
             )
             .await
         {
diff --git a/core/integration/tests/cluster/fast_primary_rejoin.rs b/core/integration/tests/cluster/fast_primary_rejoin.rs
index a362df1..03b1f90 100644
--- a/core/integration/tests/cluster/fast_primary_rejoin.rs
+++ b/core/integration/tests/cluster/fast_primary_rejoin.rs
@@ -213,11 +213,13 @@
             .poll_messages(
                 &stream,
                 &topic,
-                Some(PARTITION_ID),
+                PARTITION_ID,
                 &Consumer::default(),
                 &PollingStrategy::offset(0),
-                count,
-                false,
+                PollOptions {
+                    count,
+                    ..PollOptions::default()
+                },
             )
             .await
         {
diff --git a/core/integration/tests/cluster/multi_shard_partition_convergence.rs b/core/integration/tests/cluster/multi_shard_partition_convergence.rs
index 56f9fc8..462fc9f 100644
--- a/core/integration/tests/cluster/multi_shard_partition_convergence.rs
+++ b/core/integration/tests/cluster/multi_shard_partition_convergence.rs
@@ -104,11 +104,13 @@
         .poll_messages(
             stream,
             topic,
-            Some(PARTITION_ID),
+            PARTITION_ID,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            16,
-            false,
+            PollOptions {
+                count: 16,
+                ..PollOptions::default()
+            },
         )
         .await
         .unwrap_or_else(|error| panic!("poll_messages: {error}"))
diff --git a/core/integration/tests/cluster/parked_frame_redispatch.rs b/core/integration/tests/cluster/parked_frame_redispatch.rs
index 0901a86..519c7f2 100644
--- a/core/integration/tests/cluster/parked_frame_redispatch.rs
+++ b/core/integration/tests/cluster/parked_frame_redispatch.rs
@@ -238,11 +238,13 @@
         .poll_messages(
             stream,
             topic,
-            Some(TARGET_PARTITION),
+            TARGET_PARTITION,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            TOTAL_MESSAGES as u32,
-            false,
+            PollOptions {
+                count: TOTAL_MESSAGES as u32,
+                ..PollOptions::default()
+            },
         )
         .await
         .unwrap_or_else(|error| panic!("poll_messages: {error}"));
diff --git a/core/integration/tests/cluster/partition_dedup.rs b/core/integration/tests/cluster/partition_dedup.rs
index 482edd1..209e03f 100644
--- a/core/integration/tests/cluster/partition_dedup.rs
+++ b/core/integration/tests/cluster/partition_dedup.rs
@@ -188,11 +188,13 @@
             .poll_messages(
                 &stream,
                 &topic,
-                Some(0),
+                0,
                 &Consumer::default(),
                 &PollingStrategy::offset(0),
-                10,
-                false,
+                PollOptions {
+                    count: 10,
+                    ..PollOptions::default()
+                },
             )
             .await
             .unwrap();
@@ -1110,30 +1112,42 @@
     poll_up_to(client, 100).await
 }
 
+/// Counts the messages from offset 0, up to `max`. One reply holds at most
+/// `max_bytes`, so the count reads on until an empty batch.
 async fn poll_up_to(client: &IggyClient, max: u32) -> u32 {
     let stream_id = Identifier::named(STREAM_NAME).expect("stream identifier");
     let topic_id = Identifier::named(TOPIC_NAME).expect("topic identifier");
+    let consumer = Consumer::new(Identifier::numeric(1).expect("consumer identifier"));
     let deadline = Instant::now() + COMMIT_BUDGET;
-    loop {
+    let mut strategy = PollingStrategy::offset(0);
+    let mut total = 0;
+    while total < max {
         match client
             .poll_messages(
                 &stream_id,
                 &topic_id,
-                Some(PARTITION_ID),
-                &Consumer::new(Identifier::numeric(1).expect("consumer identifier")),
-                &PollingStrategy::offset(0),
-                max,
-                false,
+                PARTITION_ID,
+                &consumer,
+                &strategy,
+                PollOptions {
+                    count: max - total,
+                    ..PollOptions::default()
+                },
             )
             .await
         {
-            Ok(polled) => return polled.messages.len() as u32,
+            Ok(polled) if polled.messages.is_empty() => break,
+            Ok(polled) => {
+                total += polled.messages.len() as u32;
+                strategy = polled.continuation();
+            }
             Err(IggyError::TransientNotAccepted) if Instant::now() < deadline => {
                 sleep(RETRY_PAUSE).await;
             }
             Err(error) => panic!("poll messages after recovery: {error:?}"),
         }
     }
+    total
 }
 
 /// Full `SendMessages` body: metadata prefix, batch header, one message.
diff --git a/core/integration/tests/cluster/partition_primary_routing.rs b/core/integration/tests/cluster/partition_primary_routing.rs
index d1dfd7d..9806bad 100644
--- a/core/integration/tests/cluster/partition_primary_routing.rs
+++ b/core/integration/tests/cluster/partition_primary_routing.rs
@@ -387,16 +387,16 @@
     let strategy = PollingStrategy::next();
     let mut polls: FuturesUnordered<_> = (0..SPREAD_PARTITIONS)
         .map(|partition| {
-            client.poll_messages_deferred(
+            client.poll_messages(
                 &stream,
                 &topic,
-                Some(partition),
+                partition,
                 &consumer,
                 &strategy,
-                2,
-                true,
-                DeferredPollOptions {
+                PollOptions {
+                    count: 2,
                     min_count: 2,
+                    commit: CommitPolicy::OnRead,
                     ..deferred_test_options()
                 },
             )
@@ -457,21 +457,20 @@
     harness: &TestHarness,
 ) {
     let producer = harness.root_client().await.unwrap();
-    for (stream_name, auto_commit) in [
-        ("deferred-manual", AutoCommit::Disabled),
-        (
-            "deferred-on-poll",
-            AutoCommit::When(AutoCommitWhen::PollingMessages),
-        ),
+    for (stream_name, commit) in [
+        ("deferred-manual", CommitPolicy::Manual),
+        ("deferred-on-poll", CommitPolicy::OnRead),
     ] {
         let (client, stream, topic, observers) = spread_deferred_topic(harness, stream_name).await;
         let mut consumer = client
             .consumer_group(GROUP_NAME, stream_name, TOPIC_NAME)
             .unwrap()
-            .auto_commit(auto_commit)
             .polling_strategy(PollingStrategy::next())
-            .batch_length(1)
-            .poll_options(deferred_test_options())
+            .poll_options(PollOptions {
+                count: 1,
+                commit,
+                ..deferred_test_options()
+            })
             .build();
         consumer.init().await.unwrap();
         let group = Consumer::group(Identifier::named(GROUP_NAME).unwrap());
@@ -510,7 +509,7 @@
                 assert_eq!(received.partition_id, partition, "{stream_name}");
                 assert_eq!(received.message.header.offset, offset, "{stream_name}");
                 assert_eq!(received.message.payload.as_ref(), payload.as_bytes());
-                if matches!(auto_commit, AutoCommit::Disabled) {
+                if commit == CommitPolicy::Manual {
                     let stored = client
                         .get_consumer_offset(&group, &stream, &topic, Some(partition))
                         .await
@@ -537,12 +536,8 @@
     }
 }
 
-fn deferred_test_options() -> DeferredPollOptions {
-    DeferredPollOptions {
-        max_wait: Duration::from_secs(30).into(),
-        request_timeout: Duration::from_secs(40).into(),
-        ..Default::default()
-    }
+fn deferred_test_options() -> PollOptions {
+    PollOptions::wait(Duration::from_secs(30).into())
 }
 
 async fn spread_deferred_topic(
@@ -626,11 +621,13 @@
         .poll_messages(
             stream,
             topic,
-            Some(partition_id),
+            partition_id,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            1,
-            false,
+            PollOptions {
+                count: 1,
+                ..PollOptions::default()
+            },
         )
         .await
         .unwrap();
@@ -927,11 +924,13 @@
                 .poll_messages(
                     stream,
                     topic,
-                    Some(PARTITION_ID),
+                    PARTITION_ID,
                     &Consumer::default(),
                     &PollingStrategy::offset(0),
-                    u32::try_from(phases.len() + 1).unwrap(),
-                    false,
+                    PollOptions {
+                        count: u32::try_from(phases.len() + 1).unwrap(),
+                        ..PollOptions::default()
+                    },
                 )
                 .await
                 .unwrap();
@@ -1130,11 +1129,13 @@
             .poll_messages(
                 stream,
                 topic,
-                Some(PARTITION_ID),
+                PARTITION_ID,
                 &Consumer::default(),
                 &PollingStrategy::offset(offset),
-                READBACK_BATCH,
-                false,
+                PollOptions {
+                    count: READBACK_BATCH,
+                    ..PollOptions::default()
+                },
             )
             .await
             .unwrap();
@@ -1371,11 +1372,13 @@
         .poll_messages(
             &stream,
             &topic,
-            Some(partition_id),
+            partition_id,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            u32::try_from(ADMITTED.len() + 1).unwrap(),
-            false,
+            PollOptions {
+                count: u32::try_from(ADMITTED.len() + 1).unwrap(),
+                ..PollOptions::default()
+            },
         )
         .await
         .unwrap();
@@ -1707,6 +1710,96 @@
     assert_group_auto_commit_routing(harness, TransportProtocol::Quic).await;
 }
 
+#[iggy_harness(cluster_nodes = 3, server(consumer_group.rebalancing_timeout = "1s"))]
+async fn given_cached_quic_group_route_when_partition_primary_stops_should_resume_on_new_primary(
+    harness: &mut TestHarness,
+) {
+    let primary = leader_node_index_via(harness, 0).await;
+    let producer = harness.root_client_for_node(primary).await.unwrap();
+    let stream = Identifier::named(STREAM_NAME).unwrap();
+    let topic = Identifier::named(TOPIC_NAME).unwrap();
+    producer
+        .create_stream(STREAM_NAME, &StreamCreateOptions::default())
+        .await
+        .unwrap();
+    producer
+        .create_topic(
+            &stream,
+            TOPIC_NAME,
+            &TopicCreateOptions {
+                partitions_count: Some(1),
+                message_expiry: Some(IggyExpiry::NeverExpire),
+                messages_required_to_save: Some(1),
+                durability: Durability::Persisted,
+                consumer_offset_durability: Durability::Persisted,
+                ..TopicCreateOptions::default()
+            },
+        )
+        .await
+        .unwrap();
+    let mut messages: Vec<_> = GROUP_PAYLOADS.into_iter().map(message).collect();
+    producer
+        .send_messages(
+            &stream,
+            &topic,
+            &Partitioning::partition_id(PARTITION_ID),
+            &mut messages,
+        )
+        .await
+        .unwrap();
+
+    let coordinator = (primary + 1) % harness.cluster_size();
+    let member = harness
+        .node(coordinator)
+        .quic_client()
+        .unwrap()
+        .with_reconnecting_root_login()
+        .connect()
+        .await
+        .unwrap();
+    member
+        .create_consumer_group(&stream, &topic, GROUP_NAME)
+        .await
+        .unwrap();
+    let group = Identifier::named(GROUP_NAME).unwrap();
+    member
+        .join_consumer_group(&stream, &topic, &group)
+        .await
+        .unwrap();
+    integration::harness::wait_for_consumer_group_assignment(
+        &member,
+        &stream,
+        &topic,
+        &group,
+        1,
+        PRECONDITION_BUDGET,
+    )
+    .await;
+    let consumer = Consumer::group(group);
+    for expected in GROUP_PAYLOADS {
+        let polled = member
+            .poll_messages(
+                &stream,
+                &topic,
+                PARTITION_ID,
+                &consumer,
+                &PollingStrategy::next(),
+                PollOptions {
+                    count: 1,
+                    commit: CommitPolicy::OnRead,
+                    ..PollOptions::default()
+                },
+            )
+            .await
+            .unwrap();
+        assert_eq!(polled.messages.len(), 1);
+        assert_eq!(polled.messages[0].payload.as_ref(), expected.as_bytes());
+    }
+
+    assert_cached_route_after_primary_loss(harness, &member, primary, &stream, &topic, &consumer)
+        .await;
+}
+
 #[iggy_harness(cluster_nodes = 3, server(
     metadata.journal_slots = "256",
     consumer_group.rebalancing_timeout = "1s",
@@ -1743,11 +1836,14 @@
         client.poll_messages(
             &stream,
             &topic,
-            Some(PARTITION_ID),
+            PARTITION_ID,
             &consumer,
             &PollingStrategy::first(),
-            GROUP_PAYLOADS.len() as u32,
-            true,
+            PollOptions {
+                count: GROUP_PAYLOADS.len() as u32,
+                commit: CommitPolicy::OnRead,
+                ..PollOptions::default()
+            },
         ),
     )
     .await
@@ -1832,9 +1928,9 @@
     let expected: Vec<&[u8]> = expected.iter().map(|payload| payload.as_bytes()).collect();
     assert_eq!(payloads, expected);
     assert!(
-        polled.context.metadata_op >= primary_applied_op,
+        polled.context().metadata_op >= primary_applied_op,
         "the relayed poll context {:?} must cover the primary's applied op {primary_applied_op}",
-        polled.context
+        polled.context()
     );
 
     let offsets = http.url(&format!(
@@ -2017,9 +2113,9 @@
         let polled: PolledMessages = serde_json::from_str(&body).expect("decode PolledMessages");
         assert_eq!(polled.messages.len(), GROUP_PAYLOADS.len());
         assert!(
-            polled.context.metadata_op >= committed,
+            polled.context().metadata_op >= committed,
             "the poll on node {node} served at {:?}, below the committed op {committed}",
-            polled.context
+            polled.context()
         );
         assert_eq!(
             RoutingCost::of(&http).await.since(before),
@@ -2340,22 +2436,27 @@
     let messages = http.url(&format!(
         "/streams/{STREAM_NAME}/topics/{TOPIC_NAME}/messages"
     ));
-    let poll = |auto_commit: bool| {
-        format!(
-            "{messages}?consumer_id={RELAY_CONSUMER_ID}&partition_id={PARTITION_ID}\
-             &kind=offset&value=0&count={}&auto_commit={auto_commit}",
-            GROUP_PAYLOADS.len()
-        )
-    };
+    let poll = format!(
+        "{messages}?consumer_id={RELAY_CONSUMER_ID}&partition_id={PARTITION_ID}\
+         &kind=offset&value=0&count={}&auto_commit=false",
+        GROUP_PAYLOADS.len()
+    );
+    // A poll that stores its offset is a POST, so nothing resends it as a read.
+    let committing_poll = format!(
+        "{messages}/deferred?consumer_id={RELAY_CONSUMER_ID}&partition_id={PARTITION_ID}\
+         &kind=offset&value=0&count={}&auto_commit=true&wait_us=0",
+        GROUP_PAYLOADS.len()
+    );
     let get = |url: String| http.client.get(url).bearer_auth(&http.token).send();
+    let post = |url: String| http.client.post(url).bearer_auth(&http.token).send();
 
     // A frozen primary takes the relayed requests and never answers them.
     pause_node(harness, partition_primary, true);
     let offset = format!("{offsets}?consumer_id={RELAY_CONSUMER_ID}&partition_id={PARTITION_ID}");
     let (offset_read, read, auto_commit, ()) = tokio::join!(
         get(offset.clone()),
-        get(poll(false)),
-        get(poll(true)),
+        get(poll.clone()),
+        post(committing_poll),
         async {
             wait_for_queued_relays(harness, partition_primary, 3).await;
             harness
@@ -2366,7 +2467,7 @@
 
     let response = read.expect("poll without auto_commit");
     let response = if response.status() == StatusCode::SERVICE_UNAVAILABLE {
-        until_primary_resolved(|| async { get(poll(false)).await.expect("read-only poll") }).await
+        until_primary_resolved(|| async { get(poll.clone()).await.expect("read-only poll") }).await
     } else {
         response
     };
@@ -2584,24 +2685,25 @@
         .await
         .unwrap();
     assert_routed_offset_from_each_node(harness, &consumer, None).await;
-    for (group_name, policy) in [
+    for (group_name, commit) in [
         (
             "interval-offset-group",
-            AutoCommit::Interval(NonZeroIggyDuration::ONE_SECOND),
+            CommitPolicy::Auto(AutoCommit {
+                interval: Some(NonZeroIggyDuration::ONE_SECOND),
+                every: None,
+                after: CommitAfter::Delivery,
+            }),
         ),
-        (
-            "default-offset-group",
-            AutoCommit::IntervalOrWhen(
-                NonZeroIggyDuration::ONE_SECOND,
-                AutoCommitWhen::PollingMessages,
-            ),
-        ),
+        ("default-offset-group", CommitPolicy::OnRead),
     ] {
         let mut interval_consumer = member
             .consumer_group(group_name, STREAM_NAME, TOPIC_NAME)
             .unwrap()
-            .batch_length(GROUP_PAYLOADS.len() as u32)
-            .auto_commit(policy)
+            .poll_options(PollOptions {
+                count: GROUP_PAYLOADS.len() as u32,
+                commit,
+                ..PollOptions::wait(IggyDuration::ONE_SECOND)
+            })
             .build();
         interval_consumer.init().await.unwrap();
         for _ in GROUP_PAYLOADS {
@@ -2774,11 +2876,13 @@
                 .poll_messages(
                     &stream,
                     &topic,
-                    Some(PARTITION_ID),
+                    PARTITION_ID,
                     &Consumer::default(),
                     &PollingStrategy::first(),
-                    u32::try_from(GROUP_PAYLOADS.len()).unwrap(),
-                    false,
+                    PollOptions {
+                        count: u32::try_from(GROUP_PAYLOADS.len()).unwrap(),
+                        ..PollOptions::default()
+                    },
                 )
                 .await
                 .expect("the metadata leader can read the backup without committing offsets");
@@ -2834,11 +2938,14 @@
             member.poll_messages(
                 &stream,
                 &topic,
-                None,
+                PARTITION_ID,
                 &consumer,
                 &PollingStrategy::next(),
-                1,
-                true,
+                PollOptions {
+                    count: 1,
+                    commit: CommitPolicy::OnRead,
+                    ..PollOptions::default()
+                },
             ),
         )
         .await
@@ -2872,15 +2979,6 @@
 
     let expected_offset = u64::try_from(GROUP_PAYLOADS.len() - 1).unwrap();
     assert_routed_offset_from_each_node(harness, &consumer, Some(expected_offset)).await;
-    let partition_primary = assert_cached_route_after_primary_loss(
-        harness,
-        &member,
-        partition_primary,
-        &stream,
-        &topic,
-        &consumer,
-    )
-    .await;
     assert_data_session_fences(
         harness,
         &member,
@@ -2904,8 +3002,11 @@
     let mut receiver = member
         .consumer_group(GROUP_NAME, STREAM_NAME, TOPIC_NAME)
         .unwrap()
-        .batch_length(1)
-        .auto_commit(AutoCommit::When(AutoCommitWhen::PollingMessages))
+        .poll_options(PollOptions {
+            count: 1,
+            commit: CommitPolicy::OnRead,
+            ..PollOptions::wait(IggyDuration::ONE_SECOND)
+        })
         .build();
     receiver.init().await.unwrap();
     assert!(
@@ -2913,11 +3014,14 @@
             .poll_messages(
                 stream,
                 topic,
-                None,
+                PARTITION_ID,
                 consumer,
                 &PollingStrategy::next(),
-                1,
-                true
+                PollOptions {
+                    count: 1,
+                    commit: CommitPolicy::OnRead,
+                    ..PollOptions::default()
+                },
             )
             .await
             .unwrap()
@@ -2929,8 +3033,7 @@
         .await
         .unwrap()
         .unwrap();
-    let coordinator = member.get_connection_info().await.server_address;
-    harness.kill_node(previous_primary).unwrap();
+    harness.stop_node(previous_primary).unwrap();
     let route_request = GetPartitionRouteRequest {
         stream_id: identifier_to_wire(stream).unwrap(),
         topic_id: identifier_to_wire(topic).unwrap(),
@@ -2977,22 +3080,41 @@
         loop {
             match receiver.next().await.expect("the consumer must stay open") {
                 Ok(message) => break message,
-                // A readiness wait sent to the killed owner times out as not
-                // accepted, and an auto-commit poll as uncertain. The fixture
-                // killed it before polling, so the uncertain reply cannot hide
-                // admission of this new message.
-                Err(IggyError::TransientNotAccepted | IggyError::TransientNotCommitted) => {}
+                // A refused read stored no offset.
+                Err(ReadError {
+                    error: IggyError::TransientNotAccepted,
+                    ..
+                }) => {}
+                Err(ReadError {
+                    error: IggyError::TransientNotCommitted,
+                    ..
+                }) => {}
+                // The old owner stopped before this message was sent, so a lost reply cannot hide it.
+                Err(ReadError {
+                    error: IggyError::FetchProgressUncertain(operation),
+                    ..
+                }) => {
+                    assert!(
+                        matches!(
+                            receiver.seek(PARTITION_ID, PollingStrategy::next()).await,
+                            Err(IggyError::PartitionLaneBlocked(partition, blocking))
+                                if partition == PARTITION_ID && blocking == operation
+                        ),
+                        "an uncertain read must block its partition until it is abandoned"
+                    );
+                    member.abandon_operation(operation).await.unwrap();
+                    receiver
+                        .seek(PARTITION_ID, PollingStrategy::next())
+                        .await
+                        .unwrap();
+                }
                 Err(error) => panic!("poll could not recover its cached data route: {error:?}"),
             }
         }
     })
     .await
-    .expect("IggyConsumer must resume after data loss without coordinator reconnect events");
+    .expect("IggyConsumer must resume after the data primary stops");
     assert_eq!(received.message.payload.as_ref(), PAYLOAD.as_bytes());
-    assert_eq!(
-        member.get_connection_info().await.server_address,
-        coordinator
-    );
     let current = member
         .get_consumer_group(stream, topic, &consumer.id)
         .await
@@ -3013,12 +3135,13 @@
 ) {
     const OTHER_USER: &str = "routing-other-user";
     const OTHER_PASSWORD: &str = "routing-other-password";
+    let strategy = PollingStrategy::next();
     let poll = PollMessagesRequest {
         consumer: consumer_to_wire(consumer).unwrap(),
         stream_id: identifier_to_wire(stream).unwrap(),
         topic_id: identifier_to_wire(topic).unwrap(),
         partition_id: Some(PARTITION_ID),
-        strategy: polling_strategy_to_wire(&PollingStrategy::next()),
+        strategy: polling_strategy_to_wire(&strategy),
         count: 1,
         auto_commit: true,
     }
@@ -3149,7 +3272,9 @@
     )
     .await
     .unwrap();
-    let context = PolledMessages::from_bytes(first_poll).unwrap().context;
+    let context = PolledMessages::from_bytes(first_poll, &strategy)
+        .unwrap()
+        .context();
     assert_ne!(context.owner_generation, 0);
     // A caller without the owner generation sends 0, and the primary binds it
     // to the owner it installed for this session.
@@ -3165,7 +3290,9 @@
     .await
     .expect("owner 0 must bind to the owner installed for this session");
     assert_eq!(
-        PolledMessages::from_bytes(response).unwrap().partition_id,
+        PolledMessages::from_bytes(response, &strategy)
+            .unwrap()
+            .partition_id,
         PARTITION_ID
     );
 
@@ -3208,7 +3335,7 @@
     )
     .await
     .unwrap();
-    let polled = PolledMessages::from_bytes(response).unwrap();
+    let polled = PolledMessages::from_bytes(response, &strategy).unwrap();
     assert_eq!(
         polled.partition_id, RESYNC_REQUIRED_PARTITION_SENTINEL,
         "the attached session must respect the member leaving"
@@ -3224,7 +3351,9 @@
     .await
     .unwrap();
     assert_eq!(
-        PolledMessages::from_bytes(response).unwrap().partition_id,
+        PolledMessages::from_bytes(response, &strategy)
+            .unwrap()
+            .partition_id,
         RESYNC_REQUIRED_PARTITION_SENTINEL,
         "owner 0 must not outlive the membership"
     );
@@ -3271,16 +3400,18 @@
                 .poll_messages(
                     stream,
                     topic,
-                    Some(PARTITION_ID),
+                    PARTITION_ID,
                     consumer,
                     &PollingStrategy::first(),
-                    1,
-                    false,
+                    PollOptions {
+                        count: 1,
+                        ..PollOptions::default()
+                    },
                 )
                 .await
             {
-                Ok(polled) if polled.context.owner_generation > context.owner_generation => {
-                    break polled.context;
+                Ok(polled) if polled.context().owner_generation > context.owner_generation => {
+                    break polled.context();
                 }
                 Ok(_)
                 | Err(
diff --git a/core/integration/tests/cluster/partition_state_transfer.rs b/core/integration/tests/cluster/partition_state_transfer.rs
index 4decebf..8f62706 100644
--- a/core/integration/tests/cluster/partition_state_transfer.rs
+++ b/core/integration/tests/cluster/partition_state_transfer.rs
@@ -684,11 +684,13 @@
         .poll_messages(
             &Identifier::named(STREAM_NAME).expect("stream identifier"),
             &Identifier::named(TOPIC_NAME).expect("topic identifier"),
-            Some(PARTITION_ID),
+            PARTITION_ID,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            count,
-            false,
+            PollOptions {
+                count,
+                ..PollOptions::default()
+            },
         )
         .await?;
     #[allow(clippy::cast_possible_truncation)]
diff --git a/core/integration/tests/cluster/register_forwarding.rs b/core/integration/tests/cluster/register_forwarding.rs
index 5614ac0..da18a78 100644
--- a/core/integration/tests/cluster/register_forwarding.rs
+++ b/core/integration/tests/cluster/register_forwarding.rs
@@ -366,11 +366,13 @@
         .poll_messages(
             &stream_id,
             &topic_id,
-            Some(PARTITION_ID),
+            PARTITION_ID,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            1,
-            false,
+            PollOptions {
+                count: 1,
+                ..PollOptions::default()
+            },
         )
         .await
         .expect("poll after a backup-dialed login");
diff --git a/core/integration/tests/cluster/replica_links.rs b/core/integration/tests/cluster/replica_links.rs
index 1856a26..dc6b023 100644
--- a/core/integration/tests/cluster/replica_links.rs
+++ b/core/integration/tests/cluster/replica_links.rs
@@ -299,11 +299,13 @@
             .poll_messages(
                 &stream,
                 &topic,
-                Some(partition),
+                partition,
                 &Consumer::default(),
                 &PollingStrategy::offset(0),
-                MESSAGES_PER_PARTITION,
-                false,
+                PollOptions {
+                    count: MESSAGES_PER_PARTITION,
+                    ..PollOptions::default()
+                },
             )
             .await
             .unwrap_or_else(|error| panic!("poll partition {partition}: {error}"));
diff --git a/core/integration/tests/cluster/rolling_sigkill_repro.rs b/core/integration/tests/cluster/rolling_sigkill_repro.rs
index 31a9eca..a64e8fe 100644
--- a/core/integration/tests/cluster/rolling_sigkill_repro.rs
+++ b/core/integration/tests/cluster/rolling_sigkill_repro.rs
@@ -296,11 +296,13 @@
                 .poll_messages(
                     &stream,
                     &topic,
-                    Some(partition),
+                    partition,
                     &Consumer::default(),
                     &PollingStrategy::offset(next),
-                    1_000,
-                    false,
+                    PollOptions {
+                        count: 1_000,
+                        ..PollOptions::default()
+                    },
                 )
                 .await
             {
diff --git a/core/integration/tests/cluster/stream_encryption.rs b/core/integration/tests/cluster/stream_encryption.rs
index 372e38f..974bae0 100644
--- a/core/integration/tests/cluster/stream_encryption.rs
+++ b/core/integration/tests/cluster/stream_encryption.rs
@@ -374,11 +374,13 @@
             .poll_messages(
                 &Identifier::named(name).unwrap(),
                 &Identifier::named(TOPIC_NAME).unwrap(),
-                Some(0),
+                0,
                 &Consumer::default(),
                 &PollingStrategy::offset(0),
-                100,
-                false,
+                PollOptions {
+                    count: 100,
+                    ..PollOptions::default()
+                },
             )
             .await
         else {
diff --git a/core/integration/tests/cluster/stream_key_rotation.rs b/core/integration/tests/cluster/stream_key_rotation.rs
index 02ce357..98a709c 100644
--- a/core/integration/tests/cluster/stream_key_rotation.rs
+++ b/core/integration/tests/cluster/stream_key_rotation.rs
@@ -456,11 +456,13 @@
         .poll_messages(
             &Identifier::named(stream).unwrap(),
             &Identifier::named(TOPIC_NAME).unwrap(),
-            Some(0),
+            0,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            10,
-            false,
+            PollOptions {
+                count: 10,
+                ..PollOptions::default()
+            },
         )
         .await?;
     if polled.messages.is_empty() {
diff --git a/core/integration/tests/connectors/elasticsearch/elasticsearch_source.rs b/core/integration/tests/connectors/elasticsearch/elasticsearch_source.rs
index 12741be..e26ca74 100644
--- a/core/integration/tests/connectors/elasticsearch/elasticsearch_source.rs
+++ b/core/integration/tests/connectors/elasticsearch/elasticsearch_source.rs
@@ -18,7 +18,8 @@
 use super::{POLL_ATTEMPTS, POLL_INTERVAL_MS, TEST_MESSAGE_COUNT};
 use crate::connectors::fixtures::ElasticsearchSourcePreCreatedFixture;
 use iggy_common::MessageClient;
-use iggy_common::{Consumer, Identifier, PollingStrategy};
+use iggy_common::poll_messages::DEFAULT_PARTITION_ID;
+use iggy_common::{CommitPolicy, Consumer, Identifier, PollOptions, PollingStrategy};
 use integration::harness::seeds;
 use integration::iggy_harness;
 use std::time::Duration;
@@ -58,11 +59,14 @@
             .poll_messages(
                 &stream_id,
                 &topic_id,
-                None,
+                DEFAULT_PARTITION_ID,
                 &Consumer::new(consumer_id.clone()),
                 &PollingStrategy::next(),
-                10,
-                true,
+                PollOptions {
+                    count: 10,
+                    commit: CommitPolicy::OnRead,
+                    ..PollOptions::default()
+                },
             )
             .await
         {
@@ -127,11 +131,13 @@
         .poll_messages(
             &stream_id,
             &topic_id,
-            None,
+            DEFAULT_PARTITION_ID,
             &Consumer::new(consumer_id),
             &PollingStrategy::next(),
-            10,
-            false,
+            PollOptions {
+                count: 10,
+                ..PollOptions::default()
+            },
         )
         .await;
 
@@ -167,11 +173,14 @@
             .poll_messages(
                 &stream_id,
                 &topic_id,
-                None,
+                DEFAULT_PARTITION_ID,
                 &Consumer::new(consumer_id.clone()),
                 &PollingStrategy::next(),
-                100,
-                true,
+                PollOptions {
+                    count: 100,
+                    commit: CommitPolicy::OnRead,
+                    ..PollOptions::default()
+                },
             )
             .await
         {
@@ -219,11 +228,14 @@
                 .poll_messages(
                     &stream_id,
                     &topic_id,
-                    None,
+                    DEFAULT_PARTITION_ID,
                     &Consumer::new(consumer_id.clone()),
                     &PollingStrategy::next(),
-                    10,
-                    true,
+                    PollOptions {
+                        count: 10,
+                        commit: CommitPolicy::OnRead,
+                        ..PollOptions::default()
+                    },
                 )
                 .await
             {
@@ -276,11 +288,14 @@
             .poll_messages(
                 &stream_id,
                 &topic_id,
-                None,
+                DEFAULT_PARTITION_ID,
                 &Consumer::new(consumer_id.clone()),
                 &PollingStrategy::next(),
-                10,
-                true,
+                PollOptions {
+                    count: 10,
+                    commit: CommitPolicy::OnRead,
+                    ..PollOptions::default()
+                },
             )
             .await
         {
diff --git a/core/integration/tests/connectors/http/http_source.rs b/core/integration/tests/connectors/http/http_source.rs
index 5d45747..4ee4754 100644
--- a/core/integration/tests/connectors/http/http_source.rs
+++ b/core/integration/tests/connectors/http/http_source.rs
@@ -22,7 +22,8 @@
 };
 use iggy::prelude::IggyClient;
 use iggy_common::MessageClient;
-use iggy_common::{Consumer, Identifier, PollingStrategy};
+use iggy_common::poll_messages::DEFAULT_PARTITION_ID;
+use iggy_common::{CommitPolicy, Consumer, Identifier, PollOptions, PollingStrategy};
 use iggy_connector_sdk::api::{ConnectorStatus, SourceInfoResponse};
 use integration::harness::{TestHarness, seeds};
 use integration::iggy_harness;
@@ -578,11 +579,14 @@
             .poll_messages(
                 &stream_id,
                 &topic_id,
-                None,
+                DEFAULT_PARTITION_ID,
                 &Consumer::new(consumer_id.clone()),
                 &PollingStrategy::next(),
-                10,
-                true,
+                PollOptions {
+                    count: 10,
+                    commit: CommitPolicy::OnRead,
+                    ..PollOptions::default()
+                },
             )
             .await
         {
diff --git a/core/integration/tests/connectors/influxdb/influxdb_source.rs b/core/integration/tests/connectors/influxdb/influxdb_source.rs
index 4157a0a..61422ad 100644
--- a/core/integration/tests/connectors/influxdb/influxdb_source.rs
+++ b/core/integration/tests/connectors/influxdb/influxdb_source.rs
@@ -19,7 +19,8 @@
 use crate::connectors::fixtures::InfluxDbSourceFixture;
 use iggy_common::MessageClient;
 use iggy_common::Utc;
-use iggy_common::{Consumer, Identifier, PollingStrategy};
+use iggy_common::poll_messages::DEFAULT_PARTITION_ID;
+use iggy_common::{CommitPolicy, Consumer, Identifier, PollOptions, PollingStrategy};
 use integration::harness::seeds;
 use integration::iggy_harness;
 use serde_json::Value;
@@ -61,11 +62,14 @@
             .poll_messages(
                 &stream_id,
                 &topic_id,
-                None,
+                DEFAULT_PARTITION_ID,
                 &consumer,
                 &PollingStrategy::next(),
-                100,
-                true,
+                PollOptions {
+                    count: 100,
+                    commit: CommitPolicy::OnRead,
+                    ..PollOptions::default()
+                },
             )
             .await
             .expect("poll_messages failed");
@@ -108,11 +112,14 @@
             .poll_messages(
                 &stream_id,
                 &topic_id,
-                None,
+                DEFAULT_PARTITION_ID,
                 &consumer,
                 &PollingStrategy::next(),
-                10,
-                true,
+                PollOptions {
+                    count: 10,
+                    commit: CommitPolicy::OnRead,
+                    ..PollOptions::default()
+                },
             )
             .await
             .expect("poll_messages failed");
@@ -161,11 +168,13 @@
         .poll_messages(
             &stream_id,
             &topic_id,
-            None,
+            DEFAULT_PARTITION_ID,
             &consumer,
             &PollingStrategy::next(),
-            100,
-            false,
+            PollOptions {
+                count: 100,
+                ..PollOptions::default()
+            },
         )
         .await
         .expect("poll_messages failed");
@@ -207,11 +216,14 @@
             .poll_messages(
                 &stream_id,
                 &topic_id,
-                None,
+                DEFAULT_PARTITION_ID,
                 &consumer,
                 &PollingStrategy::next(),
-                100,
-                true,
+                PollOptions {
+                    count: 100,
+                    commit: CommitPolicy::OnRead,
+                    ..PollOptions::default()
+                },
             )
             .await
             .expect("poll_messages failed");
diff --git a/core/integration/tests/connectors/influxdb/influxdb_source_formats.rs b/core/integration/tests/connectors/influxdb/influxdb_source_formats.rs
index d866733..bd19a93 100644
--- a/core/integration/tests/connectors/influxdb/influxdb_source_formats.rs
+++ b/core/integration/tests/connectors/influxdb/influxdb_source_formats.rs
@@ -34,7 +34,8 @@
 use base64::{Engine as _, engine::general_purpose};
 use iggy_common::MessageClient;
 use iggy_common::Utc;
-use iggy_common::{Consumer, Identifier, PollingStrategy};
+use iggy_common::poll_messages::DEFAULT_PARTITION_ID;
+use iggy_common::{CommitPolicy, Consumer, Identifier, PollOptions, PollingStrategy};
 use integration::harness::seeds;
 use integration::iggy_harness;
 use serde_json::Value;
@@ -79,11 +80,14 @@
             .poll_messages(
                 &stream_id,
                 &topic_id,
-                None,
+                DEFAULT_PARTITION_ID,
                 &consumer,
                 &PollingStrategy::next(),
-                100,
-                true,
+                PollOptions {
+                    count: 100,
+                    commit: CommitPolicy::OnRead,
+                    ..PollOptions::default()
+                },
             )
             .await
             .expect("poll failed");
@@ -134,11 +138,14 @@
             .poll_messages(
                 &stream_id,
                 &topic_id,
-                None,
+                DEFAULT_PARTITION_ID,
                 &consumer,
                 &PollingStrategy::next(),
-                10,
-                true,
+                PollOptions {
+                    count: 10,
+                    commit: CommitPolicy::OnRead,
+                    ..PollOptions::default()
+                },
             )
             .await
             .expect("poll failed");
@@ -198,11 +205,14 @@
             .poll_messages(
                 &stream_id,
                 &topic_id,
-                None,
+                DEFAULT_PARTITION_ID,
                 &consumer,
                 &PollingStrategy::next(),
-                10,
-                true,
+                PollOptions {
+                    count: 10,
+                    commit: CommitPolicy::OnRead,
+                    ..PollOptions::default()
+                },
             )
             .await
             .expect("poll failed");
@@ -258,11 +268,14 @@
             .poll_messages(
                 &stream_id,
                 &topic_id,
-                None,
+                DEFAULT_PARTITION_ID,
                 &consumer,
                 &PollingStrategy::next(),
-                100,
-                true,
+                PollOptions {
+                    count: 100,
+                    commit: CommitPolicy::OnRead,
+                    ..PollOptions::default()
+                },
             )
             .await
             .expect("poll failed");
@@ -312,11 +325,14 @@
             .poll_messages(
                 &stream_id,
                 &topic_id,
-                None,
+                DEFAULT_PARTITION_ID,
                 &consumer,
                 &PollingStrategy::next(),
-                10,
-                true,
+                PollOptions {
+                    count: 10,
+                    commit: CommitPolicy::OnRead,
+                    ..PollOptions::default()
+                },
             )
             .await
             .expect("poll failed");
@@ -368,11 +384,14 @@
             .poll_messages(
                 &stream_id,
                 &topic_id,
-                None,
+                DEFAULT_PARTITION_ID,
                 &consumer,
                 &PollingStrategy::next(),
-                10,
-                true,
+                PollOptions {
+                    count: 10,
+                    commit: CommitPolicy::OnRead,
+                    ..PollOptions::default()
+                },
             )
             .await
             .expect("poll failed");
@@ -419,11 +438,14 @@
             .poll_messages(
                 &stream_id,
                 &topic_id,
-                None,
+                DEFAULT_PARTITION_ID,
                 &consumer,
                 &PollingStrategy::next(),
-                10,
-                true,
+                PollOptions {
+                    count: 10,
+                    commit: CommitPolicy::OnRead,
+                    ..PollOptions::default()
+                },
             )
             .await
             .expect("poll failed");
@@ -532,11 +554,14 @@
             .poll_messages(
                 &stream_id,
                 &topic_id,
-                None,
+                DEFAULT_PARTITION_ID,
                 &consumer,
                 &PollingStrategy::next(),
-                100,
-                true,
+                PollOptions {
+                    count: 100,
+                    commit: CommitPolicy::OnRead,
+                    ..PollOptions::default()
+                },
             )
             .await
             .expect("poll failed");
@@ -586,11 +611,14 @@
             .poll_messages(
                 &stream_id,
                 &topic_id,
-                None,
+                DEFAULT_PARTITION_ID,
                 &consumer,
                 &PollingStrategy::next(),
-                10,
-                true,
+                PollOptions {
+                    count: 10,
+                    commit: CommitPolicy::OnRead,
+                    ..PollOptions::default()
+                },
             )
             .await
             .expect("poll failed");
diff --git a/core/integration/tests/connectors/influxdb/influxdb_source_v3.rs b/core/integration/tests/connectors/influxdb/influxdb_source_v3.rs
index ef1d3c0..4df2ffe 100644
--- a/core/integration/tests/connectors/influxdb/influxdb_source_v3.rs
+++ b/core/integration/tests/connectors/influxdb/influxdb_source_v3.rs
@@ -19,7 +19,8 @@
 use crate::connectors::fixtures::InfluxDb3SourceFixture;
 use iggy_common::MessageClient;
 use iggy_common::Utc;
-use iggy_common::{Consumer, Identifier, PollingStrategy};
+use iggy_common::poll_messages::DEFAULT_PARTITION_ID;
+use iggy_common::{CommitPolicy, Consumer, Identifier, PollOptions, PollingStrategy};
 use integration::harness::seeds;
 use integration::iggy_harness;
 use serde_json::Value;
@@ -61,11 +62,14 @@
             .poll_messages(
                 &stream_id,
                 &topic_id,
-                None,
+                DEFAULT_PARTITION_ID,
                 &consumer,
                 &PollingStrategy::next(),
-                100,
-                true,
+                PollOptions {
+                    count: 100,
+                    commit: CommitPolicy::OnRead,
+                    ..PollOptions::default()
+                },
             )
             .await
             .expect("poll_messages failed");
@@ -108,11 +112,14 @@
             .poll_messages(
                 &stream_id,
                 &topic_id,
-                None,
+                DEFAULT_PARTITION_ID,
                 &consumer,
                 &PollingStrategy::next(),
-                10,
-                true,
+                PollOptions {
+                    count: 10,
+                    commit: CommitPolicy::OnRead,
+                    ..PollOptions::default()
+                },
             )
             .await
             .expect("poll_messages failed");
@@ -160,11 +167,13 @@
         .poll_messages(
             &stream_id,
             &topic_id,
-            None,
+            DEFAULT_PARTITION_ID,
             &consumer,
             &PollingStrategy::next(),
-            100,
-            false,
+            PollOptions {
+                count: 100,
+                ..PollOptions::default()
+            },
         )
         .await
         .expect("poll_messages failed");
@@ -203,11 +212,14 @@
             .poll_messages(
                 &stream_id,
                 &topic_id,
-                None,
+                DEFAULT_PARTITION_ID,
                 &consumer,
                 &PollingStrategy::next(),
-                100,
-                true,
+                PollOptions {
+                    count: 100,
+                    commit: CommitPolicy::OnRead,
+                    ..PollOptions::default()
+                },
             )
             .await
             .expect("poll_messages failed");
diff --git a/core/integration/tests/connectors/mqtt/mqtt_source.rs b/core/integration/tests/connectors/mqtt/mqtt_source.rs
index fb57286..09bb7e1 100644
--- a/core/integration/tests/connectors/mqtt/mqtt_source.rs
+++ b/core/integration/tests/connectors/mqtt/mqtt_source.rs
@@ -21,7 +21,10 @@
     Mqtt311Qos0Fixture, Mqtt311Qos1Fixture, Mqtt311Qos2Fixture,
 };
 use base64::Engine;
-use iggy_common::{Consumer, Identifier, MessageClient, PollingStrategy};
+use iggy_common::poll_messages::DEFAULT_PARTITION_ID;
+use iggy_common::{
+    CommitPolicy, Consumer, Identifier, MessageClient, PollOptions, PollingStrategy,
+};
 use integration::harness::{TestBinary, TestHarness, seeds};
 use integration::iggy_harness;
 use reqwest::Client;
@@ -395,11 +398,14 @@
                 .poll_messages(
                     &stream_id,
                     &topic_id,
-                    None,
+                    DEFAULT_PARTITION_ID,
                     &Consumer::new(consumer_id.clone()),
                     &PollingStrategy::next(),
-                    10,
-                    true,
+                    PollOptions {
+                        count: 10,
+                        commit: CommitPolicy::OnRead,
+                        ..PollOptions::default()
+                    },
                 )
                 .await
             {
@@ -597,11 +603,14 @@
                 .poll_messages(
                     &stream_id,
                     &topic_id,
-                    None,
+                    DEFAULT_PARTITION_ID,
                     &Consumer::new(consumer_id.clone()),
                     &PollingStrategy::next(),
-                    10,
-                    true,
+                    PollOptions {
+                        count: 10,
+                        commit: CommitPolicy::OnRead,
+                        ..PollOptions::default()
+                    },
                 )
                 .await
                 && let Some(message) = polled
@@ -636,11 +645,14 @@
                 .poll_messages(
                     &stream_id,
                     &topic_id,
-                    None,
+                    DEFAULT_PARTITION_ID,
                     &Consumer::new(consumer_id.clone()),
                     &PollingStrategy::next(),
-                    10,
-                    true,
+                    PollOptions {
+                        count: 10,
+                        commit: CommitPolicy::OnRead,
+                        ..PollOptions::default()
+                    },
                 )
                 .await
                 && let Some(message) = polled.messages.into_iter().next()
@@ -668,11 +680,14 @@
                 .poll_messages(
                     &stream_id,
                     &topic_id,
-                    None,
+                    DEFAULT_PARTITION_ID,
                     &Consumer::new(consumer_id.clone()),
                     &PollingStrategy::next(),
-                    10,
-                    true,
+                    PollOptions {
+                        count: 10,
+                        commit: CommitPolicy::OnRead,
+                        ..PollOptions::default()
+                    },
                 )
                 .await
                 && polled
diff --git a/core/integration/tests/connectors/postgres/postgres_source.rs b/core/integration/tests/connectors/postgres/postgres_source.rs
index 91c3ebb..2b83b60 100644
--- a/core/integration/tests/connectors/postgres/postgres_source.rs
+++ b/core/integration/tests/connectors/postgres/postgres_source.rs
@@ -19,7 +19,8 @@
 use std::time::Duration;
 
 use iggy_common::MessageClient;
-use iggy_common::{Consumer, Identifier, PollingStrategy};
+use iggy_common::poll_messages::DEFAULT_PARTITION_ID;
+use iggy_common::{CommitPolicy, Consumer, Identifier, PollOptions, PollingStrategy};
 use iggy_connector_sdk::ConnectorState;
 use iggy_connector_sdk::api::ConnectorStatus;
 use integration::harness::{TestHarness, seeds};
@@ -111,11 +112,14 @@
             .poll_messages(
                 &stream_id,
                 &topic_id,
-                None,
+                DEFAULT_PARTITION_ID,
                 &Consumer::new(consumer_id.clone()),
                 &PollingStrategy::next(),
-                10,
-                true,
+                PollOptions {
+                    count: 10,
+                    commit: CommitPolicy::OnRead,
+                    ..PollOptions::default()
+                },
             )
             .await
         {
@@ -253,11 +257,14 @@
             .poll_messages(
                 &stream_id,
                 &topic_id,
-                None,
+                DEFAULT_PARTITION_ID,
                 &Consumer::new(consumer_id.clone()),
                 &PollingStrategy::next(),
-                10,
-                true,
+                PollOptions {
+                    count: 10,
+                    commit: CommitPolicy::OnRead,
+                    ..PollOptions::default()
+                },
             )
             .await
         {
@@ -352,11 +359,14 @@
             .poll_messages(
                 &stream_id,
                 &topic_id,
-                None,
+                DEFAULT_PARTITION_ID,
                 &Consumer::new(consumer_id.clone()),
                 &PollingStrategy::next(),
-                10,
-                true,
+                PollOptions {
+                    count: 10,
+                    commit: CommitPolicy::OnRead,
+                    ..PollOptions::default()
+                },
             )
             .await
         {
@@ -418,11 +428,14 @@
             .poll_messages(
                 &stream_id,
                 &topic_id,
-                None,
+                DEFAULT_PARTITION_ID,
                 &Consumer::new(consumer_id.clone()),
                 &PollingStrategy::next(),
-                10,
-                true,
+                PollOptions {
+                    count: 10,
+                    commit: CommitPolicy::OnRead,
+                    ..PollOptions::default()
+                },
             )
             .await
         {
@@ -480,11 +493,14 @@
             .poll_messages(
                 &stream_id,
                 &topic_id,
-                None,
+                DEFAULT_PARTITION_ID,
                 &Consumer::new(consumer_id.clone()),
                 &PollingStrategy::next(),
-                10,
-                true,
+                PollOptions {
+                    count: 10,
+                    commit: CommitPolicy::OnRead,
+                    ..PollOptions::default()
+                },
             )
             .await
         {
@@ -542,11 +558,14 @@
             .poll_messages(
                 &stream_id,
                 &topic_id,
-                None,
+                DEFAULT_PARTITION_ID,
                 &Consumer::new(consumer_id.clone()),
                 &PollingStrategy::next(),
-                10,
-                true,
+                PollOptions {
+                    count: 10,
+                    commit: CommitPolicy::OnRead,
+                    ..PollOptions::default()
+                },
             )
             .await
         {
@@ -610,11 +629,14 @@
             .poll_messages(
                 &stream_id,
                 &topic_id,
-                None,
+                DEFAULT_PARTITION_ID,
                 &Consumer::new(consumer_id.clone()),
                 &PollingStrategy::next(),
-                1,
-                true,
+                PollOptions {
+                    count: 1,
+                    commit: CommitPolicy::OnRead,
+                    ..PollOptions::default()
+                },
             )
             .await
         {
@@ -679,11 +701,14 @@
             .poll_messages(
                 &stream_id,
                 &topic_id,
-                None,
+                DEFAULT_PARTITION_ID,
                 &Consumer::new(consumer_id.clone()),
                 &PollingStrategy::next(),
-                1,
-                true,
+                PollOptions {
+                    count: 1,
+                    commit: CommitPolicy::OnRead,
+                    ..PollOptions::default()
+                },
             )
             .await
         {
@@ -761,11 +786,14 @@
             .poll_messages(
                 &stream_id,
                 &topic_id,
-                None,
+                DEFAULT_PARTITION_ID,
                 &Consumer::new(consumer_id.clone()),
                 &PollingStrategy::next(),
-                10,
-                true,
+                PollOptions {
+                    count: 10,
+                    commit: CommitPolicy::OnRead,
+                    ..PollOptions::default()
+                },
             )
             .await
         {
@@ -853,11 +881,14 @@
                 .poll_messages(
                     &stream_id,
                     &topic_id,
-                    None,
+                    DEFAULT_PARTITION_ID,
                     &Consumer::new(consumer_id.clone()),
                     &PollingStrategy::next(),
-                    10,
-                    true,
+                    PollOptions {
+                        count: 10,
+                        commit: CommitPolicy::OnRead,
+                        ..PollOptions::default()
+                    },
                 )
                 .await
             {
@@ -909,11 +940,14 @@
             .poll_messages(
                 &stream_id,
                 &topic_id,
-                None,
+                DEFAULT_PARTITION_ID,
                 &Consumer::new(consumer_id.clone()),
                 &PollingStrategy::next(),
-                10,
-                true,
+                PollOptions {
+                    count: 10,
+                    commit: CommitPolicy::OnRead,
+                    ..PollOptions::default()
+                },
             )
             .await
         {
@@ -1262,11 +1296,14 @@
             .poll_messages(
                 &stream,
                 &topic,
-                None,
+                DEFAULT_PARTITION_ID,
                 &consumer,
                 &PollingStrategy::next(),
-                10,
-                true,
+                PollOptions {
+                    count: 10,
+                    commit: CommitPolicy::OnRead,
+                    ..PollOptions::default()
+                },
             )
             .await
         {
diff --git a/core/integration/tests/connectors/postgres/postgres_source_cdc.rs b/core/integration/tests/connectors/postgres/postgres_source_cdc.rs
index 26abb8f..2a13dfa 100644
--- a/core/integration/tests/connectors/postgres/postgres_source_cdc.rs
+++ b/core/integration/tests/connectors/postgres/postgres_source_cdc.rs
@@ -25,7 +25,8 @@
 };
 use iggy::prelude::IggyClient;
 use iggy_common::MessageClient;
-use iggy_common::{Consumer, Identifier, PollingStrategy};
+use iggy_common::poll_messages::DEFAULT_PARTITION_ID;
+use iggy_common::{CommitPolicy, Consumer, Identifier, PollOptions, PollingStrategy};
 use iggy_connector_sdk::api::ConnectorStatus;
 use integration::harness::seeds;
 use integration::iggy_harness;
@@ -73,11 +74,14 @@
             .poll_messages(
                 stream_id,
                 topic_id,
-                None,
+                DEFAULT_PARTITION_ID,
                 &Consumer::new(consumer_id.clone()),
                 &PollingStrategy::next(),
-                10,
-                true,
+                PollOptions {
+                    count: 10,
+                    commit: CommitPolicy::OnRead,
+                    ..PollOptions::default()
+                },
             )
             .await
         {
diff --git a/core/integration/tests/connectors/random_source_liveness.rs b/core/integration/tests/connectors/random_source_liveness.rs
index adbb98f..c35a8c7 100644
--- a/core/integration/tests/connectors/random_source_liveness.rs
+++ b/core/integration/tests/connectors/random_source_liveness.rs
@@ -17,7 +17,10 @@
 
 use std::time::Duration;
 
-use iggy_common::{Consumer, Identifier, MessageClient, PollingStrategy};
+use iggy_common::poll_messages::DEFAULT_PARTITION_ID;
+use iggy_common::{
+    CommitPolicy, Consumer, Identifier, MessageClient, PollOptions, PollingStrategy,
+};
 use integration::harness::{TestHarness, seeds};
 use tokio::time::{sleep, timeout};
 
@@ -38,11 +41,14 @@
                 .poll_messages(
                     &stream_id,
                     &topic_id,
-                    None,
+                    DEFAULT_PARTITION_ID,
                     &Consumer::new(consumer_id.clone()),
                     &PollingStrategy::next(),
-                    POLL_BATCH,
-                    true,
+                    PollOptions {
+                        count: POLL_BATCH,
+                        commit: CommitPolicy::OnRead,
+                        ..PollOptions::default()
+                    },
                 )
                 .await
                 && !polled.messages.is_empty()
diff --git a/core/integration/tests/connectors/runtime/sink_clients.rs b/core/integration/tests/connectors/runtime/sink_clients.rs
index 43276a8..b8fc25c 100644
--- a/core/integration/tests/connectors/runtime/sink_clients.rs
+++ b/core/integration/tests/connectors/runtime/sink_clients.rs
@@ -21,7 +21,7 @@
 //! a member leaves. These tests therefore tell the members apart by the
 //! clients that the server lists, each with the number of groups it is in.
 
-use iggy::prelude::{Identifier, IggyClient, SystemClient};
+use iggy::prelude::{Identifier, IggyClient, StreamClient, SystemClient};
 use iggy_common::ClientInfo;
 use iggy_connector_sdk::api::{ConnectorStatus, SinkInfoResponse};
 use integration::harness::{seeds, wait_for_consumer_group_assignment};
@@ -43,6 +43,8 @@
 /// expires, so only a logout passes within this budget.
 const LOGOUT_BUDGET: Duration = Duration::from_secs(10);
 const CLIENTS_POLL_INTERVAL: Duration = Duration::from_millis(100);
+const INSTANCE_CLOSE_BUDGET: Duration = Duration::from_secs(10);
+const STDOUT_SINK_OPENED: &str = "Opened stdout sink connector with ID: ";
 
 #[iggy_harness(
     server(connectors_runtime(config_path = "tests/connectors/runtime/sink_clients.toml")),
@@ -168,6 +170,61 @@
     );
 }
 
+#[iggy_harness(
+    server(connectors_runtime(config_path = "tests/connectors/runtime/sink_restart.toml")),
+    seed = seeds::connector_stream
+)]
+async fn given_running_sink_when_restart_setup_fails_should_close_the_new_plugin_instance(
+    harness: &TestHarness,
+) {
+    let client = harness
+        .root_client()
+        .await
+        .expect("root client should connect");
+    wait_for_sink_group(&client, RESTART_SINK_KEY, 1, MEMBERSHIP_BUDGET).await;
+    // A start opens the plugin before it builds the consumers, and a missing
+    // stream fails that second step.
+    client
+        .delete_stream(&Identifier::named(seeds::names::STREAM).expect("valid stream name"))
+        .await
+        .expect("stream should be deleted");
+
+    let runtime = harness
+        .connectors_runtime()
+        .expect("connector runtime should be available");
+    let response = Client::new()
+        .post(format!(
+            "{}/sinks/{RESTART_SINK_KEY}/restart",
+            runtime.http_url()
+        ))
+        .send()
+        .await
+        .expect("restart request should complete");
+    assert!(
+        !response.status().is_success(),
+        "the restart should fail without its stream, got: {}",
+        response.status()
+    );
+
+    let opened = last_opened_stdout_sink_id(&runtime.collect_logs().0)
+        .expect("the restart should open a new plugin instance");
+    let closed = format!("Stdout sink connector with ID: {opened} is closed.");
+    let deadline = Instant::now() + INSTANCE_CLOSE_BUDGET;
+    while !runtime.collect_logs().0.contains(&closed) && Instant::now() < deadline {
+        sleep(CLIENTS_POLL_INTERVAL).await;
+    }
+    assert!(
+        runtime.collect_logs().0.contains(&closed),
+        "the failed restart should close the plugin instance {opened} that it opened"
+    );
+}
+
+/// The id of the stdout sink instance that the runtime opened last.
+fn last_opened_stdout_sink_id(logs: &str) -> Option<u32> {
+    let start = logs.rfind(STDOUT_SINK_OPENED)? + STDOUT_SINK_OPENED.len();
+    logs[start..].split(',').next()?.parse().ok()
+}
+
 async fn wait_for_sink_group(
     client: &IggyClient,
     sink_key: &str,
diff --git a/core/integration/tests/data_integrity/storage_compat.rs b/core/integration/tests/data_integrity/storage_compat.rs
index a9f7017..ff53a4f 100644
--- a/core/integration/tests/data_integrity/storage_compat.rs
+++ b/core/integration/tests/data_integrity/storage_compat.rs
@@ -18,7 +18,7 @@
 //! On-disk format backwards compatibility.
 //!
 //! A server built from the master tip the change merges onto (the BASELINE)
-//! writes a rich data directory. The same directory is then restarted under
+//! writes a rich data directory. The same directory is then booted under
 //! the build under test, and everything must read back unchanged:
 //!
 //! - A checkpointed metadata snapshot holding topics with every option key,
@@ -32,9 +32,16 @@
 //!   inside every batch, and a partial segment deletion.
 //! - Individual and group consumer offsets.
 //!
-//! The swap works because `ServerHandle::start` re-reads
-//! `config.executable_path` on every call while the data directory, the
-//! ports and the harness paths all survive a `stop`.
+//! # Two halves, two source trees
+//!
+//! The seed half runs from the baseline's own source, so it talks to the
+//! baseline through the baseline's SDK and a client protocol change in the
+//! build under test cannot fail it. It copies the data directory to
+//! `COMPAT_BASELINE_DATA` and writes everything it read from the baseline
+//! into a JSON record beside it. The verify half runs from the build under
+//! test, boots on a copy of that directory and compares against the record.
+//! The harness owns the server's data path, so the directory travels by copy
+//! in both directions.
 //!
 //! # Why every assertion here is about DATA
 //!
@@ -60,9 +67,9 @@
 //!
 //! The baseline uses its extracted config and a test-only launcher that maps
 //! current harness environment names to the baseline's system-prefixed names.
-//! On replacement, the fixture copies supported values into the current schema
-//! and switches IGGY_CONFIG_PATH before restarting. Removed settings stay out
-//! of the replacement config. This isolates storage compatibility from the
+//! The verify half copies supported values into the current schema and points
+//! IGGY_CONFIG_PATH at the result. Removed settings stay out of the
+//! replacement config. This isolates storage compatibility from the
 //! deliberately breaking public configuration rename.
 //!
 //! Topic creation likewise uses the baseline options block. The new durability
@@ -88,6 +95,7 @@
     TestHarness, TestServerConfig, USER_PASSWORD, disk, resolve_config_paths,
     wait_for_consumer_group_assignment,
 };
+use serde::{Deserialize, Serialize};
 use serial_test::parallel;
 use std::collections::{BTreeMap, HashMap};
 use std::fs;
@@ -109,10 +117,17 @@
 /// Same naming rule as [`BASELINE_SERVER_ENV`].
 const BASELINE_CONFIG_ENV: &str = "COMPAT_BASELINE_CONFIG";
 
+/// Absolute path of the data directory the seed half writes and the verify
+/// half boots from. The seed's record sits beside it, see [`record_path`].
+/// Same naming rule as [`BASELINE_SERVER_ENV`].
+const BASELINE_DATA_ENV: &str = "COMPAT_BASELINE_DATA";
+
 /// Selects the server's config file, ahead of the relative default path the
 /// module documentation warns about.
 const CONFIG_PATH_ENV: &str = "IGGY_CONFIG_PATH";
 
+const JOURNAL_SLOTS_KEY: &str = "metadata.journal_slots";
+
 /// `metadata.journal_slots` floor for `prepare_queue_depth = 32`
 /// (`4 * max(64, 32)`). With the 64-op checkpoint margin a checkpoint fires
 /// once the journal holds 192 committed ops.
@@ -221,10 +236,10 @@
 
 /// Bound on every [`wait_until`] probe loop and on the group assignment wait.
 ///
-/// Kept small because the whole test has to finish inside nextest's 300s hard
+/// Kept small because the seed half has to finish inside nextest's 300s hard
 /// kill (`slow-timeout` x `terminate-after` in `.config/nextest.toml`, and the
-/// driving script deliberately runs without `--profile ci`). Two boots at 60s
-/// plus two stops at 5s plus six of these waits is 190s. A SIGKILL past
+/// driving script deliberately runs without `--profile ci`). One boot at 60s
+/// plus one stop at 5s plus six of these waits is 125s. A SIGKILL past
 /// that budget would take the named `wait_until` message with it, losing the
 /// diagnostic in exactly the run that needed it. At a 200ms
 /// [`POLL_INTERVAL`] this is still ~50 probes per wait.
@@ -264,37 +279,93 @@
 /// same time.
 const GRACEFUL_SHUTDOWN_MARKER: &str = "server shutdown complete";
 
+/// Everything the seed half read from the baseline that the verify half
+/// compares against. Names, topic options and message generators are not
+/// here: both halves share them through this file.
+#[derive(Serialize, Deserialize)]
+struct BaselineRecord {
+    data_stream_id: u32,
+    data_topic_id: u32,
+    compression_stream_id: u32,
+    streams: BTreeMap<u32, String>,
+    retained_segments: Vec<String>,
+    /// Blake3 of every comparable file, so the verify half can tell that its
+    /// directory is the one this record describes.
+    segment_digests: BTreeMap<String, String>,
+    first_retained_offset: u64,
+    retained_window: Vec<RecordedMessage>,
+    last_message: Vec<RecordedMessage>,
+    user_permissions: Permissions,
+    wal_tail_user_permissions: Permissions,
+    token: SeededToken,
+    wal_tail_token: SeededToken,
+}
+
+/// A polled message with its stored bytes kept exact. `IggyMessage`'s own
+/// serde form rebuilds user headers from a decoded map.
+#[derive(Serialize, Deserialize)]
+struct RecordedMessage {
+    header: IggyMessageHeader,
+    payload: String,
+    user_headers: Option<String>,
+}
+
+impl From<IggyMessage> for RecordedMessage {
+    fn from(message: IggyMessage) -> Self {
+        Self {
+            header: message.header,
+            payload: hex::encode(message.payload),
+            user_headers: message.user_headers.map(hex::encode),
+        }
+    }
+}
+
+impl TryFrom<RecordedMessage> for IggyMessage {
+    type Error = hex::FromHexError;
+
+    fn try_from(recorded: RecordedMessage) -> Result<Self, Self::Error> {
+        Ok(Self {
+            header: recorded.header,
+            payload: Bytes::from(hex::decode(recorded.payload)?),
+            user_headers: recorded
+                .user_headers
+                .map(hex::decode)
+                .transpose()?
+                .map(Bytes::from),
+        })
+    }
+}
+
+/// A personal access token as the baseline handed it out: the raw token a
+/// later login presents, and the stored expiry a listing must reproduce.
+#[derive(Serialize, Deserialize)]
+struct SeededToken {
+    raw: String,
+    expiry_at: IggyTimestamp,
+}
+
 #[tokio::test]
 #[parallel]
-#[ignore = "needs a baseline iggy-server built from master; run via scripts/ci/storage-compat.sh"]
-async fn should_read_back_a_data_directory_written_by_the_baseline_server() {
+#[ignore = "needs a baseline iggy-server built from master, run via scripts/ci/storage-compat.sh"]
+async fn given_baseline_server_when_seeding_data_directory_should_record_what_it_serves() {
     let baseline = baseline_server_binary();
     let baseline_config = baseline_server_config();
+    let seed_directory = baseline_data_directory();
+    assert!(
+        !seed_directory.exists(),
+        "{BASELINE_DATA_ENV}={} already exists. The seed refuses to write into it, so a stale \
+         directory can never be verified against a fresh record.",
+        seed_directory.display()
+    );
     let fixture = tempfile::tempdir().unwrap();
     let baseline_document: toml::Value =
         toml::from_str(&fs::read_to_string(&baseline_config).unwrap()).unwrap();
     let launcher = baseline_launcher(fixture.path(), &baseline, &baseline_document);
-    let replacement_config = fixture.path().join("replacement.toml");
-    let current_document: toml::Value =
-        toml::from_str(include_str!("../../../server/config.toml")).unwrap();
-    fs::write(
-        &replacement_config,
-        toml::to_string(&replacement_configuration(
-            &baseline_document,
-            current_document,
-        ))
-        .unwrap(),
-    )
-    .unwrap();
 
-    let overrides = HashMap::from([(
-        "metadata.journal_slots".to_string(),
-        JOURNAL_SLOTS.to_string(),
-    )]);
+    let overrides = HashMap::from([(JOURNAL_SLOTS_KEY.to_string(), JOURNAL_SLOTS.to_string())]);
     assert_overrides_known_to_the_baseline(&overrides, &baseline_config);
     let mut envs = resolve_config_paths(&overrides)
         .expect("metadata.journal_slots resolves against the live config catalog");
-    // The baseline reads its own schema until the explicit replacement below.
     envs.insert(CONFIG_PATH_ENV.to_string(), baseline_config.clone());
 
     let mut harness = TestHarness::builder()
@@ -668,30 +739,140 @@
          still hold. Segments: {retained:?}"
     );
 
-    // The swap: `None` selects the cargo-built binary of the crate under test.
-    harness.server_mut().set_executable_path(None);
-    harness.server_mut().add_env(
-        CONFIG_PATH_ENV,
+    copy_directory(&data_path, &seed_directory);
+    let record = BaselineRecord {
+        data_stream_id: data_stream_details.id,
+        data_topic_id: data_topic_details.id,
+        compression_stream_id: compression_stream_details.id,
+        streams: expected_streams,
+        retained_segments: retained,
+        segment_digests: segment_digests(&baseline_files),
+        first_retained_offset,
+        retained_window: retained_before
+            .into_iter()
+            .map(RecordedMessage::from)
+            .collect(),
+        last_message: tail_before.into_iter().map(RecordedMessage::from).collect(),
+        user_permissions: permissions,
+        wal_tail_user_permissions: tail_permissions,
+        token: pat,
+        wal_tail_token: tail_pat,
+    };
+    fs::write(
+        record_path(&seed_directory),
+        serde_json::to_vec_pretty(&record).unwrap(),
+    )
+    .unwrap();
+}
+
+#[tokio::test]
+#[parallel]
+#[ignore = "needs a data directory seeded by the baseline, run via scripts/ci/storage-compat.sh"]
+async fn given_baseline_data_directory_when_booted_by_build_under_test_should_read_back_the_record()
+{
+    let baseline_config = baseline_server_config();
+    let seed_directory = baseline_data_directory();
+    let record_file = record_path(&seed_directory);
+    let record: BaselineRecord =
+        serde_json::from_slice(&fs::read(&record_file).unwrap_or_else(|error| {
+            panic!(
+                "reading the seed record {}: {error}. The baseline's seed test writes it.",
+                record_file.display()
+            )
+        }))
+        .unwrap_or_else(|error| {
+            panic!(
+                "the seed record {} does not parse: {error}. The baseline's seed test and this \
+                 verify test disagree on the record shape.",
+                record_file.display()
+            )
+        });
+
+    let baseline_files = disk::collect_comparable_files(&seed_directory, false);
+    assert_eq!(
+        segment_digests(&baseline_files),
+        record.segment_digests,
+        "the segment files under {} are not the ones the seed recorded (directory left, record \
+         right), so the directory and its record come from different seed runs or the \
+         directory changed after the seed",
+        seed_directory.display()
+    );
+
+    let fixture = tempfile::tempdir().unwrap();
+    let baseline_document: toml::Value =
+        toml::from_str(&fs::read_to_string(&baseline_config).unwrap()).unwrap();
+    let replacement_config = fixture.path().join("replacement.toml");
+    let current_document: toml::Value =
+        toml::from_str(include_str!("../../../server/config.toml")).unwrap();
+    fs::write(
+        &replacement_config,
+        toml::to_string(&replacement_configuration(
+            &baseline_document,
+            current_document,
+        ))
+        .unwrap(),
+    )
+    .unwrap();
+
+    let overrides = HashMap::from([(JOURNAL_SLOTS_KEY.to_string(), JOURNAL_SLOTS.to_string())]);
+    let mut envs = resolve_config_paths(&overrides)
+        .expect("metadata.journal_slots resolves against the live config catalog");
+    envs.insert(
+        CONFIG_PATH_ENV.to_string(),
         replacement_config.to_string_lossy().into_owned(),
     );
-    harness.restart_server().await.unwrap_or_else(|error| {
+
+    // No executable path: the harness boots the cargo-built binary of the
+    // crate under test.
+    let mut harness = TestHarness::builder()
+        .cluster_nodes(1)
+        .server(TestServerConfig::builder().extra_envs(envs).build())
+        .build()
+        .unwrap();
+    let data_path = harness.server().data_path();
+    copy_directory(&seed_directory, &data_path);
+    harness.start().await.unwrap_or_else(|error| {
         panic!(
-            "the server under test did not restart on the data directory the baseline wrote. \
+            "the server under test did not start on the data directory the baseline wrote. \
              A Config(...) error below is a configuration break rather than a storage one: \
              this branch dropped or renamed a key the merge base's config file still sets, or \
              added a field without a `#[serde(default)]`: {error}"
         )
     });
 
-    // `tcp_root_client` hands out a client the harness does not own, so the
-    // reconnect loop inside `restart_server` never touched it. Take a fresh one.
     let client = harness.tcp_root_client().await.unwrap();
+    let data_stream = Identifier::numeric(record.data_stream_id).unwrap();
+    let data_topic = Identifier::numeric(record.data_topic_id).unwrap();
+    let compression_stream = Identifier::numeric(record.compression_stream_id).unwrap();
+    let partition = partition_dir(
+        &data_path,
+        record.data_stream_id,
+        record.data_topic_id,
+        SEEDED_PARTITION,
+    );
+    let readback = Consumer::new(Identifier::named(READBACK_CONSUMER).unwrap());
+    let offset_consumer = Consumer::new(Identifier::named(OFFSET_CONSUMER).unwrap());
+    let offset_group = Consumer::group(Identifier::named(OFFSET_GROUP).unwrap());
+    let first_retained_offset = record.first_retained_offset;
+    let last_offset = PRODUCED_MESSAGES - 1;
+    let retained_before = record
+        .retained_window
+        .into_iter()
+        .map(IggyMessage::try_from)
+        .collect::<Result<Vec<_>, _>>()
+        .expect("the recorded retained window is hex");
+    let tail_before = record
+        .last_message
+        .into_iter()
+        .map(IggyMessage::try_from)
+        .collect::<Result<Vec<_>, _>>()
+        .expect("the recorded last message is hex");
 
     // Ids AND names: later reads resolve by numeric id, so a corrupted name
     // would pass a bare count.
     assert_eq!(
         stream_catalog(&client).await,
-        expected_streams,
+        record.streams,
         "streams written by the baseline must all recover with their ids and names, both the \
          checkpointed prefix and the WAL tail"
     );
@@ -714,7 +895,7 @@
 
     assert_eq!(
         segment_logs(&partition),
-        retained,
+        record.retained_segments,
         "the segment chain must recover exactly as the baseline left it"
     );
 
@@ -779,10 +960,16 @@
         "the on-disk offset record must decode to the offset the baseline stored"
     );
 
-    assert_user_recovered(&harness, &client, COMPAT_USER, &permissions).await;
-    assert_user_recovered(&harness, &client, WAL_TAIL_USER, &tail_permissions).await;
-    assert_token_recovered(&harness, &client, COMPAT_PAT, &pat).await;
-    assert_token_recovered(&harness, &client, WAL_TAIL_PAT, &tail_pat).await;
+    assert_user_recovered(&harness, &client, COMPAT_USER, &record.user_permissions).await;
+    assert_user_recovered(
+        &harness,
+        &client,
+        WAL_TAIL_USER,
+        &record.wal_tail_user_permissions,
+    )
+    .await;
+    assert_token_recovered(&harness, &client, COMPAT_PAT, &record.token).await;
+    assert_token_recovered(&harness, &client, WAL_TAIL_PAT, &record.wal_tail_token).await;
 
     let groups = client
         .get_consumer_groups(&data_stream, &data_topic)
@@ -833,7 +1020,7 @@
             "{variable} is unset. It must be an ABSOLUTE path to the baseline {artefact}. \
              Without it this test would run entirely on the build under test and prove nothing, \
              so it refuses to run. Use scripts/ci/storage-compat.sh, which builds the baseline \
-             and exports both variables."
+             and exports every variable this test reads."
         )
     });
 
@@ -866,6 +1053,61 @@
     baseline_path_from_env(BASELINE_CONFIG_ENV, "core/server/config.toml")
 }
 
+/// Absolute because the two halves run from different source trees, each with
+/// its own crate directory as the working directory, so a relative value
+/// would name two different places.
+fn baseline_data_directory() -> PathBuf {
+    let raw = std::env::var(BASELINE_DATA_ENV).unwrap_or_else(|_| {
+        panic!(
+            "{BASELINE_DATA_ENV} is unset. It must be an ABSOLUTE path to the data directory the \
+             seed half writes and the verify half boots from. Use scripts/ci/storage-compat.sh, \
+             which runs both halves on one directory."
+        )
+    });
+    let path = PathBuf::from(&raw);
+    assert!(
+        path.is_absolute(),
+        "{BASELINE_DATA_ENV}={raw:?} is relative. The seed and verify halves run from different \
+         crate directories, so it must be ABSOLUTE."
+    );
+    path
+}
+
+fn record_path(data_directory: &Path) -> PathBuf {
+    let mut path = data_directory.as_os_str().to_owned();
+    path.push(".json");
+    PathBuf::from(path)
+}
+
+fn segment_digests(files: &BTreeMap<String, Vec<u8>>) -> BTreeMap<String, String> {
+    files
+        .iter()
+        .map(|(relative, bytes)| (relative.clone(), blake3::hash(bytes).to_hex().to_string()))
+        .collect()
+}
+
+fn copy_directory(source: &Path, destination: &Path) {
+    fs::create_dir_all(destination)
+        .unwrap_or_else(|error| panic!("creating {}: {error}", destination.display()));
+    let entries = fs::read_dir(source)
+        .unwrap_or_else(|error| panic!("listing {}: {error}", source.display()));
+    for entry in entries {
+        let entry = entry.unwrap_or_else(|error| panic!("listing {}: {error}", source.display()));
+        let target = destination.join(entry.file_name());
+        if entry.file_type().unwrap().is_dir() {
+            copy_directory(&entry.path(), &target);
+        } else {
+            fs::copy(entry.path(), &target).unwrap_or_else(|error| {
+                panic!(
+                    "copying {} to {}: {error}",
+                    entry.path().display(),
+                    target.display()
+                )
+            });
+        }
+    }
+}
+
 /// Refuse an override the baseline binary cannot read.
 ///
 /// Every override travels to both binaries as an `IGGY_*` variable, and an
@@ -1127,13 +1369,6 @@
     }
 }
 
-/// A personal access token as the baseline handed it out: the raw token a
-/// later login presents, and the stored expiry a listing must reproduce.
-struct SeededToken {
-    raw: String,
-    expiry_at: IggyTimestamp,
-}
-
 async fn create_token(client: &IggyClient, name: &str, expiry_secs: u64) -> SeededToken {
     let raw = client
         .create_personal_access_token(
@@ -1180,11 +1415,13 @@
         .poll_messages(
             stream,
             topic,
-            Some(SEEDED_PARTITION),
+            SEEDED_PARTITION,
             consumer,
             &PollingStrategy::offset(offset),
-            count,
-            false,
+            PollOptions {
+                count,
+                ..PollOptions::default()
+            },
         )
         .await
         .unwrap_or_else(|error| panic!("poll {count} message(s) from offset {offset}: {error}"));
diff --git a/core/integration/tests/data_integrity/verify_auto_commit_offset_replicates.rs b/core/integration/tests/data_integrity/verify_auto_commit_offset_replicates.rs
index 3de07cc..d130dbc 100644
--- a/core/integration/tests/data_integrity/verify_auto_commit_offset_replicates.rs
+++ b/core/integration/tests/data_integrity/verify_auto_commit_offset_replicates.rs
@@ -15,11 +15,11 @@
 // specific language governing permissions and limitations
 // under the License.
 
-//! A poll with `auto_commit = true` must replicate the consumer offset through
+//! A poll with `CommitPolicy::OnRead` must replicate the consumer offset through
 //! the partition consensus, not merely persist it on the serving node -- else a
 //! failover to a backup silently loses the committed offset.
 //!
-//! Discriminator: drive the poll+auto_commit on the primary (node 0) and read
+//! Discriminator: drive the `OnRead` poll on the primary (node 0) and read
 //! the offset from a backup's (node 1) on-disk store. The backup holds it only
 //! if the commit rode consensus; the pre-fix node-local persist never reaches
 //! it, so the file stays absent until the deadline and the test fails.
@@ -58,7 +58,7 @@
     let consumer = Consumer::new(Identifier::numeric(CONSUMER_ID).unwrap());
 
     // Primary (node 0, the view-0 primary): create the topic, produce, then poll
-    // with auto_commit so the offset is committed server-side.
+    // with `OnRead` so the offset is committed server-side.
     let primary = harness.tcp_root_client().await.unwrap();
     primary
         .create_stream(STREAM_NAME, &StreamCreateOptions::default())
@@ -99,11 +99,14 @@
         .poll_messages(
             &stream,
             &topic,
-            Some(PARTITION_ID),
+            PARTITION_ID,
             &consumer,
             &PollingStrategy::offset(0),
-            MESSAGE_COUNT,
-            true,
+            PollOptions {
+                count: MESSAGE_COUNT,
+                commit: CommitPolicy::OnRead,
+                ..PollOptions::default()
+            },
         )
         .await
         .unwrap();
diff --git a/core/integration/tests/data_integrity/verify_consumer_group_partition_assignment.rs b/core/integration/tests/data_integrity/verify_consumer_group_partition_assignment.rs
index 24af691..0fac2b3 100644
--- a/core/integration/tests/data_integrity/verify_consumer_group_partition_assignment.rs
+++ b/core/integration/tests/data_integrity/verify_consumer_group_partition_assignment.rs
@@ -36,7 +36,7 @@
 //! silent sockets lose their sessions.
 
 use iggy::prelude::*;
-use integration::harness::{backup_commit_budget, wait_for_consumer_group_assignment};
+use integration::harness::{GroupPoller, backup_commit_budget, wait_for_consumer_group_assignment};
 use integration::iggy_harness;
 use std::collections::HashSet;
 use std::str::FromStr;
@@ -181,16 +181,13 @@
     assert_unique_partition_assignments(&cg);
 
     // 5. Poll a message from stale_client1 WITHOUT committing offset (simulating in-flight work)
-    let consumer = Consumer::group(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
-    let polled = stale_client1
-        .poll_messages(
+    let polled = GroupPoller::new(Identifier::named(CONSUMER_GROUP_NAME).unwrap())
+        .poll(
+            &stale_client1,
             &Identifier::named(STREAM_NAME).unwrap(),
             &Identifier::named(TOPIC_NAME).unwrap(),
-            None,
-            &consumer,
             &PollingStrategy::next(),
-            1,
-            false, // manual ack - offset NOT stored
+            poll_options(1, CommitPolicy::Manual), // manual ack - offset NOT stored
         )
         .await
         .unwrap();
@@ -232,19 +229,16 @@
     assert_unique_partition_assignments(&cg);
 
     // 11. Verify each new client can poll from its assigned partition
-    let consumer = Consumer::group(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
     let mut polled_partitions = HashSet::new();
 
     for (i, client) in [&client1, &client2, &client3].iter().enumerate() {
-        let polled = client
-            .poll_messages(
+        let polled = GroupPoller::new(Identifier::named(CONSUMER_GROUP_NAME).unwrap())
+            .poll(
+                client,
                 &Identifier::named(STREAM_NAME).unwrap(),
                 &Identifier::named(TOPIC_NAME).unwrap(),
-                None,
-                &consumer,
                 &PollingStrategy::offset(0),
-                10,
-                false,
+                poll_options(10, CommitPolicy::Manual),
             )
             .await
             .unwrap();
@@ -360,7 +354,7 @@
     heartbeat.enabled = true,
     heartbeat.interval = "60s",
 ))]
-async fn should_skip_revoked_partitions_in_round_robin(harness: &TestHarness) {
+async fn should_refuse_polls_of_pending_revoked_partitions(harness: &TestHarness) {
     let root_client = harness.root_client().await.unwrap();
     setup_stream_topic_cg(&root_client).await;
     send_one_message_per_partition(&root_client).await;
@@ -372,26 +366,23 @@
         .unwrap();
     join_cg(&client1).await;
 
+    // Client1 owns every partition. A read makes the revocation of its
+    // partition wait for a commit.
     let consumer = Consumer::group(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
-
-    let mut polled_before = HashSet::new();
-    for _ in 0..PARTITIONS_COUNT {
+    for partition_id in 0..PARTITIONS_COUNT {
         let polled = client1
             .poll_messages(
                 &Identifier::named(STREAM_NAME).unwrap(),
                 &Identifier::named(TOPIC_NAME).unwrap(),
-                None,
+                partition_id,
                 &consumer,
                 &PollingStrategy::next(),
-                1,
-                false,
+                poll_options(1, CommitPolicy::Manual),
             )
             .await
             .unwrap();
         assert_eq!(polled.messages.len(), 1);
-        assert!(polled_before.insert(polled.partition_id));
     }
-    assert_eq!(polled_before.len(), PARTITIONS_COUNT as usize);
     let member1_id = get_consumer_group(&client1).await.members[0].id;
 
     let client2 = harness.new_client().await.unwrap();
@@ -404,41 +395,45 @@
     // A partition primary that is a metadata backup fences polls on its own
     // applied metadata, so it can learn client2's join after the join returns.
     let deadline = Instant::now() + backup_commit_budget(harness);
-    let polled_after = loop {
-        let mut polled_after = HashSet::new();
-        for _ in 0..PARTITIONS_COUNT {
-            let polled = client1
+    let refused = loop {
+        let mut refused = Vec::new();
+        for partition_id in 0..PARTITIONS_COUNT {
+            match client1
                 .poll_messages(
                     &Identifier::named(STREAM_NAME).unwrap(),
                     &Identifier::named(TOPIC_NAME).unwrap(),
-                    None,
+                    partition_id,
                     &consumer,
                     &PollingStrategy::next(),
-                    1,
-                    false,
+                    poll_options(1, CommitPolicy::Manual),
                 )
                 .await
-                .unwrap();
-            if !polled.messages.is_empty() {
-                polled_after.insert(polled.partition_id);
+            {
+                Ok(polled) => assert_eq!(
+                    polled.messages.len(),
+                    1,
+                    "Consumer1 must still read its partition {partition_id}"
+                ),
+                Err(IggyError::ConsumerGroupPartitionNotOwned(..)) => refused.push(partition_id),
+                Err(error) => panic!("Consumer1 poll of partition {partition_id} failed: {error}"),
             }
         }
-        if polled_after.len() < PARTITIONS_COUNT as usize {
-            break polled_after;
+        if !refused.is_empty() {
+            break refused;
         }
         assert!(
             Instant::now() < deadline,
-            "Consumer1 should skip revoked partitions but got all {PARTITIONS_COUNT}: {polled_after:?}"
+            "Consumer1 should be refused a revoked partition but read all {PARTITIONS_COUNT}"
         );
         sleep(MEMBER_POLL_INTERVAL).await;
     };
     assert!(
-        !polled_after.is_empty(),
+        refused.len() < PARTITIONS_COUNT as usize,
         "Consumer1 must keep at least 1 partition"
     );
 
     // Client1 never commits and the default rebalancing_timeout outlasts the
-    // wait, so only the pending-revocation fence can explain the skip. Read
+    // wait, so only the pending-revocation fence can explain the refusal. Read
     // through client2: whichever node answers holds the read until it applied
     // client2's join, and a node without the join shows client1 owning all.
     let cg = get_consumer_group(&client2).await;
@@ -447,10 +442,10 @@
         .iter()
         .find(|member| member.id == member1_id)
         .expect("Consumer1 is still a member");
-    for skipped in polled_before.difference(&polled_after) {
+    for partition_id in &refused {
         assert!(
-            member1.partitions.contains(skipped),
-            "Consumer1 skipped partition {skipped} after its revocation completed. \
+            member1.partitions.contains(partition_id),
+            "Consumer1 was refused partition {partition_id} after its revocation completed. \
              Members: {:?}",
             cg.members
         );
@@ -514,17 +509,15 @@
         let stop = stop.clone();
         let count = total_polled_1.clone();
         tokio::spawn(async move {
-            let consumer = Consumer::group(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
+            let mut poller = GroupPoller::new(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
             while !stop.load(Ordering::Relaxed) {
-                let polled = client
-                    .poll_messages(
+                let polled = poller
+                    .poll(
+                        &client,
                         &Identifier::named(STREAM_NAME).unwrap(),
                         &Identifier::named(TOPIC_NAME).unwrap(),
-                        None,
-                        &consumer,
                         &PollingStrategy::next(),
-                        1,
-                        true,
+                        poll_options(1, CommitPolicy::OnRead),
                     )
                     .await;
                 if let Ok(polled) = polled {
@@ -540,17 +533,15 @@
         let stop = stop.clone();
         let count = total_polled_2.clone();
         tokio::spawn(async move {
-            let consumer = Consumer::group(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
+            let mut poller = GroupPoller::new(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
             while !stop.load(Ordering::Relaxed) {
-                let polled = client
-                    .poll_messages(
+                let polled = poller
+                    .poll(
+                        &client,
                         &Identifier::named(STREAM_NAME).unwrap(),
                         &Identifier::named(TOPIC_NAME).unwrap(),
-                        None,
-                        &consumer,
                         &PollingStrategy::next(),
-                        1,
-                        true,
+                        poll_options(1, CommitPolicy::OnRead),
                     )
                     .await;
                 if let Ok(polled) = polled {
@@ -647,17 +638,14 @@
     sleep(Duration::from_millis(200)).await;
 
     // Both poll and auto-commit
-    let consumer = Consumer::group(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
     for client in [&client1, &client2] {
-        let _ = client
-            .poll_messages(
+        let _ = GroupPoller::new(Identifier::named(CONSUMER_GROUP_NAME).unwrap())
+            .poll(
+                client,
                 &Identifier::named(STREAM_NAME).unwrap(),
                 &Identifier::named(TOPIC_NAME).unwrap(),
-                None,
-                &consumer,
                 &PollingStrategy::next(),
-                1,
-                true,
+                poll_options(1, CommitPolicy::OnRead),
             )
             .await
             .unwrap();
@@ -736,15 +724,13 @@
     // 6. Both can poll unique partitions
     let mut polled_partitions = HashSet::new();
     for client in [&client1, &client3] {
-        let polled = client
-            .poll_messages(
+        let polled = GroupPoller::new(Identifier::named(CONSUMER_GROUP_NAME).unwrap())
+            .poll(
+                client,
                 &Identifier::named(STREAM_NAME).unwrap(),
                 &Identifier::named(TOPIC_NAME).unwrap(),
-                None,
-                &consumer,
                 &PollingStrategy::offset(0),
-                1,
-                false,
+                poll_options(1, CommitPolicy::Manual),
             )
             .await
             .unwrap();
@@ -838,24 +824,21 @@
 
     // 2. Start concurrent poll loops
     let stop = Arc::new(AtomicBool::new(false));
-    let consumer = Consumer::group(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
 
     let mut poll_tasks = Vec::new();
     for client in [client1.clone(), client2.clone(), client3.clone()] {
         let stop = stop.clone();
-        let consumer = consumer.clone();
         poll_tasks.push(tokio::spawn(async move {
             let mut count = 0u64;
+            let mut poller = GroupPoller::new(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
             while !stop.load(Ordering::Relaxed) {
-                let polled = client
-                    .poll_messages(
+                let polled = poller
+                    .poll(
+                        &client,
                         &Identifier::named(STREAM_NAME).unwrap(),
                         &Identifier::named(TOPIC_NAME).unwrap(),
-                        None,
-                        &consumer,
                         &PollingStrategy::next(),
-                        1,
-                        true,
+                        poll_options(1, CommitPolicy::OnRead),
                     )
                     .await;
                 if let Ok(p) = polled {
@@ -1060,18 +1043,15 @@
     }
 
     // 6. All 6 consumers poll — must get unique partitions
-    let consumer = Consumer::group(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
     let mut polled_partitions = HashSet::new();
     for client in [&client1, &client2, &client3, &client4, &client5, &client6] {
-        let polled = client
-            .poll_messages(
+        let polled = GroupPoller::new(Identifier::named(CONSUMER_GROUP_NAME).unwrap())
+            .poll(
+                client,
                 &Identifier::named(STREAM_NAME).unwrap(),
                 &Identifier::named(TOPIC_NAME).unwrap(),
-                None,
-                &consumer,
                 &PollingStrategy::offset(0),
-                10,
-                false,
+                poll_options(10, CommitPolicy::Manual),
             )
             .await
             .unwrap();
@@ -1180,18 +1160,15 @@
     }
 
     // 5. Each consumer polls — must get unique partitions, no duplicates
-    let consumer = Consumer::group(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
     let mut polled_partitions = HashSet::new();
     for client in [&client1, &client2, &client3] {
-        let polled = client
-            .poll_messages(
+        let polled = GroupPoller::new(Identifier::named(CONSUMER_GROUP_NAME).unwrap())
+            .poll(
+                client,
                 &Identifier::named(STREAM_NAME).unwrap(),
                 &Identifier::named(TOPIC_NAME).unwrap(),
-                None,
-                &consumer,
                 &PollingStrategy::offset(0),
-                10,
-                false,
+                poll_options(10, CommitPolicy::Manual),
             )
             .await
             .unwrap();
@@ -1238,19 +1215,15 @@
         join_cg(client).await;
     }
 
-    let consumer = Consumer::group(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
-
     // 2. Both consumers poll without committing (in-flight work)
     for client in [&client1, &client2] {
-        let _ = client
-            .poll_messages(
+        let _ = GroupPoller::new(Identifier::named(CONSUMER_GROUP_NAME).unwrap())
+            .poll(
+                client,
                 &Identifier::named(STREAM_NAME).unwrap(),
                 &Identifier::named(TOPIC_NAME).unwrap(),
-                None,
-                &consumer,
                 &PollingStrategy::next(),
-                1,
-                false,
+                poll_options(1, CommitPolicy::Manual),
             )
             .await
             .unwrap();
@@ -1283,16 +1256,15 @@
     let mut seen_by_client: Vec<HashSet<u32>> = Vec::new();
     for client in [&client1, &client2] {
         let mut client_partitions = HashSet::new();
+        let mut poller = GroupPoller::new(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
         for _ in 0..3 {
-            let polled = client
-                .poll_messages(
+            let polled = poller
+                .poll(
+                    client,
                     &Identifier::named(STREAM_NAME).unwrap(),
                     &Identifier::named(TOPIC_NAME).unwrap(),
-                    None,
-                    &consumer,
                     &PollingStrategy::offset(0),
-                    1,
-                    false,
+                    poll_options(1, CommitPolicy::Manual),
                 )
                 .await
                 .unwrap();
@@ -1379,17 +1351,14 @@
     }
 
     // 2. Both consumers poll without committing
-    let consumer = Consumer::group(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
     for client in [&client1, &client2] {
-        let _ = client
-            .poll_messages(
+        let _ = GroupPoller::new(Identifier::named(CONSUMER_GROUP_NAME).unwrap())
+            .poll(
+                client,
                 &Identifier::named(STREAM_NAME).unwrap(),
                 &Identifier::named(TOPIC_NAME).unwrap(),
-                None,
-                &consumer,
                 &PollingStrategy::next(),
-                1,
-                false,
+                poll_options(1, CommitPolicy::Manual),
             )
             .await
             .unwrap();
@@ -1427,15 +1396,13 @@
 
     // 5. Consumers can still poll from remaining partitions without error
     for client in [&client1, &client2] {
-        let polled = client
-            .poll_messages(
+        let polled = GroupPoller::new(Identifier::named(CONSUMER_GROUP_NAME).unwrap())
+            .poll(
+                client,
                 &Identifier::named(STREAM_NAME).unwrap(),
                 &Identifier::named(TOPIC_NAME).unwrap(),
-                None,
-                &consumer,
                 &PollingStrategy::offset(0),
-                1,
-                false,
+                poll_options(1, CommitPolicy::Manual),
             )
             .await
             .unwrap();
@@ -1553,18 +1520,15 @@
     }
 
     // 4. All consumers poll — unique partitions only
-    let consumer = Consumer::group(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
     let mut polled_partitions = HashSet::new();
     for client in [&client1, &client2, &client3] {
-        let polled = client
-            .poll_messages(
+        let polled = GroupPoller::new(Identifier::named(CONSUMER_GROUP_NAME).unwrap())
+            .poll(
+                client,
                 &Identifier::named(STREAM_NAME).unwrap(),
                 &Identifier::named(TOPIC_NAME).unwrap(),
-                None,
-                &consumer,
                 &PollingStrategy::offset(0),
-                10,
-                false,
+                poll_options(10, CommitPolicy::Manual),
             )
             .await
             .unwrap();
@@ -1773,17 +1737,15 @@
         .unwrap();
     join_cg(&client1).await;
 
-    let consumer = Consumer::group(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
+    let mut poller = GroupPoller::new(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
     for _ in 0..PARTITIONS_COUNT {
-        client1
-            .poll_messages(
+        poller
+            .poll(
+                &client1,
                 &Identifier::named(STREAM_NAME).unwrap(),
                 &Identifier::named(TOPIC_NAME).unwrap(),
-                None,
-                &consumer,
                 &PollingStrategy::next(),
-                1,
-                false,
+                poll_options(1, CommitPolicy::Manual),
             )
             .await
             .unwrap();
@@ -1859,17 +1821,15 @@
     assert_eq!(cg.members[0].partitions_count, PARTITIONS_COUNT);
 
     // 2. Consumer1 polls all partitions WITHOUT committing (manual ack)
-    let consumer = Consumer::group(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
+    let mut poller = GroupPoller::new(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
     for _ in 0..PARTITIONS_COUNT {
-        client1
-            .poll_messages(
+        poller
+            .poll(
+                &client1,
                 &Identifier::named(STREAM_NAME).unwrap(),
                 &Identifier::named(TOPIC_NAME).unwrap(),
-                None,
-                &consumer,
                 &PollingStrategy::next(),
-                1,
-                false,
+                poll_options(1, CommitPolicy::Manual),
             )
             .await
             .unwrap();
@@ -1919,15 +1879,13 @@
     }
 
     // 7. Consumer2 should be able to poll from its assigned partition
-    let polled = client2
-        .poll_messages(
+    let polled = GroupPoller::new(Identifier::named(CONSUMER_GROUP_NAME).unwrap())
+        .poll(
+            &client2,
             &Identifier::named(STREAM_NAME).unwrap(),
             &Identifier::named(TOPIC_NAME).unwrap(),
-            None,
-            &consumer,
             &PollingStrategy::offset(0),
-            10,
-            false,
+            poll_options(10, CommitPolicy::Manual),
         )
         .await
         .unwrap();
@@ -1997,8 +1955,6 @@
         .await
         .unwrap();
 
-    let consumer = Consumer::group(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
-
     // 3. Phase 1: 3 clients with stale heartbeat (1h) join CG, one polls, no commit
     let client1 = create_stale_tcp_client(&server_addr).await;
     let client2 = create_stale_tcp_client(&server_addr).await;
@@ -2014,15 +1970,13 @@
 
     // One polls the message (no commit)
     for client in [&client1, &client2, &client3] {
-        let polled = client
-            .poll_messages(
+        let polled = GroupPoller::new(Identifier::named(CONSUMER_GROUP_NAME).unwrap())
+            .poll(
+                client,
                 &Identifier::named(STREAM_NAME).unwrap(),
                 &Identifier::named(TOPIC_NAME).unwrap(),
-                None,
-                &consumer,
                 &PollingStrategy::next(),
-                1,
-                false,
+                poll_options(1, CommitPolicy::Manual),
             )
             .await
             .unwrap();
@@ -2053,10 +2007,14 @@
     let stream = Identifier::named(STREAM_NAME).unwrap();
     let topic = Identifier::named(TOPIC_NAME).unwrap();
     let strategy = PollingStrategy::offset(0);
+    let options = poll_options(10, CommitPolicy::Manual);
+    let mut poller1 = GroupPoller::new(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
+    let mut poller2 = GroupPoller::new(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
+    let mut poller3 = GroupPoller::new(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
     let (p1, p2, p3) = tokio::join!(
-        new_client1.poll_messages(&stream, &topic, None, &consumer, &strategy, 10, false),
-        new_client2.poll_messages(&stream, &topic, None, &consumer, &strategy, 10, false),
-        new_client3.poll_messages(&stream, &topic, None, &consumer, &strategy, 10, false),
+        poller1.poll(&new_client1, &stream, &topic, &strategy, options),
+        poller2.poll(&new_client2, &stream, &topic, &strategy, options),
+        poller3.poll(&new_client3, &stream, &topic, &strategy, options),
     );
 
     let mut consumers_with_message = 0u32;
@@ -2147,8 +2105,6 @@
     let client2 = harness.new_client().await.unwrap();
     let client3 = harness.new_client().await.unwrap();
 
-    let consumer = Consumer::group(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
-
     for client in [&client1, &client2, &client3] {
         client
             .login_user(DEFAULT_ROOT_USERNAME, DEFAULT_ROOT_PASSWORD)
@@ -2171,15 +2127,13 @@
     // 4. One consumer polls the message without committing.
     //    Simulates: handler is mid-processing when app gets killed.
     for client in [&client1, &client2, &client3] {
-        let polled = client
-            .poll_messages(
+        let polled = GroupPoller::new(Identifier::named(CONSUMER_GROUP_NAME).unwrap())
+            .poll(
+                client,
                 &Identifier::named(STREAM_NAME).unwrap(),
                 &Identifier::named(TOPIC_NAME).unwrap(),
-                None,
-                &consumer,
                 &PollingStrategy::next(),
-                1,
-                false,
+                poll_options(1, CommitPolicy::Manual),
             )
             .await
             .unwrap();
@@ -2226,10 +2180,14 @@
     let stream = Identifier::named(STREAM_NAME).expect("stream id");
     let topic = Identifier::named(TOPIC_NAME).expect("topic id");
     let strategy = PollingStrategy::offset(0);
+    let options = poll_options(10, CommitPolicy::Manual);
+    let mut poller1 = GroupPoller::new(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
+    let mut poller2 = GroupPoller::new(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
+    let mut poller3 = GroupPoller::new(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
     let (p1, p2, p3) = tokio::join!(
-        new_client1.poll_messages(&stream, &topic, None, &consumer, &strategy, 10, false),
-        new_client2.poll_messages(&stream, &topic, None, &consumer, &strategy, 10, false),
-        new_client3.poll_messages(&stream, &topic, None, &consumer, &strategy, 10, false),
+        poller1.poll(&new_client1, &stream, &topic, &strategy, options),
+        poller2.poll(&new_client2, &stream, &topic, &strategy, options),
+        poller3.poll(&new_client3, &stream, &topic, &strategy, options),
     );
     let mut consumers_with_message = 0u32;
     for polled in [
@@ -2254,6 +2212,14 @@
         .unwrap();
 }
 
+fn poll_options(count: u32, commit: CommitPolicy) -> PollOptions {
+    PollOptions {
+        count,
+        commit,
+        ..PollOptions::default()
+    }
+}
+
 async fn await_members_count(client: &IggyClient, expected: u32) -> ConsumerGroupDetails {
     await_members_count_within(client, expected, MEMBERS_CONVERGENCE_TIMEOUT).await
 }
@@ -2424,15 +2390,13 @@
 
     // 2. Consumer1 polls a message WITHOUT committing (simulating in-flight processing)
     let consumer = Consumer::group(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
-    let polled1 = client1
-        .poll_messages(
+    let polled1 = GroupPoller::new(Identifier::named(CONSUMER_GROUP_NAME).unwrap())
+        .poll(
+            &client1,
             &Identifier::named(STREAM_NAME).unwrap(),
             &Identifier::named(TOPIC_NAME).unwrap(),
-            None,
-            &consumer,
             &PollingStrategy::next(),
-            1,
-            false, // manual commit - offset NOT stored
+            poll_options(1, CommitPolicy::Manual), // manual commit - offset NOT stored
         )
         .await
         .unwrap();
@@ -2454,15 +2418,13 @@
     // 4. Consumer2 polls - it must NOT get the same partition as consumer1's
     //    in-flight message. Due to cooperative rebalance, the partition with
     //    in-flight work stays with consumer1 until committed.
-    let polled2 = client2
-        .poll_messages(
+    let polled2 = GroupPoller::new(Identifier::named(CONSUMER_GROUP_NAME).unwrap())
+        .poll(
+            &client2,
             &Identifier::named(STREAM_NAME).unwrap(),
             &Identifier::named(TOPIC_NAME).unwrap(),
-            None,
-            &consumer,
             &PollingStrategy::next(),
-            1,
-            false,
+            poll_options(1, CommitPolicy::Manual),
         )
         .await
         .unwrap();
@@ -2529,19 +2491,16 @@
         .unwrap();
     join_cg(&client1).await;
 
-    let consumer = Consumer::group(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
-
     // Poll all 3 partitions with auto-commit
+    let mut poller = GroupPoller::new(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
     for _ in 0..PARTITIONS_COUNT {
-        client1
-            .poll_messages(
+        poller
+            .poll(
+                &client1,
                 &Identifier::named(STREAM_NAME).unwrap(),
                 &Identifier::named(TOPIC_NAME).unwrap(),
-                None,
-                &consumer,
                 &PollingStrategy::next(),
-                1,
-                true, // auto-commit
+                poll_options(1, CommitPolicy::OnRead), // auto-commit
             )
             .await
             .unwrap();
@@ -2657,16 +2616,13 @@
         .unwrap();
     join_cg(&client1).await;
 
-    let consumer = Consumer::group(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
-    let _polled = client1
-        .poll_messages(
+    let _polled = GroupPoller::new(Identifier::named(CONSUMER_GROUP_NAME).unwrap())
+        .poll(
+            &client1,
             &Identifier::named(STREAM_NAME).unwrap(),
             &Identifier::named(TOPIC_NAME).unwrap(),
-            None,
-            &consumer,
             &PollingStrategy::next(),
-            1,
-            false,
+            poll_options(1, CommitPolicy::Manual),
         )
         .await
         .unwrap();
@@ -2698,16 +2654,15 @@
 
     // 5. Consumer2 can actually poll from all partitions
     let mut polled_partitions = HashSet::new();
+    let mut poller = GroupPoller::new(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
     for _ in 0..PARTITIONS_COUNT {
-        let polled = client2
-            .poll_messages(
+        let polled = poller
+            .poll(
+                &client2,
                 &Identifier::named(STREAM_NAME).unwrap(),
                 &Identifier::named(TOPIC_NAME).unwrap(),
-                None,
-                &consumer,
                 &PollingStrategy::offset(0),
-                1,
-                false,
+                poll_options(1, CommitPolicy::Manual),
             )
             .await
             .unwrap();
@@ -2768,15 +2723,13 @@
     join_cg(&client1).await;
 
     // 2. Consumer1 polls 1 message WITHOUT committing (manual ack)
-    let polled1 = client1
-        .poll_messages(
+    let polled1 = GroupPoller::new(Identifier::named(CONSUMER_GROUP_NAME).unwrap())
+        .poll(
+            &client1,
             &Identifier::named(STREAM_NAME).unwrap(),
             &Identifier::named(TOPIC_NAME).unwrap(),
-            None,
-            &consumer,
             &PollingStrategy::next(),
-            1,
-            false, // manual ack - offset NOT committed
+            poll_options(1, CommitPolicy::Manual), // manual ack - offset NOT committed
         )
         .await
         .unwrap();
@@ -2821,16 +2774,15 @@
     all_messages.push((first_partition, first_offset));
 
     for client in [&client1, &client2, &client3] {
+        let mut poller = GroupPoller::new(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
         for _ in 0..20 {
-            let polled = client
-                .poll_messages(
+            let polled = poller
+                .poll(
+                    client,
                     &Identifier::named(STREAM_NAME).unwrap(),
                     &Identifier::named(TOPIC_NAME).unwrap(),
-                    None,
-                    &consumer,
                     &PollingStrategy::next(),
-                    1,
-                    false, // manual ack
+                    poll_options(1, CommitPolicy::Manual), // manual ack
                 )
                 .await
                 .unwrap();
@@ -2905,16 +2857,15 @@
     let consumer = Consumer::group(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
     let mut polled_offsets: Vec<(u32, u64)> = Vec::new();
 
+    let mut poller = GroupPoller::new(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
     for _ in 0..PARTITIONS_COUNT {
-        let polled = client1
-            .poll_messages(
+        let polled = poller
+            .poll(
+                &client1,
                 &Identifier::named(STREAM_NAME).unwrap(),
                 &Identifier::named(TOPIC_NAME).unwrap(),
-                None,
-                &consumer,
                 &PollingStrategy::next(),
-                1,
-                false, // manual commit
+                poll_options(1, CommitPolicy::Manual), // manual commit
             )
             .await
             .unwrap();
@@ -2934,15 +2885,13 @@
 
     // 3. Client2 should get NOTHING - all partitions have pending revocations
     //    that can't complete because client1 hasn't committed
-    let polled2 = client2
-        .poll_messages(
+    let polled2 = GroupPoller::new(Identifier::named(CONSUMER_GROUP_NAME).unwrap())
+        .poll(
+            &client2,
             &Identifier::named(STREAM_NAME).unwrap(),
             &Identifier::named(TOPIC_NAME).unwrap(),
-            None,
-            &consumer,
             &PollingStrategy::next(),
-            1,
-            false,
+            poll_options(1, CommitPolicy::Manual),
         )
         .await
         .unwrap();
@@ -3011,16 +2960,13 @@
         .unwrap();
     join_cg(&client1).await;
 
-    let consumer = Consumer::group(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
-    let _polled = client1
-        .poll_messages(
+    let _polled = GroupPoller::new(Identifier::named(CONSUMER_GROUP_NAME).unwrap())
+        .poll(
+            &client1,
             &Identifier::named(STREAM_NAME).unwrap(),
             &Identifier::named(TOPIC_NAME).unwrap(),
-            None,
-            &consumer,
             &PollingStrategy::next(),
-            1,
-            false,
+            poll_options(1, CommitPolicy::Manual),
         )
         .await
         .unwrap();
@@ -3054,16 +3000,15 @@
 
     // 5. Consumer1 can poll from all partitions
     let mut polled_partitions = HashSet::new();
+    let mut poller = GroupPoller::new(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
     for _ in 0..PARTITIONS_COUNT {
-        let polled = client1
-            .poll_messages(
+        let polled = poller
+            .poll(
+                &client1,
                 &Identifier::named(STREAM_NAME).unwrap(),
                 &Identifier::named(TOPIC_NAME).unwrap(),
-                None,
-                &consumer,
                 &PollingStrategy::offset(0),
-                1,
-                false,
+                poll_options(1, CommitPolicy::Manual),
             )
             .await
             .unwrap();
@@ -3143,19 +3088,16 @@
     }
 
     // Each consumer polls - must get unique partitions
-    let consumer = Consumer::group(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
     let mut polled_partitions = HashSet::new();
 
     for (i, client) in [&client1, &client2, &client3].iter().enumerate() {
-        let polled = client
-            .poll_messages(
+        let polled = GroupPoller::new(Identifier::named(CONSUMER_GROUP_NAME).unwrap())
+            .poll(
+                client,
                 &Identifier::named(STREAM_NAME).unwrap(),
                 &Identifier::named(TOPIC_NAME).unwrap(),
-                None,
-                &consumer,
                 &PollingStrategy::offset(0),
-                10,
-                false,
+                poll_options(10, CommitPolicy::Manual),
             )
             .await
             .unwrap();
@@ -3202,16 +3144,15 @@
     join_cg(&client1).await;
 
     let consumer = Consumer::group(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
+    let mut poller = GroupPoller::new(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
     for _ in 0..PARTITIONS_COUNT {
-        client1
-            .poll_messages(
+        poller
+            .poll(
+                &client1,
                 &Identifier::named(STREAM_NAME).unwrap(),
                 &Identifier::named(TOPIC_NAME).unwrap(),
-                None,
-                &consumer,
                 &PollingStrategy::next(),
-                1,
-                false,
+                poll_options(1, CommitPolicy::Manual),
             )
             .await
             .unwrap();
@@ -3296,16 +3237,15 @@
 
     let consumer = Consumer::group(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
     let mut polled_offsets = Vec::with_capacity(PARTITIONS_COUNT as usize);
+    let mut poller = GroupPoller::new(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
     for _ in 0..PARTITIONS_COUNT {
-        let polled = client1
-            .poll_messages(
+        let polled = poller
+            .poll(
+                &client1,
                 &Identifier::named(STREAM_NAME).unwrap(),
                 &Identifier::named(TOPIC_NAME).unwrap(),
-                None,
-                &consumer,
                 &PollingStrategy::next(),
-                1,
-                false,
+                poll_options(1, CommitPolicy::Manual),
             )
             .await
             .unwrap();
@@ -3392,15 +3332,13 @@
     // 6. Both consumers can poll without duplicates
     let mut all_partitions = HashSet::new();
     for client in [&client1, &client2] {
-        let polled = client
-            .poll_messages(
+        let polled = GroupPoller::new(Identifier::named(CONSUMER_GROUP_NAME).unwrap())
+            .poll(
+                client,
                 &Identifier::named(STREAM_NAME).unwrap(),
                 &Identifier::named(TOPIC_NAME).unwrap(),
-                None,
-                &consumer,
                 &PollingStrategy::offset(0),
-                1,
-                false,
+                poll_options(1, CommitPolicy::Manual),
             )
             .await
             .unwrap();
@@ -3486,18 +3424,15 @@
         );
     }
 
-    let consumer = Consumer::group(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
     let mut polled_partitions = HashSet::new();
     for (i, client) in clients.iter().enumerate() {
-        let polled = client
-            .poll_messages(
+        let polled = GroupPoller::new(Identifier::named(CONSUMER_GROUP_NAME).unwrap())
+            .poll(
+                client,
                 &Identifier::named(STREAM_NAME).unwrap(),
                 &Identifier::named(TOPIC_NAME).unwrap(),
-                None,
-                &consumer,
                 &PollingStrategy::offset(0),
-                10,
-                false,
+                poll_options(10, CommitPolicy::Manual),
             )
             .await
             .unwrap();
diff --git a/core/integration/tests/sdk/consumer_group.rs b/core/integration/tests/sdk/consumer_group.rs
index ba802e4..087f2c7 100644
--- a/core/integration/tests/sdk/consumer_group.rs
+++ b/core/integration/tests/sdk/consumer_group.rs
@@ -100,8 +100,10 @@
     let mut consumer = consumer_client
         .consumer_group(CONSUMER_GROUP_NAME, STREAM_NAME, TOPIC_NAME)
         .unwrap()
-        .batch_length(1)
-        .auto_join_consumer_group()
+        .poll_options(PollOptions {
+            count: 1,
+            ..PollOptions::wait(IggyDuration::ONE_SECOND)
+        })
         .build();
     consumer.init().await.unwrap();
 
@@ -155,7 +157,13 @@
         .await
         .expect("Consumer rejoin should fail before timeout")
         .expect("Consumer stream should remain open");
-    assert!(matches!(rejoin_result, Err(IggyError::Unauthorized)));
+    assert!(matches!(
+        rejoin_result,
+        Err(ReadError {
+            error: IggyError::Unauthorized,
+            ..
+        })
+    ));
 
     root_client
         .update_permissions(&user_id, Some(consumer_permissions))
@@ -178,7 +186,7 @@
     assert_eq!(group.members.len(), 1);
 }
 
-// A group member polls its partitions round-robin. Under a strategy other than `next()` the
+// A group member reads all of its partitions. Under a strategy other than `next()` the
 // continuation must be kept per partition: one shared cursor would ask the second partition for
 // the offset reached in the first one and skip its beginning.
 #[iggy_harness(
@@ -230,8 +238,10 @@
         .consumer_group(CONSUMER_GROUP_NAME, STREAM_NAME, TOPIC_NAME)
         .unwrap()
         .polling_strategy(PollingStrategy::offset(0))
-        .batch_length(MESSAGES_PER_PARTITION)
-        .auto_commit(AutoCommit::Disabled)
+        .poll_options(PollOptions {
+            count: MESSAGES_PER_PARTITION,
+            ..PollOptions::wait(IggyDuration::ONE_SECOND)
+        })
         .build();
     consumer.init().await.unwrap();
 
diff --git a/core/integration/tests/sdk/consumer_group_membership.rs b/core/integration/tests/sdk/consumer_group_membership.rs
index 8c0075b..dc23793 100644
--- a/core/integration/tests/sdk/consumer_group_membership.rs
+++ b/core/integration/tests/sdk/consumer_group_membership.rs
@@ -22,8 +22,8 @@
 use futures::StreamExt;
 use iggy::prelude::locking::IggyRwLockFn;
 use iggy::prelude::*;
-use iggy_common::{BinaryTransport, ConsumerGroupClientState};
-use integration::harness::wait_for_consumer_group_assignment;
+use iggy_common::{BinaryTransport, ConsumerGroupClientState, sync_group_assignment};
+use integration::harness::{fetch_sections, wait_for_consumer_group_assignment};
 use integration::iggy_harness;
 use tokio::time::{sleep, timeout};
 
@@ -108,8 +108,10 @@
     let mut consumer = consumer_client
         .consumer_group(CONSUMER_GROUP_NAME, STREAM_NAME, TOPIC_NAME)
         .unwrap()
-        .batch_length(1)
-        .auto_join_consumer_group()
+        .poll_options(PollOptions {
+            count: 1,
+            ..PollOptions::wait(IggyDuration::ONE_SECOND)
+        })
         .do_not_create_consumer_group_if_not_exists()
         .build();
     consumer.init().await.unwrap();
@@ -156,7 +158,10 @@
         .expect("poll must not hang after the group is deleted")
         .expect("consumer stream should remain open");
     match item {
-        Err(IggyError::ConsumerGroupNameNotFound(..)) => {}
+        Err(ReadError {
+            error: IggyError::ConsumerGroupNameNotFound(..),
+            ..
+        }) => {}
         Err(other) => {
             panic!("expected ConsumerGroupNameNotFound after group deletion, got error {other:?}")
         }
@@ -164,14 +169,14 @@
     }
 }
 
-// A member holding zero partitions gets an empty poll reply whose partition id
-// is the `NO_ASSIGNED_PARTITION` sentinel, so a caller can tell it from an
-// empty partition, which echoes its real id.
+// A member holding zero partitions gets `ConsumerGroupPartitionNotOwned` for a
+// poll of the group's partition, so a caller can tell it from an empty
+// partition, whose poll echoes its real id.
 #[iggy_harness(
     test_client_transport = [Tcp, WebSocket, Quic],
     server(heartbeat.enabled = true, heartbeat.interval = "60s")
 )]
-async fn given_member_holds_no_partitions_when_polled_should_report_no_assigned_partition(
+async fn given_member_holds_no_partitions_when_polling_a_partition_should_refuse_it_as_not_owned(
     harness: &TestHarness,
 ) {
     let stream_id = Identifier::named(STREAM_NAME).unwrap();
@@ -218,15 +223,18 @@
 
     wait_for_assignment(&first_member, 2).await;
     let consumer = Consumer::group(group_id.clone());
+    let options = PollOptions {
+        count: 1,
+        ..PollOptions::default()
+    };
     let owner_poll = first_member
         .poll_messages(
             &stream_id,
             &topic_id,
-            None,
+            0,
             &consumer,
             &PollingStrategy::next(),
-            1,
-            false,
+            options,
         )
         .await
         .unwrap();
@@ -240,28 +248,29 @@
         .poll_messages(
             &stream_id,
             &topic_id,
-            None,
+            0,
             &consumer,
             &PollingStrategy::next(),
-            1,
-            false,
+            options,
         )
-        .await
-        .unwrap();
-    assert!(surplus_poll.messages.is_empty());
-    assert_eq!(
-        surplus_poll.partition_id, NO_ASSIGNED_PARTITION,
-        "a member without partitions must report the sentinel"
+        .await;
+    assert!(
+        matches!(
+            surplus_poll,
+            Err(IggyError::ConsumerGroupPartitionNotOwned(..))
+        ),
+        "a member without partitions must be refused the partition, got {surplus_poll:?}"
     );
 }
 
-// A member built with `do_not_auto_join_consumer_group()` relies on the caller for the join, so
-// polling must not wait for a join the consumer itself never performs.
+// The consumer always joins. A join from a session that is already a member is
+// a no-op, so a member joined by the caller must not lose its partition to the
+// consumer's own join.
 #[iggy_harness(
     test_client_transport = [Tcp, WebSocket, Quic],
     server(heartbeat.enabled = true, heartbeat.interval = "60s")
 )]
-async fn given_member_that_does_not_auto_join_when_joined_by_the_caller_should_receive_messages(
+async fn given_member_joined_by_the_caller_when_the_consumer_joins_again_should_receive_messages(
     harness: &TestHarness,
 ) {
     let stream_id = Identifier::named(STREAM_NAME).unwrap();
@@ -313,14 +322,16 @@
     let mut consumer = client
         .consumer_group(CONSUMER_GROUP_NAME, STREAM_NAME, TOPIC_NAME)
         .unwrap()
-        .batch_length(1)
-        .do_not_auto_join_consumer_group()
+        .poll_options(PollOptions {
+            count: 1,
+            ..PollOptions::wait(IggyDuration::ONE_SECOND)
+        })
         .build();
     consumer.init().await.unwrap();
 
     let received = timeout(RESOLVE_TIMEOUT, consumer.next())
         .await
-        .expect("a member joined by the caller must poll instead of waiting for a join")
+        .expect("a member joined by the caller must poll after the consumer's own join")
         .expect("consumer stream should remain open")
         .expect("the member must receive the message from its assigned partition");
     assert_eq!(received.message.payload, "message");
@@ -471,24 +482,12 @@
         .unwrap();
 
     let consumer = Consumer::group(group_id.clone());
-    // The first group poll syncs the assignment, which is what registers the
-    // membership in the transport cache.
+    // The sync is what registers the membership in the transport cache.
     wait_for_assignment(&client, 1).await;
-    client
-        .poll_messages(
-            &stream_id,
-            &topic_id,
-            None,
-            &consumer,
-            &PollingStrategy::next(),
-            1,
-            false,
-        )
-        .await
-        .unwrap();
+    sync_assignment(&client).await;
 
     let state = consumer_group_state(&client).await;
-    // The key the join and the group poll build from the same identifiers.
+    // The key the join and the sync build from the same identifiers.
     let key = format!("{stream_id}|{topic_id}|{group_id}");
     assert!(
         state.is_registered(&key),
@@ -516,22 +515,148 @@
     assert!(!state.is_registered(&key));
     assert!(!state.has_assignment(&key));
 
-    // The new identity never joined, and the poll must say so instead of
-    // running against the old assignment.
+    // The new identity never joined, so the server refuses its poll of the
+    // partition that the old session owned.
     let poll = client
         .poll_messages(
             &stream_id,
             &topic_id,
-            None,
+            0,
             &consumer,
             &PollingStrategy::next(),
-            1,
-            false,
+            PollOptions {
+                count: 1,
+                ..PollOptions::default()
+            },
         )
         .await;
     assert!(
-        matches!(poll, Err(IggyError::ConsumerGroupMemberNotFound(..))),
-        "expected ConsumerGroupMemberNotFound for a poll without a rejoin, got {poll:?}"
+        matches!(poll, Err(IggyError::ConsumerGroupPartitionNotOwned(..))),
+        "expected ConsumerGroupPartitionNotOwned for a poll without a rejoin, got {poll:?}"
+    );
+}
+
+#[iggy_harness(
+    test_client_transport = [Tcp, WebSocket, Quic],
+    server(heartbeat.enabled = true, heartbeat.interval = "60s")
+)]
+async fn given_stale_assignment_when_fetching_should_refuse_then_resync_owned_partitions(
+    harness: &TestHarness,
+) {
+    const PARTITIONS: u32 = 4;
+    const MAX_WAIT: Duration = Duration::from_secs(10);
+    const PROMPT_REPLY: Duration = Duration::from_secs(2);
+    let stream_id = Identifier::named(STREAM_NAME).unwrap();
+    let topic_id = Identifier::named(TOPIC_NAME).unwrap();
+    let group_id = Identifier::named(CONSUMER_GROUP_NAME).unwrap();
+
+    let mut clients = harness
+        .root_clients(2)
+        .await
+        .expect("Failed to create root clients");
+    let member = clients.remove(0);
+    let other_member = clients.remove(0);
+    member
+        .create_stream(STREAM_NAME, &StreamCreateOptions::default())
+        .await
+        .unwrap();
+    member
+        .create_topic(
+            &stream_id,
+            TOPIC_NAME,
+            &TopicCreateOptions {
+                partitions_count: Some(PARTITIONS),
+                message_expiry: Some(IggyExpiry::NeverExpire),
+                ..TopicCreateOptions::default()
+            },
+        )
+        .await
+        .unwrap();
+    member
+        .create_consumer_group(&stream_id, &topic_id, CONSUMER_GROUP_NAME)
+        .await
+        .unwrap();
+    for client in [&member, &other_member] {
+        client
+            .join_consumer_group(&stream_id, &topic_id, &group_id)
+            .await
+            .unwrap();
+    }
+    wait_for_assignment(&member, 2).await;
+
+    let consumer = Consumer::group(group_id.clone());
+    sync_assignment(&member).await;
+    let state = consumer_group_state(&member).await;
+    let key = format!("{stream_id}|{topic_id}|{group_id}");
+    let (version, mut owned) = state.assignment_snapshot(&key);
+    owned.sort_unstable();
+    assert_eq!(owned.len(), 2, "two members split four partitions");
+    // The member has not synced the rebalance yet.
+    state.set_assignment(key.clone(), version.generation, (0..PARTITIONS).collect());
+
+    let mut messages = vec![IggyMessage::from_str("owned").unwrap()];
+    member
+        .send_messages(
+            &stream_id,
+            &topic_id,
+            &Partitioning::partition_id(owned[0]),
+            &mut messages,
+        )
+        .await
+        .unwrap();
+    let revoked = (0..PARTITIONS)
+        .find(|partition| !owned.contains(partition))
+        .expect("the other member owns a partition");
+    let stale_targets = [(revoked, PollingStrategy::next())];
+    let options = PollOptions {
+        count: 1,
+        ..PollOptions::wait(MAX_WAIT.into())
+    };
+    let refused = timeout(
+        PROMPT_REPLY,
+        fetch_sections(
+            &member,
+            &stream_id,
+            &topic_id,
+            &stale_targets,
+            &consumer,
+            options,
+        ),
+    )
+    .await
+    .expect("a revoked target must answer before waiting");
+    // A target that the member does not own is refused in its own section.
+    let refused = refused.expect("a revoked target does not fail the whole fetch");
+    assert!(
+        matches!(
+            refused.as_slice(),
+            [section] if section.partition_id == revoked
+                && matches!(section.status, Err(IggyError::ConsumerGroupPartitionNotOwned(..)))
+        ),
+        "{refused:?}"
+    );
+    let mut reader = member
+        .reader(&stream_id, &topic_id, &consumer)
+        .start(PollingStrategy::next())
+        .options(options)
+        .build()
+        .await
+        .unwrap();
+    let batch = timeout(PROMPT_REPLY, reader.next())
+        .await
+        .expect("the reader must re-sync and read the owned partition at once")
+        .unwrap();
+    let delivered: Vec<_> = batch
+        .messages
+        .iter()
+        .map(|message| (batch.partition_id, message.header.offset))
+        .collect();
+    assert_eq!(delivered, [(owned[0], 0)]);
+    let mut resynced = state.assignment_snapshot(&key).1;
+    resynced.sort_unstable();
+    assert_eq!(
+        resynced, owned,
+        "the reader must re-sync the cached assignment"
     );
 }
 
@@ -589,22 +714,23 @@
         .poll_messages(
             &stream_id,
             &topic_id,
-            Some(0),
+            0,
             &consumer,
             &PollingStrategy::first(),
-            2,
-            false,
+            PollOptions {
+                count: 2,
+                ..PollOptions::default()
+            },
         )
         .await
         .unwrap();
     assert_eq!(polled.messages.len(), 2);
-    let position = ConsumerPosition {
-        partition_id: 0,
-        offset: STORED_OFFSET,
-        context: polled.context,
-    };
+    // The batch starts at offset 0, so the index of a message is its offset.
+    let position = polled
+        .checkpoint_at(STORED_OFFSET as usize)
+        .expect("the batch holds the stored offset");
     predecessor
-        .store_consumer_position(&consumer, &stream_id, &topic_id, position)
+        .store_consumer_position(&consumer, &stream_id, &topic_id, &position)
         .await
         .unwrap();
 
@@ -625,21 +751,26 @@
         .poll_messages(
             &stream_id,
             &topic_id,
-            Some(0),
+            0,
             &consumer,
             &PollingStrategy::next(),
-            MESSAGE_COUNT,
-            false,
+            PollOptions {
+                count: MESSAGE_COUNT,
+                ..PollOptions::default()
+            },
         )
         .await
         .unwrap();
     assert_eq!(resumed.messages.len(), 1);
     assert_eq!(resumed.messages[0].header.offset, STORED_OFFSET + 1);
-    assert!(resumed.context.owner_generation > position.context.owner_generation);
-    assert_eq!(resumed.context.incarnation, position.context.incarnation);
+    assert!(resumed.context().owner_generation > position.context().owner_generation);
+    assert_eq!(
+        resumed.context().incarnation,
+        position.context().incarnation
+    );
     assert!(matches!(
         successor
-            .store_consumer_position(&consumer, &stream_id, &topic_id, position)
+            .store_consumer_position(&consumer, &stream_id, &topic_id, &position)
             .await,
         Err(IggyError::ConsumerGroupPartitionNotOwned(..))
     ));
@@ -681,6 +812,29 @@
     .await;
 }
 
+/// Syncs the caller's assignment into the transport cache, which registers the
+/// membership there.
+async fn sync_assignment(client: &IggyClient) {
+    let stream_id = Identifier::named(STREAM_NAME).unwrap();
+    let topic_id = Identifier::named(TOPIC_NAME).unwrap();
+    let group_id = Identifier::named(CONSUMER_GROUP_NAME).unwrap();
+    match &*client.client().read().await {
+        ClientWrapper::Tcp(client) => {
+            sync_group_assignment(client, &stream_id, &topic_id, &group_id).await
+        }
+        ClientWrapper::Quic(client) => {
+            sync_group_assignment(client, &stream_id, &topic_id, &group_id).await
+        }
+        ClientWrapper::WebSocket(client) => {
+            sync_group_assignment(client, &stream_id, &topic_id, &group_id).await
+        }
+        ClientWrapper::Http(_) | ClientWrapper::Iggy(_) => {
+            panic!("the consumer-group cache is a binary-transport concern")
+        }
+    }
+    .unwrap();
+}
+
 /// The consumer-group cache lives on the transport `IggyClient` wraps.
 async fn consumer_group_state(client: &IggyClient) -> Arc<ConsumerGroupClientState> {
     match &*client.client().read().await {
diff --git a/core/integration/tests/sdk/consumer_offset.rs b/core/integration/tests/sdk/consumer_offset.rs
index ae4d797..c11b7f7 100644
--- a/core/integration/tests/sdk/consumer_offset.rs
+++ b/core/integration/tests/sdk/consumer_offset.rs
@@ -180,21 +180,24 @@
                 "expected a feature-unavailable rejection for HTTP delete with consumer_kind=consumer_group, got {delete_error:?}"
             );
 
+            // The SDK refuses a group poll over HTTP before it sends anything.
             let poll_error = client
                 .poll_messages(
                     &stream_id,
                     &topic_id,
-                    Some(OFFSET_PARTITION_ID),
+                    OFFSET_PARTITION_ID,
                     consumer,
                     &PollingStrategy::first(),
-                    1,
-                    false,
+                    PollOptions {
+                        count: 1,
+                        ..PollOptions::default()
+                    },
                 )
                 .await
                 .expect_err("HTTP poll must reject a consumer-group kind");
             assert!(
-                is_feature_unavailable(&poll_error),
-                "expected a feature-unavailable rejection for HTTP poll with consumer_kind=consumer_group, got {poll_error:?}"
+                matches!(poll_error, IggyError::FeatureUnavailable),
+                "expected FeatureUnavailable for HTTP poll with consumer_kind=consumer_group, got {poll_error:?}"
             );
         }
     }
@@ -302,11 +305,13 @@
         .poll_messages(
             &stream_id,
             &topic_id,
-            Some(OFFSET_PARTITION_ID),
+            OFFSET_PARTITION_ID,
             &external_group,
             &PollingStrategy::first(),
-            1,
-            false,
+            PollOptions {
+                count: 1,
+                ..PollOptions::default()
+            },
         )
         .await
         .expect_err("HTTP poll must reject an external-group kind");
@@ -364,15 +369,13 @@
 
     // get consumer assigned to last partition
     let mut consumer = client
-        .consumer(
-            CONSUMER_NAME,
-            STREAM_NAME,
-            TOPIC_NAME,
-            ASSIGNED_PARTITION_ID,
-        )
+        .consumer(CONSUMER_NAME, STREAM_NAME, TOPIC_NAME)
         .unwrap()
-        .auto_commit(AutoCommit::Disabled)
-        .batch_length(1)
+        .partitions([ASSIGNED_PARTITION_ID])
+        .poll_options(PollOptions {
+            count: 1,
+            ..PollOptions::wait(IggyDuration::ONE_SECOND)
+        })
         .build();
     consumer.init().await.unwrap();
 
@@ -425,13 +428,14 @@
             .topic_name(topic_name)
             .consumer_name(CONSUMER_NAME)
             .consumer_kind(ConsumerKind::Consumer)
-            .auto_commit(AutoCommit::Disabled)
-            .batch_length(1)
             .create_stream_if_not_exists(true)
             .create_topic_if_not_exists(true)
             .partitions_count(partitions_count)
-            .maybe_partition_id(partition_id)
-            .poll_options(iggy::prelude::DeferredPollOptions::default())
+            .maybe_partitions(partition_id.map(|partition_id| vec![partition_id]))
+            .poll_options(PollOptions {
+                count: 1,
+                ..PollOptions::wait(IggyDuration::ONE_SECOND)
+            })
             .polling_strategy(PollingStrategy::first())
             .polling_retry_interval(NonZeroIggyDuration::ONE_SECOND)
             .init_retries(0)
diff --git a/core/integration/tests/sdk/consumer_shutdown.rs b/core/integration/tests/sdk/consumer_shutdown.rs
index 478878b..23ef162 100644
--- a/core/integration/tests/sdk/consumer_shutdown.rs
+++ b/core/integration/tests/sdk/consumer_shutdown.rs
@@ -29,8 +29,8 @@
 const POLL_TIMEOUT: Duration = Duration::from_secs(10);
 const OFFSET_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
 
-// `AutoCommit::Disabled` leaves every commit to the caller, so the final flush of `shutdown()`
-// must not run either: it would commit a message whose handler failed.
+// The default `CommitPolicy::Manual` leaves every commit to the caller, so the final flush of
+// `shutdown()` must not run either: it would commit a message whose handler failed.
 #[iggy_harness]
 async fn given_disabled_auto_commit_when_shutdown_should_not_store_the_reading_position(
     harness: &TestHarness,
@@ -69,13 +69,10 @@
         .await
         .unwrap();
 
-    // Both messages must come in one batch: with nothing committed, `next()` serves the same
-    // batch again and the consumer's own filter drops it, so a second poll would stall.
     let mut consumer = client
-        .consumer(CONSUMER_NAME, STREAM_NAME, TOPIC_NAME, PARTITION_ID)
+        .consumer(CONSUMER_NAME, STREAM_NAME, TOPIC_NAME)
         .unwrap()
-        .auto_commit(AutoCommit::Disabled)
-        .batch_length(2)
+        .partitions([PARTITION_ID])
         .offset_drain_timeout(IggyDuration::from(OFFSET_DRAIN_TIMEOUT))
         .build();
     consumer.init().await.unwrap();
@@ -110,6 +107,6 @@
         .unwrap();
     assert!(
         stored_offset.is_none(),
-        "nothing must be stored under AutoCommit::Disabled, got {stored_offset:?}"
+        "nothing must be stored under the default client commit policy, got {stored_offset:?}"
     );
 }
diff --git a/core/integration/tests/sdk/decode_diagnostics.rs b/core/integration/tests/sdk/decode_diagnostics.rs
new file mode 100644
index 0000000..1ec04fe
--- /dev/null
+++ b/core/integration/tests/sdk/decode_diagnostics.rs
@@ -0,0 +1,151 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use iggy::prelude::*;
+use iggy_binary_protocol::batch::BATCH_MESSAGE_HEADER_SIZE;
+use integration::iggy_harness;
+use std::str::FromStr;
+use std::sync::Arc;
+use tokio::time::{Duration, timeout};
+
+const STREAM_NAME: &str = "decode-diagnostics-stream";
+const TOPIC_NAME: &str = "decode-diagnostics-topic";
+const CONSUMER_NAME: &str = "decode-diagnostics-consumer";
+const PARTITION_ID: u32 = 0;
+const PAYLOADS: [&str; 3] = ["first", "second", "third"];
+const READ_TIMEOUT: Duration = Duration::from_secs(10);
+
+/// A reader with another client-side key than the producer cannot decrypt the messages. Its
+/// error names the first message and carries its record as served, and a seek to the skip
+/// passes it.
+#[iggy_harness(test_client_transport = [Tcp, WebSocket, Quic])]
+async fn given_a_reader_with_another_key_when_reading_should_report_the_record_as_served(
+    harness: &TestHarness,
+) {
+    let transport = harness.transport().unwrap();
+    let producer_key = Arc::new(EncryptorKind::Aes256Gcm(
+        Aes256GcmEncryptor::new(&[42; 32]).unwrap(),
+    ));
+    let reader_key = Arc::new(EncryptorKind::Aes256Gcm(
+        Aes256GcmEncryptor::new(&[43; 32]).unwrap(),
+    ));
+    let producer = harness
+        .client_builder_for(transport)
+        .unwrap()
+        .with_encryptor(producer_key)
+        .with_root_login()
+        .connect()
+        .await
+        .unwrap();
+    let stream_id = Identifier::named(STREAM_NAME).unwrap();
+    let topic_id = Identifier::named(TOPIC_NAME).unwrap();
+    producer
+        .create_stream(STREAM_NAME, &StreamCreateOptions::default())
+        .await
+        .unwrap();
+    producer
+        .create_topic(
+            &stream_id,
+            TOPIC_NAME,
+            &TopicCreateOptions {
+                partitions_count: Some(1),
+                ..TopicCreateOptions::default()
+            },
+        )
+        .await
+        .unwrap();
+    let mut messages: Vec<IggyMessage> = PAYLOADS
+        .iter()
+        .map(|payload| IggyMessage::from_str(payload).unwrap())
+        .collect();
+    producer
+        .send_messages(
+            &stream_id,
+            &topic_id,
+            &Partitioning::partition_id(PARTITION_ID),
+            &mut messages,
+        )
+        .await
+        .unwrap();
+
+    // The ciphertext records as the server serves them, read without any key.
+    let plain = harness.root_client().await.unwrap();
+    let served = plain
+        .poll_messages(
+            &stream_id,
+            &topic_id,
+            PARTITION_ID,
+            &Consumer::new(Identifier::named(CONSUMER_NAME).unwrap()),
+            &PollingStrategy::offset(0),
+            PollOptions::default(),
+        )
+        .await
+        .unwrap();
+    assert_eq!(served.messages.len(), PAYLOADS.len());
+
+    let reader_client = harness
+        .client_builder_for(transport)
+        .unwrap()
+        .with_encryptor(reader_key)
+        .with_root_login()
+        .connect()
+        .await
+        .unwrap();
+    let mut reader = reader_client
+        .reader(
+            &stream_id,
+            &topic_id,
+            &Consumer::new(Identifier::named(CONSUMER_NAME).unwrap()),
+        )
+        .partitions([PARTITION_ID])
+        .start(PollingStrategy::first())
+        .build()
+        .await
+        .unwrap();
+
+    let error = timeout(READ_TIMEOUT, reader.next())
+        .await
+        .expect("the reader answers")
+        .expect_err("a message that does not decrypt is an error");
+    assert_eq!(error.partition_id, Some(PARTITION_ID));
+    assert_eq!(error.error, IggyError::CannotDecryptData);
+    assert_eq!(error.offset, Some(0), "the first message does not decrypt");
+    let record = error.record.expect("the record as served");
+    let ciphertext = &served.messages[0];
+    assert_eq!(
+        record.len(),
+        BATCH_MESSAGE_HEADER_SIZE + ciphertext.payload.len(),
+        "the record is the frame header and the ciphertext"
+    );
+    assert_eq!(
+        &record[BATCH_MESSAGE_HEADER_SIZE..],
+        &ciphertext.payload[..],
+        "the record carries the ciphertext, not a reserialized message"
+    );
+    let skip = error
+        .skip
+        .expect("a message that does not decode has a skip");
+    assert_eq!(skip.value, 1);
+
+    reader.seek(PARTITION_ID, skip).await.unwrap();
+    let error = timeout(READ_TIMEOUT, reader.next())
+        .await
+        .expect("the reader answers")
+        .expect_err("the second message does not decrypt either");
+    assert_eq!(error.offset, Some(1), "the seek passed the first message");
+    reader.close().await.unwrap();
+}
diff --git a/core/integration/tests/sdk/mod.rs b/core/integration/tests/sdk/mod.rs
index 817d09b..ed49b1b 100644
--- a/core/integration/tests/sdk/mod.rs
+++ b/core/integration/tests/sdk/mod.rs
@@ -20,6 +20,7 @@
 mod consumer_group_membership;
 mod consumer_offset;
 mod consumer_shutdown;
+mod decode_diagnostics;
 mod disconnect_relogin;
 mod hello_world;
 mod http_refresh;
diff --git a/core/integration/tests/sdk/options.rs b/core/integration/tests/sdk/options.rs
index 44959e0..84fa26b 100644
--- a/core/integration/tests/sdk/options.rs
+++ b/core/integration/tests/sdk/options.rs
@@ -190,11 +190,13 @@
         .poll_messages(
             &stream,
             &topic,
-            Some(0),
+            0,
             &Consumer::new(Identifier::numeric(1).unwrap()),
             &PollingStrategy::offset(0),
-            10,
-            false,
+            PollOptions {
+                count: 10,
+                ..PollOptions::default()
+            },
         )
         .await
         .unwrap();
diff --git a/core/integration/tests/sdk/producer/background.rs b/core/integration/tests/sdk/producer/background.rs
index 167f737..2d9c632 100644
--- a/core/integration/tests/sdk/producer/background.rs
+++ b/core/integration/tests/sdk/producer/background.rs
@@ -68,11 +68,13 @@
         .poll_messages(
             &Identifier::named(STREAM_NAME).unwrap(),
             &Identifier::named(TOPIC_NAME).unwrap(),
-            Some(PARTITION_ID),
+            PARTITION_ID,
             &consumer,
             &PollingStrategy::offset(0),
-            messages_count,
-            false,
+            PollOptions {
+                count: messages_count,
+                ..PollOptions::default()
+            },
         )
         .await
         .unwrap();
@@ -237,11 +239,13 @@
         .poll_messages(
             &Identifier::named(STREAM_NAME).unwrap(),
             &Identifier::named(TOPIC_NAME).unwrap(),
-            Some(PARTITION_ID),
+            PARTITION_ID,
             &consumer,
             &PollingStrategy::offset(0),
-            1,
-            false,
+            PollOptions {
+                count: 1,
+                ..PollOptions::default()
+            },
         )
         .await
         .unwrap();
@@ -252,11 +256,13 @@
         .poll_messages(
             &Identifier::named(STREAM_NAME).unwrap(),
             &Identifier::named(TOPIC_NAME).unwrap(),
-            Some(PARTITION_ID),
+            PARTITION_ID,
             &consumer,
             &PollingStrategy::offset(0),
-            1,
-            false,
+            PollOptions {
+                count: 1,
+                ..PollOptions::default()
+            },
         )
         .await
         .unwrap();
@@ -311,11 +317,13 @@
         .poll_messages(
             &Identifier::named(STREAM_NAME).unwrap(),
             &Identifier::named(TOPIC_NAME).unwrap(),
-            Some(PARTITION_ID),
+            PARTITION_ID,
             &consumer,
             &PollingStrategy::offset(0),
-            10,
-            false,
+            PollOptions {
+                count: 10,
+                ..PollOptions::default()
+            },
         )
         .await
         .unwrap();
@@ -367,11 +375,13 @@
         .poll_messages(
             &Identifier::named(STREAM_NAME).unwrap(),
             &Identifier::named(TOPIC_NAME).unwrap(),
-            Some(PARTITION_ID),
+            PARTITION_ID,
             &consumer,
             &PollingStrategy::offset(0),
-            1,
-            false,
+            PollOptions {
+                count: 1,
+                ..PollOptions::default()
+            },
         )
         .await
         .unwrap();
@@ -385,11 +395,13 @@
         .poll_messages(
             &Identifier::named(STREAM_NAME).unwrap(),
             &Identifier::named(TOPIC_NAME).unwrap(),
-            Some(PARTITION_ID),
+            PARTITION_ID,
             &consumer,
             &PollingStrategy::offset(0),
-            1,
-            false,
+            PollOptions {
+                count: 1,
+                ..PollOptions::default()
+            },
         )
         .await
         .unwrap();
@@ -435,11 +447,13 @@
         .poll_messages(
             &Identifier::named(STREAM_NAME).unwrap(),
             &Identifier::named(TOPIC_NAME).unwrap(),
-            Some(PARTITION_ID),
+            PARTITION_ID,
             &consumer,
             &PollingStrategy::offset(0),
-            messages_count,
-            false,
+            PollOptions {
+                count: messages_count,
+                ..PollOptions::default()
+            },
         )
         .await
         .unwrap();
@@ -495,11 +509,13 @@
         .poll_messages(
             &Identifier::named(STREAM_NAME).unwrap(),
             &Identifier::named(TOPIC_NAME).unwrap(),
-            Some(PARTITION_ID),
+            PARTITION_ID,
             &consumer,
             &PollingStrategy::offset(0),
-            messages_count,
-            false,
+            PollOptions {
+                count: messages_count,
+                ..PollOptions::default()
+            },
         )
         .await
         .unwrap();
diff --git a/core/integration/tests/sdk/producer/mod.rs b/core/integration/tests/sdk/producer/mod.rs
index c5dd320..df09d38 100644
--- a/core/integration/tests/sdk/producer/mod.rs
+++ b/core/integration/tests/sdk/producer/mod.rs
@@ -68,11 +68,13 @@
         .poll_messages(
             &Identifier::named(STREAM_NAME).unwrap(),
             &Identifier::named(TOPIC_NAME).unwrap(),
-            Some(PARTITION_ID),
+            PARTITION_ID,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            1,
-            false,
+            PollOptions {
+                count: 1,
+                ..PollOptions::default()
+            },
         )
         .await
         .unwrap();
diff --git a/core/integration/tests/sdk/stream_builder.rs b/core/integration/tests/sdk/stream_builder.rs
index 55eaab4..6d43388 100644
--- a/core/integration/tests/sdk/stream_builder.rs
+++ b/core/integration/tests/sdk/stream_builder.rs
@@ -56,8 +56,10 @@
         .topic_name(TOPIC_NAME)
         .consumer_name(CONSUMER_NAME)
         .consumer_kind(ConsumerKind::Consumer)
-        .auto_commit(AutoCommit::Disabled)
-        .batch_length(1)
+        .poll_options(PollOptions {
+            count: 1,
+            ..PollOptions::wait(IggyDuration::ONE_SECOND)
+        })
         .create_stream_if_not_exists(true)
         .create_topic_if_not_exists(true)
         .partitions_count(1)
diff --git a/core/integration/tests/server/consumer_offset_quota_vsr.rs b/core/integration/tests/server/consumer_offset_quota_vsr.rs
index 0970a0c..4e8077d 100644
--- a/core/integration/tests/server/consumer_offset_quota_vsr.rs
+++ b/core/integration/tests/server/consumer_offset_quota_vsr.rs
@@ -115,11 +115,14 @@
         .poll_messages(
             &stream,
             &topic,
-            Some(PARTITION_ID),
+            PARTITION_ID,
             &first_consumer,
             &PollingStrategy::first(),
-            1,
-            true,
+            PollOptions {
+                count: 1,
+                commit: CommitPolicy::OnRead,
+                ..PollOptions::default()
+            },
         )
         .await
         .expect("new auto-commit consumer fits");
@@ -141,11 +144,14 @@
             .poll_messages(
                 &stream,
                 &topic,
-                Some(PARTITION_ID),
+                PARTITION_ID,
                 &first_consumer,
                 &PollingStrategy::next(),
-                1,
-                true
+                PollOptions {
+                    count: 1,
+                    commit: CommitPolicy::OnRead,
+                    ..PollOptions::default()
+                }
             )
             .await
             .expect("next poll")
@@ -191,26 +197,31 @@
         .poll_messages(
             &stream,
             &topic,
-            Some(PARTITION_ID),
+            PARTITION_ID,
             &rejected_consumer,
             &PollingStrategy::first(),
-            1,
-            true,
+            PollOptions {
+                count: 1,
+                commit: CommitPolicy::OnRead,
+                ..PollOptions::default()
+            },
         )
         .await;
     assert!(
         matches!(poll_rejected, Err(IggyError::TooManyConsumerOffsets)),
-        "auto-commit must not return data when its new key cannot be admitted"
+        "auto-commit must not return data when its new key cannot be admitted: {poll_rejected:?}"
     );
     client
         .poll_messages(
             &stream,
             &topic,
-            Some(PARTITION_ID),
+            PARTITION_ID,
             &rejected_consumer,
             &PollingStrategy::first(),
-            1,
-            false,
+            PollOptions {
+                count: 1,
+                ..PollOptions::default()
+            },
         )
         .await
         .expect("the same poll succeeds when auto-commit is disabled");
diff --git a/core/integration/tests/server/delete_segments.rs b/core/integration/tests/server/delete_segments.rs
index e83629c..7753a69 100644
--- a/core/integration/tests/server/delete_segments.rs
+++ b/core/integration/tests/server/delete_segments.rs
@@ -57,6 +57,13 @@
     false
 }
 
+#[iggy_harness(cluster_nodes = 1, test_client_transport = [Tcp, WebSocket, Quic])]
+async fn given_empty_active_segment_after_retention_when_fetching_should_advance_then_park(
+    harness: &TestHarness,
+) {
+    delete_segments_scenario::run_fetch_after_retention(harness).await;
+}
+
 fn restart_on() -> bool {
     true
 }
diff --git a/core/integration/tests/server/descriptor_exhaustion_vsr.rs b/core/integration/tests/server/descriptor_exhaustion_vsr.rs
index 1e2f9b8..6a88cc7 100644
--- a/core/integration/tests/server/descriptor_exhaustion_vsr.rs
+++ b/core/integration/tests/server/descriptor_exhaustion_vsr.rs
@@ -187,11 +187,14 @@
                 client.poll_messages(
                     &stream_id,
                     &topic_id,
-                    Some(PARTITION_ID),
+                    PARTITION_ID,
                     &consumer,
                     &PollingStrategy::offset(0),
-                    1,
-                    true,
+                    PollOptions {
+                        count: 1,
+                        commit: CommitPolicy::OnRead,
+                        ..PollOptions::default()
+                    },
                 ),
             )
             .await
@@ -229,11 +232,13 @@
         .poll_messages(
             &stream_id,
             &topic_id,
-            Some(PARTITION_ID),
+            PARTITION_ID,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            10,
-            false,
+            PollOptions {
+                count: 10,
+                ..PollOptions::default()
+            },
         )
         .await
         .expect("poll after the restart");
diff --git a/core/integration/tests/server/http_vsr.rs b/core/integration/tests/server/http_vsr.rs
index 23f3493..27f30cd 100644
--- a/core/integration/tests/server/http_vsr.rs
+++ b/core/integration/tests/server/http_vsr.rs
@@ -436,9 +436,9 @@
     let polled = http.poll(STREAM, TOPIC, PARTITION_ID, 0, 10).await;
     assert_eq!(polled.messages.len(), 1, "the sent message must come back");
     assert!(
-        polled.context.metadata_op >= topic_create_op,
+        polled.context().metadata_op >= topic_create_op,
         "the served context {:?} must cover the topic create at op {topic_create_op}",
-        polled.context
+        polled.context()
     );
 }
 
@@ -935,7 +935,7 @@
         StatusCode::CREATED,
         "produce must commit"
     );
-    let mut context = http.poll(STREAM, TOPIC, PARTITION_ID, 0, 1).await.context;
+    let mut context = http.poll(STREAM, TOPIC, PARTITION_ID, 0, 1).await.context();
     context.metadata_op += UNAPPLIED_METADATA_OPS;
 
     let response = http
diff --git a/core/integration/tests/server/partition_view_durability_vsr.rs b/core/integration/tests/server/partition_view_durability_vsr.rs
index bf066de..e72fa82 100644
--- a/core/integration/tests/server/partition_view_durability_vsr.rs
+++ b/core/integration/tests/server/partition_view_durability_vsr.rs
@@ -236,11 +236,13 @@
         .poll_messages(
             &Identifier::named(STREAM_NAME).expect("stream identifier"),
             &Identifier::named(TOPIC_NAME).expect("topic identifier"),
-            Some(PARTITION_ID),
+            PARTITION_ID,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            MESSAGES_COUNT,
-            false,
+            PollOptions {
+                count: MESSAGES_COUNT,
+                ..PollOptions::default()
+            },
         )
         .await?;
     Ok(polled.messages.len() as u32)
diff --git a/core/integration/tests/server/pipelined_frames_vsr.rs b/core/integration/tests/server/pipelined_frames_vsr.rs
index f510f32..76ef16d 100644
--- a/core/integration/tests/server/pipelined_frames_vsr.rs
+++ b/core/integration/tests/server/pipelined_frames_vsr.rs
@@ -23,16 +23,21 @@
 //! each reply before it sends the next frame.
 
 use iggy::prelude::*;
-use iggy_binary_protocol::codes::{GET_ME_CODE, GET_STREAM_CODE, PING_CODE, POLL_MESSAGES_CODE};
+use iggy_binary_protocol::codes::{
+    FETCH_MESSAGES_CODE, GET_ME_CODE, GET_STREAM_CODE, PING_CODE, POLL_MESSAGES_CODE,
+};
 use iggy_binary_protocol::consensus::{Operation, ReplyHeader, result_code};
 use iggy_binary_protocol::requests::consumer_offsets::StoreConsumerOffsetRequest;
-use iggy_binary_protocol::requests::messages::PollMessagesRequest;
+use iggy_binary_protocol::requests::messages::{
+    FetchMessagesRequest, FetchMessagesTarget, PollMessagesRequest,
+};
 use iggy_binary_protocol::requests::streams::{CreateStreamRequest, GetStreamRequest};
 use iggy_binary_protocol::responses::streams::GetStreamResponse;
 use iggy_binary_protocol::{
     AckLevel, HEADER_SIZE, WireConsumer, WireDecode, WireEncode, WireIdentifier, WireName,
     WireOptions, WirePollingStrategy,
 };
+use iggy_common::{DeferredPollOptions, FetchedSection};
 use integration::harness::{TestBinary, TestHarness};
 use integration::iggy_harness;
 
@@ -156,7 +161,7 @@
 async fn given_two_pipelined_next_polls_when_auto_committing_should_return_distinct_messages(
     harness: &mut TestHarness,
 ) {
-    let context = seed_poll_messages(harness).await;
+    let context = seed_poll_messages(harness, 1).await;
     let mut socket = connect(harness).await;
     let session = register_root(&mut socket, CLIENT_ID).await;
     let body = next_poll().to_bytes();
@@ -172,7 +177,7 @@
         let reply = reply_header(&header);
         assert_eq!(reply.status, 0);
         assert_eq!(reply.request, offset + 1);
-        let polled = PolledMessages::from_bytes(body.into()).unwrap();
+        let polled = PolledMessages::from_bytes(body.into(), &PollingStrategy::next()).unwrap();
         assert_eq!(polled.messages.len(), 1);
         assert_eq!(polled.messages[0].header.offset, offset);
     }
@@ -182,7 +187,7 @@
 async fn given_a_pipelined_poll_then_store_when_both_complete_should_preserve_consumer_progress(
     harness: &mut TestHarness,
 ) {
-    let context = seed_poll_messages(harness).await;
+    let context = seed_poll_messages(harness, 1).await;
     let mut socket = connect(harness).await;
     let session = register_root(&mut socket, CLIENT_ID).await;
     let poll = next_poll();
@@ -218,7 +223,7 @@
         "the poll must finish before its following store"
     );
     assert_eq!(reply.status, 0);
-    let polled = PolledMessages::from_bytes(body.into()).unwrap();
+    let polled = PolledMessages::from_bytes(body.into(), &PollingStrategy::next()).unwrap();
     assert_eq!(polled.messages.len(), 1);
     assert_eq!(polled.messages[0].header.offset, 0);
     let (header, _) = read_reply(&mut socket).await;
@@ -242,7 +247,102 @@
 const STREAM: &str = "pipelined-stream";
 const TOPIC: &str = "pipelined-topic";
 
-async fn seed_poll_messages(harness: &mut TestHarness) -> PartitionContext {
+#[iggy_harness(cluster_nodes = 1)]
+async fn given_a_committing_fetch_when_a_store_follows_on_its_second_target_should_preserve_progress(
+    harness: &mut TestHarness,
+) {
+    const FETCH_REQUEST: u64 = 1;
+    const STORE_REQUEST: u64 = 2;
+    const SECOND_PARTITION: u32 = 1;
+    const STORED_OFFSET: u64 = 2;
+    const FIRST_PARTITION_END: u64 = 3;
+    let first_context = seed_poll_messages(harness, 2).await;
+    let client = harness.tcp_root_client().await.unwrap();
+    let stream = Identifier::named(STREAM).unwrap();
+    let topic = Identifier::named(TOPIC).unwrap();
+    let second_context = partition_context(&client, &stream, &topic, SECOND_PARTITION).await;
+    let mut socket = connect(harness).await;
+    let session = register_root(&mut socket, CLIENT_ID).await;
+    let options = DeferredPollOptions::default();
+    let poll = next_poll();
+    let fetch = FetchMessagesRequest {
+        consumer: poll.consumer,
+        stream_id: poll.stream_id,
+        topic_id: poll.topic_id,
+        wait_us: 0,
+        request_timeout_us: options.request_timeout.as_micros(),
+        min_count: 1,
+        count: 1,
+        max_bytes: options.max_bytes,
+        auto_commit: true,
+        targets: [
+            (0, FIRST_PARTITION_END, first_context),
+            (SECOND_PARTITION, 0, second_context),
+        ]
+        .map(|(partition_id, offset, context)| FetchMessagesTarget {
+            partition_id,
+            strategy: WirePollingStrategy::offset(offset),
+            max_bytes: options.max_bytes,
+            incarnation: context.incarnation,
+            owner_generation: context.owner_generation,
+        })
+        .into(),
+    };
+    let body = fetch.to_bytes();
+    let mut header = non_replicated_header(CLIENT_ID, session, FETCH_REQUEST, FETCH_MESSAGES_CODE);
+    header.size = u32::try_from(HEADER_SIZE + body.len()).unwrap();
+    write_frame(&mut socket, &header, &body).await;
+    let store = StoreConsumerOffsetRequest {
+        consumer: fetch.consumer,
+        stream_id: fetch.stream_id,
+        topic_id: fetch.topic_id,
+        partition_id: Some(SECOND_PARTITION),
+        offset: STORED_OFFSET,
+        ack: AckLevel::Quorum,
+    }
+    .to_bytes();
+    let mut header = request_header(
+        Operation::StoreConsumerOffset,
+        CLIENT_ID,
+        session,
+        STORE_REQUEST,
+        store.len(),
+    );
+    header.partition_incarnation = second_context.incarnation;
+    header.minimum_metadata_op = second_context.metadata_op;
+    write_frame(&mut socket, &header, &store).await;
+    let (header, body) = read_reply(&mut socket).await;
+    let reply = reply_header(&header);
+    assert_eq!(
+        reply.request, FETCH_REQUEST,
+        "a store on the second target waits for its committing fetch"
+    );
+    assert_eq!(reply.status, 0);
+    let sections = FetchedSection::from_reply(&body.into()).unwrap();
+    assert_eq!(sections.len(), 1);
+    assert_eq!(sections[0].partition_id, SECOND_PARTITION);
+    assert_eq!(sections[0].decode().unwrap().messages[0].header.offset, 0);
+    let (header, _) = read_reply(&mut socket).await;
+    let reply = reply_header(&header);
+    assert_eq!(reply.request, STORE_REQUEST);
+    assert_eq!(reply.status, 0);
+    let stored = client
+        .get_consumer_offset(
+            &Consumer::new(Identifier::numeric(1).unwrap()),
+            &stream,
+            &topic,
+            Some(SECOND_PARTITION),
+        )
+        .await
+        .unwrap()
+        .unwrap();
+    assert_eq!(
+        stored.stored_offset, STORED_OFFSET,
+        "the earlier fetch must not overwrite the later store"
+    );
+}
+
+async fn seed_poll_messages(harness: &mut TestHarness, partitions: u32) -> PartitionContext {
     let client = harness.tcp_root_client().await.unwrap();
     let stream = Identifier::named(STREAM).unwrap();
     let topic = Identifier::named(TOPIC).unwrap();
@@ -255,7 +355,7 @@
             &stream,
             TOPIC,
             &TopicCreateOptions {
-                partitions_count: Some(1),
+                partitions_count: Some(partitions),
                 messages_required_to_save: Some(1),
                 durability: Durability::Persisted,
                 ..Default::default()
@@ -271,15 +371,17 @@
                 .unwrap()
         })
         .collect::<Vec<_>>();
-    client
-        .send_messages(
-            &stream,
-            &topic,
-            &Partitioning::partition_id(0),
-            &mut messages,
-        )
-        .await
-        .unwrap();
+    for partition in 0..partitions {
+        client
+            .send_messages(
+                &stream,
+                &topic,
+                &Partitioning::partition_id(partition),
+                &mut messages,
+            )
+            .await
+            .unwrap();
+    }
     drop(client);
     // Recovery leaves the messages on disk, so polling must yield for I/O.
     harness.restart_server().await.unwrap();
diff --git a/core/integration/tests/server/poll_semantics_vsr.rs b/core/integration/tests/server/poll_semantics_vsr.rs
index 5df62c0..e4f50e8 100644
--- a/core/integration/tests/server/poll_semantics_vsr.rs
+++ b/core/integration/tests/server/poll_semantics_vsr.rs
@@ -26,8 +26,11 @@
 
 use futures::StreamExt;
 use iggy::prelude::*;
-use integration::harness::wait_for_consumer_group_assignment;
+use integration::harness::{fetch_sections, partition_primary, wait_for_consumer_group_assignment};
 use integration::iggy_harness;
+use rand::rngs::StdRng;
+use rand::{RngExt, SeedableRng};
+use std::collections::BTreeMap;
 use std::str::FromStr;
 use std::sync::Arc;
 use tokio::time::{Duration, sleep};
@@ -62,11 +65,13 @@
         .poll_messages(
             &stream_id,
             &topic_id,
-            Some(7),
+            7,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            1,
-            false,
+            PollOptions {
+                count: 1,
+                ..PollOptions::default()
+            },
         )
         .await;
 
@@ -81,11 +86,13 @@
         .poll_messages(
             &stream_id,
             &topic_id,
-            Some(0),
+            0,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            1,
-            false,
+            PollOptions {
+                count: 1,
+                ..PollOptions::default()
+            },
         )
         .await
         .expect("poll on the existing partition still succeeds");
@@ -104,11 +111,13 @@
         .poll_messages(
             &stream_id,
             &topic_id,
-            Some(0),
+            0,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            1,
-            false,
+            PollOptions {
+                count: 1,
+                ..PollOptions::default()
+            },
         )
         .await;
 
@@ -135,11 +144,13 @@
         .poll_messages(
             &stream_id,
             &topic_id,
-            Some(0),
+            0,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            1,
-            false,
+            PollOptions {
+                count: 1,
+                ..PollOptions::default()
+            },
         )
         .await;
 
@@ -323,11 +334,13 @@
         .poll_messages(
             &stream_id,
             &topic_id,
-            Some(0),
+            0,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            10,
-            false,
+            PollOptions {
+                count: 10,
+                ..PollOptions::default()
+            },
         )
         .await
         .expect("poll all");
@@ -344,11 +357,13 @@
         .poll_messages(
             &stream_id,
             &topic_id,
-            Some(0),
+            0,
             &Consumer::default(),
             &PollingStrategy::timestamp(second_timestamp.into()),
-            10,
-            false,
+            PollOptions {
+                count: 10,
+                ..PollOptions::default()
+            },
         )
         .await
         .expect("poll by timestamp");
@@ -387,18 +402,17 @@
     let topic = Identifier::from_str_value("deferred-topic").unwrap();
     let consumer = Consumer::default();
     let strategy = PollingStrategy::first();
-    let mut poll = Box::pin(client.poll_messages_deferred(
+    let mut poll = Box::pin(client.poll_messages(
         &stream,
         &topic,
-        Some(0),
+        0,
         &consumer,
         &strategy,
-        2,
-        false,
-        DeferredPollOptions {
-            max_wait: 2_000_000.into(),
+        PollOptions {
+            count: 2,
             min_count: 2,
-            ..Default::default()
+            // Far above the run time, so a slow runner cannot end the wait before the second send.
+            ..PollOptions::wait(30_000_000.into())
         },
     ));
     tokio::select! {
@@ -447,18 +461,16 @@
     );
     let started = tokio::time::Instant::now();
     let partial = client
-        .poll_messages_deferred(
+        .poll_messages(
             &stream,
             &topic,
-            Some(0),
+            0,
             &consumer,
             &strategy,
-            3,
-            false,
-            DeferredPollOptions {
-                max_wait: 200_000.into(),
+            PollOptions {
+                count: 3,
                 min_count: 3,
-                ..Default::default()
+                ..PollOptions::wait(200_000.into())
             },
         )
         .await
@@ -469,36 +481,31 @@
         "partial result returned before deadline"
     );
     let empty = client
-        .poll_messages_deferred(
+        .poll_messages(
             &stream,
             &topic,
-            Some(0),
+            0,
             &consumer,
             &PollingStrategy::offset(2),
-            1,
-            false,
-            DeferredPollOptions {
-                max_wait: 50_000.into(),
-                min_count: 1,
-                ..Default::default()
+            PollOptions {
+                count: 1,
+                ..PollOptions::wait(50_000.into())
             },
         )
         .await
         .unwrap();
     assert!(empty.messages.is_empty());
     let immediate = client
-        .poll_messages_deferred(
+        .poll_messages(
             &stream,
             &topic,
-            Some(0),
+            0,
             &consumer,
             &strategy,
-            3,
-            false,
-            DeferredPollOptions {
-                max_wait: 0.into(),
+            PollOptions {
+                count: 3,
                 min_count: 3,
-                ..Default::default()
+                ..PollOptions::default()
             },
         )
         .await
@@ -514,10 +521,11 @@
         http.jwt.decoding_secret = "0123456789abcdef0123456789abcdef"
     )
 )]
-async fn given_deferred_auto_commit_when_cancelled_should_preserve_unseen_messages(
+async fn given_committing_poll_when_dropped_should_settle_through_its_one_reply(
     harness: &TestHarness,
 ) {
-    let client = if harness.transport().unwrap() == TransportProtocol::Http {
+    let http = harness.transport().unwrap() == TransportProtocol::Http;
+    let client = if http {
         harness
             .node(1)
             .http_client()
@@ -563,81 +571,118 @@
         )
         .await
         .unwrap();
-    let mut cancelled = Box::pin(client.poll_messages_deferred(
+    // Over HTTP the server reads after the wait, so the poll ends before the next steps.
+    let max_wait = if http {
+        Duration::from_millis(200)
+    } else {
+        Duration::from_secs(10)
+    };
+    let mut dropped = Box::pin(client.poll_messages(
         &stream,
         &topic,
-        Some(0),
+        0,
         &consumer,
         &strategy,
-        2,
-        true,
-        DeferredPollOptions {
-            max_wait: 200_000.into(),
+        PollOptions {
+            count: 2,
             min_count: 2,
-            ..Default::default()
+            commit: CommitPolicy::OnRead,
+            ..PollOptions::wait(max_wait.into())
         },
     ));
     tokio::select! {
-        result = &mut cancelled => panic!("partial result completed before cancellation: {result:?}"),
+        result = &mut dropped => panic!("partial result completed before the drop: {result:?}"),
         () = sleep(Duration::from_millis(50)) => {}
     }
-    drop(cancelled);
+    drop(dropped);
+    if !http {
+        // The poll waits for a second message, so the cancel wins before any store. The server
+        // answers it once with nothing stored, and that answer opens the partition.
+        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
+        let polled = loop {
+            let polled = client
+                .poll_messages(
+                    &stream,
+                    &topic,
+                    0,
+                    &consumer,
+                    &strategy,
+                    PollOptions {
+                        count: 1,
+                        ..PollOptions::default()
+                    },
+                )
+                .await;
+            match polled {
+                Err(IggyError::PartitionLaneBlocked(0, _))
+                    if tokio::time::Instant::now() < deadline =>
+                {
+                    sleep(Duration::from_millis(50)).await;
+                }
+                polled => break polled,
+            }
+        };
+        let polled = polled.expect("the answer to the cancel opens the partition");
+        assert_eq!(
+            polled
+                .messages
+                .iter()
+                .map(|message| message.header.offset)
+                .collect::<Vec<_>>(),
+            [0],
+            "a cancelled poll stores nothing"
+        );
+        return;
+    }
+    // HTTP has no cancel, so the server can still store the offset after the drop.
     sleep(Duration::from_millis(250)).await;
-    let next = client
-        .poll_messages_deferred(
+    let blocked = client
+        .poll_messages(
             &stream,
             &topic,
-            Some(0),
+            0,
             &consumer,
             &strategy,
-            1,
-            false,
-            DeferredPollOptions {
-                max_wait: 200_000.into(),
-                min_count: 1,
-                ..Default::default()
+            PollOptions {
+                count: 1,
+                ..PollOptions::wait(200_000.into())
             },
         )
-        .await
-        .unwrap();
-    assert_eq!(
-        next.messages.len(),
-        1,
-        "cancelled poll must not accept its partial result"
-    );
-    assert_eq!(next.messages[0].header.offset, 0);
+        .await;
+    let operation = match blocked {
+        Err(IggyError::PartitionLaneBlocked(0, operation)) => operation,
+        other => panic!("a dropped committing poll must block its partition, got {other:?}"),
+    };
+    client.abandon_operation(operation).await.unwrap();
 
+    // The server may have stored offset 0 for the dropped poll, so read from an explicit offset.
     let committed = client
-        .poll_messages_deferred(
+        .poll_messages(
             &stream,
             &topic,
-            Some(0),
+            0,
             &consumer,
-            &strategy,
-            1,
-            true,
-            DeferredPollOptions {
-                max_wait: 500_000.into(),
-                min_count: 1,
-                ..Default::default()
+            &PollingStrategy::offset(0),
+            PollOptions {
+                count: 1,
+                commit: CommitPolicy::OnRead,
+                ..PollOptions::wait(500_000.into())
             },
         )
         .await
-        .unwrap();
+        .expect("an abandoned operation must release its partition");
     assert_eq!(committed.messages[0].header.offset, 0);
     let next = client
-        .poll_messages_deferred(
+        .poll_messages(
             &stream,
             &topic,
-            Some(0),
+            0,
             &consumer,
             &strategy,
-            1,
-            true,
-            DeferredPollOptions {
-                max_wait: 200_000.into(),
-                min_count: 1,
-                ..Default::default()
+            PollOptions {
+                count: 1,
+                commit: CommitPolicy::OnRead,
+                ..PollOptions::wait(200_000.into())
             },
         )
         .await
@@ -674,13 +719,10 @@
         .consumer_group("deferred-group", "group-stream", "group-topic")
         .unwrap()
         .create_consumer_group_if_not_exists()
-        .auto_join_consumer_group()
-        .auto_commit(AutoCommit::Disabled)
         .polling_strategy(PollingStrategy::first())
-        .batch_length(1)
-        .poll_options(DeferredPollOptions {
-            max_wait: Duration::from_secs(2).into(),
-            ..Default::default()
+        .poll_options(PollOptions {
+            count: 1,
+            ..PollOptions::wait(Duration::from_secs(2).into())
         })
         .build();
     consumer.init().await.unwrap();
@@ -708,119 +750,301 @@
     consumer.shutdown().await.unwrap();
 }
 
-#[iggy_harness(test_client_transport = [Tcp, WebSocket, Quic])]
-async fn given_readiness_wait_when_a_message_arrives_should_name_its_partition_without_reading_it(
+#[iggy_harness(cluster_nodes = 1, test_client_transport = [Tcp, WebSocket, Quic])]
+async fn given_group_fetch_when_one_partition_holds_messages_should_not_wait_on_idle_ones(
     harness: &TestHarness,
 ) {
+    const MAX_WAIT: Duration = Duration::from_secs(10);
+    const PROMPT_REPLY: Duration = Duration::from_secs(2);
+    const PARTITIONS: u32 = 3;
+    const FETCHES: u64 = 3;
+    const READY_PARTITION: u32 = 1;
     let client = harness.root_client().await.unwrap();
-    client
-        .create_stream("readiness-stream", &StreamCreateOptions::default())
-        .await
-        .unwrap();
-    let stream = Identifier::from_str_value("readiness-stream").unwrap();
-    client
-        .create_topic(
-            &stream,
-            "readiness-topic",
-            &TopicCreateOptions {
-                partitions_count: Some(2),
-                ..Default::default()
-            },
-        )
-        .await
-        .unwrap();
-    let topic = Identifier::from_str_value("readiness-topic").unwrap();
-    let consumer = Consumer::default();
-    let next = [(0, PollingStrategy::next()), (1, PollingStrategy::next())];
-    let options = DeferredPollOptions {
-        max_wait: Duration::from_secs(2).into(),
-        ..Default::default()
+    let (stream, topic, group) =
+        create_sole_group_member(&client, "ready-stream", PARTITIONS).await;
+    let consumer = Consumer::group(group);
+    let targets: Vec<_> = (0..PARTITIONS)
+        .map(|partition| (partition, PollingStrategy::next()))
+        .collect();
+    let options = PollOptions {
+        count: 1,
+        commit: CommitPolicy::OnRead,
+        ..PollOptions::wait(MAX_WAIT.into())
     };
-    let mut wait = Box::pin(client.wait_for_messages(&stream, &topic, &next, &consumer, options));
-    tokio::select! {
-        result = &mut wait => panic!("wait on empty partitions completed early: {result:?}"),
-        () = sleep(Duration::from_millis(50)) => {}
-    }
-    let mut messages = vec![
-        IggyMessage::builder()
-            .payload("ready".into())
-            .build()
-            .unwrap(),
-    ];
-    client
-        .send_messages(
-            &stream,
-            &topic,
-            &Partitioning::partition_id(1),
-            &mut messages,
+    for offset in 0..FETCHES {
+        send_to_partition(&client, &stream, &topic, READY_PARTITION, 1).await;
+        let sections = tokio::time::timeout(
+            PROMPT_REPLY,
+            fetch_sections(&client, &stream, &topic, &targets, &consumer, options),
         )
         .await
+        .expect("an idle partition must not hold the fetch for max_wait")
         .unwrap();
-    let ready = tokio::time::timeout(Duration::from_secs(3), wait)
+        assert_eq!(
+            sections.len(),
+            PARTITIONS as usize,
+            "every Next target resolves"
+        );
+        let delivered: Vec<_> = sections
+            .iter()
+            .flat_map(|section| {
+                section
+                    .decode()
+                    .unwrap()
+                    .messages
+                    .into_iter()
+                    .map(|message| (section.partition_id, message.header.offset))
+            })
+            .collect();
+        assert_eq!(delivered, [(READY_PARTITION, offset)]);
+        assert_eq!(
+            client
+                .get_consumer_offset(&consumer, &stream, &topic, Some(READY_PARTITION))
+                .await
+                .unwrap()
+                .unwrap()
+                .stored_offset,
+            offset,
+        );
+    }
+}
+
+#[iggy_harness(cluster_nodes = 1, test_client_transport = [Tcp, WebSocket, Quic])]
+async fn given_group_fetch_when_every_partition_is_idle_should_resolve_next_after_max_wait(
+    harness: &TestHarness,
+) {
+    const MAX_WAIT: Duration = Duration::from_secs(2);
+    const EARLY_TOLERANCE: Duration = Duration::from_millis(100);
+    const LATE_TOLERANCE: Duration = Duration::from_secs(1);
+    const PARTITIONS: u32 = 3;
+    let client = harness.root_client().await.unwrap();
+    let (stream, topic, group) = create_sole_group_member(&client, "idle-stream", PARTITIONS).await;
+    let targets: Vec<_> = (0..PARTITIONS)
+        .map(|partition| (partition, PollingStrategy::next()))
+        .collect();
+    let started = tokio::time::Instant::now();
+    let sections = fetch_sections(
+        &client,
+        &stream,
+        &topic,
+        &targets,
+        &Consumer::group(group),
+        PollOptions {
+            count: 1,
+            commit: CommitPolicy::OnRead,
+            ..PollOptions::wait(MAX_WAIT.into())
+        },
+    )
+    .await
+    .unwrap();
+    let elapsed = started.elapsed();
+    assert_eq!(sections.len(), PARTITIONS as usize);
+    for (partition, section) in sections.iter().enumerate() {
+        assert_eq!(section.partition_id, partition as u32);
+        assert_eq!(section.next_offset, 0);
+        assert!(section.decode().unwrap().messages.is_empty());
+    }
+    assert!(
+        elapsed >= MAX_WAIT - EARLY_TOLERANCE && elapsed < MAX_WAIT + LATE_TOLERANCE,
+        "an empty fetch returned after {elapsed:?}, expected about {MAX_WAIT:?}"
+    );
+}
+
+#[iggy_harness(test_client_transport = [Tcp, WebSocket, Quic])]
+async fn given_group_fetch_when_one_partition_keeps_a_backlog_should_read_another_within_max_wait(
+    harness: &TestHarness,
+) {
+    const MAX_WAIT: Duration = Duration::from_secs(2);
+    const LATE_TOLERANCE: Duration = Duration::from_secs(1);
+    const PARTITIONS: u32 = 2;
+    const BACKLOG_PARTITION: u32 = 0;
+    const LATE_PARTITION: u32 = 1;
+    const BACKLOG_BATCH: usize = 2;
+    let client = harness.root_client().await.unwrap();
+    let (stream, topic, _) = create_sole_group_member(&client, "backlog-stream", PARTITIONS).await;
+    let mut consumer = client
+        .consumer_group(GROUP, "backlog-stream", TOPIC)
+        .unwrap()
+        .polling_strategy(PollingStrategy::next())
+        .poll_options(PollOptions {
+            count: 1,
+            commit: CommitPolicy::OnRead,
+            ..PollOptions::wait(MAX_WAIT.into())
+        })
+        .build();
+    consumer.init().await.unwrap();
+    send_to_partition(&client, &stream, &topic, BACKLOG_PARTITION, BACKLOG_BATCH).await;
+    let first = tokio::time::timeout(MAX_WAIT + LATE_TOLERANCE, consumer.next())
+        .await
+        .unwrap()
+        .unwrap()
+        .unwrap();
+    assert_eq!(first.partition_id, BACKLOG_PARTITION);
+    send_to_partition(&client, &stream, &topic, LATE_PARTITION, 1).await;
+    let late = tokio::time::timeout(MAX_WAIT + LATE_TOLERANCE, async {
+        loop {
+            send_to_partition(&client, &stream, &topic, BACKLOG_PARTITION, BACKLOG_BATCH).await;
+            let delivered = consumer.next().await.unwrap().unwrap();
+            if delivered.partition_id == LATE_PARTITION {
+                return delivered;
+            }
+            assert_eq!(delivered.partition_id, BACKLOG_PARTITION);
+        }
+    })
+    .await
+    .expect("a backlog on one partition must not hide another for longer than max_wait");
+    assert_eq!(late.message.header.offset, 0);
+    consumer.shutdown().await.unwrap();
+}
+
+#[iggy_harness(cluster_nodes = 1, test_client_transport = [Tcp, WebSocket, Quic])]
+async fn given_fetch_when_a_message_arrives_should_return_its_section_without_committing(
+    harness: &TestHarness,
+) {
+    const READY_PARTITION: u32 = 1;
+    const PARK_TIME: Duration = Duration::from_millis(50);
+    const REPLY_TIMEOUT: Duration = Duration::from_secs(3);
+    let client = harness.root_client().await.unwrap();
+    let (stream, topic, _) = create_sole_group_member(&client, "fetch-stream", 2).await;
+    let consumer = Consumer::default();
+    let targets = [
+        (0, PollingStrategy::next()),
+        (READY_PARTITION, PollingStrategy::next()),
+    ];
+    let options = PollOptions {
+        count: 1,
+        ..PollOptions::wait(Duration::from_secs(2).into())
+    };
+    let mut fetch = Box::pin(fetch_sections(
+        &client, &stream, &topic, &targets, &consumer, options,
+    ));
+    tokio::select! {
+        result = &mut fetch => panic!("fetch on empty partitions completed early: {result:?}"),
+        () = sleep(PARK_TIME) => {}
+    }
+    send_to_partition(&client, &stream, &topic, READY_PARTITION, 1).await;
+    let sections = tokio::time::timeout(REPLY_TIMEOUT, fetch)
         .await
         .unwrap()
         .unwrap();
-    assert_eq!(ready, [1]);
+    assert_eq!(
+        sections.len(),
+        targets.len(),
+        "every Next target has a resolved start"
+    );
+    let idle = &sections[0];
+    assert_eq!(
+        (idle.partition_id, idle.next_offset, idle.messages_count),
+        (0, 0, 0)
+    );
+    let ready = &sections[1];
+    assert_eq!(
+        (ready.partition_id, ready.next_offset, ready.messages_count),
+        (READY_PARTITION, 1, 1)
+    );
+    assert_eq!(ready.decode().unwrap().messages[0].header.offset, 0);
     assert!(
         client
-            .get_consumer_offset(&consumer, &stream, &topic, Some(1))
+            .get_consumer_offset(&consumer, &stream, &topic, Some(READY_PARTITION))
             .await
             .unwrap()
             .is_none(),
-        "a wait stores no offset"
+        "a non-committing fetch stores no offset"
     );
 
-    let now = DeferredPollOptions {
-        max_wait: 0.into(),
-        ..Default::default()
+    let immediate = PollOptions {
+        count: 1,
+        ..PollOptions::default()
     };
-    let offsets = [
-        (0, PollingStrategy::offset(0)),
-        (1, PollingStrategy::offset(0)),
-    ];
-    assert_eq!(
-        client
-            .wait_for_messages(&stream, &topic, &offsets, &consumer, now)
-            .await
-            .unwrap(),
-        [1],
-        "a zero wait reports what is ready now"
-    );
-    let past_head = [(1, PollingStrategy::offset(1))];
+    let continued: Vec<_> = sections
+        .iter()
+        .map(|section| {
+            (
+                section.partition_id,
+                PollingStrategy::continuation(
+                    section.partition_id,
+                    PollingKind::Offset,
+                    section.next_offset,
+                    section.context,
+                ),
+            )
+        })
+        .collect();
     assert!(
-        client
-            .wait_for_messages(&stream, &topic, &past_head, &consumer, now)
+        fetch_sections(&client, &stream, &topic, &continued, &consumer, immediate)
             .await
             .unwrap()
-            .is_empty()
+            .is_empty(),
+        "continuing from the section table must not replay records"
     );
-    let polled = client
-        .poll_messages(
+    let replayed = fetch_sections(
+        &client,
+        &stream,
+        &topic,
+        &[(READY_PARTITION, PollingStrategy::next())],
+        &consumer,
+        immediate,
+    )
+    .await
+    .unwrap();
+    assert_eq!(replayed[0].decode().unwrap().messages[0].header.offset, 0);
+    assert!(matches!(
+        fetch_sections(
+            &client,
             &stream,
             &topic,
-            Some(1),
+            &[(READY_PARTITION, PollingStrategy::first())],
             &consumer,
-            &PollingStrategy::next(),
-            1,
-            false,
+            immediate
         )
-        .await
-        .unwrap();
-    assert_eq!(polled.messages[0].header.offset, 0);
-    assert!(matches!(
-        client
-            .wait_for_messages(
-                &stream,
-                &topic,
-                &[(1, PollingStrategy::first())],
-                &consumer,
-                now
-            )
-            .await,
+        .await,
         Err(IggyError::InvalidCommand)
     ));
 }
 
+#[iggy_harness(cluster_nodes = 1, test_client_transport = [Tcp, WebSocket, Quic])]
+async fn given_repeated_fetches_when_each_partition_receives_a_message_should_return_it(
+    harness: &TestHarness,
+) {
+    const PARTITIONS: u32 = 8;
+    const PARK_TIME: Duration = Duration::from_millis(50);
+    const REPLY_TIMEOUT: Duration = Duration::from_secs(3);
+    let client = harness.root_client().await.unwrap();
+    let (stream, topic, _) = create_sole_group_member(&client, "watch-stream", PARTITIONS).await;
+    let consumer = Consumer::default();
+    let options = PollOptions {
+        count: 1,
+        ..PollOptions::wait(Duration::from_secs(2).into())
+    };
+    let mut targets: Vec<_> = (0..PARTITIONS)
+        .map(|partition| (partition, PollingStrategy::offset(0)))
+        .collect();
+    for partition in 0..PARTITIONS {
+        let mut fetch = Box::pin(fetch_sections(
+            &client, &stream, &topic, &targets, &consumer, options,
+        ));
+        tokio::select! {
+            result = &mut fetch => panic!("fetch completed before its message: {result:?}"),
+            () = sleep(PARK_TIME) => {}
+        }
+        send_to_partition(&client, &stream, &topic, partition, 1).await;
+        let sections = tokio::time::timeout(REPLY_TIMEOUT, fetch)
+            .await
+            .unwrap()
+            .unwrap();
+        assert_eq!(sections.len(), 1);
+        let section = &sections[0];
+        assert_eq!((section.partition_id, section.next_offset), (partition, 1));
+        assert_eq!(section.decode().unwrap().messages[0].header.offset, 0);
+        targets[partition as usize].1 = PollingStrategy::continuation(
+            section.partition_id,
+            PollingKind::Offset,
+            section.next_offset,
+            section.context,
+        );
+    }
+}
+
 #[iggy_harness(test_client_transport = [Tcp, WebSocket, Quic])]
 async fn given_commit_on_poll_group_when_the_application_pauses_should_commit_only_delivered_messages(
     harness: &TestHarness,
@@ -849,13 +1073,11 @@
         .consumer_group("on-demand-group", "on-demand-stream", "on-demand-topic")
         .unwrap()
         .create_consumer_group_if_not_exists()
-        .auto_join_consumer_group()
-        .auto_commit(AutoCommit::When(AutoCommitWhen::PollingMessages))
         .polling_strategy(PollingStrategy::next())
-        .batch_length(1)
-        .poll_options(DeferredPollOptions {
-            max_wait: Duration::from_secs(2).into(),
-            ..Default::default()
+        .poll_options(PollOptions {
+            count: 1,
+            commit: CommitPolicy::OnRead,
+            ..PollOptions::wait(Duration::from_secs(2).into())
         })
         .build();
     consumer.init().await.unwrap();
@@ -948,9 +1170,11 @@
         .consumer_group("shutdown-group", "shutdown-stream", TOPIC)
         .unwrap()
         .create_consumer_group_if_not_exists()
-        .auto_join_consumer_group()
-        .auto_commit(AutoCommit::When(AutoCommitWhen::PollingMessages))
-        .batch_length(SENT)
+        .poll_options(PollOptions {
+            count: SENT,
+            commit: CommitPolicy::OnRead,
+            ..PollOptions::wait(IggyDuration::ONE_SECOND)
+        })
         .build();
     consumer.init().await.unwrap();
     read_offsets(&mut consumer, READ).await;
@@ -963,28 +1187,29 @@
 }
 
 #[iggy_harness(test_client_transport = [Tcp])]
-async fn given_interval_and_poll_commits_when_the_application_pauses_should_store_the_last_delivered_offset(
+async fn given_commit_on_poll_consumer_when_shut_down_mid_batch_should_store_the_last_delivered_offset(
     harness: &TestHarness,
 ) {
     const SENT: u32 = 10;
     const READ: u64 = 3;
     let client = harness.root_client().await.unwrap();
-    let (stream, topic) = create_partition_with_messages(&client, "interval-stream", SENT).await;
+    let (stream, topic) = create_partition_with_messages(&client, "on-read-stream", SENT).await;
     let mut consumer = client
-        .consumer("interval-reader", "interval-stream", TOPIC, 0)
+        .consumer("on-read-reader", "on-read-stream", TOPIC)
         .unwrap()
-        .auto_commit(AutoCommit::IntervalOrWhen(
-            NonZeroIggyDuration::new(Duration::from_millis(50)).unwrap(),
-            AutoCommitWhen::PollingMessages,
-        ))
-        .batch_length(SENT)
+        .partitions([0])
+        .poll_options(PollOptions {
+            count: SENT,
+            commit: CommitPolicy::OnRead,
+            ..PollOptions::wait(IggyDuration::ONE_SECOND)
+        })
         .build();
     consumer.init().await.unwrap();
 
     read_offsets(&mut consumer, READ).await;
 
-    let reader = Consumer::new(Identifier::named("interval-reader").unwrap());
-    wait_for_stored_offset(&client, &reader, &stream, &topic, READ - 1).await;
+    let reader = Consumer::new(Identifier::named("on-read-reader").unwrap());
+    wait_for_stored_offset(&client, &reader, &stream, &topic, u64::from(SENT) - 1).await;
     consumer.shutdown().await.unwrap();
     wait_for_stored_offset(&client, &reader, &stream, &topic, READ - 1).await;
 }
@@ -1040,18 +1265,15 @@
 
     let started = tokio::time::Instant::now();
     let result = client
-        .poll_messages_deferred(
+        .poll_messages(
             &stream,
             &topic,
-            None,
+            0,
             &Consumer::group(group.clone()),
             &PollingStrategy::first(),
-            1,
-            false,
-            DeferredPollOptions {
-                max_wait: 10_000_000.into(),
-                min_count: 1,
-                ..Default::default()
+            PollOptions {
+                count: 1,
+                ..PollOptions::wait(10_000_000.into())
             },
         )
         .await
@@ -1096,17 +1318,15 @@
     let topic = Identifier::named("topic").unwrap();
     let consumer = Consumer::default();
     let strategy = PollingStrategy::offset(0);
-    let mut poll = Box::pin(client.poll_messages_deferred(
+    let mut poll = Box::pin(client.poll_messages(
         &stream,
         &topic,
-        Some(0),
+        0,
         &consumer,
         &strategy,
-        100,
-        false,
-        DeferredPollOptions {
-            max_wait: Duration::from_secs(5).into(),
-            ..Default::default()
+        PollOptions {
+            count: 100,
+            ..PollOptions::wait(Duration::from_secs(5).into())
         },
     ));
     tokio::select! {
@@ -1170,23 +1390,25 @@
             + iggy_binary_protocol::batch::BATCH_HEADER_SIZE
             + iggy_binary_protocol::batch::BATCH_MESSAGE_HEADER_SIZE
             + PAYLOAD.len()) as u32;
-    let options = DeferredPollOptions {
+    let options = PollOptions {
+        count: 3,
         min_count: 3,
         max_bytes,
-        ..Default::default()
+        ..PollOptions::wait(IggyDuration::ONE_SECOND)
     };
     for offset in 0..3 {
         let polled = tokio::time::timeout(
             Duration::from_millis(700),
-            client.poll_messages_deferred(
+            client.poll_messages(
                 &stream,
                 &topic,
-                Some(0),
+                0,
                 &Consumer::default(),
                 &PollingStrategy::next(),
-                3,
-                true,
-                options,
+                PollOptions {
+                    commit: CommitPolicy::OnRead,
+                    ..options
+                },
             ),
         )
         .await
@@ -1195,43 +1417,62 @@
         assert_eq!(polled.messages.len(), 1);
         assert_eq!(polled.messages[0].header.offset, offset);
     }
+    // The server returns a first record larger than `max_bytes` alone, in the one reply of the
+    // request.
     let oversized = client
-        .poll_messages_deferred(
+        .poll_messages(
             &stream,
             &topic,
-            Some(0),
+            0,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            3,
-            false,
-            DeferredPollOptions {
+            PollOptions {
                 max_bytes: max_bytes - 1,
                 ..options
             },
         )
-        .await;
-    let error = oversized.expect_err("an oversized first record must fail, never disappear");
-    let code = if let IggyError::HttpResponseError(_, body) = &error {
-        serde_json::from_str::<serde_json::Value>(body).unwrap()["id"]
-            .as_u64()
-            .unwrap()
-    } else {
-        u64::from(error.as_code())
-    };
+        .await
+        .expect("an oversized first record must come alone, never fail or disappear");
     assert_eq!(
-        code,
-        u64::from(IggyError::InvalidSizeBytes.as_code()),
-        "{error:?}"
+        oversized
+            .messages
+            .iter()
+            .map(|message| message.header.offset)
+            .collect::<Vec<_>>(),
+        [0]
     );
-    let replay = client
-        .poll_messages_deferred(
+    // A `Last` start counts back from the end of the partition, and its first record comes alone
+    // in the same way.
+    let last = client
+        .poll_messages(
             &stream,
             &topic,
-            Some(0),
+            0,
+            &Consumer::default(),
+            &PollingStrategy::last(),
+            PollOptions {
+                count: 2,
+                min_count: 1,
+                max_bytes: max_bytes - 1,
+                ..options
+            },
+        )
+        .await
+        .expect("an oversized first record of a `Last` start must come alone");
+    assert_eq!(
+        last.messages
+            .iter()
+            .map(|message| message.header.offset)
+            .collect::<Vec<_>>(),
+        [1]
+    );
+    let replay = client
+        .poll_messages(
+            &stream,
+            &topic,
+            0,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            3,
-            false,
             options,
         )
         .await
@@ -1274,9 +1515,13 @@
         .await
         .unwrap();
     let mut consumer = client
-        .consumer("safe-reader", "safe-stream", "topic", 0)
+        .consumer("safe-reader", "safe-stream", "topic")
         .unwrap()
-        .batch_length(2)
+        .partitions([0])
+        .poll_options(PollOptions {
+            count: 2,
+            ..PollOptions::wait(IggyDuration::ONE_SECOND)
+        })
         .prefetch_messages(4)
         .build();
     consumer.init().await.unwrap();
@@ -1298,9 +1543,13 @@
             .is_none()
     );
     let mut restarted = client
-        .consumer("safe-reader", "safe-stream", "topic", 0)
+        .consumer("safe-reader", "safe-stream", "topic")
         .unwrap()
-        .batch_length(2)
+        .partitions([0])
+        .poll_options(PollOptions {
+            count: 2,
+            ..PollOptions::wait(IggyDuration::ONE_SECOND)
+        })
         .build();
     restarted.init().await.unwrap();
     let first = tokio::time::timeout(Duration::from_secs(2), restarted.next())
@@ -1312,8 +1561,9 @@
     restarted.store_offset(0, Some(0)).await.unwrap();
     drop(restarted);
     let mut committed = client
-        .consumer("safe-reader", "safe-stream", "topic", 0)
+        .consumer("safe-reader", "safe-stream", "topic")
         .unwrap()
+        .partitions([0])
         .build();
     committed.init().await.unwrap();
     let next = tokio::time::timeout(Duration::from_secs(2), committed.next())
@@ -1352,10 +1602,6 @@
         .create_consumer_group(&stream, &topic, "readers")
         .await
         .unwrap();
-    client
-        .join_consumer_group(&stream, &topic, &group)
-        .await
-        .unwrap();
     let mut messages = (0..10)
         .map(|_| IggyMessage::from_str("buffered").unwrap())
         .collect::<Vec<_>>();
@@ -1371,8 +1617,10 @@
     let mut consumer = client
         .consumer_group("readers", "revoke-stream", "topic")
         .unwrap()
-        .do_not_auto_join_consumer_group()
-        .batch_length(10)
+        .poll_options(PollOptions {
+            count: 10,
+            ..PollOptions::wait(IggyDuration::ONE_SECOND)
+        })
         .prefetch_messages(10)
         .build();
     consumer.init().await.unwrap();
@@ -1392,19 +1640,246 @@
         .await
         .unwrap();
     sleep(Duration::from_millis(1200)).await;
-    let received = tokio::time::timeout(Duration::from_secs(2), consumer.next())
+    // The reader joins again at an assignment refresh and reads the group position after a retry
+    // interval, so the next message comes from a new read.
+    let received = tokio::time::timeout(REJOIN_READ_TIMEOUT, consumer.next())
         .await
         .unwrap()
-        .unwrap();
-    assert!(
-        received.is_err(),
+        .unwrap()
+        .expect("the member joins again and reads on");
+    assert_eq!(
+        received.message.header.offset, 0,
         "revoked prefetched messages must never reach the application"
     );
     consumer.shutdown().await.unwrap();
 }
 
+#[iggy_harness(cluster_nodes = 1, test_client_transport = [Tcp, WebSocket, Quic])]
+async fn given_one_prefetch_slot_when_a_fetch_has_many_sections_should_deliver_every_section(
+    harness: &TestHarness,
+) {
+    const PARTITIONS: u32 = 3;
+    const PARK_TIME: Duration = Duration::from_millis(100);
+    const REPLY_TIMEOUT: Duration = Duration::from_secs(3);
+    let client = harness.root_client().await.unwrap();
+    let (stream, topic, _) = create_sole_group_member(&client, "prefetch-stream", PARTITIONS).await;
+    let options = PollOptions {
+        count: PARTITIONS,
+        min_count: PARTITIONS,
+        ..PollOptions::wait(Duration::from_secs(2).into())
+    };
+    let mut consumer = client
+        .consumer_group(GROUP, "prefetch-stream", TOPIC)
+        .unwrap()
+        .polling_strategy(PollingStrategy::offset(0))
+        .prefetch_messages(PARTITIONS)
+        .prefetch_bytes(options.max_bytes)
+        .poll_options(options)
+        .build();
+    consumer.init().await.unwrap();
+    let mut first = Box::pin(consumer.next());
+    tokio::select! {
+        result = &mut first => panic!("consumer completed before its messages: {result:?}"),
+        () = sleep(PARK_TIME) => {}
+    }
+    for partition in 0..PARTITIONS {
+        send_to_partition(&client, &stream, &topic, partition, 1).await;
+    }
+    let first = tokio::time::timeout(REPLY_TIMEOUT, first)
+        .await
+        .unwrap()
+        .unwrap()
+        .unwrap();
+    let mut delivered = vec![(first.partition_id, first.message.header.offset)];
+    for _ in 1..PARTITIONS {
+        let message = tokio::time::timeout(REPLY_TIMEOUT, consumer.next())
+            .await
+            .expect("the remaining sections must survive one-slot backpressure")
+            .unwrap()
+            .unwrap();
+        delivered.push((message.partition_id, message.message.header.offset));
+    }
+    delivered.sort_unstable();
+    assert_eq!(delivered, [(0, 0), (1, 0), (2, 0)]);
+    consumer.shutdown().await.unwrap();
+}
+
+#[iggy_harness(cluster_nodes = 3)]
+async fn given_seeded_fetches_when_replies_are_lost_or_cancelled_should_preserve_partition_progress(
+    harness: &TestHarness,
+) {
+    const SEED: u64 = 109;
+    const PARTITIONS: u32 = 6;
+    const ROUNDS: usize = 12;
+    const MAX_BATCH: usize = 3;
+    const MAX_FETCHES: usize = ROUNDS * MAX_BATCH;
+    const FETCH_BYTES: u32 = 512;
+    const CANCEL_EVERY: usize = 3;
+    const DROP_EVERY: usize = 2;
+    const PARK_TIME: Duration = Duration::from_millis(50);
+    let client = harness.tcp_root_client().await.unwrap();
+    let (stream, topic, _) =
+        create_sole_group_member(&client, "fetch-model-stream", PARTITIONS).await;
+    let consumer = Consumer::default();
+    let mut primaries = BTreeMap::<u8, Vec<u32>>::new();
+    for partition in 0..PARTITIONS {
+        let primary = partition_primary(&client, &stream, &topic, partition)
+            .await
+            .unwrap();
+        primaries.entry(primary).or_default().push(partition);
+    }
+    let groups: Vec<_> = primaries.into_values().collect();
+    assert!(
+        groups.len() > 1,
+        "the sequence must exercise more than one primary"
+    );
+    let mut rng = StdRng::seed_from_u64(SEED);
+    let mut produced = [0_u64; PARTITIONS as usize];
+    let mut positions = [(0_u64, None); PARTITIONS as usize];
+    let mut dropped_replies = 0;
+    let options = PollOptions {
+        max_bytes: FETCH_BYTES,
+        ..PollOptions::default()
+    };
+    for round in 0..ROUNDS {
+        let partition = rng.random_range(0..PARTITIONS);
+        if round % CANCEL_EVERY == 0 {
+            let targets = [(
+                partition,
+                PollingStrategy::offset(produced[partition as usize]),
+            )];
+            let mut parked = Box::pin(fetch_sections(
+                &client,
+                &stream,
+                &topic,
+                &targets,
+                &consumer,
+                PollOptions {
+                    count: 1,
+                    max_wait: Duration::from_secs(2).into(),
+                    ..options
+                },
+            ));
+            tokio::select! {
+                result = &mut parked => panic!("seed {SEED}, round {round}: empty fetch did not park: {result:?}"),
+                () = sleep(PARK_TIME) => {}
+            }
+            drop(parked);
+        }
+        let batch = rng.random_range(1..=MAX_BATCH);
+        send_to_partition(&client, &stream, &topic, partition, batch).await;
+        produced[partition as usize] += batch as u64;
+        let mut caught_up = false;
+        for fetch_round in 0..MAX_FETCHES {
+            for group in &groups {
+                let mut targets: Vec<_> = group
+                    .iter()
+                    .map(|&partition| {
+                        let (offset, context) = positions[partition as usize];
+                        let strategy = match context {
+                            Some(context) => PollingStrategy::continuation(
+                                partition,
+                                PollingKind::Offset,
+                                offset,
+                                context,
+                            ),
+                            None => PollingStrategy::offset(offset),
+                        };
+                        (partition, strategy)
+                    })
+                    .collect();
+                let rotation = rng.random_range(0..targets.len());
+                targets.rotate_left(rotation);
+                let count = rng.random_range(1..=MAX_BATCH as u32);
+                let fetch_options = PollOptions { count, ..options };
+                let mut sections =
+                    fetch_sections(&client, &stream, &topic, &targets, &consumer, fetch_options)
+                        .await
+                        .unwrap();
+                if round % DROP_EVERY == 0 && fetch_round == 0 && !sections.is_empty() {
+                    let lost: Vec<_> = sections
+                        .iter()
+                        .map(|section| {
+                            (
+                                section.partition_id,
+                                section.next_offset,
+                                section.records.clone(),
+                            )
+                        })
+                        .collect();
+                    sections = fetch_sections(
+                        &client,
+                        &stream,
+                        &topic,
+                        &targets,
+                        &consumer,
+                        fetch_options,
+                    )
+                    .await
+                    .unwrap();
+                    let replayed: Vec<_> = sections
+                        .iter()
+                        .map(|section| {
+                            (
+                                section.partition_id,
+                                section.next_offset,
+                                section.records.clone(),
+                            )
+                        })
+                        .collect();
+                    assert_eq!(
+                        replayed, lost,
+                        "seed {SEED}, round {round}: a lost non-committing reply must replay"
+                    );
+                    dropped_replies += 1;
+                }
+                assert!(
+                    sections
+                        .iter()
+                        .map(|section| section.messages_count)
+                        .sum::<u32>()
+                        <= count
+                );
+                assert!(
+                    sections
+                        .iter()
+                        .map(|section| section.records.len())
+                        .sum::<usize>()
+                        <= FETCH_BYTES as usize
+                );
+                for section in sections {
+                    let polled = section.decode().unwrap();
+                    let position = &mut positions[section.partition_id as usize];
+                    for message in polled.messages {
+                        assert_eq!(
+                            message.header.offset, position.0,
+                            "seed {SEED}, round {round}, partition {}: no gap or unrequested duplicate",
+                            section.partition_id
+                        );
+                        position.0 += 1;
+                    }
+                    assert_eq!(section.next_offset, position.0);
+                    position.1 = Some(section.context);
+                }
+            }
+            if positions.iter().map(|position| position.0).eq(produced) {
+                caught_up = true;
+                break;
+            }
+        }
+        assert!(
+            caught_up,
+            "seed {SEED}, round {round}: positions {positions:?}, produced {produced:?}"
+        );
+    }
+    assert!(dropped_replies > 0, "the seed must cover discarded replies");
+}
+
 const TOPIC: &str = "topic";
+const GROUP: &str = "group";
+const ASSIGNMENT_TIMEOUT: Duration = Duration::from_secs(5);
 const RECEIVE_TIMEOUT: Duration = Duration::from_secs(3);
+const REJOIN_READ_TIMEOUT: Duration = Duration::from_secs(5);
 const STORED_OFFSET_TIMEOUT: Duration = Duration::from_secs(3);
 const STORED_OFFSET_RECHECK: Duration = Duration::from_millis(20);
 
@@ -1446,6 +1921,66 @@
     (stream, topic)
 }
 
+/// Creates `stream_name` with a topic of `partitions` partitions and a group
+/// that the client joins as its only member.
+async fn create_sole_group_member(
+    client: &IggyClient,
+    stream_name: &str,
+    partitions: u32,
+) -> (Identifier, Identifier, Identifier) {
+    client
+        .create_stream(stream_name, &StreamCreateOptions::default())
+        .await
+        .unwrap();
+    let stream = Identifier::named(stream_name).unwrap();
+    client
+        .create_topic(
+            &stream,
+            TOPIC,
+            &TopicCreateOptions {
+                partitions_count: Some(partitions),
+                ..Default::default()
+            },
+        )
+        .await
+        .unwrap();
+    let topic = Identifier::named(TOPIC).unwrap();
+    client
+        .create_consumer_group(&stream, &topic, GROUP)
+        .await
+        .unwrap();
+    let group = Identifier::named(GROUP).unwrap();
+    client
+        .join_consumer_group(&stream, &topic, &group)
+        .await
+        .unwrap();
+    wait_for_consumer_group_assignment(client, &stream, &topic, &group, 1, ASSIGNMENT_TIMEOUT)
+        .await;
+    (stream, topic, group)
+}
+
+/// Sends `count` messages to `partition`.
+async fn send_to_partition(
+    client: &IggyClient,
+    stream: &Identifier,
+    topic: &Identifier,
+    partition: u32,
+    count: usize,
+) {
+    let mut messages = (0..count)
+        .map(|index| IggyMessage::from_str(&format!("message-{index}")).unwrap())
+        .collect::<Vec<_>>();
+    client
+        .send_messages(
+            stream,
+            topic,
+            &Partitioning::partition_id(partition),
+            &mut messages,
+        )
+        .await
+        .unwrap();
+}
+
 /// Reads offsets `0..count` of partition 0, in order.
 async fn read_offsets(consumer: &mut IggyConsumer, count: u64) {
     for offset in 0..count {
diff --git a/core/integration/tests/server/scenarios/authentication_scenario.rs b/core/integration/tests/server/scenarios/authentication_scenario.rs
index 147632b..1e3747a 100644
--- a/core/integration/tests/server/scenarios/authentication_scenario.rs
+++ b/core/integration/tests/server/scenarios/authentication_scenario.rs
@@ -33,8 +33,8 @@
 use iggy_binary_protocol::codes::*;
 use iggy_binary_protocol::dispatch::COMMAND_TABLE;
 use iggy_binary_protocol::requests::messages::{
-    DeferredPollMessagesRequest, GetPartitionRouteRequest, PollMessagesRequest,
-    WaitForMessagesRequest, WaitForMessagesTarget,
+    DeferredPollMessagesRequest, FetchMessagesRequest, FetchMessagesTarget,
+    GetPartitionRouteRequest, PollMessagesRequest,
 };
 use iggy_binary_protocol::requests::system::{BindSessionRequest, SessionIdentity};
 use iggy_binary_protocol::requests::users::login_register::BindSecret;
@@ -42,7 +42,7 @@
 use iggy_common::wire_conversions::{
     consumer_to_wire, identifier_to_wire, polling_strategy_to_wire,
 };
-use iggy_common::{MetadataChangeGroups, VsrSessionControl};
+use iggy_common::{DeferredPollOptions, MetadataChangeGroups, VsrSessionControl};
 use integration::harness::{TestHarness, login_root};
 
 const STREAM_NAME: &str = "auth-test-stream";
@@ -321,16 +321,10 @@
                     .await
                     .map(|_| ())
             }
+            // `poll_messages` sends the deferred poll, so the plain poll goes
+            // as a raw frame.
             POLL_MESSAGES_CODE => client
-                .poll_messages(
-                    &ctx.stream_id,
-                    &ctx.topic_id,
-                    Some(0),
-                    &ctx.consumer,
-                    &PollingStrategy::offset(0),
-                    1,
-                    false,
-                )
+                .send_binary_request(code, poll_request(&ctx).to_bytes())
                 .await
                 .map(|_| ()),
             POLL_MESSAGES_DEFERRED_CODE => {
@@ -347,18 +341,25 @@
                     .await
                     .map(|_| ())
             }
-            WAIT_FOR_MESSAGES_CODE => {
+            FETCH_MESSAGES_CODE => {
                 let options = DeferredPollOptions::default();
                 let poll = poll_request(&ctx);
-                let request = WaitForMessagesRequest {
+                let request = FetchMessagesRequest {
                     consumer: poll.consumer,
                     stream_id: poll.stream_id,
                     topic_id: poll.topic_id,
                     wait_us: options.max_wait.as_micros(),
                     request_timeout_us: options.request_timeout.as_micros(),
-                    partitions: vec![WaitForMessagesTarget {
+                    min_count: options.min_count,
+                    count: poll.count,
+                    max_bytes: options.max_bytes,
+                    auto_commit: false,
+                    targets: vec![FetchMessagesTarget {
                         partition_id: 0,
                         strategy: poll.strategy,
+                        max_bytes: options.max_bytes,
+                        incarnation: 0,
+                        owner_generation: 0,
                     }],
                 };
                 client
@@ -476,11 +477,13 @@
         .poll_messages(
             &ctx.stream_id,
             &ctx.topic_id,
-            Some(0),
+            0,
             &ctx.consumer,
             &PollingStrategy::offset(0),
-            10,
-            false,
+            PollOptions {
+                count: 10,
+                ..PollOptions::default()
+            },
         )
         .await
         .expect("poll_messages");
diff --git a/core/integration/tests/server/scenarios/concurrent_produce_consume_scenario.rs b/core/integration/tests/server/scenarios/concurrent_produce_consume_scenario.rs
index d96d281..9573a3a 100644
--- a/core/integration/tests/server/scenarios/concurrent_produce_consume_scenario.rs
+++ b/core/integration/tests/server/scenarios/concurrent_produce_consume_scenario.rs
@@ -108,11 +108,15 @@
                 .poll_messages(
                     &stream,
                     &topic,
-                    Some(0),
+                    0,
                     &consumer,
                     &PollingStrategy::next(),
-                    CONSUMER_BATCH_SIZE,
-                    true, // auto_commit - this is what makes the skip permanent
+                    PollOptions {
+                        count: CONSUMER_BATCH_SIZE,
+                        // The server commit is what makes the skip permanent.
+                        commit: CommitPolicy::OnRead,
+                        ..PollOptions::default()
+                    },
                 )
                 .await;
 
diff --git a/core/integration/tests/server/scenarios/consumer_group_auto_commit_reconnection_scenario.rs b/core/integration/tests/server/scenarios/consumer_group_auto_commit_reconnection_scenario.rs
index 5a0e174..01c9ff2 100644
--- a/core/integration/tests/server/scenarios/consumer_group_auto_commit_reconnection_scenario.rs
+++ b/core/integration/tests/server/scenarios/consumer_group_auto_commit_reconnection_scenario.rs
@@ -139,13 +139,16 @@
     let mut consumer = client
         .consumer_group(CONSUMER_GROUP_NAME, STREAM_NAME, TOPIC_NAME)
         .unwrap()
-        .batch_length(10)
+        .poll_options(PollOptions {
+            count: 10,
+            commit: CommitPolicy::Auto(AutoCommit {
+                interval: Some(NonZeroIggyDuration::from_str("1s").unwrap()),
+                every: Some(CommitEvery::Message),
+                after: CommitAfter::Delivery,
+            }),
+            ..PollOptions::wait(IggyDuration::ONE_SECOND)
+        })
         .polling_strategy(PollingStrategy::next())
-        .auto_join_consumer_group()
-        .auto_commit(AutoCommit::IntervalOrAfter(
-            NonZeroIggyDuration::from_str("1s").unwrap(),
-            AutoCommitAfter::ConsumingEachMessage,
-        ))
         .build();
 
     consumer.init().await.unwrap();
diff --git a/core/integration/tests/server/scenarios/consumer_group_new_messages_after_restart_scenario.rs b/core/integration/tests/server/scenarios/consumer_group_new_messages_after_restart_scenario.rs
index eecdb97..e6abf8a 100644
--- a/core/integration/tests/server/scenarios/consumer_group_new_messages_after_restart_scenario.rs
+++ b/core/integration/tests/server/scenarios/consumer_group_new_messages_after_restart_scenario.rs
@@ -222,11 +222,13 @@
     let mut consumer = client
         .consumer_group(CONSUMER_GROUP_NAME, STREAM_NAME, TOPIC_NAME)
         .unwrap()
-        .batch_length(10)
+        .poll_options(PollOptions {
+            count: 10,
+            commit: CommitPolicy::OnRead,
+            ..PollOptions::wait(IggyDuration::ONE_SECOND)
+        })
         .polling_strategy(PollingStrategy::next())
-        .auto_join_consumer_group()
         .create_consumer_group_if_not_exists()
-        .auto_commit(AutoCommit::When(AutoCommitWhen::PollingMessages))
         .build();
 
     consumer.init().await.unwrap();
diff --git a/core/integration/tests/server/scenarios/consumer_group_offset_cleanup_scenario.rs b/core/integration/tests/server/scenarios/consumer_group_offset_cleanup_scenario.rs
index 9ffe576..1704d94 100644
--- a/core/integration/tests/server/scenarios/consumer_group_offset_cleanup_scenario.rs
+++ b/core/integration/tests/server/scenarios/consumer_group_offset_cleanup_scenario.rs
@@ -155,13 +155,16 @@
     let mut consumer = client
         .consumer_group(consumer_group_name, STREAM_NAME, TOPIC_NAME)
         .unwrap()
-        .batch_length(10)
+        .poll_options(PollOptions {
+            count: 10,
+            commit: CommitPolicy::Auto(AutoCommit {
+                interval: Some(NonZeroIggyDuration::from_str("100ms").unwrap()),
+                every: Some(CommitEvery::Message),
+                after: CommitAfter::Delivery,
+            }),
+            ..PollOptions::wait(IggyDuration::ONE_SECOND)
+        })
         .polling_strategy(PollingStrategy::next())
-        .auto_join_consumer_group()
-        .auto_commit(AutoCommit::IntervalOrAfter(
-            NonZeroIggyDuration::from_str("100ms").unwrap(),
-            AutoCommitAfter::ConsumingEachMessage,
-        ))
         .build();
 
     consumer.init().await.unwrap();
diff --git a/core/integration/tests/server/scenarios/consumer_group_with_multiple_clients_polling_messages_scenario.rs b/core/integration/tests/server/scenarios/consumer_group_with_multiple_clients_polling_messages_scenario.rs
index e0dce7a..fdf2286 100644
--- a/core/integration/tests/server/scenarios/consumer_group_with_multiple_clients_polling_messages_scenario.rs
+++ b/core/integration/tests/server/scenarios/consumer_group_with_multiple_clients_polling_messages_scenario.rs
@@ -20,7 +20,9 @@
     create_client, get_consumer_group, join_consumer_group,
 };
 use iggy::prelude::*;
-use integration::harness::{TestHarness, assert_clean_system, create_user, login_user};
+use integration::harness::{
+    GroupPoller, TestHarness, assert_clean_system, create_user, login_user,
+};
 use std::str::{FromStr, from_utf8};
 
 pub async fn run(harness: &TestHarness) {
@@ -136,7 +138,7 @@
 }
 
 async fn poll_messages(client: &IggyClient) -> u32 {
-    let consumer = Consumer::group(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
+    let mut poller = GroupPoller::new(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
     let mut total_read_messages_count = 0;
     let mut last_entity_id: Option<u32> = None;
     const MAX_RETRIES: u32 = 5;
@@ -145,15 +147,13 @@
     for _ in 1..=PARTITIONS_COUNT * MESSAGES_COUNT {
         let mut retries = 0;
         let polled_messages = loop {
-            let polled = client
-                .poll_messages(
+            let polled = poller
+                .poll(
+                    client,
                     &Identifier::named(STREAM_NAME).unwrap(),
                     &Identifier::named(TOPIC_NAME).unwrap(),
-                    None,
-                    &consumer,
                     &PollingStrategy::next(),
-                    1,
-                    true,
+                    single_committed_message(),
                 )
                 .await
                 .unwrap();
@@ -250,7 +250,7 @@
 }
 
 async fn validate_message_polling(client: &IggyClient) {
-    let consumer = Consumer::group(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
+    let mut poller = GroupPoller::new(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
     let mut partition_id: Option<u32> = None;
     const MAX_RETRIES: u32 = 5;
     let mut total_retries = 0;
@@ -258,15 +258,13 @@
     for i in 1..=MESSAGES_COUNT {
         let mut retries = 0;
         let polled_messages = loop {
-            let polled = client
-                .poll_messages(
+            let polled = poller
+                .poll(
+                    client,
                     &Identifier::named(STREAM_NAME).unwrap(),
                     &Identifier::named(TOPIC_NAME).unwrap(),
-                    None,
-                    &consumer,
                     &PollingStrategy::next(),
-                    1,
-                    true,
+                    single_committed_message(),
                 )
                 .await
                 .unwrap();
@@ -313,21 +311,27 @@
         );
     }
 
-    let polled_messages = client
-        .poll_messages(
+    let polled_messages = poller
+        .poll(
+            client,
             &Identifier::named(STREAM_NAME).unwrap(),
             &Identifier::named(TOPIC_NAME).unwrap(),
-            None,
-            &consumer,
             &PollingStrategy::next(),
-            1,
-            true,
+            single_committed_message(),
         )
         .await
         .unwrap();
     assert!(polled_messages.messages.is_empty())
 }
 
+fn single_committed_message() -> PollOptions {
+    PollOptions {
+        count: 1,
+        commit: CommitPolicy::OnRead,
+        ..PollOptions::default()
+    }
+}
+
 fn parse_partition_id_from_extended_payload(payload: &str) -> u32 {
     let parts: Vec<&str> = payload.split('-').collect();
     parts[1]
diff --git a/core/integration/tests/server/scenarios/consumer_group_with_single_client_polling_messages_scenario.rs b/core/integration/tests/server/scenarios/consumer_group_with_single_client_polling_messages_scenario.rs
index f03da44..68e9d10 100644
--- a/core/integration/tests/server/scenarios/consumer_group_with_single_client_polling_messages_scenario.rs
+++ b/core/integration/tests/server/scenarios/consumer_group_with_single_client_polling_messages_scenario.rs
@@ -104,21 +104,20 @@
     }
 
     // 2. Poll the messages for the single client which has assigned all partitions in the consumer group
-    // Hash-based partitioning distributes messages unpredictably across partitions, and polling
-    // round-robins across partitions, so we verify completeness rather than order.
+    // Hash-based partitioning distributes messages unpredictably across partitions, and the
+    // test polls the partitions in turn, so we verify completeness rather than order.
     let consumer = Consumer::group(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
     let mut received_entity_ids = HashSet::new();
 
-    for _ in 1..=PARTITIONS_COUNT * MESSAGES_COUNT {
+    for i in 1..=PARTITIONS_COUNT * MESSAGES_COUNT {
         let polled_messages = client
             .poll_messages(
                 &Identifier::named(STREAM_NAME).unwrap(),
                 &Identifier::named(TOPIC_NAME).unwrap(),
-                None,
+                (i - 1) % PARTITIONS_COUNT,
                 &consumer,
                 &PollingStrategy::next(),
-                1,
-                true,
+                single_committed_message(),
             )
             .await
             .unwrap();
@@ -186,6 +185,8 @@
     }
 
     // 2. Poll the messages for the single client which has assigned all partitions in the consumer group
+    // The balanced sends filled the partitions in turn, so polling them in the same turn
+    // reads the messages in send order.
     let consumer = Consumer::group(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
     let mut partition_id = 1;
     let mut offset = 0;
@@ -194,11 +195,10 @@
             .poll_messages(
                 &Identifier::named(STREAM_NAME).unwrap(),
                 &Identifier::named(TOPIC_NAME).unwrap(),
-                None,
+                (i - 1) % PARTITIONS_COUNT,
                 &consumer,
                 &PollingStrategy::next(),
-                1,
-                true,
+                single_committed_message(),
             )
             .await
             .unwrap();
@@ -219,16 +219,15 @@
         }
     }
 
-    for _ in 1..=PARTITIONS_COUNT {
+    for partition_id in 0..PARTITIONS_COUNT {
         let polled_messages = client
             .poll_messages(
                 &Identifier::named(STREAM_NAME).unwrap(),
                 &Identifier::named(TOPIC_NAME).unwrap(),
-                None,
+                partition_id,
                 &consumer,
                 &PollingStrategy::next(),
-                1,
-                true,
+                single_committed_message(),
             )
             .await
             .unwrap();
@@ -236,6 +235,14 @@
     }
 }
 
+fn single_committed_message() -> PollOptions {
+    PollOptions {
+        count: 1,
+        commit: CommitPolicy::OnRead,
+        ..PollOptions::default()
+    }
+}
+
 fn create_extended_message_payload(partition_id: u32, entity_id: u32) -> String {
     format!("message-{partition_id}-{entity_id}")
 }
diff --git a/core/integration/tests/server/scenarios/consumer_timestamp_polling_scenario.rs b/core/integration/tests/server/scenarios/consumer_timestamp_polling_scenario.rs
index 495a74c..7832da3 100644
--- a/core/integration/tests/server/scenarios/consumer_timestamp_polling_scenario.rs
+++ b/core/integration/tests/server/scenarios/consumer_timestamp_polling_scenario.rs
@@ -79,7 +79,7 @@
     init_system(client).await;
     produce_messages(client).await;
 
-    // Last strategy with batch_length=100 returns the last 100 messages
+    // Last strategy with count=100 returns the last 100 messages
     let received = consume_with_strategy(client, "last-consumer", PollingStrategy::last()).await;
 
     assert_eq!(
@@ -179,14 +179,19 @@
     };
 
     let mut consumer = client
-        .consumer(consumer_name, STREAM_NAME, TOPIC_NAME, PARTITION_ID)
+        .consumer(consumer_name, STREAM_NAME, TOPIC_NAME)
         .unwrap()
-        .auto_commit(AutoCommit::IntervalOrWhen(
-            NonZeroIggyDuration::from_str("2ms").unwrap(),
-            AutoCommitWhen::ConsumingAllMessages,
-        ))
+        .partitions([PARTITION_ID])
         .polling_strategy(strategy)
-        .batch_length(BATCH_LENGTH)
+        .poll_options(PollOptions {
+            count: BATCH_LENGTH,
+            commit: CommitPolicy::Auto(AutoCommit {
+                interval: Some(NonZeroIggyDuration::from_str("2ms").unwrap()),
+                every: Some(CommitEvery::Batch),
+                after: CommitAfter::Delivery,
+            }),
+            ..PollOptions::wait(IggyDuration::ONE_SECOND)
+        })
         .build();
 
     consumer.init().await.unwrap();
diff --git a/core/integration/tests/server/scenarios/delete_segments_scenario.rs b/core/integration/tests/server/scenarios/delete_segments_scenario.rs
index c8d6cf8..09959c7 100644
--- a/core/integration/tests/server/scenarios/delete_segments_scenario.rs
+++ b/core/integration/tests/server/scenarios/delete_segments_scenario.rs
@@ -19,11 +19,12 @@
 use bytes::Bytes;
 use iggy::prelude::*;
 use iggy_common::Credentials;
-use integration::harness::TestHarness;
+use integration::harness::{TestHarness, fetch_sections};
 use secrecy::SecretString;
 use std::fs::{metadata, read_dir};
 use std::path::Path;
 use std::str::FromStr;
+use std::time::{Duration, Instant};
 
 const STREAM_NAME: &str = "test_stream";
 const TOPIC_NAME: &str = "test_topic";
@@ -53,6 +54,9 @@
     NG_BATCH_HEADER_SIZE + NG_MESSAGE_HEADER_SIZE + PAYLOAD_SIZE as u64;
 const INDEX_SIZE_PER_MSG: u64 = 24;
 const TOTAL_MESSAGES: u32 = 25;
+/// The default 1 MiB reply holds only part of the fixture. The server caps a reply at 16 MiB
+/// (`deferred_poll_max_read_bytes`), which holds all [`TOTAL_MESSAGES`].
+const POLL_ALL_MAX_BYTES: u32 = 16 * 1024 * 1024;
 
 /// 5 sealed segments (5 msgs each at 220304B on disk; the post-append size
 /// check seals at 1101520B >= 1MiB) + 1 empty active segment at offset 25.
@@ -258,11 +262,13 @@
         .poll_messages(
             &stream_ident,
             &topic_ident,
-            Some(PARTITION_ID),
+            PARTITION_ID,
             &consumer,
             &PollingStrategy::next(),
-            100,
-            false,
+            PollOptions {
+                count: 100,
+                ..PollOptions::default()
+            },
         )
         .await
         .unwrap();
@@ -457,6 +463,82 @@
 
 /// Single consumer group barrier with message-by-message progression.
 ///
+pub async fn run_fetch_after_retention(harness: &TestHarness) {
+    const MAX_WAIT: Duration = Duration::from_secs(2);
+    const EARLY_TOLERANCE: Duration = Duration::from_millis(100);
+    let client = harness.root_client().await.unwrap();
+    let stream = client
+        .create_stream(STREAM_NAME, &StreamCreateOptions::default())
+        .await
+        .unwrap();
+    let stream_id = Identifier::named(STREAM_NAME).unwrap();
+    let topic = client
+        .create_topic(&stream_id, TOPIC_NAME, &layout_topic_options())
+        .await
+        .unwrap();
+    let topic_id = Identifier::named(TOPIC_NAME).unwrap();
+    send_messages(&client, &stream_id, &topic_id, TOTAL_MESSAGES).await;
+    let path = partition_path(&harness.server().data_path(), stream.id, topic.id);
+    let retained_start = u64::from(TOTAL_MESSAGES);
+    let layout = get_sorted_segment_offsets(&path);
+    assert_eq!(
+        layout.last(),
+        Some(&retained_start),
+        "the fixture leaves an empty active segment"
+    );
+    client
+        .delete_segments(&stream_id, &topic_id, PARTITION_ID, u32::MAX)
+        .await
+        .unwrap();
+    await_segment_layout(&path, &[retained_start]).await;
+
+    let consumer = Consumer::default();
+    let sections = fetch_sections(
+        &client,
+        &stream_id,
+        &topic_id,
+        &[(PARTITION_ID, PollingStrategy::offset(0))],
+        &consumer,
+        PollOptions {
+            count: 1,
+            ..PollOptions::wait(IggyDuration::ONE_SECOND)
+        },
+    )
+    .await
+    .unwrap();
+    assert_eq!(sections.len(), 1, "the expired start must be advanced once");
+    let section = &sections[0];
+    assert_eq!(section.next_offset, retained_start);
+    assert!(section.decode().unwrap().messages.is_empty());
+    let started = Instant::now();
+    let continued = fetch_sections(
+        &client,
+        &stream_id,
+        &topic_id,
+        &[(
+            PARTITION_ID,
+            PollingStrategy::continuation(
+                PARTITION_ID,
+                PollingKind::Offset,
+                section.next_offset,
+                section.context,
+            ),
+        )],
+        &consumer,
+        PollOptions {
+            count: 1,
+            ..PollOptions::wait(MAX_WAIT.into())
+        },
+    )
+    .await
+    .unwrap();
+    assert!(continued.is_empty());
+    assert!(
+        started.elapsed() >= MAX_WAIT - EARLY_TOLERANCE,
+        "the continued fetch must park at the retained start instead of busy-looping"
+    );
+}
+
 /// Polls one message at a time (next + auto_commit), attempts delete_segments(u32::MAX) after
 /// each poll. Verifies that each sealed segment is released exactly when the committed offset
 /// reaches its end_offset — not one message earlier, not one later.
@@ -496,11 +578,17 @@
     let mut consumer = client
         .consumer_group("test_group", STREAM_NAME, TOPIC_NAME)
         .unwrap()
-        .auto_commit(AutoCommit::When(AutoCommitWhen::ConsumingEachMessage))
         .create_consumer_group_if_not_exists()
-        .auto_join_consumer_group()
         .polling_strategy(PollingStrategy::next())
-        .batch_length(1)
+        .poll_options(PollOptions {
+            count: 1,
+            commit: CommitPolicy::Auto(AutoCommit {
+                interval: None,
+                every: Some(CommitEvery::Message),
+                after: CommitAfter::Delivery,
+            }),
+            ..PollOptions::wait(IggyDuration::ONE_SECOND)
+        })
         .build();
     consumer.init().await.unwrap();
 
@@ -639,11 +727,13 @@
     let mut fast_consumer = client
         .consumer_group("fast_group", STREAM_NAME, TOPIC_NAME)
         .unwrap()
-        .auto_commit(AutoCommit::When(AutoCommitWhen::PollingMessages))
         .create_consumer_group_if_not_exists()
-        .auto_join_consumer_group()
         .polling_strategy(PollingStrategy::offset(0))
-        .batch_length(TOTAL_MESSAGES)
+        .poll_options(PollOptions {
+            count: TOTAL_MESSAGES,
+            commit: CommitPolicy::OnRead,
+            ..PollOptions::wait(IggyDuration::ONE_SECOND)
+        })
         .build();
     fast_consumer.init().await.unwrap();
 
@@ -1033,11 +1123,14 @@
         .poll_messages(
             stream_ident,
             topic_ident,
-            Some(PARTITION_ID),
+            PARTITION_ID,
             &consumer,
             &PollingStrategy::offset(0),
-            TOTAL_MESSAGES * 2,
-            false,
+            PollOptions {
+                count: TOTAL_MESSAGES * 2,
+                max_bytes: POLL_ALL_MAX_BYTES,
+                ..PollOptions::default()
+            },
         )
         .await
         .map(|polled| polled.messages.iter().map(|m| m.header.offset).collect())
diff --git a/core/integration/tests/server/scenarios/encryption_scenario.rs b/core/integration/tests/server/scenarios/encryption_scenario.rs
index d02dcaa..82d1f78 100644
--- a/core/integration/tests/server/scenarios/encryption_scenario.rs
+++ b/core/integration/tests/server/scenarios/encryption_scenario.rs
@@ -167,11 +167,13 @@
         .poll_messages(
             &Identifier::named(stream_name).unwrap(),
             &Identifier::named(topic_name).unwrap(),
-            Some(0),
+            0,
             &consumer,
             &PollingStrategy::offset(0),
-            messages_per_batch.try_into().unwrap(),
-            false,
+            PollOptions {
+                count: messages_per_batch.try_into().unwrap(),
+                ..PollOptions::default()
+            },
         )
         .await
         .unwrap();
@@ -271,11 +273,13 @@
         .poll_messages(
             &Identifier::named(stream_name).unwrap(),
             &Identifier::named(topic_name).unwrap(),
-            Some(0),
+            0,
             &consumer,
             &PollingStrategy::offset(0),
-            messages_per_batch as u32 * 2,
-            false,
+            PollOptions {
+                count: messages_per_batch as u32 * 2,
+                ..PollOptions::default()
+            },
         )
         .await
         .unwrap();
@@ -425,11 +429,13 @@
         .poll_messages(
             &stream_id,
             &topic_id,
-            Some(0),
+            0,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            10,
-            false,
+            PollOptions {
+                count: 10,
+                ..PollOptions::default()
+            },
         )
         .await
         .unwrap();
@@ -465,11 +471,13 @@
         .poll_messages(
             &stream_id,
             &topic_id,
-            Some(0),
+            0,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            10,
-            false,
+            PollOptions {
+                count: 10,
+                ..PollOptions::default()
+            },
         )
         .await
         .unwrap();
@@ -1064,11 +1072,13 @@
         .poll_messages(
             stream_id,
             topic_id,
-            Some(0),
+            0,
             &Consumer::default(),
             &PollingStrategy::offset(offset),
-            count,
-            false,
+            PollOptions {
+                count,
+                ..PollOptions::default()
+            },
         )
         .await
 }
diff --git a/core/integration/tests/server/scenarios/external_group_offsets_scenario.rs b/core/integration/tests/server/scenarios/external_group_offsets_scenario.rs
index 1cbf1b9..6ad1ce5 100644
--- a/core/integration/tests/server/scenarios/external_group_offsets_scenario.rs
+++ b/core/integration/tests/server/scenarios/external_group_offsets_scenario.rs
@@ -85,11 +85,13 @@
         .poll_messages(
             &stream,
             &topic,
-            Some(PARTITION_ID),
+            PARTITION_ID,
             &external,
             &PollingStrategy::offset(0),
-            1,
-            false,
+            PollOptions {
+                count: 1,
+                ..PollOptions::default()
+            },
         )
         .await
         .unwrap_err();
diff --git a/core/integration/tests/server/scenarios/message_cleanup_scenario.rs b/core/integration/tests/server/scenarios/message_cleanup_scenario.rs
index da8a32e..70c3af1 100644
--- a/core/integration/tests/server/scenarios/message_cleanup_scenario.rs
+++ b/core/integration/tests/server/scenarios/message_cleanup_scenario.rs
@@ -25,6 +25,7 @@
 //! segment count, so the exact rotation point does not matter -- only that the
 //! volume sent clears the cap several times over.
 
+use super::POLL_REPLY_MAX_BYTES;
 use bytes::Bytes;
 use iggy::prelude::*;
 use iggy_common::IggyByteSize;
@@ -135,11 +136,14 @@
         .poll_messages(
             &Identifier::named(STREAM_NAME).unwrap(),
             &Identifier::named(TOPIC_NAME).unwrap(),
-            Some(PARTITION_ID),
+            PARTITION_ID,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            total_messages as u32,
-            false,
+            PollOptions {
+                count: total_messages as u32,
+                max_bytes: POLL_REPLY_MAX_BYTES,
+                ..PollOptions::default()
+            },
         )
         .await
         .unwrap();
@@ -158,11 +162,14 @@
         .poll_messages(
             &Identifier::named(STREAM_NAME).unwrap(),
             &Identifier::named(TOPIC_NAME).unwrap(),
-            Some(PARTITION_ID),
+            PARTITION_ID,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            total_messages as u32,
-            false,
+            PollOptions {
+                count: total_messages as u32,
+                max_bytes: POLL_REPLY_MAX_BYTES,
+                ..PollOptions::default()
+            },
         )
         .await
         .unwrap();
@@ -310,11 +317,14 @@
         .poll_messages(
             &Identifier::named(STREAM_NAME).unwrap(),
             &Identifier::named(TOPIC_NAME).unwrap(),
-            Some(PARTITION_ID),
+            PARTITION_ID,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            total_messages as u32,
-            false,
+            PollOptions {
+                count: total_messages as u32,
+                max_bytes: POLL_REPLY_MAX_BYTES,
+                ..PollOptions::default()
+            },
         )
         .await
         .unwrap();
@@ -596,11 +606,13 @@
             .poll_messages(
                 &Identifier::named(STREAM_NAME).unwrap(),
                 &Identifier::named(TOPIC_NAME).unwrap(),
-                Some(partition_id),
+                partition_id,
                 &Consumer::default(),
                 &PollingStrategy::offset(0),
-                messages_per_partition,
-                false,
+                PollOptions {
+                    count: messages_per_partition,
+                    ..PollOptions::default()
+                },
             )
             .await
             .unwrap();
@@ -708,11 +720,14 @@
             .poll_messages(
                 &Identifier::named(TEST_STREAM).unwrap(),
                 &Identifier::named(TEST_TOPIC).unwrap(),
-                Some(PARTITION_ID),
+                PARTITION_ID,
                 &consumer,
                 &PollingStrategy::next(),
-                batch_size,
-                true, // auto_commit
+                PollOptions {
+                    count: batch_size,
+                    commit: CommitPolicy::OnRead,
+                    ..PollOptions::default()
+                },
             )
             .await
             .unwrap();
@@ -741,11 +756,13 @@
         .poll_messages(
             &Identifier::named(TEST_STREAM).unwrap(),
             &Identifier::named(TEST_TOPIC).unwrap(),
-            Some(PARTITION_ID),
+            PARTITION_ID,
             &consumer,
             &PollingStrategy::next(),
-            10,
-            false,
+            PollOptions {
+                count: 10,
+                ..PollOptions::default()
+            },
         )
         .await
         .unwrap();
diff --git a/core/integration/tests/server/scenarios/message_headers_scenario.rs b/core/integration/tests/server/scenarios/message_headers_scenario.rs
index e0c780b..c7a8170 100644
--- a/core/integration/tests/server/scenarios/message_headers_scenario.rs
+++ b/core/integration/tests/server/scenarios/message_headers_scenario.rs
@@ -62,11 +62,13 @@
         .poll_messages(
             &Identifier::named(STREAM_NAME).unwrap(),
             &Identifier::named(TOPIC_NAME).unwrap(),
-            Some(PARTITION_ID),
+            PARTITION_ID,
             &consumer,
             &PollingStrategy::offset(0),
-            MESSAGES_COUNT,
-            false,
+            PollOptions {
+                count: MESSAGES_COUNT,
+                ..PollOptions::default()
+            },
         )
         .await
         .unwrap();
diff --git a/core/integration/tests/server/scenarios/message_size_scenario.rs b/core/integration/tests/server/scenarios/message_size_scenario.rs
index 9a63a07..0ac3a5a 100644
--- a/core/integration/tests/server/scenarios/message_size_scenario.rs
+++ b/core/integration/tests/server/scenarios/message_size_scenario.rs
@@ -86,21 +86,34 @@
 }
 
 async fn assert_message_count(client: &IggyClient, expected_count: u32) {
-    // 4. Poll messages and validate the count
-    let polled_messages = client
-        .poll_messages(
-            &STREAM_NAME.try_into().unwrap(),
-            &TOPIC_NAME.try_into().unwrap(),
-            Some(PARTITION_ID),
-            &Consumer::default(),
-            &PollingStrategy::offset(0),
-            expected_count * 2,
-            false,
-        )
-        .await
-        .unwrap();
+    // 4. Poll messages until an empty reply and validate the count. A reply
+    // holds at most `max_bytes`, and a larger message comes alone, so the
+    // 10 MB messages take one poll each.
+    let mut strategy = PollingStrategy::offset(0);
+    let mut polled_count = 0;
+    loop {
+        let polled_messages = client
+            .poll_messages(
+                &STREAM_NAME.try_into().unwrap(),
+                &TOPIC_NAME.try_into().unwrap(),
+                PARTITION_ID,
+                &Consumer::default(),
+                &strategy,
+                PollOptions {
+                    count: expected_count * 2,
+                    ..PollOptions::default()
+                },
+            )
+            .await
+            .unwrap();
+        if polled_messages.messages.is_empty() {
+            break;
+        }
+        polled_count += polled_messages.messages.len() as u32;
+        strategy = polled_messages.continuation();
+    }
 
-    assert_eq!(polled_messages.messages.len() as u32, expected_count);
+    assert_eq!(polled_count, expected_count);
 }
 
 async fn init_system(client: &IggyClient) {
diff --git a/core/integration/tests/server/scenarios/mod.rs b/core/integration/tests/server/scenarios/mod.rs
index 0a28726..0fea848 100644
--- a/core/integration/tests/server/scenarios/mod.rs
+++ b/core/integration/tests/server/scenarios/mod.rs
@@ -71,6 +71,9 @@
 // expectation holds, then make the terminal assertion for a real mismatch.
 const CONVERGENCE_TIMEOUT: Duration = Duration::from_secs(10);
 const RETRY_INTERVAL: Duration = Duration::from_millis(100);
+// The server cap of one poll reply (`sharding.deferred_poll_max_read_bytes`).
+// A poll that must return more than the 1 MiB default in one reply asks for it.
+const POLL_REPLY_MAX_BYTES: u32 = 16 * 1024 * 1024;
 const STREAM_NAME: &str = "test-stream";
 const TOPIC_NAME: &str = "test-topic";
 const PARTITIONS_COUNT: u32 = 3;
@@ -200,8 +203,8 @@
     );
 }
 
-/// Poll until the partition serves `expected_count` messages or
-/// [`CONVERGENCE_TIMEOUT`] expires, returning the last poll result.
+/// Poll from `strategy` until the partition serves `expected_count` messages
+/// or [`CONVERGENCE_TIMEOUT`] expires, returning every message read.
 ///
 /// `send_messages` acks at consensus commit while the owning shard applies
 /// the batch asynchronously (see the materialisation race note at the top
@@ -209,32 +212,43 @@
 /// send burst can observe fewer messages than were acked. Retrying absorbs
 /// that convergence window without weakening the caller's assertion: real
 /// message loss still returns short and fails it once the deadline expires.
+/// One reply holds at most `max_bytes`, so a large read takes several polls.
 async fn poll_until_expected_count(
     client: &IggyClient,
     stream_name: &str,
     topic_name: &str,
     strategy: &PollingStrategy,
     expected_count: u32,
-) -> PolledMessages {
+) -> Vec<IggyMessage> {
+    let stream = Identifier::named(stream_name).unwrap();
+    let topic = Identifier::named(topic_name).unwrap();
     let deadline = Instant::now() + CONVERGENCE_TIMEOUT;
-    loop {
+    let mut messages = Vec::with_capacity(expected_count as usize);
+    let mut next = *strategy;
+    while messages.len() < expected_count as usize && Instant::now() < deadline {
         let polled = client
             .poll_messages(
-                &Identifier::named(stream_name).unwrap(),
-                &Identifier::named(topic_name).unwrap(),
-                Some(PARTITION_ID),
+                &stream,
+                &topic,
+                PARTITION_ID,
                 &Consumer::default(),
-                strategy,
-                expected_count,
-                false,
+                &next,
+                PollOptions {
+                    count: expected_count - messages.len() as u32,
+                    max_bytes: POLL_REPLY_MAX_BYTES,
+                    ..PollOptions::default()
+                },
             )
             .await
             .unwrap();
-        if polled.messages.len() as u32 == expected_count || Instant::now() >= deadline {
-            return polled;
+        if polled.messages.is_empty() {
+            sleep(RETRY_INTERVAL).await;
+            continue;
         }
-        sleep(RETRY_INTERVAL).await;
+        next = polled.continuation();
+        messages.extend(polled.messages);
     }
+    messages
 }
 
 async fn create_client(harness: &TestHarness) -> IggyClient {
diff --git a/core/integration/tests/server/scenarios/offset_scenario.rs b/core/integration/tests/server/scenarios/offset_scenario.rs
index 987c8a8..c52678d 100644
--- a/core/integration/tests/server/scenarios/offset_scenario.rs
+++ b/core/integration/tests/server/scenarios/offset_scenario.rs
@@ -207,7 +207,7 @@
     topic_name: &str,
     total_messages: u32,
 ) {
-    let polled = poll_until_expected_count(
+    let messages = poll_until_expected_count(
         client,
         stream_name,
         topic_name,
@@ -217,11 +217,11 @@
     .await;
 
     assert_eq!(
-        polled.messages.len() as u32,
+        messages.len() as u32,
         total_messages,
         "Expected {} messages from start, got {}",
         total_messages,
-        polled.messages.len()
+        messages.len()
     );
 }
 
@@ -238,7 +238,7 @@
         let prior_batches_sum: u32 = batch_lengths[..3].iter().sum();
         let remaining_messages = total_messages - prior_batches_sum;
 
-        let polled = poll_until_expected_count(
+        let messages = poll_until_expected_count(
             client,
             stream_name,
             topic_name,
@@ -248,11 +248,11 @@
         .await;
 
         assert_eq!(
-            polled.messages.len() as u32,
+            messages.len() as u32,
             remaining_messages,
             "Expected {} messages from middle, got {}",
             remaining_messages,
-            polled.messages.len()
+            messages.len()
         );
     }
 }
@@ -268,11 +268,13 @@
             .poll_messages(
                 &Identifier::named(stream_name).unwrap(),
                 &Identifier::named(topic_name).unwrap(),
-                Some(PARTITION_ID),
+                PARTITION_ID,
                 &Consumer::default(),
                 &PollingStrategy::offset(final_offset + 1),
-                1,
-                false,
+                PollOptions {
+                    count: 1,
+                    ..PollOptions::default()
+                },
             )
             .await
             .unwrap();
@@ -298,11 +300,13 @@
         .poll_messages(
             &Identifier::named(stream_name).unwrap(),
             &Identifier::named(topic_name).unwrap(),
-            Some(PARTITION_ID),
+            PARTITION_ID,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            subset_size,
-            false,
+            PollOptions {
+                count: subset_size,
+                ..PollOptions::default()
+            },
         )
         .await
         .unwrap();
@@ -330,11 +334,13 @@
             .poll_messages(
                 &Identifier::named(stream_name).unwrap(),
                 &Identifier::named(topic_name).unwrap(),
-                Some(PARTITION_ID),
+                PARTITION_ID,
                 &Consumer::default(),
                 &PollingStrategy::offset(span_offset),
-                span_size,
-                false,
+                PollOptions {
+                    count: span_size,
+                    ..PollOptions::default()
+                },
             )
             .await
             .unwrap();
@@ -364,11 +370,13 @@
             .poll_messages(
                 &Identifier::named(stream_name).unwrap(),
                 &Identifier::named(topic_name).unwrap(),
-                Some(PARTITION_ID),
+                PARTITION_ID,
                 &Consumer::default(),
                 &PollingStrategy::offset(span_offset),
-                span_size,
-                false,
+                PollOptions {
+                    count: span_size,
+                    ..PollOptions::default()
+                },
             )
             .await
             .unwrap();
@@ -428,28 +436,27 @@
     for chunk_start in (0..total_messages).step_by(chunk_size as usize) {
         let read_size = std::cmp::min(chunk_size, total_messages - chunk_start);
 
-        let polled = client
-            .poll_messages(
-                &Identifier::named(stream_name).unwrap(),
-                &Identifier::named(topic_name).unwrap(),
-                Some(PARTITION_ID),
-                &Consumer::default(),
-                &PollingStrategy::offset(chunk_start as u64),
-                read_size,
-                false,
-            )
-            .await
-            .unwrap();
+        let messages = poll_until_expected_count(
+            client,
+            stream_name,
+            topic_name,
+            &PollingStrategy::offset(chunk_start as u64),
+            read_size,
+        )
+        .await;
 
         assert_eq!(
-            polled.messages.len() as u32,
+            messages.len() as u32,
             read_size,
             "Failed to read chunk at offset {} with size {}",
             chunk_start,
             read_size
         );
 
-        verified_count += polled.messages.len() as u32;
+        for (index, message) in messages.iter().enumerate() {
+            assert_eq!(message.header.offset, chunk_start as u64 + index as u64);
+        }
+        verified_count += messages.len() as u32;
     }
 
     assert_eq!(
diff --git a/core/integration/tests/server/scenarios/permissions_scenario.rs b/core/integration/tests/server/scenarios/permissions_scenario.rs
index c643307..f00c586 100644
--- a/core/integration/tests/server/scenarios/permissions_scenario.rs
+++ b/core/integration/tests/server/scenarios/permissions_scenario.rs
@@ -226,11 +226,13 @@
             .poll_messages(
                 &stream_id,
                 &topic_id,
-                Some(0),
+                0,
                 &Consumer::default(),
                 &PollingStrategy::offset(0),
-                1,
-                false,
+                PollOptions {
+                    count: 1,
+                    ..PollOptions::default()
+                },
             )
             .await,
         "poll_messages",
@@ -733,11 +735,13 @@
         .poll_messages(
             &stream_id,
             &topic_id,
-            Some(0),
+            0,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            1,
-            false,
+            PollOptions {
+                count: 1,
+                ..PollOptions::default()
+            },
         )
         .await
         .expect("poll_messages: poll_messages should work");
@@ -801,11 +805,13 @@
             .poll_messages(
                 &stream_id,
                 &topic_id,
-                Some(0),
+                0,
                 &Consumer::default(),
                 &PollingStrategy::offset(0),
-                1,
-                false,
+                PollOptions {
+                    count: 1,
+                    ..PollOptions::default()
+                },
             )
             .await,
         "send_messages: poll_messages",
@@ -846,11 +852,13 @@
         .poll_messages(
             &stream_id,
             &topic_id,
-            Some(0),
+            0,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            1,
-            false,
+            PollOptions {
+                count: 1,
+                ..PollOptions::default()
+            },
         )
         .await
         .expect("both: poll_messages should work");
@@ -928,11 +936,13 @@
         .poll_messages(
             &stream1_id,
             &topic_id,
-            Some(0),
+            0,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            1,
-            false,
+            PollOptions {
+                count: 1,
+                ..PollOptions::default()
+            },
         )
         .await
         .expect("stream-specific: poll_messages 1 should work");
@@ -968,11 +978,13 @@
             .poll_messages(
                 &stream2_id,
                 &topic_id,
-                Some(0),
+                0,
                 &Consumer::default(),
                 &PollingStrategy::offset(0),
-                1,
-                false,
+                PollOptions {
+                    count: 1,
+                    ..PollOptions::default()
+                },
             )
             .await,
         "stream-specific: poll_messages 2",
@@ -1061,11 +1073,13 @@
         .poll_messages(
             &stream_id,
             &topic1_id,
-            Some(0),
+            0,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            1,
-            false,
+            PollOptions {
+                count: 1,
+                ..PollOptions::default()
+            },
         )
         .await
         .expect("topic-specific: poll_messages 1 should work");
@@ -1093,11 +1107,13 @@
             .poll_messages(
                 &stream_id,
                 &topic2_id,
-                Some(0),
+                0,
                 &Consumer::default(),
                 &PollingStrategy::offset(0),
-                1,
-                false,
+                PollOptions {
+                    count: 1,
+                    ..PollOptions::default()
+                },
             )
             .await,
         "topic-specific: poll_messages 2",
@@ -1156,11 +1172,13 @@
         .poll_messages(
             &stream_id,
             &topic_id,
-            Some(0),
+            0,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            1,
-            false,
+            PollOptions {
+                count: 1,
+                ..PollOptions::default()
+            },
         )
         .await
         .expect("read_streams → poll_messages");
@@ -1270,11 +1288,13 @@
         .poll_messages(
             &stream_id,
             &topic_id,
-            Some(0),
+            0,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            1,
-            false,
+            PollOptions {
+                count: 1,
+                ..PollOptions::default()
+            },
         )
         .await
         .expect("manage_streams → poll_messages");
@@ -1334,11 +1354,13 @@
         .poll_messages(
             &stream_id,
             &topic_id,
-            Some(0),
+            0,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            1,
-            false,
+            PollOptions {
+                count: 1,
+                ..PollOptions::default()
+            },
         )
         .await
         .expect("manage_topics → poll_messages");
@@ -1390,11 +1412,13 @@
         .poll_messages(
             &stream_id,
             &topic_id,
-            Some(0),
+            0,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            1,
-            false,
+            PollOptions {
+                count: 1,
+                ..PollOptions::default()
+            },
         )
         .await
         .expect("read_topics → poll_messages");
@@ -1528,11 +1552,13 @@
         .poll_messages(
             &stream_id,
             &topic_id,
-            Some(0),
+            0,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            1,
-            false,
+            PollOptions {
+                count: 1,
+                ..PollOptions::default()
+            },
         )
         .await
         .expect("stream.read_stream → poll_messages");
@@ -1650,11 +1676,13 @@
         .poll_messages(
             &stream_id,
             &topic_id,
-            Some(0),
+            0,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            1,
-            false,
+            PollOptions {
+                count: 1,
+                ..PollOptions::default()
+            },
         )
         .await
         .expect("stream.manage_stream → poll_messages");
@@ -1731,11 +1759,13 @@
         .poll_messages(
             &stream_id,
             &topic_id,
-            Some(0),
+            0,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            1,
-            false,
+            PollOptions {
+                count: 1,
+                ..PollOptions::default()
+            },
         )
         .await
         .expect("stream.manage_topics implies poll_messages: poll_messages should work");
@@ -1814,11 +1844,13 @@
         .poll_messages(
             &stream_id,
             &topic_id,
-            Some(0),
+            0,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            1,
-            false,
+            PollOptions {
+                count: 1,
+                ..PollOptions::default()
+            },
         )
         .await
         .expect("topic.read_topic implies poll_messages: poll_messages should work");
@@ -1883,11 +1915,13 @@
         .poll_messages(
             &stream_id,
             &topic_id,
-            Some(0),
+            0,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            1,
-            false,
+            PollOptions {
+                count: 1,
+                ..PollOptions::default()
+            },
         )
         .await
         .expect("topic.manage_topic implies poll_messages: poll_messages should work");
@@ -1933,11 +1967,13 @@
         .poll_messages(
             &stream_id,
             &topic_id,
-            Some(0),
+            0,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            1,
-            false,
+            PollOptions {
+                count: 1,
+                ..PollOptions::default()
+            },
         )
         .await
         .expect("poll_messages: poll should work");
@@ -2287,11 +2323,13 @@
         .poll_messages(
             &stream1_id,
             &topic_id,
-            Some(0),
+            0,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            1,
-            false,
+            PollOptions {
+                count: 1,
+                ..PollOptions::default()
+            },
         )
         .await
         .expect("additive: poll topic 1 should work");
@@ -2301,11 +2339,13 @@
         .poll_messages(
             &stream1_id,
             &topic2_id,
-            Some(0),
+            0,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            1,
-            false,
+            PollOptions {
+                count: 1,
+                ..PollOptions::default()
+            },
         )
         .await
         .expect("additive: poll topic 2 should work (via stream.read_stream)");
@@ -2388,11 +2428,13 @@
             .poll_messages(
                 &nonexistent_stream,
                 &nonexistent_topic,
-                Some(0),
+                0,
                 &Consumer::default(),
                 &PollingStrategy::offset(0),
-                1,
-                false,
+                PollOptions {
+                    count: 1,
+                    ..PollOptions::default()
+                },
             )
             .await,
         "authorized: poll from non-existent stream/topic",
@@ -2440,11 +2482,13 @@
             .poll_messages(
                 &nonexistent_stream,
                 &nonexistent_topic,
-                Some(0),
+                0,
                 &Consumer::default(),
                 &PollingStrategy::offset(0),
-                1,
-                false,
+                PollOptions {
+                    count: 1,
+                    ..PollOptions::default()
+                },
             )
             .await,
         "unauthorized: poll from non-existent",
@@ -2660,11 +2704,13 @@
         .poll_messages(
             &stream_id,
             &topic_id,
-            Some(0),
+            0,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            1,
-            false,
+            PollOptions {
+                count: 1,
+                ..PollOptions::default()
+            },
         )
         .await
         .expect("poll must still work (poll grant retained)");
diff --git a/core/integration/tests/server/scenarios/read_during_persistence_scenario.rs b/core/integration/tests/server/scenarios/read_during_persistence_scenario.rs
index 0964d83..26dbaac 100644
--- a/core/integration/tests/server/scenarios/read_during_persistence_scenario.rs
+++ b/core/integration/tests/server/scenarios/read_during_persistence_scenario.rs
@@ -110,11 +110,13 @@
             .poll_messages(
                 &stream_id,
                 &topic_id,
-                Some(0),
+                0,
                 &consumer_kind,
                 &PollingStrategy::offset(next_offset),
-                messages_per_batch,
-                false,
+                PollOptions {
+                    count: messages_per_batch,
+                    ..PollOptions::default()
+                },
             )
             .await;
 
diff --git a/core/integration/tests/server/scenarios/reconnect_after_restart_scenario.rs b/core/integration/tests/server/scenarios/reconnect_after_restart_scenario.rs
index 3b48ec6..855b2e1 100644
--- a/core/integration/tests/server/scenarios/reconnect_after_restart_scenario.rs
+++ b/core/integration/tests/server/scenarios/reconnect_after_restart_scenario.rs
@@ -15,6 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
+use super::POLL_REPLY_MAX_BYTES;
 use futures::StreamExt;
 use iggy::prelude::*;
 use iggy_common::TransportProtocol;
@@ -105,11 +106,13 @@
         .poll_messages(
             &Identifier::named(STREAM_NAME).unwrap(),
             &Identifier::named(TOPIC_NAME).unwrap(),
-            Some(0),
+            0,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            10,
-            false,
+            PollOptions {
+                count: 10,
+                ..PollOptions::default()
+            },
         )
         .await
         .expect("Failed to poll messages after restart");
@@ -160,10 +163,14 @@
     Client::connect(&client).await.expect("Failed to connect");
 
     let mut consumer = client
-        .consumer("test-consumer", STREAM_NAME, TOPIC_NAME, 0)
+        .consumer("test-consumer", STREAM_NAME, TOPIC_NAME)
         .expect("Failed to create consumer builder")
+        .partitions([0])
         .polling_strategy(PollingStrategy::next())
-        .batch_length(10)
+        .poll_options(PollOptions {
+            count: 10,
+            ..PollOptions::wait(IggyDuration::ONE_SECOND)
+        })
         .polling_retry_interval(NonZeroIggyDuration::from_str("500ms").unwrap())
         .build();
 
@@ -263,7 +270,7 @@
     let mut payloads = Vec::with_capacity(expected as usize);
     let deadline = tokio::time::Instant::now() + max_wait;
     while (payloads.len() as u32) < expected && tokio::time::Instant::now() < deadline {
-        let poll_result: Option<Result<ReceivedMessage, IggyError>> =
+        let poll_result: Option<Result<ReceivedMessage, ReadError>> =
             tokio::time::timeout(Duration::from_millis(500), consumer.next())
                 .await
                 .ok()
@@ -323,11 +330,14 @@
         .poll_messages(
             &Identifier::named(STREAM).unwrap(),
             &Identifier::named(TOPIC).unwrap(),
-            Some(0),
+            0,
             &consumer,
             &PollingStrategy::next(),
-            10,
-            true,
+            PollOptions {
+                count: 10,
+                commit: CommitPolicy::OnRead,
+                ..PollOptions::default()
+            },
         )
         .await
         .unwrap();
@@ -369,11 +379,14 @@
         .poll_messages(
             &Identifier::named(STREAM).unwrap(),
             &Identifier::named(TOPIC).unwrap(),
-            Some(0),
+            0,
             &consumer,
             &PollingStrategy::next(),
-            10,
-            true,
+            PollOptions {
+                count: 10,
+                commit: CommitPolicy::OnRead,
+                ..PollOptions::default()
+            },
         )
         .await
         .unwrap();
@@ -446,11 +459,14 @@
         .poll_messages(
             &Identifier::named(STREAM).unwrap(),
             &Identifier::named(TOPIC).unwrap(),
-            Some(0),
+            0,
             &consumer,
             &PollingStrategy::next(),
-            100,
-            true,
+            PollOptions {
+                count: 100,
+                commit: CommitPolicy::OnRead,
+                ..PollOptions::default()
+            },
         )
         .await
         .unwrap();
@@ -513,11 +529,14 @@
         .poll_messages(
             &Identifier::named(STREAM).unwrap(),
             &Identifier::named(TOPIC).unwrap(),
-            Some(0),
+            0,
             &consumer,
             &PollingStrategy::next(),
-            100,
-            true,
+            PollOptions {
+                count: 100,
+                commit: CommitPolicy::OnRead,
+                ..PollOptions::default()
+            },
         )
         .await
         .unwrap();
@@ -609,11 +628,16 @@
             .poll_messages(
                 &Identifier::named(STREAM_NAME).unwrap(),
                 &Identifier::named(TOPIC_NAME).unwrap(),
-                Some(0),
+                0,
                 &Consumer::default(),
                 &PollingStrategy::offset(0),
-                expected * 2,
-                false,
+                PollOptions {
+                    count: expected * 2,
+                    // Each message is its own batch, so the default byte limit
+                    // holds only a few thousand of them.
+                    max_bytes: POLL_REPLY_MAX_BYTES,
+                    ..PollOptions::default()
+                },
             )
             .await;
         if let Ok(polled) = &polled
diff --git a/core/integration/tests/server/scenarios/restart_offset_skip_scenario.rs b/core/integration/tests/server/scenarios/restart_offset_skip_scenario.rs
index e806e10..1d7ac90 100644
--- a/core/integration/tests/server/scenarios/restart_offset_skip_scenario.rs
+++ b/core/integration/tests/server/scenarios/restart_offset_skip_scenario.rs
@@ -178,11 +178,14 @@
                 .poll_messages(
                     &stream,
                     &topic,
-                    Some(0),
+                    0,
                     &consumer,
                     &PollingStrategy::next(),
-                    CONSUMER_BATCH_SIZE,
-                    true, // auto_commit
+                    PollOptions {
+                        count: CONSUMER_BATCH_SIZE,
+                        commit: CommitPolicy::OnRead,
+                        ..PollOptions::default()
+                    },
                 )
                 .await;
 
diff --git a/core/integration/tests/server/scenarios/single_message_per_batch_scenario.rs b/core/integration/tests/server/scenarios/single_message_per_batch_scenario.rs
index 9f71daa..2129815 100644
--- a/core/integration/tests/server/scenarios/single_message_per_batch_scenario.rs
+++ b/core/integration/tests/server/scenarios/single_message_per_batch_scenario.rs
@@ -125,11 +125,13 @@
             .poll_messages(
                 &stream_id,
                 &topic_id,
-                Some(0),
+                0,
                 &consumer,
                 &PollingStrategy::offset(next_offset),
-                batch_size,
-                false,
+                PollOptions {
+                    count: batch_size,
+                    ..PollOptions::default()
+                },
             )
             .await
             .unwrap();
diff --git a/core/integration/tests/server/scenarios/stale_client_consumer_group_scenario.rs b/core/integration/tests/server/scenarios/stale_client_consumer_group_scenario.rs
index 4f34079..aeb83f7 100644
--- a/core/integration/tests/server/scenarios/stale_client_consumer_group_scenario.rs
+++ b/core/integration/tests/server/scenarios/stale_client_consumer_group_scenario.rs
@@ -18,6 +18,7 @@
 use futures::StreamExt;
 use iggy::prelude::*;
 use iggy_common::{Credentials, Durability};
+use integration::harness::GroupPoller;
 use integration::iggy_harness;
 use secrecy::SecretString;
 use std::str::FromStr;
@@ -193,11 +194,10 @@
         .poll_messages(
             &stream,
             &topic,
-            None,
+            0,
             &consumer,
             &PollingStrategy::next(),
-            1,
-            true,
+            on_read_options(),
         )
         .await
         .unwrap();
@@ -250,11 +250,13 @@
             .poll_messages(
                 &stream,
                 &topic,
-                Some(0),
+                0,
                 &Consumer::default(),
                 &PollingStrategy::offset(1),
-                1,
-                false,
+                PollOptions {
+                    count: 1,
+                    ..PollOptions::default()
+                },
             )
             .await
             .unwrap();
@@ -269,17 +271,18 @@
     };
     assert_eq!(direct.messages.len(), 1, "retained data must be readable");
 
+    // The original member can hold the partition until its session expires, so the
+    // replacement polls only what its synced assignment holds.
+    let mut poller = GroupPoller::new(group_id.clone());
     let deadline = tokio::time::Instant::now() + STALE_EVICTION_TIMEOUT;
     loop {
-        let polled = replacement
-            .poll_messages(
+        let polled = poller
+            .poll(
+                &replacement,
                 &stream,
                 &topic,
-                None,
-                &consumer,
                 &PollingStrategy::next(),
-                1,
-                true,
+                on_read_options(),
             )
             .await
             .expect("replacement group poll must succeed");
@@ -402,6 +405,15 @@
     }
 }
 
+/// One message per poll, whose offset the server stores at the read.
+fn on_read_options() -> PollOptions {
+    PollOptions {
+        count: 1,
+        commit: CommitPolicy::OnRead,
+        ..PollOptions::default()
+    }
+}
+
 /// Tests that a stale client receives clean errors and can manually reconnect.
 #[iggy_harness(server(
     heartbeat.enabled = true,
@@ -432,20 +444,18 @@
         .await
         .unwrap();
 
-    let consumer = Consumer::group(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
+    let mut stale_poller = GroupPoller::new(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
 
     // Poll first 5 messages
     let mut messages_polled = 0;
     while messages_polled < 5 {
-        let polled = stale_client
-            .poll_messages(
+        let polled = stale_poller
+            .poll(
+                &stale_client,
                 &Identifier::named(STREAM_NAME).unwrap(),
                 &Identifier::named(TOPIC_NAME).unwrap(),
-                None,
-                &consumer,
                 &PollingStrategy::next(),
-                1,
-                true,
+                on_read_options(),
             )
             .await
             .unwrap();
@@ -471,15 +481,13 @@
 
     let mut got_error = false;
     for _ in 0..3 {
-        if stale_client
-            .poll_messages(
+        if stale_poller
+            .poll(
+                &stale_client,
                 &Identifier::named(STREAM_NAME).unwrap(),
                 &Identifier::named(TOPIC_NAME).unwrap(),
-                None,
-                &consumer,
                 &PollingStrategy::next(),
-                1,
-                true,
+                on_read_options(),
             )
             .await
             .is_err()
@@ -508,18 +516,17 @@
         .unwrap();
 
     // Poll remaining messages
+    let mut new_poller = GroupPoller::new(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
     let mut remaining_polled = 0;
     let start = std::time::Instant::now();
     while remaining_polled < 5 && start.elapsed() < Duration::from_secs(5) {
-        match new_client
-            .poll_messages(
+        match new_poller
+            .poll(
+                &new_client,
                 &Identifier::named(STREAM_NAME).unwrap(),
                 &Identifier::named(TOPIC_NAME).unwrap(),
-                None,
-                &consumer,
                 &PollingStrategy::next(),
-                1,
-                true,
+                on_read_options(),
             )
             .await
         {
@@ -555,11 +562,13 @@
     let mut consumer: IggyConsumer = consumer_client
         .consumer_group(CONSUMER_GROUP_NAME, STREAM_NAME, TOPIC_NAME)
         .unwrap()
-        .batch_length(1)
+        .poll_options(PollOptions {
+            count: 1,
+            commit: CommitPolicy::OnRead,
+            ..PollOptions::wait(IggyDuration::ONE_SECOND)
+        })
         .polling_strategy(PollingStrategy::next())
-        .auto_join_consumer_group()
         .create_consumer_group_if_not_exists()
-        .auto_commit(AutoCommit::When(AutoCommitWhen::PollingMessages))
         .polling_retry_interval(NonZeroIggyDuration::from_str("500ms").unwrap())
         .build();
 
@@ -570,7 +579,7 @@
     let timeout = STALE_EVICTION_TIMEOUT + Duration::from_secs(15);
 
     while messages_consumed < TOTAL_MESSAGES && start.elapsed() < timeout {
-        let poll_result: Option<Result<ReceivedMessage, IggyError>> =
+        let poll_result: Option<Result<ReceivedMessage, ReadError>> =
             tokio::time::timeout(Duration::from_millis(500), consumer.next())
                 .await
                 .ok()
diff --git a/core/integration/tests/server/scenarios/system_scenario.rs b/core/integration/tests/server/scenarios/system_scenario.rs
index 3559ca6..41dcb27 100644
--- a/core/integration/tests/server/scenarios/system_scenario.rs
+++ b/core/integration/tests/server/scenarios/system_scenario.rs
@@ -263,11 +263,13 @@
         .poll_messages(
             &Identifier::named(STREAM_NAME).unwrap(),
             &Identifier::named(TOPIC_NAME).unwrap(),
-            Some(PARTITION_ID),
+            PARTITION_ID,
             &consumer,
             &PollingStrategy::offset(0),
-            MESSAGES_COUNT,
-            false,
+            PollOptions {
+                count: MESSAGES_COUNT,
+                ..PollOptions::default()
+            },
         )
         .await
         .unwrap();
@@ -287,11 +289,13 @@
             .poll_messages(
                 &Identifier::named(STREAM_NAME).unwrap(),
                 &Identifier::named(TOPIC_NAME).unwrap(),
-                Some(PARTITION_ID),
+                PARTITION_ID,
                 &consumer,
                 &PollingStrategy::offset(start_offset),
-                batch_length,
-                false,
+                PollOptions {
+                    count: batch_length,
+                    ..PollOptions::default()
+                },
             )
             .await
             .unwrap();
@@ -329,11 +333,13 @@
         .poll_messages(
             &Identifier::named(STREAM_NAME).unwrap(),
             &Identifier::named(TOPIC_NAME).unwrap(),
-            Some(PARTITION_ID + 1),
+            PARTITION_ID + 1,
             &consumer,
             &PollingStrategy::offset(0),
-            MESSAGES_COUNT,
-            false,
+            PollOptions {
+                count: MESSAGES_COUNT,
+                ..PollOptions::default()
+            },
         )
         .await
         .unwrap();
@@ -420,11 +426,14 @@
         .poll_messages(
             &Identifier::named(STREAM_NAME).unwrap(),
             &Identifier::named(TOPIC_NAME).unwrap(),
-            Some(PARTITION_ID),
+            PARTITION_ID,
             &consumer,
             &PollingStrategy::next(),
-            messages_count,
-            true,
+            PollOptions {
+                count: messages_count,
+                commit: CommitPolicy::OnRead,
+                ..PollOptions::default()
+            },
         )
         .await
         .unwrap();
diff --git a/core/integration/tests/server/scenarios/tcp_tls_scenario.rs b/core/integration/tests/server/scenarios/tcp_tls_scenario.rs
index 852a3c3..9a655b4 100644
--- a/core/integration/tests/server/scenarios/tcp_tls_scenario.rs
+++ b/core/integration/tests/server/scenarios/tcp_tls_scenario.rs
@@ -69,11 +69,14 @@
         .poll_messages(
             &Identifier::named(stream_name).unwrap(),
             &Identifier::named(topic_name).unwrap(),
-            Some(0),
+            0,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            1,
-            true,
+            PollOptions {
+                count: 1,
+                commit: CommitPolicy::OnRead,
+                ..PollOptions::default()
+            },
         )
         .await
         .unwrap();
diff --git a/core/integration/tests/server/scenarios/timestamp_scenario.rs b/core/integration/tests/server/scenarios/timestamp_scenario.rs
index 23508fb..6504cbd 100644
--- a/core/integration/tests/server/scenarios/timestamp_scenario.rs
+++ b/core/integration/tests/server/scenarios/timestamp_scenario.rs
@@ -232,7 +232,7 @@
     initial_timestamp: u64,
     total_messages: u32,
 ) {
-    let polled = poll_until_expected_count(
+    let messages = poll_until_expected_count(
         client,
         stream_name,
         topic_name,
@@ -242,11 +242,11 @@
     .await;
 
     assert_eq!(
-        polled.messages.len() as u32,
+        messages.len() as u32,
         total_messages,
         "Expected {} messages from initial timestamp, got {}",
         total_messages,
-        polled.messages.len()
+        messages.len()
     );
 }
 
@@ -263,7 +263,7 @@
         let prior_batches_sum: u32 = batch_lengths[..3].iter().sum();
         let remaining_messages = total_messages - prior_batches_sum;
 
-        let polled = poll_until_expected_count(
+        let messages = poll_until_expected_count(
             client,
             stream_name,
             topic_name,
@@ -273,11 +273,11 @@
         .await;
 
         assert_eq!(
-            polled.messages.len() as u32,
+            messages.len() as u32,
             remaining_messages,
             "Expected {} messages from middle timestamp, got {}",
             remaining_messages,
-            polled.messages.len()
+            messages.len()
         );
     }
 }
@@ -293,11 +293,13 @@
         .poll_messages(
             &Identifier::named(stream_name).unwrap(),
             &Identifier::named(topic_name).unwrap(),
-            Some(PARTITION_ID),
+            PARTITION_ID,
             &Consumer::default(),
             &PollingStrategy::timestamp(IggyTimestamp::from(future_timestamp)),
-            10,
-            false,
+            PollOptions {
+                count: 10,
+                ..PollOptions::default()
+            },
         )
         .await
         .unwrap();
@@ -323,11 +325,13 @@
         .poll_messages(
             &Identifier::named(stream_name).unwrap(),
             &Identifier::named(topic_name).unwrap(),
-            Some(PARTITION_ID),
+            PARTITION_ID,
             &Consumer::default(),
             &PollingStrategy::timestamp(IggyTimestamp::from(initial_timestamp)),
-            subset_size,
-            false,
+            PollOptions {
+                count: subset_size,
+                ..PollOptions::default()
+            },
         )
         .await
         .unwrap();
@@ -355,11 +359,13 @@
             .poll_messages(
                 &Identifier::named(stream_name).unwrap(),
                 &Identifier::named(topic_name).unwrap(),
-                Some(PARTITION_ID),
+                PARTITION_ID,
                 &Consumer::default(),
                 &PollingStrategy::timestamp(IggyTimestamp::from(span_timestamp)),
-                span_size,
-                false,
+                PollOptions {
+                    count: span_size,
+                    ..PollOptions::default()
+                },
             )
             .await
             .unwrap();
@@ -389,11 +395,13 @@
             .poll_messages(
                 &Identifier::named(stream_name).unwrap(),
                 &Identifier::named(topic_name).unwrap(),
-                Some(PARTITION_ID),
+                PARTITION_ID,
                 &Consumer::default(),
                 &PollingStrategy::timestamp(IggyTimestamp::from(span_timestamp)),
-                span_size,
-                false,
+                PollOptions {
+                    count: span_size,
+                    ..PollOptions::default()
+                },
             )
             .await
             .unwrap();
diff --git a/core/integration/tests/server/scenarios/websocket_tls_scenario.rs b/core/integration/tests/server/scenarios/websocket_tls_scenario.rs
index 4c08a6e..9a5335b 100644
--- a/core/integration/tests/server/scenarios/websocket_tls_scenario.rs
+++ b/core/integration/tests/server/scenarios/websocket_tls_scenario.rs
@@ -69,11 +69,14 @@
         .poll_messages(
             &Identifier::named(stream_name).unwrap(),
             &Identifier::named(topic_name).unwrap(),
-            Some(0),
+            0,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            1,
-            true,
+            PollOptions {
+                count: 1,
+                commit: CommitPolicy::OnRead,
+                ..PollOptions::default()
+            },
         )
         .await
         .unwrap();
diff --git a/core/integration/tests/server/unknown_code_vsr.rs b/core/integration/tests/server/unknown_code_vsr.rs
index e0b4051..1450a85 100644
--- a/core/integration/tests/server/unknown_code_vsr.rs
+++ b/core/integration/tests/server/unknown_code_vsr.rs
@@ -56,7 +56,7 @@
 
 /// A code no `COMMAND_TABLE` entry claims.
 const UNKNOWN_CODE: u32 = 9999;
-const RETIRED_ROUTING_CODES: [u32; 3] = [103, 104, 123];
+const RETIRED_COMMAND_CODES: [u32; 4] = [103, 104, 107, 123];
 
 /// An operation byte `Operation` does not declare.
 /// `reserved_codes_remain_unknown` pins 131 as never reused, so a new
@@ -82,7 +82,7 @@
 async fn given_bound_session_when_unknown_or_retired_code_sent_should_deny_invalid_command(
     harness: &TestHarness,
 ) {
-    for code in [UNKNOWN_CODE].into_iter().chain(RETIRED_ROUTING_CODES) {
+    for code in [UNKNOWN_CODE].into_iter().chain(RETIRED_COMMAND_CODES) {
         assert!(
             lookup_command(code).is_none(),
             "code {code} must stay retired"
diff --git a/core/message_bus/src/transports/quic.rs b/core/message_bus/src/transports/quic.rs
index 390a587..37f38cd 100644
--- a/core/message_bus/src/transports/quic.rs
+++ b/core/message_bus/src/transports/quic.rs
@@ -76,12 +76,12 @@
 use futures::FutureExt;
 use futures::channel::oneshot;
 use futures::stream::{FuturesUnordered, StreamExt};
-use iggy_binary_protocol::codes::{POLL_MESSAGES_DEFERRED_CODE, WAIT_FOR_MESSAGES_CODE};
-use iggy_binary_protocol::requests::messages::{
-    DeferredPollMessagesRequest, WaitForMessagesRequest,
+use iggy_binary_protocol::codes::{
+    CANCEL_DEFERRED_REQUEST_CODE, FETCH_MESSAGES_CODE, POLL_MESSAGES_DEFERRED_CODE,
 };
+use iggy_binary_protocol::requests::messages::{DeferredPollMessagesRequest, FetchMessagesRequest};
 use iggy_binary_protocol::{
-    Command, GenericHeader, HEADER_SIZE, ReplyHeader, RequestHeader, WireDecode,
+    Command, GenericHeader, HEADER_SIZE, Operation, ReplyHeader, RequestHeader, WireDecode,
 };
 use server_common::Message;
 use std::cell::RefCell;
@@ -325,6 +325,13 @@
     read_u64(bytes, offset_of!(ReplyHeader, request)).map_or(ReplyRoute::Unkeyed, ReplyRoute::Keyed)
 }
 
+/// Whether `header` is a 108, which cancels a deferred request and gets no
+/// reply of its own.
+fn is_cancellation(header: &RequestHeader) -> bool {
+    header.operation == Operation::NonReplicated
+        && u32::from_le_bytes(header.reserved) == CANCEL_DEFERRED_REQUEST_CODE
+}
+
 /// The request id of a client frame, read as raw bytes like [`reply_route`],
 /// so a frame with an undeclared operation still has one.
 fn request_id(request: &Message<GenericHeader>) -> Option<u64> {
@@ -339,11 +346,8 @@
                 .ok()?
                 .request_timeout_us
         }
-        WAIT_FOR_MESSAGES_CODE => {
-            WaitForMessagesRequest::decode_from(body)
-                .ok()?
-                .request_timeout_us
-        }
+        // The server decodes the targets anyway, so the transport thread reads only the timers.
+        FETCH_MESSAGES_CODE => FetchMessagesRequest::peek_request_timeout_us(body)?,
         _ => return None,
     };
     Some(Duration::from_micros(micros))
@@ -498,6 +502,19 @@
         let header =
             bytemuck::checked::try_from_bytes::<RequestHeader>(&request.as_slice()[..HEADER_SIZE])
                 .ok();
+        // A cancellation gets no reply. Waiting under the id of the request it
+        // cancels could take that request's reply from the stream that waits
+        // for it, so it only passes through.
+        if header.is_some_and(is_cancellation) {
+            if in_tx.send(request).await.is_err() {
+                debug!(%label, %peer, "quic: inbound queue dropped");
+                return ControlFlow::Break(());
+            }
+            if let Err(e) = send.finish() {
+                debug!(%label, %peer, error = ?e, "quic: send.finish() failed");
+            }
+            return ControlFlow::Continue(());
+        }
         let cancellation = header.and_then(RequestHeader::deferred_cancellation);
         // Only a cancellable request waits, so one predicate decides both.
         let deferred_timeout = header
@@ -744,7 +761,14 @@
     use compio::io::AsyncWrite;
     use compio::net::UdpSocket;
     use compio_quic::ClientBuilder;
-    use iggy_binary_protocol::{Command, GenericHeader, HEADER_SIZE, Operation, SIZE_FIELD_OFFSET};
+    use iggy_binary_protocol::requests::messages::{
+        FetchMessagesTarget, MAX_DEFERRED_POLL_TIMEOUT_US, MAX_DEFERRED_POLL_WAIT_US,
+        MIN_DEFERRED_POLL_MAX_BYTES, MIN_DEFERRED_POLL_REPLY_MARGIN_US,
+    };
+    use iggy_binary_protocol::{
+        Command, GenericHeader, HEADER_SIZE, Operation, SIZE_FIELD_OFFSET, WireConsumer,
+        WireEncode, WireIdentifier, WirePollingStrategy,
+    };
     use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
     use server_common::iobuf::Frozen;
     use server_common::{MESSAGE_ALIGN, Message};
@@ -771,6 +795,13 @@
     /// No `Operation` has this discriminant.
     const UNDECLARED_OPERATION: u8 = u8::MAX;
     const UNCLAIMED_MAX: usize = 16;
+    const NO_SESSION: u64 = 0;
+    const NO_WAIT_US: u64 = 0;
+    /// Many times `SHORT_BACKSTOP`, so only its own request timeout can keep a
+    /// fetch stream open past the backstop.
+    const FETCH_REQUEST_TIMEOUT_US: u64 = 5_000_000;
+    /// No consumer kind has this value.
+    const UNKNOWN_CONSUMER_KIND: u8 = u8::MAX;
 
     fn install_crypto_provider() {
         // Idempotent. Tests in the same process race on
@@ -895,6 +926,48 @@
             .into_frozen()
     }
 
+    /// A fetch with the given timers, whose other fields only need to pass
+    /// `FetchMessagesRequest::validate`.
+    fn fetch(wait_us: u64, request_timeout_us: u64) -> FetchMessagesRequest {
+        let max_bytes = u32::try_from(MIN_DEFERRED_POLL_MAX_BYTES).expect("a u32 byte limit");
+        FetchMessagesRequest {
+            consumer: WireConsumer::consumer(WireIdentifier::numeric(1)),
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(1),
+            wait_us,
+            request_timeout_us,
+            min_count: 1,
+            count: 1,
+            max_bytes,
+            auto_commit: false,
+            targets: vec![FetchMessagesTarget {
+                partition_id: 1,
+                strategy: WirePollingStrategy::next(),
+                max_bytes,
+                incarnation: 1,
+                owner_generation: 1,
+            }],
+        }
+    }
+
+    /// The frame an SDK sends for a fetch.
+    fn fetch_request(request_id: u64, payload: &FetchMessagesRequest) -> Frozen<MESSAGE_ALIGN> {
+        let body = payload.to_bytes();
+        let header = RequestHeader::for_request(
+            FETCH_MESSAGES_CODE,
+            TEST_CLIENT,
+            request_id,
+            NO_SESSION,
+            &body,
+        )
+        .expect("a fetch fits one frame");
+        let mut frame = Message::<RequestHeader>::new(HEADER_SIZE + body.len());
+        let (head, tail) = frame.as_mut_slice().split_at_mut(HEADER_SIZE);
+        head.copy_from_slice(bytemuck::bytes_of(&header));
+        tail.copy_from_slice(&body);
+        frame.into_frozen()
+    }
+
     /// A reply to `request_id`. `commit` tells two replies to one id apart.
     #[allow(clippy::cast_possible_truncation)]
     fn reply_message(request_id: u64, commit: u64) -> Message<GenericHeader> {
@@ -920,8 +993,13 @@
     /// answer.
     #[allow(clippy::future_not_send)]
     async fn send_request(client: &Connection, request_id: u64) -> RecvStream {
+        send_frame(client, request(request_id)).await
+    }
+
+    #[allow(clippy::future_not_send)]
+    async fn send_frame(client: &Connection, frame: Frozen<MESSAGE_ALIGN>) -> RecvStream {
         let (mut send, recv) = client.open_bi_wait().await.expect("open_bi");
-        let BufResult(result, _) = send.write_all(request(request_id)).await;
+        let BufResult(result, _) = send.write_all(frame).await;
         result.expect("write");
         send.finish().expect("finish");
         recv
@@ -1159,6 +1237,52 @@
         let _ = server.await;
     }
 
+    /// A client cancels a committing request with a 108 on a new stream and
+    /// keeps the stream of the request open for the one reply that tells
+    /// whether the request stored anything.
+    #[compio::test]
+    #[allow(clippy::future_not_send)]
+    async fn given_a_cancellation_on_its_own_stream_when_the_request_replies_should_answer_only_the_request_stream()
+     {
+        let (conn, client, _endpoints) = connect(TWO_STREAMS).await;
+        let (outbound, inbound, shutdown, server) = drive(conn);
+        let frame = fetch_request(FIRST_REQUEST, &fetch(NO_WAIT_US, FETCH_REQUEST_TIMEOUT_US));
+        let request_header =
+            *bytemuck::checked::from_bytes::<RequestHeader>(&frame.as_slice()[..HEADER_SIZE]);
+        let cancellation = request_header
+            .deferred_cancellation()
+            .expect("a fetch has a cancellation");
+        let mut cancel_frame = Message::<RequestHeader>::new(HEADER_SIZE);
+        cancel_frame
+            .as_mut_slice()
+            .copy_from_slice(bytemuck::bytes_of(&cancellation));
+        let mut original = send_frame(&client, frame).await;
+        assert_eq!(forwarded(&inbound).await, FIRST_REQUEST);
+        let mut cancel = send_frame(&client, cancel_frame.into_frozen()).await;
+        assert_eq!(
+            forwarded(&inbound).await,
+            FIRST_REQUEST,
+            "the cancellation reaches the server"
+        );
+
+        let ended = compio::time::timeout(
+            TEST_WAIT,
+            framing::read_message(&mut cancel, framing::MAX_MESSAGE_SIZE),
+        )
+        .await
+        .expect("the cancellation stream ends at once");
+        assert!(ended.is_err(), "a cancellation gets no answer");
+        outbound
+            .send(reply(FIRST_REQUEST, ANY_COMMIT))
+            .await
+            .unwrap();
+        let answer = read_answer(&mut original).await;
+        assert_eq!(reply_header(answer.as_slice()).request, FIRST_REQUEST);
+
+        shutdown.trigger();
+        let _ = server.await;
+    }
+
     /// The backstop resets the one stream that has no reply, and the
     /// connection serves the next stream.
     #[compio::test]
@@ -1191,6 +1315,36 @@
         let _ = server.await;
     }
 
+    /// A fetch keeps its stream past the backstop up to its own request
+    /// timeout, so a long wait gets its reply instead of a reset and a cancel.
+    #[compio::test]
+    #[allow(clippy::future_not_send)]
+    async fn given_a_fetch_within_its_request_timeout_when_the_backstop_passes_should_keep_its_stream()
+     {
+        let (mut conn, client, _endpoints) = connect(ONE_STREAM).await;
+        conn.reply_backstop = SHORT_BACKSTOP;
+        let (outbound, inbound, shutdown, server) = drive(conn);
+        let request = fetch_request(FIRST_REQUEST, &fetch(NO_WAIT_US, FETCH_REQUEST_TIMEOUT_US));
+        let mut waiting = send_frame(&client, request).await;
+        assert_eq!(forwarded(&inbound).await, FIRST_REQUEST);
+        assert!(
+            compio::time::timeout(2 * SHORT_BACKSTOP, inbound.recv())
+                .await
+                .is_err(),
+            "the backstop must not cancel a fetch before its request timeout"
+        );
+
+        outbound
+            .send(reply(FIRST_REQUEST, ANY_COMMIT))
+            .await
+            .unwrap();
+        let answer = read_answer(&mut waiting).await;
+        assert_eq!(reply_header(answer.as_slice()).request, FIRST_REQUEST);
+
+        shutdown.trigger();
+        let _ = server.await;
+    }
+
     #[test]
     fn given_two_streams_with_one_request_id_when_two_replies_arrive_should_answer_them_in_stream_order()
      {
@@ -1323,6 +1477,67 @@
         ));
     }
 
+    /// Only the timers of a fetch are read, so a fetch that the server refuses
+    /// for its targets still gives its timeout.
+    #[test]
+    fn given_a_fetch_body_when_its_deferred_timeout_is_read_should_give_its_request_timeout() {
+        for (wait_us, request_timeout_us) in [
+            (NO_WAIT_US, MIN_DEFERRED_POLL_REPLY_MARGIN_US),
+            (MAX_DEFERRED_POLL_WAIT_US, MAX_DEFERRED_POLL_TIMEOUT_US),
+        ] {
+            let without_targets = FetchMessagesRequest {
+                targets: Vec::new(),
+                ..fetch(wait_us, request_timeout_us)
+            }
+            .to_bytes();
+            assert!(
+                FetchMessagesRequest::decode_from(&without_targets).is_err(),
+                "the server refuses a fetch without targets"
+            );
+            for body in [
+                fetch(wait_us, request_timeout_us).to_bytes(),
+                without_targets,
+            ] {
+                assert_eq!(
+                    deferred_timeout(FETCH_MESSAGES_CODE, &body),
+                    Some(Duration::from_micros(request_timeout_us)),
+                    "wait {wait_us}, timeout {request_timeout_us}"
+                );
+            }
+        }
+    }
+
+    /// A fetch whose timers cannot be read, or that the server refuses for
+    /// them, keeps the default backstop.
+    #[test]
+    fn given_a_truncated_or_malformed_fetch_body_when_its_deferred_timeout_is_read_should_give_none()
+     {
+        let request = fetch(NO_WAIT_US, MIN_DEFERRED_POLL_REPLY_MARGIN_US);
+        let body = request.to_bytes();
+        let timers_end = request.consumer.encoded_size()
+            + request.stream_id.encoded_size()
+            + request.topic_id.encoded_size()
+            + 2 * size_of::<u64>();
+        let mut unknown_consumer = body.to_vec();
+        unknown_consumer[0] = UNKNOWN_CONSUMER_KIND;
+        let above_ceiling = fetch(NO_WAIT_US, MAX_DEFERRED_POLL_TIMEOUT_US + 1).to_bytes();
+        for (case, malformed) in [
+            ("empty", &body[..0]),
+            ("cut inside the request timeout", &body[..timers_end - 1]),
+            ("unknown consumer kind", &unknown_consumer[..]),
+            (
+                "request timeout above the protocol ceiling",
+                &above_ceiling[..],
+            ),
+        ] {
+            assert_eq!(
+                deferred_timeout(FETCH_MESSAGES_CODE, malformed),
+                None,
+                "{case}"
+            );
+        }
+    }
+
     #[test]
     fn transport_config_from_default_builds() {
         // Lightweight smoke: confirm the helper builds without panicking
diff --git a/core/metadata/src/stm/consumer_group.rs b/core/metadata/src/stm/consumer_group.rs
index cca236f..8a7b349 100644
--- a/core/metadata/src/stm/consumer_group.rs
+++ b/core/metadata/src/stm/consumer_group.rs
@@ -209,11 +209,17 @@
             let owner_left = assignment
                 .owner
                 .is_none_or(|owner| !members.contains(&(owner.client_id, owner.session)));
+            // An installed owner that left and joined again has no reads left
+            // to commit, so a handoff back to it keeps the skip of its leave. One
+            // that stayed still commits under its generation and drains first.
+            let skip_drain = owner_left
+                || (assignment.owner.is_some_and(same_member)
+                    && assignment.pending.is_some_and(|pending| pending.skip_drain));
             if let Some(pending) = assignment.pending.as_mut() {
                 if same_member(pending.owner) {
                     // Only a member owner can still commit, so the drain
                     // wait follows the current membership.
-                    pending.skip_drain = owner_left;
+                    pending.skip_drain = skip_drain;
                     continue;
                 }
             } else if assignment.owner.is_some_and(same_member)
@@ -228,7 +234,7 @@
                 owner: desired,
                 metadata_op,
                 created_at,
-                skip_drain: owner_left,
+                skip_drain,
             });
         }
         self.rebuild_member_assignments();
@@ -1489,6 +1495,41 @@
     }
 
     #[test]
+    fn given_installed_owner_when_it_rejoins_before_its_revocation_completes_should_skip_the_drain()
+    {
+        // An owner that left and joined again waited a whole rebalancing
+        // timeout for a drain of reads that its leave already gave up.
+        let owner = || {
+            let mut member = ConsumerGroupMember::new(0, 7);
+            member.session = Some(11);
+            member
+        };
+        let mut group = ConsumerGroup::new(0, Arc::from("group"));
+        group.members.insert(owner());
+        let partitions = vec![Partition::new(0, 1, IggyTimestamp::from(1), 1, 0)];
+        group.rebalance_members(&partitions, 12, 100);
+        let assignment = &group.assignments[&0];
+        let pending = assignment.pending.unwrap();
+        let installation = InstallConsumerGroupOwnerRequest {
+            incarnation: assignment.incarnation,
+            group_id: group.id,
+            owner: pending.owner,
+            metadata_op: pending.metadata_op,
+        };
+        assert!(group.complete_revocation(0, &installation, 1));
+        group.members.remove(0);
+        group.rebalance_members(&partitions, 14, 200);
+        assert!(group.assignments[&0].pending.unwrap().owner.is_unassigned());
+        group.members.insert(owner());
+
+        group.rebalance_members(&partitions, 16, 300);
+
+        let pending = group.assignments[&0].pending.unwrap();
+        assert_eq!((pending.owner.client_id, pending.owner.session), (7, 11));
+        assert!(pending.skip_drain);
+    }
+
+    #[test]
     fn given_installed_owner_leaves_when_successor_is_kept_should_skip_its_drain() {
         const OWNER: (u128, u64) = (7, 11);
         const SUCCESSOR: (u128, u64) = (8, 13);
@@ -1548,6 +1589,64 @@
     }
 
     #[test]
+    fn given_pending_handoff_when_its_target_returns_to_an_owner_that_stayed_should_keep_the_drain()
+    {
+        // The handoff back to an installed owner that never left skipped the
+        // drain, so the owner got a new generation at once and its offset
+        // stores under the generation it read with were refused.
+        const OWNER: (u128, u64) = (7, 11);
+        const SECOND: (u128, u64) = (8, 13);
+        let member = |id, (client_id, session)| {
+            let mut member = ConsumerGroupMember::new(id, client_id);
+            member.session = Some(session);
+            member
+        };
+        let partitions: Vec<_> = (0..2)
+            .map(|id| Partition::new(id, id as u64 + 1, IggyTimestamp::from(1), 1, 0))
+            .collect();
+        let mut group = ConsumerGroup::new(0, Arc::from("group"));
+        group.members.insert(member(0, OWNER));
+        group.rebalance_members(&partitions, 12, 100);
+        for partition_id in 0..2 {
+            let assignment = &group.assignments[&partition_id];
+            let pending = assignment.pending.unwrap();
+            let installation = InstallConsumerGroupOwnerRequest {
+                incarnation: assignment.incarnation,
+                group_id: group.id,
+                owner: pending.owner,
+                metadata_op: pending.metadata_op,
+            };
+            assert!(group.complete_revocation(partition_id, &installation, 1));
+        }
+        group.members.insert(member(1, SECOND));
+        group.rebalance_members(&partitions, 14, 200);
+        let (moving, assignment) = group
+            .assignments
+            .iter()
+            .find(|(_, assignment)| assignment.pending.is_some())
+            .expect("the second member takes one partition");
+        let (moving, installed) = (*moving, assignment.owner);
+        assert_eq!(
+            assignment.pending.map(|pending| pending.owner.client_id),
+            Some(SECOND.0)
+        );
+
+        group.members.remove(1);
+        group.rebalance_members(&partitions, 16, 300);
+
+        let assignment = &group.assignments[&moving];
+        assert_eq!(assignment.owner, installed);
+        let pending = assignment
+            .pending
+            .expect("the owner is installed again after its drain");
+        assert_eq!(
+            pending.owner.client_id,
+            installed.expect("an installed owner").client_id
+        );
+        assert!(!pending.skip_drain, "the owner commits what it read first");
+    }
+
+    #[test]
     fn given_pending_owner_when_its_target_changes_should_keep_the_drain_deadline() {
         const OWNER: (u128, u64) = (7, 11);
         const SECOND: (u128, u64) = (8, 13);
diff --git a/core/metadata/src/stm/stream.rs b/core/metadata/src/stm/stream.rs
index dc2a1d5..b9e01e1 100644
--- a/core/metadata/src/stm/stream.rs
+++ b/core/metadata/src/stm/stream.rs
@@ -1166,6 +1166,24 @@
     }
 }
 
+/// What one partition of [`Streams::poll_targets`] resolves to.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum PollTarget {
+    Pollable {
+        namespace: IggyNamespace,
+        metadata: PollMetadata,
+    },
+    /// The group does not hold the partition pollable for the client, as
+    /// [`Streams::consumer_group_fence`] decides. A group poll also gets this
+    /// when its stream, topic, group or member is gone.
+    NotOwned,
+    /// No such stream, topic or partition.
+    NotFound,
+    /// [`Streams::poll_metadata`] refuses the poll: a lifecycle intent blocks
+    /// it, or the installed owner is not the client in this incarnation.
+    Unavailable,
+}
+
 impl Streams {
     #[must_use]
     pub fn poll_metadata(
@@ -1240,6 +1258,106 @@
         })
     }
 
+    /// The poll target of each of `partition_ids` in one topic for
+    /// `client_id`, in order, and the id of the group that `group_id` names.
+    ///
+    /// One read guard resolves the stream, the topic, the group and its member
+    /// once. Each partition then runs the checks of
+    /// [`Self::consumer_group_fence`] (group polls only),
+    /// [`Self::namespace_from_partition`] and [`Self::poll_metadata`], in that
+    /// order, so a poll of many partitions resolves as one poll per partition
+    /// would.
+    #[must_use]
+    pub fn poll_targets(
+        &self,
+        stream_id: &WireIdentifier,
+        topic_id: &WireIdentifier,
+        group_id: Option<&WireIdentifier>,
+        client_id: u128,
+        partition_ids: &[u32],
+    ) -> (Option<u64>, Vec<PollTarget>) {
+        self.read(|inner| {
+            let missing = if group_id.is_some() {
+                PollTarget::NotOwned
+            } else {
+                PollTarget::NotFound
+            };
+            let Some((stream_id, topic_id, topic)) =
+                inner.resolve_stream_id(stream_id).and_then(|stream_id| {
+                    let topic_id = inner.resolve_topic_id(stream_id, topic_id)?;
+                    let topic = inner.items.get(stream_id)?.topics.get(topic_id)?;
+                    Some((stream_id, topic_id, topic))
+                })
+            else {
+                return (None, vec![missing; partition_ids.len()]);
+            };
+            let group = match group_id {
+                Some(group_id) => {
+                    let group = topic
+                        .resolve_group_id(group_id)
+                        .and_then(|id| topic.consumer_groups.get(&id));
+                    let member = group.and_then(|group| {
+                        group
+                            .members
+                            .iter()
+                            .find(|(_, member)| member.client_id == client_id)
+                    });
+                    let (Some(group), Some((_, member))) = (group, member) else {
+                        return (
+                            group.map(|group| group.id),
+                            vec![missing; partition_ids.len()],
+                        );
+                    };
+                    Some((group, member))
+                }
+                None => None,
+            };
+            let group_id = group.map(|(group, _)| group.id);
+            let blocked = inner.lifecycle_blocks_consumer(stream_id, topic_id, group_id);
+            let targets = partition_ids
+                .iter()
+                .map(|&partition_id| {
+                    let partition_id = partition_id as usize;
+                    if group.is_some_and(|(_, member)| !member.is_pollable(partition_id)) {
+                        return PollTarget::NotOwned;
+                    }
+                    let Some(partition) = find_partition(&topic.partitions, partition_id) else {
+                        return PollTarget::NotFound;
+                    };
+                    if blocked {
+                        return PollTarget::Unavailable;
+                    }
+                    let owner = match group {
+                        Some((group, _)) => {
+                            let Some(owner) = group
+                                .assignments
+                                .get(&partition_id)
+                                .filter(|assignment| {
+                                    assignment.incarnation == partition.created_revision
+                                })
+                                .and_then(|assignment| assignment.owner)
+                                .filter(|owner| owner.client_id == client_id)
+                            else {
+                                return PollTarget::Unavailable;
+                            };
+                            Some((group.id, owner.generation))
+                        }
+                        None => None,
+                    };
+                    PollTarget::Pollable {
+                        namespace: IggyNamespace::new(stream_id, topic_id, partition_id),
+                        metadata: PollMetadata {
+                            created_revision: partition.created_revision,
+                            group: owner,
+                            client_id,
+                        },
+                    }
+                })
+                .collect();
+            (group_id, targets)
+        })
+    }
+
     #[must_use]
     pub fn read<F, R>(&self, f: F) -> R
     where
@@ -3375,6 +3493,7 @@
 #[cfg(test)]
 mod tests {
     use super::*;
+    use crate::stm::consumer_group::{ConsumerGroupAssignment, ConsumerGroupMember};
     use crate::stm::snapshot::MetadataSnapshot;
     use iggy_binary_protocol::WireName;
     use iggy_binary_protocol::codec::WireDecode;
@@ -5543,6 +5662,115 @@
         assert!(find_partition(&dense, 2).is_none());
     }
 
+    /// A fetch resolves its stream, topic and group once, so each of its
+    /// partitions must still get the answer of the three calls that a poll of
+    /// that partition alone runs: the group fence, the namespace and the poll
+    /// metadata.
+    #[test]
+    fn given_a_group_poll_of_many_partitions_when_resolving_once_should_match_one_poll_each() {
+        const CLIENT: u128 = 7;
+        const OTHER_CLIENT: u128 = 8;
+        let mut inner = StreamsInner::new();
+        create_stream(&mut inner, "alpha");
+        let create_topic = CreateTopicWithAssignmentsRequest {
+            request: make_topic_request(0, 4, "logs"),
+            derived_options: WireOptions::empty(),
+            partitions: (0..4)
+                .map(|partition_id| CreatedPartitionAssignment {
+                    initial_view: 0,
+                    partition_id,
+                    consensus_group_id: u64::from(partition_id) + 1,
+                })
+                .collect(),
+        };
+        let _ = StateHandler::apply(&create_topic, &mut inner, IggyTimestamp::now());
+        let topic = &mut inner.items[0].topics[0];
+        let incarnation = topic.partitions[0].created_revision;
+        let owner = |client_id, incarnation| ConsumerGroupAssignment {
+            incarnation,
+            owner: Some(ConsumerGroupOwner {
+                client_id,
+                session: 1,
+                generation: 5,
+            }),
+            pending: None,
+        };
+        let mut group = ConsumerGroup::new(3, Arc::from("readers"));
+        group.members.insert(ConsumerGroupMember {
+            partitions: vec![0, 1, 2, 3],
+            pending_revocations: vec![1],
+            ..ConsumerGroupMember::new(0, CLIENT)
+        });
+        // Partition 0 serves the client, 1 drains a handoff, 2 is owned by
+        // another client, 3 belongs to an older incarnation and 4 is missing.
+        group.assignments.insert(0, owner(CLIENT, incarnation));
+        group.assignments.insert(1, owner(CLIENT, incarnation));
+        group
+            .assignments
+            .insert(2, owner(OTHER_CLIENT, incarnation));
+        group.assignments.insert(3, owner(CLIENT, incarnation + 1));
+        topic.consumer_groups.insert(group.id, group);
+        topic.consumer_group_index.insert(Arc::from("readers"), 3);
+        let streams = Streams::from(inner);
+        let stream_id = WireIdentifier::numeric(0);
+        let topic_id = WireIdentifier::numeric(0);
+        let partitions = [0, 1, 2, 3, 4];
+
+        for (group_id, client_id) in [
+            (WireIdentifier::named("readers").unwrap(), CLIENT),
+            (WireIdentifier::named("readers").unwrap(), OTHER_CLIENT),
+            (WireIdentifier::numeric(9), CLIENT),
+        ] {
+            let (resolved_group, targets) = streams.poll_targets(
+                &stream_id,
+                &topic_id,
+                Some(&group_id),
+                client_id,
+                &partitions,
+            );
+            for (&partition_id, target) in partitions.iter().zip(targets) {
+                let one_poll = streams
+                    .consumer_group_fence(
+                        &stream_id,
+                        &topic_id,
+                        &group_id,
+                        client_id,
+                        partition_id,
+                        true,
+                    )
+                    .map_or(PollTarget::NotOwned, |group| {
+                        assert_eq!(resolved_group, Some(group));
+                        streams
+                            .namespace_from_partition(&stream_id, &topic_id, partition_id)
+                            .map_or(PollTarget::NotFound, |namespace| {
+                                streams
+                                    .poll_metadata(namespace, Some(group), client_id)
+                                    .map_or(PollTarget::Unavailable, |metadata| {
+                                        PollTarget::Pollable {
+                                            namespace,
+                                            metadata,
+                                        }
+                                    })
+                            })
+                    });
+                assert_eq!(target, one_poll, "partition {partition_id} of {group_id}");
+            }
+        }
+        let (group, targets) =
+            streams.poll_targets(&stream_id, &topic_id, None, CLIENT, &partitions);
+        assert_eq!(group, None);
+        assert!(matches!(targets[0], PollTarget::Pollable { .. }));
+        assert_eq!(targets[4], PollTarget::NotFound);
+        let (_, targets) = streams.poll_targets(
+            &WireIdentifier::numeric(5),
+            &topic_id,
+            None,
+            CLIENT,
+            &partitions,
+        );
+        assert_eq!(targets, vec![PollTarget::NotFound; partitions.len()]);
+    }
+
     /// A checkpoint reads a stream's total and each of its topics' as separate
     /// loads while the partition plane keeps counting, so the two can disagree
     /// in either direction. The boot restore adopts neither level, and journal
diff --git a/core/partitions/src/consumer_offset_capacity.rs b/core/partitions/src/consumer_offset_capacity.rs
index 098f241..0932d5c 100644
--- a/core/partitions/src/consumer_offset_capacity.rs
+++ b/core/partitions/src/consumer_offset_capacity.rs
@@ -166,6 +166,44 @@
     }
 }
 
+/// When a client last stored or deleted each consumer offset, on the bus
+/// clock. A parked read compares stamps, because a store can leave the value
+/// as it was.
+#[derive(Debug, Default)]
+pub struct ExplicitOffsetStores {
+    stored_at: RefCell<HashMap<(ConsumerKind, u32), u64>>,
+    /// The last delete of any offset, which an offset without a stamp reads.
+    deleted_at: Cell<Option<u64>>,
+}
+
+impl ExplicitOffsetStores {
+    pub(crate) fn record_store(&self, kind: ConsumerKind, id: u32, now: u64) {
+        self.stored_at.borrow_mut().insert((kind, id), now);
+    }
+
+    pub(crate) fn record_delete(&self, kind: ConsumerKind, id: u32, now: u64) {
+        self.stored_at.borrow_mut().remove(&(kind, id));
+        self.deleted_at.set(Some(now));
+    }
+
+    /// Forgets every stamp, as a delete of every offset at `now`.
+    pub(crate) fn clear(&self, now: u64) {
+        self.stored_at.borrow_mut().clear();
+        self.deleted_at.set(Some(now));
+    }
+
+    /// Whether a client stored or deleted the offset at or after `since`. An
+    /// offset without a stamp counts the last delete of any offset.
+    pub(crate) fn since(&self, kind: ConsumerKind, id: u32, since: u64) -> bool {
+        self.stored_at
+            .borrow()
+            .get(&(kind, id))
+            .copied()
+            .or_else(|| self.deleted_at.get())
+            .is_some_and(|at| at >= since)
+    }
+}
+
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 pub struct ConsumerOffsetCapacityError {
     pub kind: ConsumerKind,
@@ -470,6 +508,36 @@
     use super::*;
 
     #[test]
+    fn given_a_store_of_the_same_value_when_checking_since_admission_should_report_it() {
+        let stores = ExplicitOffsetStores::default();
+        stores.record_store(ConsumerKind::Consumer, 1, 5);
+        assert!(!stores.since(ConsumerKind::Consumer, 1, 6));
+        stores.record_store(ConsumerKind::Consumer, 1, 6);
+        assert!(stores.since(ConsumerKind::Consumer, 1, 6));
+        assert!(
+            !stores.since(ConsumerKind::ConsumerGroup, 1, 6),
+            "a store of another kind leaves the offset alone"
+        );
+        assert!(!stores.since(ConsumerKind::Consumer, 2, 6));
+    }
+
+    #[test]
+    fn given_a_deleted_offset_when_checking_since_admission_should_count_the_last_delete() {
+        let stores = ExplicitOffsetStores::default();
+        stores.record_store(ConsumerKind::Consumer, 1, 2);
+        stores.record_store(ConsumerKind::Consumer, 2, 2);
+        stores.record_delete(ConsumerKind::Consumer, 1, 7);
+        assert!(stores.since(ConsumerKind::Consumer, 1, 7));
+        assert!(
+            !stores.since(ConsumerKind::Consumer, 2, 7),
+            "an offset with its own stamp ignores deletes of others"
+        );
+        assert!(stores.since(ConsumerKind::Consumer, 3, 7));
+        stores.clear(9);
+        assert!(stores.since(ConsumerKind::Consumer, 2, 9));
+    }
+
+    #[test]
     fn given_cached_inactive_tokens_when_admitting_should_count_only_active_keys() {
         let durable = DurableConsumerOffsets::default();
         let capacity = ConsumerOffsetCapacity::new(ConsumerKind::Consumer, 2);
diff --git a/core/partitions/src/iggy_partition.rs b/core/partitions/src/iggy_partition.rs
index 0b88532..8bd6c2e 100644
--- a/core/partitions/src/iggy_partition.rs
+++ b/core/partitions/src/iggy_partition.rs
@@ -16,7 +16,8 @@
 // under the License.
 
 use crate::consumer_offset_capacity::{
-    ConsumerOffsetCapacity, ConsumerOffsetCapacityError, DurableConsumerOffsets, DurableOffsetState,
+    ConsumerOffsetCapacity, ConsumerOffsetCapacityError, DurableConsumerOffsets,
+    DurableOffsetState, ExplicitOffsetStores,
 };
 use crate::journal::{
     MessageLookup, PartitionJournal, PartitionJournalMemStorage, RetainedBatchMeta,
@@ -288,6 +289,7 @@
     /// transfer artifact. It is the only store of external group offsets: that
     /// kind is never polled, so it has no eager progress to keep apart.
     pub(crate) durable_consumer_offsets: DurableConsumerOffsets,
+    pub(crate) explicit_offset_stores: ExplicitOffsetStores,
     /// Admission capacity of each kind, by [`ConsumerKind::index`].
     pub(crate) consumer_offset_capacities: [ConsumerOffsetCapacity; ConsumerKind::COUNT],
     pub(crate) observed_view: u32,
@@ -937,6 +939,7 @@
             workerless_offset_files: Rc::default(),
             poll_notifier: None,
             durable_consumer_offsets: DurableConsumerOffsets::default(),
+            explicit_offset_stores: ExplicitOffsetStores::default(),
             consumer_offset_capacities: ConsumerKind::ALL
                 .map(|kind| ConsumerOffsetCapacity::new(kind, crate::DEFAULT_CONSUMER_OFFSETS_MAX)),
             observed_view,
@@ -1278,6 +1281,7 @@
             .remove(&ConsumerGroupId(group_id as usize));
         self.mark_consumer_group_offsets_need_reconcile();
         self.fence_published.set(true);
+        self.publish_poll_visibility();
         Ok(())
     }
 
@@ -1396,6 +1400,7 @@
         }
         self.required_metadata_frontier = self.required_metadata_frontier.max(catalog.metadata_op);
         self.owner_retirement = Some(catalog);
+        self.publish_poll_visibility();
         Ok(())
     }
 
@@ -4356,8 +4361,8 @@
     }
 
     /// The notifier runs inside partition mutations whenever [`Self::poll_visibility`]
-    /// can change. It must only record the change, and it must not re-enter the
-    /// partition.
+    /// or an installed group owner can change. It must only record the change,
+    /// and it must not re-enter the partition.
     pub fn set_poll_notifier(&mut self, notifier: Option<Rc<dyn Fn()>>) {
         self.poll_notifier = notifier;
     }
@@ -4711,6 +4716,19 @@
             self.mark_consumer_group_offsets_need_reconcile();
         }
         let id = pending.consumer_id;
+        if !pending.auto_commit {
+            let now = self.consensus.message_bus().monotonic_micros();
+            match pending.mutation {
+                PendingConsumerOffsetMutation::Upsert(_) => {
+                    self.explicit_offset_stores
+                        .record_store(pending.kind, id, now);
+                }
+                PendingConsumerOffsetMutation::Delete => {
+                    self.explicit_offset_stores
+                        .record_delete(pending.kind, id, now);
+                }
+            }
+        }
         let key = usize::try_from(id).expect("u32 consumer id must fit usize");
         match (pending.kind, pending.mutation) {
             (ConsumerKind::Consumer, PendingConsumerOffsetMutation::Upsert(offset)) => {
@@ -4770,6 +4788,30 @@
         }
     }
 
+    /// Whether a client stored or deleted the offset of `consumer` at or after
+    /// `since` on the bus clock, also with the value it held, or has such a
+    /// store staged that is not applied yet. A parked committing read is
+    /// fenced by any such store.
+    #[must_use]
+    pub fn consumer_offset_stored_since(&self, consumer: PollingConsumer, since: u64) -> bool {
+        let (kind, id) = match consumer {
+            PollingConsumer::Consumer(id, _) => (ConsumerKind::Consumer, id),
+            PollingConsumer::ConsumerGroup(id, _) => (ConsumerKind::ConsumerGroup, id),
+        };
+        let Ok(id) = u32::try_from(id) else {
+            return false;
+        };
+        // A staged store applies before the commit of a read that finishes
+        // now, which would then pass over it.
+        self.explicit_offset_stores.since(kind, id, since)
+            || self
+                .pending_consumer_offset_commits
+                .values()
+                .any(|pending| {
+                    !pending.auto_commit && pending.kind == kind && pending.consumer_id == id
+                })
+    }
+
     /// Group ids that currently have a stored offset on this partition. Used by
     /// the reconciler to find offsets belonging to deleted consumer groups.
     #[must_use]
@@ -20389,6 +20431,61 @@
     }
 
     #[test]
+    fn given_a_parked_poll_when_a_group_owner_is_installed_or_retired_should_notify_it() {
+        // A parked group read learned that it lost its partition only from a
+        // maintenance sweep, so a fetch over many partitions waited for it.
+        let mut partition = test_partition();
+        let notified = Rc::new(Cell::new(0_u32));
+        let counter = Rc::clone(&notified);
+        partition.set_poll_notifier(Some(Rc::new(move || counter.set(counter.get() + 1))));
+        let install = InstallConsumerGroupOwnerRequest {
+            incarnation: partition.created_revision(),
+            group_id: 7,
+            owner: ConsumerGroupOwner {
+                client_id: 1,
+                session: 1,
+                generation: 1,
+            },
+            metadata_op: 1,
+        };
+        partition
+            .apply_consumer_group_owner(install, 1)
+            .expect("the owner installs");
+        assert_eq!(
+            notified.get(),
+            1,
+            "an installed owner wakes the parked reads"
+        );
+        partition
+            .apply_consumer_group_owner(
+                InstallConsumerGroupOwnerRequest {
+                    owner: ConsumerGroupOwner {
+                        client_id: 0,
+                        session: 0,
+                        generation: 2,
+                    },
+                    metadata_op: install.metadata_op + 1,
+                    ..install
+                },
+                2,
+            )
+            .expect("the unassigned owner installs");
+        partition
+            .apply_consumer_group_owner_retirement(RetireConsumerGroupOwnersRequest {
+                incarnation: install.incarnation,
+                metadata_op: install.metadata_op + 2,
+                next_group_id: install.group_id + 1,
+                live_group_ids: Vec::new(),
+            })
+            .expect("the retirement applies");
+        assert_eq!(
+            notified.get(),
+            3,
+            "each owner change wakes the parked reads"
+        );
+    }
+
+    #[test]
     fn given_recorded_last_polled_when_retirement_prunes_the_group_should_clear_it() {
         const GROUP_ID: u32 = 7;
         const POLLED_OFFSET: u64 = 3;
@@ -30140,6 +30237,87 @@
         );
     }
 
+    /// Both the commit walk and a no-ack store apply through
+    /// `apply_consumer_offset_commit`, so its stamps hold for both.
+    #[test]
+    fn given_client_stores_and_auto_commits_when_applied_should_stamp_only_client_stores() {
+        let (partition, _) = recording_partition();
+        let consumer = PollingConsumer::Consumer(7, 0);
+        let before = partition.consensus.message_bus().monotonic_micros();
+        partition.apply_consumer_offset_commit(PendingConsumerOffsetCommit::upsert_auto_commit(
+            ConsumerKind::Consumer,
+            7,
+            3,
+        ));
+        assert!(
+            !partition.consumer_offset_stored_since(consumer, before),
+            "the commit of a read is no client store"
+        );
+        partition.apply_consumer_offset_commit(PendingConsumerOffsetCommit::explicit(
+            ConsumerKind::Consumer,
+            7,
+            Some(3),
+        ));
+        assert_eq!(partition.get_consumer_offset(consumer), Some(3));
+        assert!(
+            partition.consumer_offset_stored_since(consumer, before),
+            "a client store of the value it held stamps the offset"
+        );
+        assert!(
+            !partition.consumer_offset_stored_since(PollingConsumer::ConsumerGroup(7, 0), before),
+            "the stamp names its kind"
+        );
+        let other = PollingConsumer::Consumer(8, 0);
+        assert!(!partition.consumer_offset_stored_since(other, before));
+        partition.apply_consumer_offset_commit(PendingConsumerOffsetCommit::explicit(
+            ConsumerKind::Consumer,
+            7,
+            None,
+        ));
+        assert_eq!(partition.get_consumer_offset(consumer), None);
+        assert!(
+            partition.consumer_offset_stored_since(other, before),
+            "an offset without a stamp of its own counts the last delete"
+        );
+    }
+
+    /// The primary stages a client store when it admits it and stamps it when
+    /// it commits. A parked read that finishes in between would commit at a
+    /// higher op than the store, so its monotone apply would pass over the
+    /// rewind.
+    #[test]
+    fn given_a_staged_client_store_when_checking_since_admission_should_report_it_before_it_applies()
+     {
+        let (mut partition, _) = recording_partition();
+        let consumer = PollingConsumer::Consumer(7, 0);
+        let other = PollingConsumer::Consumer(8, 0);
+        let before = partition.consensus.message_bus().monotonic_micros();
+        partition.stage_consumer_offset_upsert(1, ConsumerKind::Consumer, 7, 3, true);
+        assert!(
+            !partition.consumer_offset_stored_since(consumer, before),
+            "a staged commit of a read is no client store"
+        );
+        partition.stage_consumer_offset_upsert(2, ConsumerKind::Consumer, 7, 1, false);
+        assert_eq!(
+            partition.get_consumer_offset(consumer),
+            None,
+            "the staged store is not applied yet"
+        );
+        assert!(
+            partition.consumer_offset_stored_since(consumer, before),
+            "a staged client store fences a read that finishes before it applies"
+        );
+        assert!(
+            !partition.consumer_offset_stored_since(other, before),
+            "the staged store names its consumer"
+        );
+        partition.stage_consumer_offset_delete(3, ConsumerKind::Consumer, 8);
+        assert!(
+            partition.consumer_offset_stored_since(other, before),
+            "a staged delete fences as a staged store does"
+        );
+    }
+
     #[test]
     fn given_follower_when_group_is_deleted_should_wait_for_ordered_reclamation() {
         let (partition, _) = recording_partition_at(1, 3);
diff --git a/core/partitions/src/journal.rs b/core/partitions/src/journal.rs
index 0a01c0f..513f49f 100644
--- a/core/partitions/src/journal.rs
+++ b/core/partitions/src/journal.rs
@@ -1351,7 +1351,7 @@
 /// cover the reply's absolute size. The resident tier applies the same ratio
 /// per prepare entry but only copies up to [`RESIDENT_SPARSE_COPY_MAX_BYTES`];
 /// a sparse selection past that stays a slice and pins its prepare.
-const SPARSE_CHUNK_PIN_DIVISOR: usize = 4;
+pub const SPARSE_CHUNK_PIN_DIVISOR: usize = 4;
 
 /// Resident copies run inline on the shard pump (the disk walk is detached),
 /// and a poll selects at most two partial batches (its first and last), so
diff --git a/core/partitions/src/poll_plan.rs b/core/partitions/src/poll_plan.rs
index 7f837e4..75dac42 100644
--- a/core/partitions/src/poll_plan.rs
+++ b/core/partitions/src/poll_plan.rs
@@ -24,7 +24,8 @@
 use crate::iggy_index::{IGGY_INDEX_SIZE, IggyIndexCache};
 use crate::iggy_index_reader::IggyIndexReader;
 use crate::journal::{
-    MessageLookup, push_selected_batch_fragments, select_batch_slice, unpin_sparse_source,
+    MessageLookup, SPARSE_CHUNK_PIN_DIVISOR, push_selected_batch_fragments, select_batch_slice,
+    unpin_sparse_source,
 };
 use crate::{Fragment, PollFragments, PollingConsumer};
 use compio::io::AsyncReadAtExt;
@@ -209,22 +210,32 @@
 impl PollReadResult {
     /// Cap the encoded selection at `budget` before the owner accepts its
     /// final offset. The first message passes the budget, so a poll always
-    /// makes progress, but nothing passes `max_bytes`. A sliced batch retains
-    /// its original offset base and receives a new checksum.
+    /// makes progress. Nothing else passes `max_bytes`: a first message larger
+    /// than it comes alone when `allow_oversized_first` answers true, asked
+    /// once and only then, so a message above any limit still reaches its
+    /// consumer in one reply. A sliced batch retains its original offset base
+    /// and receives a new checksum.
     ///
     /// # Errors
     /// Returns `InvalidSizeBytes` when the first message does not fit in
-    /// `max_bytes`, or `CannotReadMessage` if the selected batch framing is
-    /// invalid.
+    /// `max_bytes` and is not allowed past it, or `CannotReadMessage` if the
+    /// selected batch framing is invalid.
     pub fn limit_bytes(
         mut self,
         max_bytes: usize,
         budget: usize,
+        allow_oversized_first: impl FnOnce() -> bool,
     ) -> Result<(Self, bool), IggyError> {
         let ceiling = max_bytes
             .checked_sub(POLL_RESPONSE_HEADER_SIZE)
             .ok_or(IggyError::InvalidSizeBytes)?;
+        let mut allow_oversized_first = Some(allow_oversized_first);
         let mut remaining = ceiling.min(budget.saturating_sub(POLL_RESPONSE_HEADER_SIZE));
+        // A selection that fits keeps its fragments, count and last offset, so
+        // its records need no decoding.
+        if self.encoded_bytes() < remaining {
+            return Ok((self, false));
+        }
         let mut fragments = std::mem::take(&mut self.fragments).into_iter();
         self.message_count = 0;
         self.last_matching_offset = None;
@@ -252,12 +263,11 @@
             let mut selected = 0;
             let mut end = 0;
             for record in batch.iter_with_offsets() {
-                let limit = if self.message_count == 0 && selected == 0 {
-                    ceiling
-                } else {
-                    remaining
-                };
-                if BATCH_HEADER_SIZE.saturating_add(record.end) > limit {
+                let first = self.message_count == 0 && selected == 0;
+                let limit = if first { ceiling } else { remaining };
+                if BATCH_HEADER_SIZE.saturating_add(record.end) > limit
+                    && !(first && allow_oversized_first.take().is_some_and(|allow| allow()))
+                {
                     limited = true;
                     break;
                 }
@@ -337,6 +347,13 @@
         self.message_count
     }
 
+    /// The offset of the last selected message, `None` if the read selected
+    /// nothing.
+    #[must_use]
+    pub const fn last_matching_offset(&self) -> Option<u64> {
+        self.last_matching_offset
+    }
+
     /// The selected bytes with their batch framing, which is what
     /// [`Self::limit_bytes`] counts against its limits.
     #[must_use]
@@ -636,11 +653,6 @@
 /// larger re-read, without widening subsequent chunks or segments.
 pub const DISK_POLL_CHUNK_MAX: u64 = 1 << 20;
 
-/// Budget charge per byte a chunk read holds: the read buffer itself, a sparse
-/// copy of its selection, and headroom for the fragment headers and
-/// descriptors that the selection adds.
-const READ_ALLOCATION_FACTOR: usize = 4;
-
 /// Smallest first read of a disk poll. Below this the syscall and the segment
 /// walk cost more than the bytes the smaller read saves, and a poll for a
 /// handful of messages would issue a read per batch.
@@ -953,22 +965,26 @@
         walk: &mut DiskWalk,
     ) -> SegmentWalk {
         let mut chunk_len = self.next_chunk_len(walk, count);
+        // The smallest read that holds the batch at the position, once a read
+        // came back without a whole one.
+        let mut whole_batch = 0;
         while walk.matched < count && walk.position < persisted {
-            let len = (persisted - walk.position).min(chunk_len) as usize;
+            let mut len = (persisted - walk.position).min(chunk_len) as usize;
             // The batch that holds the first match is read whatever its width,
             // as the legacy poll does: refusing it would refuse the same offset
-            // on every retry. Later reads stop at the budget or once the
-            // selection fills the reply, and the walk serves the prefix it
-            // holds.
-            if walk.matched > 0
-                && (walk.selected_bytes >= walk.max_selected_bytes
-                    || len
-                        .saturating_mul(READ_ALLOCATION_FACTOR)
-                        .saturating_add(walk.retained_bytes)
-                        > walk.max_bytes)
-            {
-                walk.limited = true;
-                return SegmentWalk::Limited;
+            // on every retry. Later reads shrink to the budget left, stop once
+            // the selection fills the reply or the budget holds no useful read,
+            // and the walk serves the prefix it holds.
+            if walk.matched > 0 {
+                let room = chunk_read_room(walk.max_bytes.saturating_sub(walk.retained_bytes));
+                let floor = DISK_POLL_CHUNK_MIN
+                    .max(walk.batch_read_floor)
+                    .max(whole_batch) as usize;
+                if walk.selected_bytes >= walk.max_selected_bytes || (room < len && room < floor) {
+                    walk.limited = true;
+                    return SegmentWalk::Limited;
+                }
+                len = len.min(room);
             }
             let Some(chunk) = self.read_chunk_with_retry(file, len, walk).await else {
                 // Chunk read exhausted retries: same fail-closed reason as
@@ -1039,10 +1055,12 @@
                         .max((needed as u64).min(DISK_POLL_CHUNK_MAX));
                     needed as u64
                 } else {
-                    chunk_len.saturating_mul(4)
+                    (len as u64).saturating_mul(4)
                 };
+                whole_batch = chunk_len;
                 continue;
             }
+            whole_batch = 0;
             if walk.matched > 0 {
                 walk.skipped = 0;
             }
@@ -1284,6 +1302,14 @@
 
 /// Bytes of the allocations `fragments` keep alive that `charged` did not
 /// count yet, so slices of one allocation charge it once.
+/// The widest chunk that `budget` bytes hold while the walk processes it: the
+/// read buffer itself, plus a sparse copy of its selection, which is made only
+/// below `1 / SPARSE_CHUNK_PIN_DIVISOR` of the chunk. The walk charges the
+/// fragment descriptors once it holds them.
+const fn chunk_read_room(budget: usize) -> usize {
+    budget / (SPARSE_CHUNK_PIN_DIVISOR + 1) * SPARSE_CHUNK_PIN_DIVISOR
+}
+
 fn charge_allocations<'a>(
     charged: &mut HashSet<usize>,
     fragments: impl IntoIterator<Item = &'a Fragment>,
@@ -1461,7 +1487,7 @@
                     },
                 }
                 .execute_resident();
-                let (result, limited) = result.limit_bytes(max_bytes, budget).unwrap();
+                let (result, limited) = result.limit_bytes(max_bytes, budget, || false).unwrap();
                 assert!(limited);
                 assert_eq!(result.message_count(), 1);
                 assert_eq!(result.last_matching_offset, Some(42));
@@ -1714,15 +1740,23 @@
     }
 
     fn disk_batch(payload_length: u32, offset: u64) -> Vec<u8> {
-        let mut messages = IggyMessages::with_capacity(1);
-        messages.push(IggyMessage {
-            header: IggyMessageHeader {
-                payload_length,
-                ..Default::default()
-            },
-            payload: Bytes::from(vec![1; usize::try_from(payload_length).unwrap()]),
-            user_headers: None,
-        });
+        disk_batch_of(1, payload_length, offset)
+    }
+
+    /// One stored batch of `count` messages of `payload_length` bytes, the
+    /// first at `offset`.
+    fn disk_batch_of(count: usize, payload_length: u32, offset: u64) -> Vec<u8> {
+        let mut messages = IggyMessages::with_capacity(count);
+        for _ in 0..count {
+            messages.push(IggyMessage {
+                header: IggyMessageHeader {
+                    payload_length,
+                    ..Default::default()
+                },
+                payload: Bytes::from(vec![1; usize::try_from(payload_length).unwrap()]),
+                user_headers: None,
+            });
+        }
         let mut batch =
             SendMessagesOwned::from_messages(IggyNamespace::new(1, 1, 0), &messages).unwrap();
         batch.header.base_offset = offset;
@@ -1954,7 +1988,11 @@
                 let (result, limited) = result
                     .checked()
                     .unwrap()
-                    .limit_bytes(POLL_RESPONSE_HEADER_SIZE + first_batch_bytes, usize::MAX)
+                    .limit_bytes(
+                        POLL_RESPONSE_HEADER_SIZE + first_batch_bytes,
+                        usize::MAX,
+                        || false,
+                    )
                     .unwrap();
                 assert!(limited);
                 assert_eq!(result.message_count(), 1);
@@ -1988,10 +2026,10 @@
 
     #[compio::test]
     async fn a_byte_limited_disk_walk_serves_the_prefix_it_already_read() {
-        // The budget admits the first chunk and stops the next one. Before this
-        // was fixed the walk threw away every batch it had matched, so the
-        // consumer polled the same offset again and was refused again.
-        const BATCHES: u32 = 128;
+        // The budget runs out before the data does. Before this was fixed the
+        // walk threw away every batch it had matched, so the consumer polled
+        // the same offset again and was refused again.
+        const BATCHES: u32 = 512;
         let directory = tempfile::tempdir().unwrap();
         let mut bytes = Vec::new();
         for offset in 0..u64::from(BATCHES) {
@@ -2036,9 +2074,6 @@
                 },
             },
         };
-        // Chunks are 1 MiB here, and the walk refuses a chunk whose buffer
-        // would put four times its length over the budget. This admits the
-        // first chunk and stops the second, which is the case under test.
         let result = plan.execute_with_limit(4608 << 10, usize::MAX).await;
         let served = result.message_count();
         assert!(
@@ -2054,6 +2089,56 @@
     }
 
     #[compio::test]
+    async fn a_disk_walk_with_budget_left_reads_on_in_smaller_chunks() {
+        // The walk stopped at the first chunk that did not fit whole, so a poll
+        // served about a quarter of what its budget holds and fell short of its
+        // count while more messages waited on disk.
+        const BATCHES: u32 = 512;
+        const WALK_BUDGET: usize = 4608 << 10;
+        let directory = tempfile::tempdir().unwrap();
+        let mut bytes = Vec::new();
+        for offset in 0..u64::from(BATCHES) {
+            bytes.extend(disk_batch(16 << 10, offset));
+        }
+        std::fs::write(directory.path().join("00000000000000000000.log"), &bytes).unwrap();
+
+        let result = deferred_disk_poll(directory.path(), bytes.len(), BATCHES)
+            .execute_with_limit(WALK_BUDGET, usize::MAX)
+            .await;
+
+        assert!(result.truncated());
+        assert!(
+            result.encoded_bytes() >= WALK_BUDGET * 3 / 4,
+            "served {} bytes of a {WALK_BUDGET} byte budget",
+            result.encoded_bytes()
+        );
+    }
+
+    #[compio::test]
+    async fn a_disk_walk_over_batches_wider_than_a_chunk_fills_its_count() {
+        // Batches of 100, 200 and 300 messages of 20 KB are two to six times
+        // the chunk ceiling, the shape a poll of 500 messages with a 16 MiB
+        // reply reads. The last batch matches in part and is read whole.
+        const COUNT: u32 = 500;
+        let directory = tempfile::tempdir().unwrap();
+        let mut bytes = Vec::new();
+        let mut offset = 0;
+        for batch in [100, 200, 300] {
+            bytes.extend(disk_batch_of(batch, 20_000, offset));
+            offset += batch as u64;
+        }
+        std::fs::write(directory.path().join("00000000000000000000.log"), &bytes).unwrap();
+        let max_bytes = 16 << 20;
+
+        let result = deferred_disk_poll(directory.path(), bytes.len(), COUNT)
+            .execute_with_limit(max_bytes - (2 << 20), max_bytes - POLL_RESPONSE_HEADER_SIZE)
+            .await;
+
+        assert_eq!(result.message_count(), COUNT);
+        assert!(!result.truncated());
+    }
+
+    #[compio::test]
     async fn deferred_disk_read_reads_at_most_one_record_past_max_bytes() {
         // The walk budget alone admitted a second chunk, so a read fetched
         // nearly twice what its reply kept and the next poll read it again.
@@ -2076,7 +2161,7 @@
             result.encoded_bytes()
         );
         let (reply, limited) = result
-            .limit_bytes(DEFAULT_MAX_BYTES, DEFAULT_MAX_READ_BYTES)
+            .limit_bytes(DEFAULT_MAX_BYTES, DEFAULT_MAX_READ_BYTES, || false)
             .unwrap();
         assert!(limited);
         assert_eq!(reply.message_count() as usize, max_selected_bytes / record);
@@ -2105,7 +2190,7 @@
         assert_eq!(result.message_count(), 1);
         assert_eq!(result.encoded_bytes(), oversized);
         assert!(matches!(
-            result.limit_bytes(DEFAULT_MAX_BYTES, DEFAULT_MAX_READ_BYTES),
+            result.limit_bytes(DEFAULT_MAX_BYTES, DEFAULT_MAX_READ_BYTES, || false),
             Err(IggyError::InvalidSizeBytes)
         ));
 
@@ -2115,12 +2200,52 @@
                 DEFAULT_MAX_READ_BYTES - POLL_RESPONSE_HEADER_SIZE,
             )
             .await
-            .limit_bytes(u32::MAX as usize, DEFAULT_MAX_READ_BYTES)
+            .limit_bytes(u32::MAX as usize, DEFAULT_MAX_READ_BYTES, || false)
             .unwrap();
         assert_eq!(alone.message_count(), 1);
         assert_eq!(alone.encoded_bytes(), oversized);
     }
 
+    #[compio::test]
+    async fn given_an_oversized_first_record_when_allowed_should_return_it_alone_in_one_reply() {
+        // A record above `max_bytes` was refused, so every client needed a
+        // second read alone, and under `OnRead` a second read that commits.
+        let directory = tempfile::tempdir().unwrap();
+        let mut bytes = disk_batch(2 << 20, 0);
+        let oversized = bytes.len();
+        bytes.extend(disk_batch(16 << 10, 1));
+        std::fs::write(directory.path().join("00000000000000000000.log"), &bytes).unwrap();
+        let read = || {
+            deferred_disk_poll(directory.path(), bytes.len(), 2).execute_with_limit(
+                DEFAULT_WALK_BUDGET,
+                DEFAULT_MAX_BYTES - POLL_RESPONSE_HEADER_SIZE,
+            )
+        };
+
+        let (reply, limited) = read()
+            .await
+            .limit_bytes(DEFAULT_MAX_BYTES, DEFAULT_MAX_READ_BYTES, || true)
+            .unwrap();
+
+        assert_eq!(reply.message_count(), 1, "the oversized record comes alone");
+        assert_eq!(reply.encoded_bytes(), oversized);
+        assert!(limited);
+        let mut asked = false;
+        assert!(matches!(
+            read()
+                .await
+                .limit_bytes(DEFAULT_MAX_BYTES, DEFAULT_MAX_READ_BYTES, || {
+                    asked = true;
+                    false
+                }),
+            Err(IggyError::InvalidSizeBytes)
+        ));
+        assert!(
+            asked,
+            "the caller decides only when the first record does not fit"
+        );
+    }
+
     /// The SDK's default `max_bytes`, the server's default read reservation,
     /// and the third of it that the walk of a deferred disk read may hold.
     const DEFAULT_MAX_BYTES: usize = 1 << 20;
diff --git a/core/partitions/src/state_transfer.rs b/core/partitions/src/state_transfer.rs
index 5128095..e19bc12 100644
--- a/core/partitions/src/state_transfer.rs
+++ b/core/partitions/src/state_transfer.rs
@@ -4169,6 +4169,8 @@
         self.last_polled_offsets.pin().clear();
         self.durable_consumer_offsets.clear();
         self.durable_consumer_offsets.set_emptied_at(emptied_at);
+        self.explicit_offset_stores
+            .clear(self.consensus().message_bus().monotonic_micros());
         self.pending_consumer_offset_commits.clear();
         for kind in ConsumerKind::ALL {
             self.consumer_offset_capacity_for(kind)
diff --git a/core/sdk/Cargo.toml b/core/sdk/Cargo.toml
index e8d8f00..0304d73 100644
--- a/core/sdk/Cargo.toml
+++ b/core/sdk/Cargo.toml
@@ -17,7 +17,7 @@
 
 [package]
 name = "iggy"
-version = "0.11.1-edge.5"
+version = "0.11.1-edge.6"
 description = "Iggy is the persistent message streaming platform written in Rust, supporting QUIC, TCP and HTTP transport protocols, capable of processing millions of messages per second."
 edition = "2024"
 rust-version.workspace = true
diff --git a/core/sdk/README.md b/core/sdk/README.md
index ce1b7f3..5eab426 100644
--- a/core/sdk/README.md
+++ b/core/sdk/README.md
@@ -21,7 +21,7 @@
   <a href="https://github.com/apache/iggy/blob/master/LICENSE"><img alt="License: Apache 2.0" src="https://img.shields.io/badge/license-Apache%202.0-blue.svg?style=flat-square"></a>
 </p>
 
-Official Rust client SDK for [Apache Iggy](https://iggy.apache.org), the persistent message streaming platform written in Rust. The SDK ships a low-level transport client (QUIC, TCP, HTTP, WebSocket) for direct command access and a high-level producer/consumer API with batching, consumer groups, and auto-commit.
+Official Rust client SDK for [Apache Iggy](https://iggy.apache.org), the persistent message streaming platform written in Rust. The SDK ships a low-level transport client (QUIC, TCP, HTTP, WebSocket) for direct command access and a high-level producer/consumer API with batching, consumer groups, and commit policies.
 
 ## Features
 
@@ -30,13 +30,14 @@
 - **Connection strings**: `iggy://` (TCP default), `iggy+tcp://`, `iggy+quic://`, `iggy+http://`, `iggy+ws://`. Binary transports apply credentials on `connect()`; HTTP requires an explicit login. Option keys and reconnection support differ by transport.
 - **Authentication**: username/password and Personal Access Tokens (PAT).
 - **Async, non-blocking** client built on Tokio with custom zero-copy (de)serialization.
-- **High-level builders** on `IggyClient`: `producer(stream, topic)`, `consumer(name, stream, topic, partition)`, and `consumer_group(name, stream, topic)`.
+- **High-level builders** on `IggyClient`: `producer(stream, topic)`, `consumer(name, stream, topic)`, `consumer_group(name, stream, topic)`, and `reader(stream, topic, consumer)`.
 - **Producer modes**: `direct` (awaited send) and `background` (buffered with parallel shard workers using `OrderedSharding` or `BalancedSharding`). Configurable batch length / size and linger time.
 - **Partitioning**: `balanced`, `messages_key`, or explicit `partition_id`. Custom `Partitioner` is pluggable.
 - **Consumer**: standalone or consumer-group over binary transports; HTTP supports standalone consumers only. Consumed as an async `Stream`. Polling strategies: `next`, `offset`, `timestamp`, `first`, `last`.
-- **Auto-commit** offset policies: `Interval`, `When`, `After`, `IntervalOrWhen`, `IntervalOrAfter`, or disabled.
+- **Reader**: `PartitionReader` returns batches of many partitions, with routing, prefetch and the position of each partition inside.
+- **Commits**: one `CommitPolicy` in `PollOptions`: `Manual` (explicit commits of a `Checkpoint`), `OnRead` (the server stores the offset of each read) or `Auto` (the SDK stores on an interval or an event, after delivery, processing or a mark). See [reading messages](#reading-messages).
 - **Stream builder** (`IggyStream`, `IggyStreamProducer`, `IggyStreamConsumer`) for declarative producer + consumer setup on shared or separate stream/topic.
-- **Reliability**: managed replica connections, heartbeat, ordered mutation lanes, explicit recovery of uncertain mutations, and offset auto-commit.
+- **Reliability**: managed replica connections, heartbeat, ordered mutation lanes, explicit recovery of uncertain mutations and lost committing reads.
 - **Message features**: optional headers (`HeaderKey` / `HeaderValue`), client-side AES-256-GCM encryption (via `Aes256GcmEncryptor`), topic compression metadata (`None` and `Gzip`; no runtime compression yet), server-honored message expiry, and server-side deduplication.
 - **Admin**: stream/topic/partition CRUD, consumer-group management, server-side consumer offsets, system stats.
 - **Resource options**: typed create and update options for streams, topics and users (`StreamCreateOptions`, `TopicUpdateOptions`, `UserUpdateOptions` and friends). An update sends only the settings it names and resets others by key. A stream can carry its own at-rest key (`StreamCreateOptions::encryption_key`), reads report only its source and fingerprints.
@@ -51,8 +52,9 @@
 in the configured directory. See the [server recovery guide](../server/README.md#upgrade-recovery).
 Mixed versions and rolling upgrades are unsupported. Binary clients share one
 logical session across replica links using BindSession (15) and discover
-partition primaries with GetPartitionRoute (105). Commands 14, 103, 104, 123 and 124
-are retired. HTTP clients use server-side forwarding.
+partition primaries with GetPartitionRoute (105). The minimum binary protocol
+version is 0.12.1. Commands 14, 103, 104, 107, 123 and 124 are retired. HTTP clients
+use server-side forwarding.
 
 ```bash
 cargo add iggy
@@ -159,11 +161,18 @@
 while a writer still holds the frame. Replaying a retained request reuses its
 reservation.
 
-Auto-commit and `Next` polls share their partition's mutation barrier while the
-call is active. They retain their weak acknowledgement: cancellation or failover
-can leave progress unknown. Error 57 or a lost reply after send ends the call
-immediately without replay; errors 58/89 still retry known non-admission.
-These polls are not retained for recovery.
+Reads with `CommitPolicy::OnRead` use a second pool with the same limits.
+Each such read holds one slot for the whole call, also while the server waits
+for messages. After an unknown outcome, its block keeps the slot until
+`abandon_operation`. When the pool is full, the read returns
+`TransientNotAccepted` at its deadline.
+
+If a `next` poll without `OnRead` loses its reply after the send, or gets error
+57, it ends at once with `TransientNotCommitted`. It is not sent again.
+Errors 58 and 89 still retry a request that the server did not admit.
+Polls are not retained for recovery. After an unknown outcome, a read with
+`OnRead` keeps a local progress block instead, as described under
+[reading messages](#reading-messages).
 
 ## Quick start
 
@@ -212,9 +221,7 @@
     let mut consumer = client
         .consumer_group(CONSUMER_GROUP, STREAM, TOPIC)?
         .create_consumer_group_if_not_exists()
-        .auto_join_consumer_group()
         .polling_strategy(PollingStrategy::next())
-        .batch_length(1000)
         .build();
     consumer.init().await?;
 
@@ -234,11 +241,9 @@
                         println!("  header {key} = {value:?}");
                     }
                 }
-                consumer
-                    .store_offset(message.message.header.offset, Some(message.partition_id))
-                    .await?;
+                consumer.commit(message.checkpoint()).await?;
             }
-            Err(error) => eprintln!("poll error: {error}"),
+            Err(error) => eprintln!("read error: {error}"),
         }
     }
     Ok(())
@@ -249,141 +254,324 @@
 
 `IggyClient::send_binary_request_to_replica(code, payload, replica_id)` sends a raw command to one node of a cluster, named by the replica id that cluster metadata returns.
 
-Each node answers non-replicated commands such as `GetStats` from its own state, so this reads the state of that node. The request carries the session's metadata watermark. Replicated commands, polls, readiness waits, deferred-request cancels, consumer-offset reads and session-control codes fail with `InvalidCommand` before anything is sent.
+Each node answers non-replicated commands such as `GetStats` from its own state, so this reads the state of that node. The request carries the session's metadata watermark. Replicated commands, polls, fetches, deferred-request cancels, consumer-offset reads and session-control codes fail with `InvalidCommand` before anything is sent.
 
 An id that cluster metadata does not name fails with `InvalidIdentifier`. An unreachable node is retried until the request deadline, and a `NotPrimary` reply is returned rather than sent to another node.
 
 HTTP clients return `FeatureUnavailable` for this call, as for `send_binary_request`.
 
-For `IggyConsumerConfig`, `partitions_count` controls topic creation only. An ordinary consumer uses partition `0` unless the builder's `partition_id` or the config's `with_partition_id` selects another partition. Code that previously used `partitions_count` to select an existing partition must set `partition_id` explicitly. Consumer-group assignment ignores `partition_id`.
+For `IggyConsumerConfig`, `partitions_count` controls topic creation only. An ordinary consumer from the config reads partition `0` unless `with_partitions` selects others, and a consumer group ignores that setting. Code that previously used `partitions_count` to select an existing partition must call `with_partitions`. `IggyConsumerBuilder` has no default partition: a standalone consumer must set `partitions`, and `init()` refuses them for a group.
 
-## Deferred polling
+## Reading messages
 
-High-level consumers use long polling by default. `DeferredPollOptions` separates
-readiness from batch limits: `min_count = 1`, `max_wait = 1s`, `max_bytes = 1 MiB`
-and `request_timeout = 11s`. `batch_length` remains the maximum message count.
-Use `.poll_options(options)` on either consumer builder, or pass options to the
-low-level `poll_messages_deferred` method. A zero `max_wait` skips readiness
-waiting while preserving the same byte limits and request budget.
+The SDK reads in three ways. Each one takes `PollOptions` and starts from a `PollingStrategy`:
+`next`, `offset`, `timestamp`, `first` or `last`.
 
-The server responds when the minimum is readable, the byte cap is reached, or the
-readiness wait expires. Expiry allows a final bounded read; the separate request
-timeout bounds routing, waiting, I/O and response handling. It returns partial or
-empty data when appropriate. Read failures and exhausted request budgets return
-errors. `max_bytes` covers the binary body, including response metadata and batch
-framing; HTTP applies the same selection limit before JSON encoding. The server
-also stops a reply at `sharding.deferred_poll_max_read_bytes`, unless its first
-record alone is wider. A first record that cannot fit returns `InvalidSizeBytes`,
-without advancing its offset.
-The high-level consumer then fetches that record alone, without the byte limit.
+- `poll_messages(stream, topic, partition, consumer, strategy, options)` reads one batch of one
+  partition. The partition is required.
+- `PartitionReader` reads many partitions and returns batches. Build one with
+  `client.reader(stream, topic, consumer)`. A standalone `Consumer` reads the partitions of
+  `.partitions(...)`. A consumer group member reads its assignment: the reader joins the group,
+  joins again after a session loss, and leaves the group in `close()`.
+- `IggyConsumer` is a `Stream` of messages over one `PartitionReader`, with commit policies, group
+  creation, init retries and a replay filter. A standalone consumer needs `.partitions(...)`.
 
-Both builders default to `Next` and manual commits. Process messages in partition order,
-then call `store_offset(offset, Some(partition_id))`. Fetch positions advance
-independently of stored offsets. An initial `Next` is resolved once per partition.
-A partition that a rebalance leaves with its group member continues after the last
-message that the application took. The member resolves `Next` again for a partition
-that it gets back from another member. After the client starts a new session, the
-member resolves `Next` again for every partition. Restarting before a commit can replay work.
-Explicit auto-commit policies remain available and can commit messages before processing.
+The reader and the consumer route each read, prefetch, and keep the position of each partition. The
+[rustdoc](https://docs.rs/iggy) of `MessageClient::poll_messages`, `PollOptions`, `PartitionReader`
+and `IggyConsumer` gives the full contracts.
 
-This default is incompatible with earlier releases. The builder used to commit
-with `AutoCommit::IntervalOrWhen(1s, PollingMessages)`, and `IggyConsumerConfig`
-used `AutoCommit::When(PollingMessages)` with `Last`. Code that relied on those
-defaults must set `auto_commit` explicitly, or each restart reads the same
-messages again. `IggyConsumerConfig::with_auto_commit` sets it on a `default` or
-`from_stream_topic` config. A consumer that delivered messages but stored no
-offset logs a warning at shutdown. With `AutoCommit::Disabled` and `Next`,
-`consume_messages` also warns at its start, because its message consumer cannot
-store offsets.
+```rust
+use iggy::prelude::*;
+use tokio::sync::watch;
 
-Under `PollingMessages` the server commits every batch that it reads, so the
-consumer does not prefetch. It fetches one batch when the application asks for a
-message and its buffer is empty. A group member first waits until one of its
-partitions holds a message. It sends one readiness wait (binary command 107) per
-partition primary, which watches every assigned partition, and then fetches from
-a ready partition.
+// Reads partition 0 until it is empty, and commits after each batch.
+async fn read_one(client: &IggyClient, consumer: &Consumer) -> Result<(), IggyError> {
+    let (stream, topic) = (Identifier::named("orders")?, Identifier::named("events")?);
+    let mut strategy = PollingStrategy::next();
+    loop {
+        let batch = client
+            .poll_messages(&stream, &topic, 0, consumer, &strategy, PollOptions::default())
+            .await?;
+        let Some(checkpoint) = batch.checkpoint() else {
+            return Ok(());
+        };
+        // Process batch.messages here.
+        client
+            .store_consumer_position(consumer, &stream, &topic, &checkpoint)
+            .await?;
+        strategy = batch.continuation();
+    }
+}
 
-Prefetch reserves both byte and message capacity before each request and holds
-it through queued and partially consumed batches. Defaults are 16 MiB of encoded
-response bytes and 16,000 messages, or `batch_length` messages when that is
-larger; use `.prefetch_bytes(...)` and
-`.prefetch_messages(...)` to change them. Each must fit at least one maximum
-response. Decoded message metadata and bounded HTTP JSON overhead are additional;
-these limits are not process RSS limits. Memory handed to application code is
-outside the consumer budget. Assignment refresh, reconnection and cancellation
-continue while the application stalls. Revoked generations are discarded before
-delivery. The normal `poll_interval` setting has been removed; error backoff is
-still configurable.
+// Reads partitions 0 to 7 of a standalone consumer until `shutdown` changes, and commits after
+// each batch.
+async fn read_many(
+    client: &IggyClient,
+    consumer: &Consumer,
+    mut shutdown: watch::Receiver<bool>,
+) -> Result<(), IggyError> {
+    let (stream, topic) = (Identifier::named("orders")?, Identifier::named("events")?);
+    let mut reader = client
+        .reader(&stream, &topic, consumer)
+        .partitions(0..8)
+        .build()
+        .await?;
+    loop {
+        // next() is cancel safe, so select! can drop it.
+        let result = tokio::select! {
+            result = reader.next() => result,
+            _ = shutdown.changed() => break,
+        };
+        let batch = match result {
+            Ok(batch) => batch,
+            // A recreated partition stops until a seek picks a start in its new history.
+            Err(ReadError {
+                partition_id: Some(partition),
+                error: IggyError::HistoryUnavailable,
+                ..
+            }) => {
+                reader.seek(partition, PollingStrategy::first()).await?;
+                continue;
+            }
+            // The reader retries by itself after the other errors.
+            Err(error) => {
+                eprintln!("{error}");
+                continue;
+            }
+        };
+        // Process batch.messages here.
+        if let Some(checkpoint) = batch.checkpoint() {
+            reader.commit(checkpoint).await?;
+        }
+    }
+    reader.close().await
+}
+```
 
-Binary data requests share multiplexed connections with control traffic.
-A client admits at most 64 deferred requests, and each high-level consumer admits
-at most 16 across fetches and readiness waits, including waits split across replicas.
-Requests waiting for admission spend their original request budget. A group member with more
-partitions than prefetch slots fetches only the partitions that hold a message,
-and one readiness wait per partition primary watches the others. Cancellation
-sends command 108 for the original request on the
-same authenticated connection. QUIC stream cancellation is translated into that
-command by the server transport. It stops a poll still waiting for readiness.
-The binary transport retries a poll that does not commit and does not use
-`next` with the same request identity, reducing its wait and timeout before each attempt.
-A poll that can move an offset is not resent after a lost reply: it fails with
-`TransientNotCommitted`, and the high-level worker retries its explicit
-position. With opt-in server auto-commit, a lost reply, timeout or cancellation
-may race an accepted offset update. `TransientNotCommitted` means the offset may
-already have advanced; it does not prove that the server rejected the poll.
-Timing margins cannot remove this uncertainty. Use manual commits after processing
-when a lost response must not skip messages.
+### Poll options
 
-Progress polls (`Next` or auto-commit), offset reads, stores and deletes share a
-progress lane per partition. Sends use the separate mutation lane, so a parked
-poll does not delay a producer sharing the client. Offset stores and deletes
-hold both lanes; an unresolved store or delete blocks later progress until it is
-resolved or abandoned. Different consumers on one partition still share the
-progress lane.
+`PollOptions` sets the limits of one request, a poll of one partition or a fetch of several. A fetch
+applies `count`, `min_count` and `max_bytes` to all of its partitions together:
 
-A parked progress poll holds the progress lane of its partition for up to
-`max_wait`. Offset reads, stores and deletes of the same client on that
-partition wait for it. Each of these calls waits for at most the 30-second
-operation timeout. If the poll holds the lane longer, the call fails with
-`TransientNotAccepted`. The client did not send the call, so it is safe to
-retry.
+- `count`: the most messages in one request. Default 1000.
+- `max_wait`: how long the server waits for `min_count` messages, at most 600 s. Default zero,
+  which answers at once.
+- `min_count`: from 1 to `count`. Default 1.
+- `max_bytes`: the most bytes in one reply, with the response metadata and the batch framing.
+  Default 1 MiB.
+- `commit`: the `CommitPolicy`, `Manual` by default. See [positions and commits](#positions-and-commits).
 
-HTTP uses `GET /streams/{stream}/topics/{topic}/messages/deferred` with the ordinary
-poll fields plus `wait_us`, `min_count`, `max_bytes` and `request_timeout_us`.
-Omitted options use the SDK defaults. Binary command 106 carries the same
-contract. `wait_for_messages` (binary command 107) waits until any of several
-partitions holds a message and reads nothing. It has no HTTP route. Upgrade
-servers before SDKs; unsupported servers reject these requests.
-SDK retries, server forwarding and the high-level worker deduct the elapsed
-time from the original budgets. Routing and admission waits also spend that
-budget, and the caller keeps its original request timeout. Proxy timeouts
-should exceed the configured request timeout. The low-level immediate poll API retains
-its existing command and route.
+`PollOptions::wait(max_wait)` keeps the other defaults. `PartitionReader` and `IggyConsumer` use
+`PollOptions::wait` of five seconds unless `.options(...)` on the reader builder or
+`.poll_options(...)` on the consumer builder sets others. `IggyConsumerConfig` has the same
+default, and `with_poll_options` changes it.
 
-The server defaults to a 30-second maximum readiness wait, 1024 pending polls per
-shard, 64 per logical session (or HTTP user), 16 MiB per read reservation and
-64 MiB of in-flight read reservations. A longer requested wait is shortened to
-the server maximum. A disk read stops after the messages that fill `max_bytes`,
-capped at the read reservation. If its buffers fill their share of that
-reservation first, it stops there. It returns what it read, even below
-`min_count`. Configure
-`sharding.deferred_poll_*` accordingly. The response cap is applied to the selected
-result; it does not cap all storage work. Capacity refusal returns
-`DeferredPollCapacityExceeded` (90); clients back off on the same route. Over
-HTTP, a partition primary with no free relay slot refuses a relayed deferred poll
-with the same code. If the caller's credential holds its whole share of slots,
-the status is 429. In all other cases, it is 503. A
-high-level consumer whose poll is still refused when its request timeout ends
-returns the error to the application and polls that partition again after
-`polling_retry_interval`. A replica
-that cannot serve a partition still returns `TransientNotAccepted` (58) or
-`NotPrimary` (89), prompting route refresh. Upgrade clients with code 90 support
-before enabling deferred polling against this server. Metrics expose pending polls, read reservations and
-completion outcomes.
+The server answers at the first of three events: `min_count` messages are ready, the reply reaches
+`max_bytes`, or the wait ends. After the wait it makes one last bounded read, so a reply can hold
+fewer than `min_count` messages, or none. A fetch of a reader checks `min_count` against all its
+partitions together, so one batch can hold fewer. HTTP applies the same byte limit before JSON
+encoding. The server also stops a reply at `sharding.deferred_poll_max_read_bytes`, unless its first
+message alone is larger. A first message larger than `max_bytes` comes alone in the reply, so every
+client reply cap holds the largest message the server accepts.
 
-Bench exposes `--max-wait`, `--min-count`, `--max-bytes` and `--request-timeout`
-for both consumer APIs and records them in report names. Intentional batching
-waits contribute to measured latency.
+### Positions and commits
+
+A batch (`PolledMessages`) gives two positions:
+
+- `continuation()` is the position after the batch, for the next read of its partition. An empty
+  batch keeps the start of its request, so `next` stays `next`. A continuation belongs to its
+  partition, so a read of another partition refuses it with `InvalidCommand`. It also carries the
+  partition history: after another client deletes and recreates the partition, a read from it
+  returns `HistoryUnavailable` (87).
+- `checkpoint()` is a `Checkpoint`, the position after the last message of the batch.
+  `checkpoint_at(index)` covers a processed part of the batch, and `ReceivedMessage::checkpoint()`
+  covers one message. A commit stores its offset under the partition history that delivered it, and
+  it covers every earlier message of the partition.
+
+Only a commit or a `CommitPolicy` other than `Manual` moves the stored offset. The reader and the
+consumer keep their own read position. They resolve a `next` start once per partition: after the
+stored offset, or at offset 0 without one. Both default to `next` and `CommitPolicy::Manual`, which
+stores nothing, so a restart before a commit reads the same messages again. `PollOptions::commit`
+picks one of three policies:
+
+- `Manual`: commit explicitly, after processing and in partition order: `store_consumer_position(...)`
+  after `poll_messages`, `reader.commit(checkpoint)` or `consumer.commit(received.checkpoint())`.
+  This gives at-least-once delivery. The reader skips a checkpoint at or below its last commit. It
+  also skips a checkpoint from before a `seek` or a new assignment of its partition.
+- `Auto(AutoCommit { interval, every, after })`: the reader and the consumer store the latest
+  counted position on an `interval`, on an event (`every`: each message, `Messages(n)` counted
+  messages of a partition since its last store, or each batch), or both. `after` decides when a
+  message counts: `Processing` (default) once the application asks for the next one, its handler
+  returns with success, or it closes the reader, `Delivery` when it is handed over, `Marked` only
+  after `mark(checkpoint)`, for concurrent processing. A handler that fails under `Processing` ends
+  `consume_messages()` without counting its message. Stores only move forward, drop positions of an
+  older generation, and also happen on close, on shutdown and before a rebalance takes a partition
+  away. `AutoCommit::default()` stores every 10,000 messages or 5 s after processing. A one-shot
+  `poll_messages` refuses `Auto` with `InvalidConfiguration`, because it has nothing to count.
+- `OnRead`: the server stores the offset of the last message of each read as part of the read, in
+  one round trip. While the primary stays up a message is delivered at most once. A failover before
+  the store replicates can deliver the last batch again. The reader reads only when the application
+  asks for a batch and none is queued, and then fills its prefetch budget: one fetch per primary that
+  may wait for messages, and the partitions with messages left behind spread over the other slots. A
+  `seek` to an offset above 0 first stores the offset before it, so a restart resumes at the new
+  position. `commit` and `mark` return `InvalidCommand`. An encryptor is refused, because the server stores the offset before the
+  client decrypts the batch. `close()` and `shutdown()` move the offset back to the last delivered
+  message.
+
+A consumer that delivered messages but stored no offset logs a warning at shutdown. With the default
+policy and `next`, `consume_messages()` also warns at its start, because its handler cannot commit.
+
+Earlier releases committed by default: the builder used
+`AutoCommit::IntervalOrWhen(1s, PollingMessages)`, and `IggyConsumerConfig` used
+`AutoCommit::When(PollingMessages)` with `Last`. Code that relied on this must now set a
+`CommitPolicy`, or each restart reads the same messages again. On `IggyConsumerConfig`,
+`with_poll_options` sets it. The old types map like this: `Interval(d)` sets `interval`, `After(e)`
+counts after `Processing`, `When(e)` after `Delivery`, `IntervalOrWhen` and `IntervalOrAfter` set
+both `interval` and `every`, and `When(PollingMessages)` becomes `OnRead`.
+
+### Errors and lost replies
+
+`PartitionReader::next()` and the consumer stream return a `ReadError` with `partition_id` (`None`
+for an error of the whole reader), `error` and `skip`. The reader stays usable after it:
+
+- It follows a moved primary without an error. Connection loss and transient refusals come out as
+  errors, and the reader retries by itself. An error of one partition does not drop the batches of
+  the other partitions.
+- `HistoryUnavailable` (87) stops its partition until `seek(partition, start)`, because an old
+  offset names no position in the new history. The other partitions go on.
+- A message that does not decode or decrypt comes out as an error after the batch of the messages
+  before it. Its `skip` is the continuation after the message. The reader reads the message again
+  until `seek(partition, skip)` moves past it.
+
+A read without `OnRead` stores nothing, so a retry after a lost reply is safe. The binary transport
+sends such a poll again, with less wait and time left, unless it starts from `next`. A one-shot poll
+from `next` returns `TransientNotCommitted` (57) instead.
+
+With `OnRead`, the server can still store the offset after the client gives up. So a read with
+`OnRead` whose reply is lost, or reports an unknown outcome, returns `FetchProgressUncertain(op)`
+(14010), never `TransientNotCommitted` (57). A `disconnect()` while such a read waits leaves it the
+same way. A dropped call that already sent its request leaves the same state. The SDK never sends an
+`OnRead` read again. The partition stays blocked on this client, also after a reconnect or a new
+sign-in: later progress calls on it return `PartitionLaneBlocked(partition_id, op)` (14009). Offset
+reads still work during this block, but they do not prove the outcome. A dropped call over TCP, QUIC
+or WebSocket sends a cancel (on QUIC on a stream of its own) and keeps the stream or slot of its
+reply, and the server answers it once: `TransientNotAccepted` (58) when nothing was stored, or the
+normal reply when the store won. That answer proves the outcome and lifts the block.
+
+`client.abandon_operation(op)` frees the partition and accepts the unknown outcome. It does not stop
+the old server request or prove what it stored. `resolve_operation(op)` cannot prove the outcome of
+a read, and it returns `FetchProgressUncertain` without I/O. `PartitionLaneBlocked` can also name an
+offset store or delete with an unknown outcome, and `resolve_operation` can prove that one (see
+[mutation recovery](#mutation-recovery)).
+
+`PartitionReader` and `IggyConsumer` recover by themselves. After a delivery they read each
+partition from its stored offset, so the server refuses the commit of an older read once the offset
+moved. For a lost reply they abandon the operation, and while they hold it, a next read that starts
+past the last delivered message makes them read the skipped messages again by offset without a
+commit and hand them over first. A later read that stores from the expected offset ends that,
+because its store fences out the lost read. A read that starts past the last delivered message
+without a lost read goes on from there: the application or another session stored that offset. Under
+`OnRead`, `IggyConsumer::store_offset` also moves the reader to the message after the stored offset.
+Only a rebalance that moves the partition to another member before the catch-up loses messages that
+a lost read stored. `close()` and `shutdown()` cancel each `OnRead` read in flight on TCP, QUIC and
+WebSocket and take its one reply, which tells what the server stored, so a read that waits on the
+server ends at once. Over HTTP, which has no cancel, they wait until the deadline of the read. Then
+they move the offset of a partition with a lost reply back to its last delivered message. A
+partition that another call on the client blocked stops until `abandon_operation(op)` and a `seek`.
+
+### Timeouts, admission and prefetch
+
+The request timeout of a read is `max_wait` plus 10 s. One deadline covers admission, route
+refreshes, retries and server forwarding. If the deadline passes before the request leaves the
+client, the call returns `TransientNotAccepted` (58), and a retry is safe. A proxy between the
+client and the server must allow more than `max_wait` plus 10 s for one request.
+
+A client admits at most 64 waiting reads, and each reader at most 16 of them. A read that waits for
+admission spends its own deadline. On TCP, QUIC and WebSocket, reads share the multiplexed
+connections with other traffic. There, a read with `OnRead` also holds a slot of a pending pool (see
+[mutation recovery](#mutation-recovery)). A dropped binary read sends command 108, which stops a
+request that still waits on the server. The server turns a cancelled QUIC stream into the same
+command.
+
+The reader keeps reads in flight and batches queued within a budget of encoded bytes and messages:
+`prefetch_bytes` (default 16 MiB) and `prefetch_messages` (default: the larger of 16,000 and
+`count`). Each request reserves `count` messages and `max_bytes` bytes until `next()` hands its
+batch over, and one request always fits. A full budget stops new requests. A batch that the
+application holds is outside the budget, but `IggyConsumer` keeps the batch that it yields from
+until its last message. The budget counts encoded bytes, not process memory: decoded metadata and
+HTTP JSON overhead come on top. Routing, reconnection and cancellation go on while the application
+stalls.
+
+### Routing and lanes
+
+On TCP, QUIC and WebSocket, the reader groups its partitions by primary and sends one fetch (command
+109) for each group. The partitions whose last read ended before their newest message are spread
+over more fetches of the group, one per free slot of the budget, so each slot reads a full
+`max_bytes`. The quiet partitions of a group share one waiting fetch. With `OnRead` the reader
+starts them only when the application waits for a batch, and the waiting fetch of a group runs to
+its end before the next one starts. After a
+rebalance, a partition that stays with the member continues after its last delivered message. A
+newly assigned partition starts at the start of the builder: `.start(...)` on the reader,
+`.polling_strategy(...)` on the consumer. In a new session, a group member starts every partition
+there again. `last` and `timestamp` starts read with single polls until the first message.
+
+On these transports, progress calls of one partition share a progress lane: polls and fetches from
+`next` or with `OnRead`, and offset reads, stores and deletes. Sends use a separate mutation lane,
+so a waiting poll does not delay a producer of the same client. Offset stores and deletes hold both
+lanes, so an unresolved store or delete blocks later progress until it is resolved or abandoned. All
+consumers of one partition on one client share its progress lane.
+
+A waiting progress poll holds the lane of its partition until it ends. Offset reads, stores and
+deletes of the same client on that partition wait for it, each for at most the 30-second operation
+timeout. Then the call fails with `TransientNotAccepted`. The client did not send it, so a retry is
+safe.
+
+### HTTP
+
+HTTP has no fetch. A standalone reader on HTTP keeps one waiting poll for each partition, within its
+budget. HTTP cannot join a consumer group, so a group reader or consumer, and a poll as a group,
+return `FeatureUnavailable`. `resolve_operation` is unavailable too. HTTP has no partition id
+independent of the spelling of the stream and the topic, so it keys the block of a lost `OnRead`
+read by the stream and the topic as the read spelled them and the partition id, and by the partition
+incarnation when the read carried its context. A later call meets the block in the same spelling, or
+in any spelling when it carries a context of the same incarnation. A call that names the partition
+by another spelling and carries no context does not see the block. The progress polls of one
+partition in one spelling run one at a time, so a poll sees the block that the poll before it left.
+
+HTTP polls use `GET /streams/{stream}/topics/{topic}/messages/deferred` with the poll fields plus
+`wait_us`, `min_count`, `max_bytes` and `request_timeout_us`. A poll that commits (`OnRead`) is a
+`POST` to the same path with the same query and an empty body, because a proxy may send a `GET`
+again, and the server refuses `auto_commit=true` on `GET`. A field that the query leaves out
+takes the server default: a 1 s wait, a `min_count` of 1, 1 MiB and an 11 s timeout. Binary command
+106 carries the same contract. The fetch (109) has no HTTP route, and it replaces the retired
+`wait_for_messages` API and command 107. Upgrade servers before SDKs.
+
+### Server limits
+
+The server defaults to a 30-second maximum wait, and to 1024 pending polls per shard and 64 per
+logical session (or HTTP user). A read reservation is at most 16 MiB, and the reservations in flight
+of one shard at most 64 MiB. A longer requested wait is shortened to the server maximum. A disk read
+stops after the messages that fill `max_bytes`, capped at the read reservation. If its buffers fill
+their share of that reservation first, it stops there and returns what it read, even below
+`min_count`. Configure `sharding.deferred_poll_*` accordingly. The reply limit applies to the
+selected result, not to all storage work.
+
+A full server refuses a poll with `DeferredPollCapacityExceeded` (90), and the client backs off on
+the same route. Over HTTP, a partition primary without a free relay slot refuses a relayed poll with
+the same code: 429 if the credential of the caller holds its whole share of slots, else 503. If a
+read is still refused at its deadline, the reader returns the error. It reads that partition again
+after its retry interval (`polling_retry_interval` on the consumer). A replica that cannot serve a
+partition returns `TransientNotAccepted` (58) or `NotPrimary` (89), and the client refreshes the
+route. Upgrade clients to support code 90 before you enable deferred polling against this server.
+Metrics expose pending polls, read reservations and completion outcomes.
+
+### Benchmarks
+
+iggy-bench sets the options of both consumer APIs with `--max-wait` (default 1 s), `--min-count`
+(default 1) and `--max-bytes`. The default of `--max-bytes` is the larger of 1 MiB and one full
+batch. Report names record them with the derived request timeout. `--request-timeout` is gone,
+because the timeout is always `max_wait` plus 10 s. Intentional batching waits count in the measured
+latency.
 
 ## Metadata changes
 
diff --git a/core/sdk/src/binary/mod.rs b/core/sdk/src/binary/mod.rs
index fc9a5d3..0c28952 100644
--- a/core/sdk/src/binary/mod.rs
+++ b/core/sdk/src/binary/mod.rs
@@ -138,11 +138,18 @@
                 self.driver.poll(request, context).await
             }
 
-            async fn send_wait_with_response(
+            async fn send_fetch_with_response(
                 &self,
-                request: &iggy_binary_protocol::requests::messages::WaitForMessagesRequest,
+                request: &iggy_binary_protocol::requests::messages::FetchMessagesRequest,
             ) -> Result<bytes::Bytes, iggy_common::IggyError> {
-                self.driver.wait_for_messages(request).await
+                self.driver.fetch_messages(request).await
+            }
+
+            async fn send_fetch_sections(
+                &self,
+                request: &iggy_binary_protocol::requests::messages::FetchMessagesRequest,
+            ) -> Result<Vec<iggy_common::FetchedSection>, iggy_common::IggyError> {
+                self.driver.fetch_sections(request).await
             }
 
             async fn send_poll_with_response_and_options(
diff --git a/core/sdk/src/client_wrappers/binary_consumer_offset_client.rs b/core/sdk/src/client_wrappers/binary_consumer_offset_client.rs
index e679917..bc6d4d1 100644
--- a/core/sdk/src/client_wrappers/binary_consumer_offset_client.rs
+++ b/core/sdk/src/client_wrappers/binary_consumer_offset_client.rs
@@ -18,7 +18,7 @@
 use crate::client_wrappers::client_wrapper::ClientWrapper;
 use async_trait::async_trait;
 use iggy_common::{
-    Consumer, ConsumerOffsetClient, ConsumerOffsetInfo, ConsumerPosition, Identifier, IggyError,
+    Checkpoint, Consumer, ConsumerOffsetClient, ConsumerOffsetInfo, Identifier, IggyError,
 };
 
 #[async_trait]
@@ -28,11 +28,13 @@
         consumer: &Consumer,
         stream_id: &Identifier,
         topic_id: &Identifier,
-        position: ConsumerPosition,
+        checkpoint: &Checkpoint,
     ) -> Result<(), IggyError> {
         super::dispatch_client!(
             self,
-            ConsumerOffsetClient::store_consumer_position(consumer, stream_id, topic_id, position)
+            ConsumerOffsetClient::store_consumer_position(
+                consumer, stream_id, topic_id, checkpoint
+            )
         )
         .await
     }
@@ -90,4 +92,20 @@
         )
         .await
     }
+
+    async fn delete_consumer_position(
+        &self,
+        consumer: &Consumer,
+        stream_id: &Identifier,
+        topic_id: &Identifier,
+        checkpoint: &Checkpoint,
+    ) -> Result<(), IggyError> {
+        super::dispatch_client!(
+            self,
+            ConsumerOffsetClient::delete_consumer_position(
+                consumer, stream_id, topic_id, checkpoint
+            )
+        )
+        .await
+    }
 }
diff --git a/core/sdk/src/client_wrappers/binary_message_client.rs b/core/sdk/src/client_wrappers/binary_message_client.rs
index 098f1e0..9a38dd6 100644
--- a/core/sdk/src/client_wrappers/binary_message_client.rs
+++ b/core/sdk/src/client_wrappers/binary_message_client.rs
@@ -18,8 +18,8 @@
 use crate::client_wrappers::client_wrapper::ClientWrapper;
 use async_trait::async_trait;
 use iggy_common::{
-    Consumer, DeferredPollOptions, Identifier, IggyError, IggyMessage, MessageClient, Partitioning,
-    PolledMessages, PollingStrategy, SendMessagesResponse,
+    Consumer, DecodeError, Identifier, IggyError, IggyMessage, MessageClient, Partitioning,
+    PollOptions, PolledMessages, PollingStrategy, SendMessagesResponse,
 };
 
 #[async_trait]
@@ -28,85 +28,44 @@
         &self,
         stream_id: &Identifier,
         topic_id: &Identifier,
-        partition_id: Option<u32>,
+        partition_id: u32,
         consumer: &Consumer,
         strategy: &PollingStrategy,
-        count: u32,
-        auto_commit: bool,
-    ) -> Result<PolledMessages, IggyError> {
-        self.poll_messages_with_strategy_for(
-            stream_id,
-            topic_id,
-            partition_id,
-            consumer,
-            &|_: u32| *strategy,
-            count,
-            auto_commit,
-        )
-        .await
-    }
-
-    async fn poll_messages_with_strategy_for(
-        &self,
-        stream_id: &Identifier,
-        topic_id: &Identifier,
-        partition_id: Option<u32>,
-        consumer: &Consumer,
-        strategy_for: &(dyn Fn(u32) -> PollingStrategy + Send + Sync),
-        count: u32,
-        auto_commit: bool,
-    ) -> Result<PolledMessages, IggyError> {
-        self.poll_messages_with_strategy_for_and_options(
-            stream_id,
-            topic_id,
-            partition_id,
-            consumer,
-            strategy_for,
-            count,
-            auto_commit,
-            None,
-        )
-        .await
-    }
-
-    async fn poll_messages_with_strategy_for_and_options(
-        &self,
-        stream_id: &Identifier,
-        topic_id: &Identifier,
-        partition_id: Option<u32>,
-        consumer: &Consumer,
-        strategy_for: &(dyn Fn(u32) -> PollingStrategy + Send + Sync),
-        count: u32,
-        auto_commit: bool,
-        options: Option<DeferredPollOptions>,
+        options: PollOptions,
     ) -> Result<PolledMessages, IggyError> {
         super::dispatch_client!(
             self,
-            MessageClient::poll_messages_with_strategy_for_and_options(
+            MessageClient::poll_messages(
                 stream_id,
                 topic_id,
                 partition_id,
                 consumer,
-                strategy_for,
-                count,
-                auto_commit,
+                strategy,
                 options
             )
         )
         .await
     }
 
-    async fn wait_for_messages(
+    async fn poll_messages_raw(
         &self,
         stream_id: &Identifier,
         topic_id: &Identifier,
-        partitions: &[(u32, PollingStrategy)],
+        partition_id: u32,
         consumer: &Consumer,
-        options: DeferredPollOptions,
-    ) -> Result<Vec<u32>, IggyError> {
+        strategy: &PollingStrategy,
+        options: PollOptions,
+    ) -> Result<PolledMessages, DecodeError> {
         super::dispatch_client!(
             self,
-            MessageClient::wait_for_messages(stream_id, topic_id, partitions, consumer, options)
+            MessageClient::poll_messages_raw(
+                stream_id,
+                topic_id,
+                partition_id,
+                consumer,
+                strategy,
+                options
+            )
         )
         .await
     }
diff --git a/core/sdk/src/client_wrappers/client_wrapper.rs b/core/sdk/src/client_wrappers/client_wrapper.rs
index fc533ec..24643b4 100644
--- a/core/sdk/src/client_wrappers/client_wrapper.rs
+++ b/core/sdk/src/client_wrappers/client_wrapper.rs
@@ -24,9 +24,9 @@
 use iggy_common::locking::IggyRwLockFn;
 use iggy_common::{
     AssignmentVersion, BinaryClient, BinaryOperationLineage, BinarySendPartitioning,
-    BinaryTransport, ClientState, Identifier, IggyError, IggyMessage, MessageClient,
-    SendMessagesResponse, SystemClient, group_cache_key, send_binary_messages,
-    sync_group_assignment,
+    BinaryTransport, ClientState, Consumer, DeferredPollOptions, FetchedSection, Identifier,
+    IggyError, IggyMessage, MessageClient, PollingStrategy, SendMessagesResponse, SystemClient,
+    fetch_binary_messages, group_cache_key, send_binary_messages, sync_group_assignment,
 };
 
 #[allow(clippy::large_enum_variant)]
@@ -97,7 +97,7 @@
     pub(crate) async fn retains_operation(&self, op: u64) -> bool {
         match self {
             Self::Iggy(client) => Box::pin(client.client.read().await.retains_operation(op)).await,
-            Self::Http(_) => false,
+            Self::Http(client) => client.retains_operation(op),
             Self::Tcp(client) => client.retains_operation(op),
             Self::Quic(client) => client.retains_operation(op),
             Self::WebSocket(client) => client.retains_operation(op),
@@ -138,9 +138,64 @@
         }
     }
 
-    /// The partitions grouped by primary endpoint and split to fit one wait
-    /// request each, so that a consumer can keep one readiness wait in flight
-    /// per group.
+    /// One `FetchMessages` (109) request for the `targets` of one readiness group, with the
+    /// contract of [`fetch_binary_messages`]. The sections of a nested [`IggyClient`] decode
+    /// with its encryptor. HTTP has no fetch and returns `FeatureUnavailable`.
+    #[allow(clippy::too_many_arguments)]
+    pub(crate) async fn fetch_messages(
+        &self,
+        stream_id: &Identifier,
+        topic_id: &Identifier,
+        targets: &[(u32, PollingStrategy)],
+        consumer: &Consumer,
+        count: u32,
+        auto_commit: bool,
+        options: DeferredPollOptions,
+    ) -> Result<Vec<FetchedSection>, IggyError> {
+        macro_rules! fetch {
+            ($client:expr) => {
+                fetch_binary_messages(
+                    $client,
+                    stream_id,
+                    topic_id,
+                    targets,
+                    consumer,
+                    count,
+                    auto_commit,
+                    options,
+                )
+                .await
+            };
+        }
+        match self {
+            Self::Tcp(client) => fetch!(client),
+            Self::Quic(client) => fetch!(client),
+            Self::WebSocket(client) => fetch!(client),
+            Self::Http(_) => Err(IggyError::FeatureUnavailable),
+            Self::Iggy(client) => {
+                let sections = Box::pin(client.client.read().await.fetch_messages(
+                    stream_id,
+                    topic_id,
+                    targets,
+                    consumer,
+                    count,
+                    auto_commit,
+                    options,
+                ))
+                .await?;
+                Ok(sections
+                    .into_iter()
+                    .map(|section| section.with_encryptor(client.encryptor.clone()))
+                    .collect())
+            }
+        }
+    }
+
+    /// The partitions grouped by primary endpoint and split to fit one request
+    /// each, so that a consumer can keep one [`fetch_messages`](Self::fetch_messages)
+    /// in flight per group. Each group keeps the order of `partitions`, and the first
+    /// failed route lookup in that order is the error. HTTP has no groups and
+    /// returns `FeatureUnavailable`.
     pub(crate) async fn readiness_groups(
         &self,
         stream: &Identifier,
diff --git a/core/sdk/src/clients/binary_consumer_offset.rs b/core/sdk/src/clients/binary_consumer_offset.rs
index 8fd5396..153d4f9 100644
--- a/core/sdk/src/clients/binary_consumer_offset.rs
+++ b/core/sdk/src/clients/binary_consumer_offset.rs
@@ -19,7 +19,7 @@
 use async_trait::async_trait;
 use iggy_common::ConsumerOffsetClient;
 use iggy_common::locking::IggyRwLockFn;
-use iggy_common::{Consumer, ConsumerOffsetInfo, ConsumerPosition, Identifier, IggyError};
+use iggy_common::{Checkpoint, Consumer, ConsumerOffsetInfo, Identifier, IggyError};
 
 #[async_trait]
 impl ConsumerOffsetClient for IggyClient {
@@ -28,9 +28,9 @@
         consumer: &Consumer,
         stream_id: &Identifier,
         topic_id: &Identifier,
-        position: ConsumerPosition,
+        checkpoint: &Checkpoint,
     ) -> Result<(), IggyError> {
-        super::with_client!(self, client => client.store_consumer_position(consumer, stream_id, topic_id, position).await)
+        super::with_client!(self, client => client.store_consumer_position(consumer, stream_id, topic_id, checkpoint).await)
     }
 
     async fn store_consumer_offset(
@@ -63,4 +63,14 @@
     ) -> Result<(), IggyError> {
         super::with_client!(self, client => client.delete_consumer_offset(consumer, stream_id, topic_id, partition_id).await)
     }
+
+    async fn delete_consumer_position(
+        &self,
+        consumer: &Consumer,
+        stream_id: &Identifier,
+        topic_id: &Identifier,
+        checkpoint: &Checkpoint,
+    ) -> Result<(), IggyError> {
+        super::with_client!(self, client => client.delete_consumer_position(consumer, stream_id, topic_id, checkpoint).await)
+    }
 }
diff --git a/core/sdk/src/clients/binary_message.rs b/core/sdk/src/clients/binary_message.rs
index e80dae7..d1f7c4e 100644
--- a/core/sdk/src/clients/binary_message.rs
+++ b/core/sdk/src/clients/binary_message.rs
@@ -19,117 +19,99 @@
 use async_trait::async_trait;
 use bytes::Bytes;
 use iggy_common::locking::IggyRwLockFn;
-use iggy_common::{BinarySendPartitioning, MessageClient};
+use iggy_common::{BinarySendPartitioning, MessageClient, with_binary_deadline};
 use iggy_common::{
-    Consumer, DeferredPollOptions, Identifier, IggyError, IggyMessage, Partitioning,
-    PolledMessages, PollingStrategy, SendMessagesResponse,
+    CommitPolicy, Consumer, DecodeError, Identifier, IggyError, IggyMessage, Partitioning,
+    PollOptions, PolledMessages, PollingStrategy, SendMessagesResponse,
 };
+use tokio::time::Instant;
 
 #[async_trait]
 impl MessageClient for IggyClient {
+    /// The call ends at its own deadline of `max_wait` plus 10 s, so a wait of up to 600 s
+    /// outlives the operation timeout of the other calls.
+    ///
+    /// With an encryptor, `CommitPolicy::OnRead` fails with
+    /// [`IggyError::InvalidConfiguration`] before anything is sent: the server would store the
+    /// offset of a batch before the client decrypts it, so a batch that fails to decrypt would
+    /// be lost.
     async fn poll_messages(
         &self,
         stream_id: &Identifier,
         topic_id: &Identifier,
-        partition_id: Option<u32>,
+        partition_id: u32,
         consumer: &Consumer,
         strategy: &PollingStrategy,
-        count: u32,
-        auto_commit: bool,
+        options: PollOptions,
     ) -> Result<PolledMessages, IggyError> {
-        self.poll_messages_with_strategy_for(
+        self.poll_messages_raw(
             stream_id,
             topic_id,
             partition_id,
             consumer,
-            &|_: u32| *strategy,
-            count,
-            auto_commit,
+            strategy,
+            options,
         )
         .await
+        .map_err(|error| error.error)
     }
 
-    async fn poll_messages_with_strategy_for(
+    async fn poll_messages_raw(
         &self,
         stream_id: &Identifier,
         topic_id: &Identifier,
-        partition_id: Option<u32>,
+        partition_id: u32,
         consumer: &Consumer,
-        strategy_for: &(dyn Fn(u32) -> PollingStrategy + Send + Sync),
-        count: u32,
-        auto_commit: bool,
-    ) -> Result<PolledMessages, IggyError> {
-        self.poll_messages_with_strategy_for_and_options(
-            stream_id,
-            topic_id,
-            partition_id,
-            consumer,
-            strategy_for,
-            count,
-            auto_commit,
-            None,
-        )
-        .await
-    }
-
-    async fn poll_messages_with_strategy_for_and_options(
-        &self,
-        stream_id: &Identifier,
-        topic_id: &Identifier,
-        partition_id: Option<u32>,
-        consumer: &Consumer,
-        strategy_for: &(dyn Fn(u32) -> PollingStrategy + Send + Sync),
-        count: u32,
-        auto_commit: bool,
-        options: Option<iggy_common::DeferredPollOptions>,
-    ) -> Result<PolledMessages, IggyError> {
-        if count == 0 {
-            return Err(IggyError::InvalidMessagesCount);
+        strategy: &PollingStrategy,
+        options: PollOptions,
+    ) -> Result<PolledMessages, DecodeError> {
+        if options.commit == CommitPolicy::OnRead && self.encryptor.is_some() {
+            return Err(DecodeError::without_record(IggyError::InvalidConfiguration));
         }
-
-        let mut polled_messages = super::with_client!(self, client => client.poll_messages_with_strategy_for_and_options(
-                stream_id,
-                topic_id,
-                partition_id,
-                consumer,
-                strategy_for,
-                count,
-                auto_commit,
-                options,
-            ).await)?;
+        let deadline = Instant::now()
+            .checked_add(options.request_timeout().get_duration())
+            .ok_or(IggyError::InvalidCommand)
+            .map_err(DecodeError::without_record)?;
+        let mut polled_messages = with_binary_deadline(deadline, async {
+            let client = tokio::time::timeout_at(deadline, self.client.read())
+                .await
+                .map_err(|_| DecodeError::without_record(IggyError::TransientNotAccepted))?;
+            client
+                .poll_messages_raw(
+                    stream_id,
+                    topic_id,
+                    partition_id,
+                    consumer,
+                    strategy,
+                    options,
+                )
+                .await
+        })
+        .await?;
 
         if let Some(ref encryptor) = self.encryptor {
-            for message in &mut polled_messages.messages {
-                let payload = encryptor.decrypt(&message.payload)?;
-                message.payload = Bytes::from(payload);
-                message.header.payload_length = message.payload.len() as u32;
-
-                if let Some(ref user_headers) = message.user_headers {
-                    let decrypted_headers = encryptor.decrypt(user_headers)?;
-                    message.header.user_headers_length = decrypted_headers.len() as u32;
-                    message.user_headers = Some(Bytes::from(decrypted_headers));
-                }
+            let failed =
+                polled_messages
+                    .messages
+                    .iter_mut()
+                    .enumerate()
+                    .find_map(|(index, message)| {
+                        encryptor
+                            .decrypt_message(message)
+                            .err()
+                            .map(|error| (index, error))
+                    });
+            if let Some((index, error)) = failed {
+                return Err(DecodeError {
+                    error,
+                    record: polled_messages.undecoded_record(index).map(Box::new),
+                });
             }
         }
 
         Ok(polled_messages)
     }
 
-    async fn wait_for_messages(
-        &self,
-        stream_id: &Identifier,
-        topic_id: &Identifier,
-        partitions: &[(u32, PollingStrategy)],
-        consumer: &Consumer,
-        options: DeferredPollOptions,
-    ) -> Result<Vec<u32>, IggyError> {
-        self.client
-            .read()
-            .await
-            .wait_for_messages(stream_id, topic_id, partitions, consumer, options)
-            .await
-    }
-
     async fn send_messages(
         &self,
         stream_id: &Identifier,
@@ -177,3 +159,140 @@
         super::with_client!(self, client => client.send_messages_with_partitioning(stream_id, topic_id, partitioning, messages).await)
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::client_wrappers::client_wrapper::ClientWrapper;
+    use crate::links::OPERATION_TIMEOUT;
+    use crate::links::fake_cluster::{Answer, FakeCluster, connected, endpoint};
+    use crate::tcp::tcp_client::TcpClient;
+    use iggy_binary_protocol::codes::POLL_MESSAGES_DEFERRED_CODE;
+    use iggy_binary_protocol::requests::messages::DeferredPollMessagesRequest;
+    use iggy_binary_protocol::responses::messages::{
+        PartitionRouteResponse, PollMessagesResponseHeader,
+    };
+    use iggy_binary_protocol::{WireDecode, WireEncode};
+    use iggy_common::{Aes256GcmEncryptor, EncryptorKind, IggyDuration, PartitionContext};
+    use std::sync::Arc;
+    use std::sync::atomic::AtomicBool;
+    use tokio::sync::Notify;
+    use tokio::time::sleep;
+
+    const PARTITION_ID: u32 = 0;
+    const ENCRYPTION_KEY: [u8; 32] = [1; 32];
+
+    /// A cluster of one replica that routes the partition.
+    fn routed_cluster() -> Arc<FakeCluster> {
+        let cluster = FakeCluster::new(1);
+        *cluster.route.lock().unwrap() = Some(PartitionRouteResponse {
+            incarnation: 7,
+            metadata_op: 11,
+            view: 0,
+            partitions_count: 1,
+            primary_replica: 0,
+        });
+        cluster
+    }
+
+    fn empty_reply() -> Vec<u8> {
+        PollMessagesResponseHeader {
+            partition_id: PARTITION_ID,
+            current_offset: 0,
+            messages_count: 0,
+            context: PartitionContext::default(),
+        }
+        .to_bytes()
+        .to_vec()
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_a_wait_past_the_operation_timeout_when_polling_should_wait_for_the_reply() {
+        let reply_after = OPERATION_TIMEOUT * 2;
+        let max_wait = OPERATION_TIMEOUT * 3;
+        let cluster = routed_cluster();
+        let release = Arc::new(Notify::new());
+        cluster.script(
+            &endpoint(0),
+            POLL_MESSAGES_DEFERRED_CODE,
+            [Answer::GatedBody {
+                release: Arc::clone(&release),
+                completed: Arc::new(AtomicBool::new(false)),
+                body: empty_reply(),
+            }],
+        );
+        let client = IggyClient::new(ClientWrapper::Tcp(TcpClient::from_links(
+            connected(&cluster, 1).await,
+        )));
+        let id = Identifier::numeric(1).unwrap();
+        let consumer = Consumer::new(id.clone());
+        let strategy = PollingStrategy::offset(0);
+        let start = Instant::now();
+
+        let (result, ()) = tokio::join!(
+            client.poll_messages(
+                &id,
+                &id,
+                PARTITION_ID,
+                &consumer,
+                &strategy,
+                PollOptions::wait(max_wait.into()),
+            ),
+            async {
+                sleep(reply_after).await;
+                release.notify_one();
+            }
+        );
+
+        let polled = result.expect("the poll must outlive the operation timeout of other calls");
+        assert_eq!(polled.partition_id, PARTITION_ID);
+        assert_eq!(start.elapsed(), reply_after);
+        let request = DeferredPollMessagesRequest::decode_from(
+            &cluster.received(POLL_MESSAGES_DEFERRED_CODE)[0].body,
+        )
+        .unwrap();
+        assert_eq!(request.wait_us, IggyDuration::from(max_wait).as_micros());
+    }
+
+    /// The server would store the offset of an `OnRead` batch before the client decrypts it.
+    #[tokio::test(start_paused = true)]
+    async fn given_an_encryptor_when_polling_with_on_read_should_refuse_it_before_sending() {
+        let cluster = routed_cluster();
+        cluster.script(
+            &endpoint(0),
+            POLL_MESSAGES_DEFERRED_CODE,
+            [Answer::Body(empty_reply())],
+        );
+        let encryptor = EncryptorKind::Aes256Gcm(Aes256GcmEncryptor::new(&ENCRYPTION_KEY).unwrap());
+        let client = IggyClient::create(
+            ClientWrapper::Tcp(TcpClient::from_links(connected(&cluster, 1).await)),
+            None,
+            Some(Arc::new(encryptor)),
+        );
+        let id = Identifier::numeric(1).unwrap();
+        let consumer = Consumer::new(id.clone());
+        let next = PollingStrategy::next();
+        let poll = |commit| {
+            client.poll_messages(
+                &id,
+                &id,
+                PARTITION_ID,
+                &consumer,
+                &next,
+                PollOptions {
+                    commit,
+                    ..PollOptions::default()
+                },
+            )
+        };
+
+        let refused = poll(CommitPolicy::OnRead).await;
+        let sent_after_refusal = cluster.count(POLL_MESSAGES_DEFERRED_CODE);
+        let polled = poll(CommitPolicy::Manual).await;
+
+        assert_eq!(refused.map(drop), Err(IggyError::InvalidConfiguration));
+        assert_eq!(sent_after_refusal, 0);
+        assert_eq!(polled.map(|polled| polled.count), Ok(0));
+        assert_eq!(cluster.count(POLL_MESSAGES_DEFERRED_CODE), 1);
+    }
+}
diff --git a/core/sdk/src/clients/client.rs b/core/sdk/src/clients/client.rs
index 6e55c8d..703b8d0 100644
--- a/core/sdk/src/clients/client.rs
+++ b/core/sdk/src/clients/client.rs
@@ -25,6 +25,7 @@
 use crate::prelude::IggyConsumerBuilder;
 use crate::prelude::IggyError;
 use crate::prelude::IggyProducerBuilder;
+use crate::prelude::ReaderBuilder;
 use crate::quic::quic_client::QuicClient;
 use crate::tcp::tcp_client::TcpClient;
 use crate::websocket::websocket_client::WebSocketClient;
@@ -107,6 +108,12 @@
 /// Additionally, you can bypass invoking methods from these traits and directly talk to the server with [`send_binary_request`] and [`send_http_request`] for http.
 /// Both trade typed API's safety for low-level control. You need to know the server codes and the wire format.
 ///
+/// On TCP, QUIC and WebSocket, each read with [`CommitPolicy::OnRead`] holds one slot of a
+/// pool with the [`PendingOperationsConfig`] limits (256 operations and 64 MiB by default) for the
+/// whole call, also while the server waits for messages, and after an unknown outcome until
+/// [`abandon_operation`]. When the pool is full, the read returns
+/// [`IggyError::TransientNotAccepted`] at its deadline.
+///
 /// # Usage
 ///
 /// The typical lifecycle of an `IggyClient` is construct, [`connect()`], use, and finally shutdown.
@@ -174,13 +181,16 @@
 ///     .send(vec![IggyMessage::from_str("our-first-message")?])
 ///     .await?;
 ///
-/// // A consumer pinned to one partition, committing its offset
-/// // automatically when messages are polled (not consumed!).
+/// // A consumer of one partition, whose offset the server stores
+/// // when it reads a batch (not when the batch is consumed!).
 /// let mut consumer = client
-///     .consumer("consumer_name", "stream_name", "topic_name", 1)?
-///     .auto_commit(AutoCommit::When(AutoCommitWhen::PollingMessages))
+///     .consumer("consumer_name", "stream_name", "topic_name")?
+///     .partitions([1])
+///     .poll_options(PollOptions {
+///         commit: CommitPolicy::OnRead,
+///         ..PollOptions::wait(IggyDuration::ONE_SECOND)
+///     })
 ///     .polling_strategy(PollingStrategy::next())
-///     .batch_length(1000)
 ///     .build();
 /// consumer.init().await?;
 ///
@@ -229,6 +239,9 @@
 /// [`ConsumerOffsetClient`]: crate::prelude::ConsumerOffsetClient
 /// [`ConsumerGroupClient`]: crate::prelude::ConsumerGroupClient
 /// [`ClusterClient`]: crate::prelude::ClusterClient
+/// [`CommitPolicy::OnRead`]: crate::prelude::CommitPolicy::OnRead
+/// [`PendingOperationsConfig`]: crate::prelude::PendingOperationsConfig
+/// [`abandon_operation`]: crate::prelude::Client::abandon_operation
 #[derive(Debug)]
 #[allow(dead_code)]
 pub struct IggyClient {
@@ -337,7 +350,7 @@
     /// - `reconnection_reestablish_after`: [`IggyDuration`]. Stable connection duration that resets the retry count and backoff. Default: `5s`.
     /// - `max_pending_operations`: positive usize. Active and retained mutations per session. Default: `256`.
     /// - `max_pending_operation_bytes`: positive usize. Frozen mutation headers and bodies. Default: `67108864` (64 MiB).
-    /// - `response_buffer_size`: u64. Number of bytes in the response receive buffer. Default: `10000000`.
+    /// - `response_buffer_size`: u64. Number of bytes in the response receive buffer, at least the largest read reply of one record. Default: `64156564`.
     /// - `max_concurrent_bidi_streams`: u64. Number of concurrent bidirectional streams. Default: `10000`.
     /// - `datagram_send_buffer_size`: u64. Number of bytes in the datagram send buffer. Default: `100000`.
     /// - `initial_mtu`: u16. Initial MTU estimate (in bytes). Default: `1200`.
@@ -354,7 +367,7 @@
     ///     "iggy+quic://user:secret@localhost:8080\
     ///      ?validate_certificate=true&heartbeat_interval=5s\
     ///      &reconnection_max_retries=unlimited&reconnection_interval=1s&reconnection_reestablish_after=5s\
-    ///      &response_buffer_size=10000000&max_concurrent_bidi_streams=10000\
+    ///      &response_buffer_size=64156564&max_concurrent_bidi_streams=10000\
     ///      &datagram_send_buffer_size=100000&initial_mtu=1200\
     ///      &send_window=100000&receive_window=100000\
     ///      &keep_alive_interval=5000&max_idle_timeout=10000",
@@ -622,7 +635,8 @@
     /// into a [`IggyConsumerBuilder`] and returns it.
     /// Sets the consumer to [`ConsumerKind::Consumer`], i.e. a single consumer,
     /// with the provided name.
-    /// Registers a consumer for `stream`, `topic` and `partition`.
+    /// Registers a consumer for `stream` and `topic`. It reads the partitions of
+    /// [`IggyConsumerBuilder::partitions`], which a standalone consumer requires.
     ///
     /// To get builder for a load-balanced consumer group, use
     /// [`consumer_group`](IggyClient::consumer_group) instead.
@@ -632,8 +646,8 @@
     ///
     /// # Examples
     ///
-    /// Connect a client, build a consumer pinned to partition 1, and configure
-    /// it to auto-commit when messages are polled.
+    /// Connect a client, build a consumer of partitions 0 and 1, and configure
+    /// it to store the processed positions every 10,000 messages or 5 s.
     ///
     /// ```no_run
     /// use iggy::prelude::*;
@@ -645,10 +659,13 @@
     /// client.connect().await?;
     ///
     /// let consumer = client
-    ///     .consumer("consumer_name", "stream_name", "topic_name", 1)? // returns IggyConsumerBuilder from IggyClient
-    ///     .auto_commit(AutoCommit::When(AutoCommitWhen::PollingMessages))
+    ///     .consumer("consumer_name", "stream_name", "topic_name")? // returns IggyConsumerBuilder from IggyClient
+    ///     .partitions([0, 1])
+    ///     .poll_options(PollOptions {
+    ///         commit: CommitPolicy::Auto(AutoCommit::default()),
+    ///         ..PollOptions::wait(IggyDuration::ONE_SECOND)
+    ///     })
     ///     .polling_strategy(PollingStrategy::next())
-    ///     .batch_length(1000)
     ///     .build(); // returns IggyConsumer from IggyConsumerBuilder
     /// # let _ = consumer;
     /// # Ok(())
@@ -667,7 +684,6 @@
         name: &str,
         stream: &str,
         topic: &str,
-        partition: u32,
     ) -> Result<IggyConsumerBuilder, IggyError> {
         Ok(IggyConsumerBuilder::new(
             self.client.clone(),
@@ -675,7 +691,6 @@
             Consumer::new(name.try_into()?),
             Identifier::named(stream)?,
             Identifier::named(topic)?,
-            Some(partition),
             self.encryptor.clone(),
         ))
     }
@@ -694,7 +709,7 @@
     /// Since the new consumer starts reading from the last commit, messages are delivered
     /// at-least-once.
     ///
-    /// For a consumer pinned to a single partition, use
+    /// For a consumer of chosen partitions, use
     /// [`consumer`](IggyClient::consumer) instead.
     ///
     /// Refer to the [`IggyConsumer`] type and the [`IggyConsumerBuilder`]
@@ -703,7 +718,7 @@
     /// # Examples
     ///
     /// Connect a client, build a member of a consumer group, and configure it to
-    /// auto-commit when polled.
+    /// let the server store the offset of each batch that it reads.
     ///
     /// ```no_run
     /// use iggy::prelude::*;
@@ -716,9 +731,11 @@
     ///
     /// let consumer = client
     ///     .consumer_group("group_name", "stream_name", "topic_name")? // returns IggyConsumerBuilder from IggyClient
-    ///     .auto_commit(AutoCommit::When(AutoCommitWhen::PollingMessages))
+    ///     .poll_options(PollOptions {
+    ///         commit: CommitPolicy::OnRead,
+    ///         ..PollOptions::wait(IggyDuration::ONE_SECOND)
+    ///     })
     ///     .polling_strategy(PollingStrategy::next())
-    ///     .batch_length(1000)
     ///     .build(); // returns IggyConsumer from IggyConsumerBuilder
     /// # let _ = consumer;
     /// # Ok(())
@@ -744,11 +761,57 @@
             Consumer::group(Identifier::named(name)?),
             Identifier::named(stream)?,
             Identifier::named(topic)?,
-            None,
             self.encryptor.clone(),
         ))
     }
 
+    /// Returns a [`ReaderBuilder`] for a [`PartitionReader`] of `topic` in `stream`, which reads
+    /// as `consumer` and decodes with the encryptor of the client.
+    ///
+    /// A standalone [`Consumer`] reads the partitions of [`ReaderBuilder::partitions`]. A
+    /// consumer group reads the assignment of its member.
+    ///
+    /// # Examples
+    ///
+    /// ```no_run
+    /// use iggy::prelude::*;
+    ///
+    /// # async fn run() -> Result<(), IggyError> {
+    /// let client = IggyClient::from_connection_string(
+    ///     "iggy+tcp://user:secret@localhost:8090",
+    /// )?;
+    /// client.connect().await?;
+    ///
+    /// let (stream, topic) = (Identifier::named("orders")?, Identifier::named("events")?);
+    /// let mut reader = client
+    ///     .reader(&stream, &topic, &Consumer::new(Identifier::named("audit")?))
+    ///     .partitions(0..8)
+    ///     .build()
+    ///     .await?;
+    /// let batch = reader.next().await?;
+    /// if let Some(checkpoint) = batch.checkpoint() {
+    ///     reader.commit(checkpoint).await?;
+    /// }
+    /// reader.close().await
+    /// # }
+    /// ```
+    ///
+    /// [`PartitionReader`]: crate::prelude::PartitionReader
+    pub fn reader(
+        &self,
+        stream: &Identifier,
+        topic: &Identifier,
+        consumer: &Consumer,
+    ) -> ReaderBuilder {
+        ReaderBuilder::new(
+            self.client.clone(),
+            stream.clone(),
+            topic.clone(),
+            consumer.clone(),
+            self.encryptor.clone(),
+        )
+    }
+
     /// Returns an [`IggyProducerBuilder`].
     ///
     /// Copies the client and the encryptor registered with the [`IggyClient`]
@@ -914,6 +977,21 @@
         })
     }
 
+    /// Sends a raw binary request with a caller-supplied deadline.
+    ///
+    /// The deadline covers client-lock acquisition, routing, admission and retries.
+    /// An earlier enclosing operation deadline still applies. Routing, cancellation
+    /// and error handling follow [`Self::send_binary_request`].
+    pub async fn send_binary_request_with_deadline(
+        &self,
+        code: u32,
+        payload: Bytes,
+        deadline: Instant,
+    ) -> Result<Bytes, IggyError> {
+        iggy_common::with_binary_deadline(deadline.into(), self.send_binary_request(code, payload))
+            .await
+    }
+
     /// Sends a raw request to the replica `replica_id` and returns its raw
     /// reply body.
     ///
@@ -953,7 +1031,7 @@
     ///
     /// # Errors
     /// [`IggyError::InvalidCommand`] before anything is sent if `code` is a
-    /// session-control code, a replicated command, a poll, a readiness wait, a
+    /// session-control code, a replicated command, a poll, a fetch, a
     /// deferred-request cancel, or a consumer-offset read. Writes and partition reads reach their primary through the typed
     /// API and [`send_binary_request`](IggyClient::send_binary_request).
     /// [`IggyError::InvalidIdentifier`] if the roster does not name
@@ -982,6 +1060,25 @@
         })
     }
 
+    /// Sends a raw binary request to a named replica with a caller-supplied deadline.
+    ///
+    /// The deadline covers client-lock acquisition, connection setup and the reply.
+    /// An earlier enclosing operation deadline still applies. The command restrictions
+    /// and error handling follow [`Self::send_binary_request_to_replica`].
+    pub async fn send_binary_request_to_replica_with_deadline(
+        &self,
+        code: u32,
+        payload: Bytes,
+        replica_id: u8,
+        deadline: Instant,
+    ) -> Result<Bytes, IggyError> {
+        iggy_common::with_binary_deadline(
+            deadline.into(),
+            self.send_binary_request_to_replica(code, payload, replica_id),
+        )
+        .await
+    }
+
     /// Invokes a HTTP endpoint and returns the raw response body.
     ///
     /// This is the HTTP counterpart to
@@ -1173,7 +1270,7 @@
     use crate::links::test_support::LOCALHOST;
     use crate::links::{OPERATION_TIMEOUT, ReconnectPolicy};
     use iggy_binary_protocol::codes::{
-        CREATE_STREAM_CODE, GET_STATS_CODE, SYNC_CONSUMER_GROUP_CODE,
+        CREATE_STREAM_CODE, GET_STATS_CODE, PING_CODE, SYNC_CONSUMER_GROUP_CODE,
     };
     use iggy_common::{
         StreamClient, StreamCreateOptions, TcpClientConfig, TcpClientReconnectionConfig,
@@ -1184,6 +1281,213 @@
     use tokio::time::sleep;
 
     #[tokio::test(start_paused = true)]
+    async fn given_a_raw_request_with_a_long_deadline_when_the_reply_is_late_should_return_it() {
+        const CUSTOM_CODE: u32 = u32::MAX;
+        for replica in [None, Some(1)] {
+            let cluster = FakeCluster::new(2);
+            let client = IggyClient::new(ClientWrapper::Tcp(TcpClient::from_links(
+                connected(&cluster, 1).await,
+            )));
+            let destination = replica.unwrap_or(0);
+            let payload = Bytes::from_static(b"request");
+            let response = Bytes::from_static(b"response");
+            cluster.script(
+                &endpoint(destination),
+                CUSTOM_CODE,
+                [Answer::Body(response.to_vec())],
+            );
+            cluster.hold(CUSTOM_CODE);
+            let started = tokio::time::Instant::now();
+            let deadline = started + Duration::from_secs(60);
+            let request = async {
+                match replica {
+                    None => {
+                        client
+                            .send_binary_request_with_deadline(
+                                CUSTOM_CODE,
+                                payload.clone(),
+                                deadline.into_std(),
+                            )
+                            .await
+                    }
+                    Some(replica) => {
+                        client
+                            .send_binary_request_to_replica_with_deadline(
+                                CUSTOM_CODE,
+                                payload.clone(),
+                                replica,
+                                deadline.into_std(),
+                            )
+                            .await
+                    }
+                }
+            };
+            let (result, ()) = tokio::join!(request, async {
+                eventually("the raw request reaches its destination", || {
+                    cluster.count(CUSTOM_CODE) == 1
+                })
+                .await;
+                sleep(Duration::from_secs(45)).await;
+                cluster.release(CUSTOM_CODE);
+            });
+            assert_eq!(result, Ok(response));
+            assert!(tokio::time::Instant::now() - started > OPERATION_TIMEOUT);
+            let received = cluster.received(CUSTOM_CODE);
+            assert_eq!(received.len(), 1);
+            assert_eq!(received[0].endpoint, endpoint(destination));
+            assert_eq!(received[0].body, payload);
+        }
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_a_raw_request_deadline_when_the_wrapper_is_locked_should_expire_without_sending()
+    {
+        for replica in [None, Some(1)] {
+            let cluster = FakeCluster::new(2);
+            let client = IggyClient::new(ClientWrapper::Tcp(TcpClient::from_links(
+                connected(&cluster, 1).await,
+            )));
+            let guard = client.client.write().await;
+            let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
+            let result = match replica {
+                None => {
+                    client
+                        .send_binary_request_with_deadline(
+                            GET_STATS_CODE,
+                            Bytes::new(),
+                            deadline.into_std(),
+                        )
+                        .await
+                }
+                Some(replica) => {
+                    client
+                        .send_binary_request_to_replica_with_deadline(
+                            GET_STATS_CODE,
+                            Bytes::new(),
+                            replica,
+                            deadline.into_std(),
+                        )
+                        .await
+                }
+            };
+            assert_eq!(result, Err(IggyError::TransientNotAccepted));
+            assert_eq!(tokio::time::Instant::now(), deadline);
+            assert_eq!(cluster.count(GET_STATS_CODE), 0);
+            drop(guard);
+        }
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_an_enclosing_deadline_when_a_raw_request_asks_for_longer_should_keep_the_earlier_budget()
+     {
+        for replica in [None, Some(1)] {
+            let cluster = FakeCluster::new(2);
+            let client = IggyClient::new(ClientWrapper::Tcp(TcpClient::from_links(
+                connected(&cluster, 1).await,
+            )));
+            let started = tokio::time::Instant::now();
+            let outer = started + Duration::from_secs(5);
+            let requested = started + Duration::from_secs(60);
+            let result = iggy_common::with_binary_deadline(outer, async {
+                match replica {
+                    None => {
+                        client
+                            .send_binary_request_with_deadline(
+                                GET_STATS_CODE,
+                                Bytes::new(),
+                                requested.into_std(),
+                            )
+                            .await
+                    }
+                    Some(replica) => {
+                        client
+                            .send_binary_request_to_replica_with_deadline(
+                                GET_STATS_CODE,
+                                Bytes::new(),
+                                replica,
+                                requested.into_std(),
+                            )
+                            .await
+                    }
+                }
+            })
+            .await;
+            assert_eq!(result, Err(IggyError::TransientNotCommitted));
+            assert_eq!(tokio::time::Instant::now(), outer);
+            assert_eq!(cluster.count(GET_STATS_CODE), 1);
+            assert!(iggy_common::binary_operation_deadline() > outer);
+        }
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_an_expired_raw_request_deadline_when_called_should_send_nothing() {
+        for replica in [None, Some(1)] {
+            let cluster = FakeCluster::new(2);
+            let client = IggyClient::new(ClientWrapper::Tcp(TcpClient::from_links(
+                connected(&cluster, 1).await,
+            )));
+            let deadline = (tokio::time::Instant::now() - Duration::from_secs(1)).into_std();
+            let result = match replica {
+                None => {
+                    client
+                        .send_binary_request_with_deadline(GET_STATS_CODE, Bytes::new(), deadline)
+                        .await
+                }
+                Some(replica) => {
+                    client
+                        .send_binary_request_to_replica_with_deadline(
+                            GET_STATS_CODE,
+                            Bytes::new(),
+                            replica,
+                            deadline,
+                        )
+                        .await
+                }
+            };
+            assert_eq!(result, Err(IggyError::TransientNotAccepted));
+            assert_eq!(cluster.count(GET_STATS_CODE), 0);
+        }
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_an_unsupported_raw_request_when_refused_should_keep_standard_requests_working() {
+        const CUSTOM_CODE: u32 = u32::MAX;
+        for replica in [None, Some(1)] {
+            let cluster = FakeCluster::new(2);
+            let client = IggyClient::new(ClientWrapper::Tcp(TcpClient::from_links(
+                connected(&cluster, 1).await,
+            )));
+            cluster.script(
+                &endpoint(replica.unwrap_or(0)),
+                CUSTOM_CODE,
+                [Answer::Refuse(IggyError::InvalidCommand)],
+            );
+            let deadline = (tokio::time::Instant::now() + Duration::from_secs(60)).into_std();
+            let result = match replica {
+                None => {
+                    client
+                        .send_binary_request_with_deadline(CUSTOM_CODE, Bytes::new(), deadline)
+                        .await
+                }
+                Some(replica) => {
+                    client
+                        .send_binary_request_to_replica_with_deadline(
+                            CUSTOM_CODE,
+                            Bytes::new(),
+                            replica,
+                            deadline,
+                        )
+                        .await
+                }
+            };
+            assert_eq!(result, Err(IggyError::InvalidCommand));
+            assert_eq!(cluster.count(CUSTOM_CODE), 1);
+            assert_eq!(client.ping().await, Ok(()));
+            assert_eq!(cluster.count(PING_CODE), 1);
+        }
+    }
+
+    #[tokio::test(start_paused = true)]
     async fn given_nested_client_locks_when_reading_should_share_one_deadline() {
         let cluster = FakeCluster::new(1);
         let inner = IggyClient::new(ClientWrapper::Tcp(TcpClient::from_links(
diff --git a/core/sdk/src/clients/consumer.rs b/core/sdk/src/clients/consumer.rs
index 9aad250..1f83e53 100644
--- a/core/sdk/src/clients/consumer.rs
+++ b/core/sdk/src/clients/consumer.rs
@@ -16,88 +16,30 @@
 // under the License.
 
 use crate::client_wrappers::client_wrapper::ClientWrapper;
-use crate::driver::{operation_deadline, with_deadline};
-use crate::links::OPERATION_TIMEOUT;
-use dashmap::DashMap;
+use crate::clients::reader::membership::Membership;
+use crate::clients::reader::progress::{Progress, same_incarnation_and_owner};
+use crate::clients::reader::{Hold, PartitionReader, ReadError};
+use crate::clients::reader_builder::ReaderBuilder;
 use futures::Stream;
-use futures_util::StreamExt;
 use iggy_binary_protocol::primitives::partition_history::PartitionContext;
-use iggy_common::ConsumerPosition;
 use iggy_common::locking::{IggyRwLock, IggyRwLockFn};
 use iggy_common::{
-    AssignmentVersion, Consumer, ConsumerGroupClientState, ConsumerKind, DeferredPollOptions,
-    DiagnosticEvent, EncryptorKind, IdKind, Identifier, IggyDuration, IggyError, IggyMessage,
-    NonZeroIggyDuration, PolledMessages, PollingKind, PollingStrategy, group_cache_key,
+    Checkpoint, CommitAfter, CommitPolicy, Consumer, ConsumerKind, EncryptorKind, IdKind,
+    Identifier, IggyDuration, IggyError, IggyMessage, NonZeroIggyDuration, PollOptions,
+    PolledMessages, PollingKind, PollingStrategy, ReaderStamp,
 };
-use iggy_common::{Client, ConsumerGroupClient, ConsumerOffsetClient, StreamClient, TopicClient};
-use std::collections::{BTreeSet, VecDeque};
+use iggy_common::{StreamClient, TopicClient};
 use std::fmt::{self, Debug, Formatter};
 use std::pin::Pin;
 use std::sync::Arc;
-use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64};
+use std::sync::atomic::{AtomicBool, AtomicU32};
 use std::task::{Context, Poll};
-use tokio::sync::{Mutex, Notify};
-use tokio::task::JoinHandle;
+use std::vec;
 use tokio::time;
-use tokio::time::Instant;
-use tracing::{debug, error, info, trace, warn};
+use tracing::{error, info, warn};
 
 const ORDERING: std::sync::atomic::Ordering = std::sync::atomic::Ordering::SeqCst;
 
-#[derive(Clone, Copy)]
-enum OffsetMutation {
-    Store { partition_id: u32, offset: u64 },
-    StorePosition(ConsumerPosition),
-    Delete { partition_id: Option<u32> },
-}
-
-/// The auto-commit configuration for storing the offset on the server.
-#[derive(Debug, PartialEq, Copy, Clone)]
-pub enum AutoCommit {
-    /// The auto-commit is disabled and the offset must be stored manually by the consumer.
-    Disabled,
-    /// The auto-commit is enabled and the offset is stored on the server after a certain interval.
-    Interval(NonZeroIggyDuration),
-    /// The auto-commit is enabled and the offset is stored on the server after a certain interval or depending on the mode when consuming the messages.
-    IntervalOrWhen(NonZeroIggyDuration, AutoCommitWhen),
-    /// The auto-commit is enabled and the offset is stored on the server after a certain interval or depending on the mode after consuming the messages.
-    ///
-    /// **This will only work with the `IggyConsumerMessageExt` trait when using `consume_messages()`.**
-    IntervalOrAfter(NonZeroIggyDuration, AutoCommitAfter),
-    /// The auto-commit is enabled and the offset is stored on the server depending on the mode when consuming the messages.
-    When(AutoCommitWhen),
-    /// The auto-commit is enabled and the offset is stored on the server depending on the mode after consuming the messages.
-    ///
-    /// **This will only work with the `IggyConsumerMessageExt` trait when using `consume_messages()`.**
-    After(AutoCommitAfter),
-}
-
-/// The auto-commit mode for storing the offset on the server.
-#[derive(Debug, PartialEq, Copy, Clone)]
-pub enum AutoCommitWhen {
-    /// The offset is stored on the server when the messages are received.
-    PollingMessages,
-    /// The offset is stored on the server when all the messages are consumed.
-    ConsumingAllMessages,
-    /// The offset is stored on the server when consuming each message.
-    ConsumingEachMessage,
-    /// The offset is stored on the server when consuming every Nth message.
-    ConsumingEveryNthMessage(u32),
-}
-
-/// The auto-commit mode for storing the offset on the server **after** receiving the messages.
-///
-/// **This will only work with the `IggyConsumerMessageExt` trait when using `consume_messages()`.**
-#[derive(Debug, PartialEq, Copy, Clone)]
-pub enum AutoCommitAfter {
-    /// The offset is stored on the server after all the messages are consumed.
-    ConsumingAllMessages,
-    /// The offset is stored on the server after consuming each message.
-    ConsumingEachMessage,
-    /// The offset is stored on the server after consuming every Nth message.
-    ConsumingEveryNthMessage(u32),
-}
-
 /// A cheap, cloneable view of the state shared with an [`IggyConsumer`].
 ///
 /// Consuming borrows the consumer as `&mut` for the whole run, so reading its getters or
@@ -108,15 +50,14 @@
 /// ID can already have moved on by the time an offset is read for it.
 #[derive(Clone)]
 pub struct IggyConsumerState {
-    client: IggyRwLock<ClientWrapper>,
+    /// The record of the reader of the consumer, so all commits of a consumer share one path.
+    progress: Arc<Progress>,
     consumer: Arc<Consumer>,
     stream_id: Arc<Identifier>,
     topic_id: Arc<Identifier>,
     is_consumer_group: bool,
-    allow_replay: bool,
+    commit: CommitPolicy,
     current_partition_id: Arc<AtomicU32>,
-    last_consumed_offsets: Arc<DashMap<u32, ConsumerPosition>>,
-    last_stored_offsets: Arc<DashMap<u32, ConsumerPosition>>,
 }
 
 impl Debug for IggyConsumerState {
@@ -126,7 +67,7 @@
             .field("stream_id", &self.stream_id)
             .field("topic_id", &self.topic_id)
             .field("is_consumer_group", &self.is_consumer_group)
-            .field("allow_replay", &self.allow_replay)
+            .field("commit", &self.commit)
             .field("current_partition_id", &self.partition_id())
             .finish_non_exhaustive()
     }
@@ -138,22 +79,31 @@
         consumer: Arc<Consumer>,
         stream_id: Arc<Identifier>,
         topic_id: Arc<Identifier>,
-        is_consumer_group: bool,
-        allow_replay: bool,
+        commit: CommitPolicy,
     ) -> Self {
-        Self {
+        let progress = Arc::new(Progress::new(
             client,
+            Arc::clone(&consumer),
+            Arc::clone(&stream_id),
+            Arc::clone(&topic_id),
+            commit,
+        ));
+        Self {
+            progress,
+            is_consumer_group: consumer.kind == ConsumerKind::ConsumerGroup,
             consumer,
             stream_id,
             topic_id,
-            is_consumer_group,
-            allow_replay,
+            commit,
             current_partition_id: Arc::new(AtomicU32::new(0)),
-            last_consumed_offsets: Arc::new(DashMap::new()),
-            last_stored_offsets: Arc::new(DashMap::new()),
         }
     }
 
+    /// The commit policy of the [`PollOptions`] of the consumer.
+    pub fn commit_policy(&self) -> CommitPolicy {
+        self.commit
+    }
+
     /// Returns the current partition ID of the consumer.
     pub fn partition_id(&self) -> u32 {
         self.current_partition_id.load(ORDERING)
@@ -163,21 +113,21 @@
     /// a message from the partition has been consumed.
     /// To get the current partition ID use `partition_id()`
     pub fn get_last_consumed_offset(&self, partition_id: u32) -> Option<u64> {
-        let offset = self.last_consumed_offsets.get(&partition_id)?;
-        Some(offset.offset)
+        self.progress
+            .last_delivered(partition_id)
+            .map(|checkpoint| checkpoint.offset())
     }
 
     /// Returns the last offset successfully stored for this partition, or
     /// `None` until this consumer has stored one.
     /// To get the current partition ID use `partition_id()`
     pub fn get_last_stored_offset(&self, partition_id: u32) -> Option<u64> {
-        let offset = self.last_stored_offsets.get(&partition_id)?;
-        Some(offset.offset)
+        self.progress
+            .last_committed(partition_id)
+            .map(|checkpoint| checkpoint.offset())
     }
 
-    /// Stores the consumer offset on the server either for the current partition or the provided partition ID.
-    ///
-    /// It commits under the context of the latest message consumed from that partition, see
+    /// Stores an offset either for the current partition or the provided partition ID, see
     /// [`IggyConsumer::store_offset`].
     pub async fn store_offset(
         &self,
@@ -185,314 +135,88 @@
         partition_id: Option<u32>,
     ) -> Result<(), IggyError> {
         let partition_id = partition_id.unwrap_or_else(|| self.partition_id());
-        let position = self
-            .last_consumed_offsets
-            .get(&partition_id)
-            .map(|last| ConsumerPosition { offset, ..*last });
-        if let Some(position) = position {
-            return self.store_position(position).await;
-        }
-        // Nothing was consumed from this partition, so there is no captured context to keep.
-        let position = ConsumerPosition {
-            partition_id,
-            offset,
-            context: PartitionContext::default(),
+        let stored = match self.progress.last_delivered(partition_id) {
+            Some(last) => {
+                let stored = Checkpoint::new(partition_id, offset, last.context());
+                self.progress.store(stored).await?;
+                stored
+            }
+            // Nothing was consumed from this partition, so there is no captured context to keep.
+            None => {
+                self.progress.store_offset(partition_id, offset).await?;
+                Checkpoint::new(partition_id, offset, PartitionContext::default())
+            }
         };
-        self.mutate_offset(OffsetMutation::Store {
-            partition_id,
-            offset,
-        })
-        .await?;
-        self.last_stored_offsets.insert(partition_id, position);
+        // The batches that the reader holds and its close would undo the store.
+        if self.commit == CommitPolicy::OnRead {
+            self.progress.read_after(stored);
+        }
         Ok(())
     }
 
-    /// Deletes the consumer offset on the server either for the current partition or the provided partition ID.
+    /// Deletes the stored offset either for the current partition or the provided partition ID,
+    /// see [`IggyConsumer::delete_offset`].
     pub async fn delete_offset(&self, mut partition_id: Option<u32>) -> Result<(), IggyError> {
         // `None` is only resolved server-side for consumer groups. For a standalone consumer
         // explicitly assign the current partition_id.
         if partition_id.is_none() && !self.is_consumer_group {
             partition_id = Some(self.partition_id());
         }
-        // Cancellation can hide a committed delete, so a later store must reach the server.
-        if let Some(partition_id) = partition_id {
-            self.last_stored_offsets.remove(&partition_id);
-        } else {
-            self.last_stored_offsets.clear();
+        self.progress.delete_offset(partition_id).await
+    }
+
+    /// Stores the offset of a checkpoint that the consumer yielded, see [`IggyConsumer::commit`].
+    pub async fn commit(&self, checkpoint: Checkpoint) -> Result<(), IggyError> {
+        if self.commit == CommitPolicy::OnRead {
+            return Err(IggyError::InvalidCommand);
         }
-        self.mutate_offset(OffsetMutation::Delete { partition_id })
-            .await
+        self.progress.commit(checkpoint).await
     }
 
-    /// Store the exact position delivered to the application, even after another poll or rebalance.
-    pub async fn store_position(&self, position: ConsumerPosition) -> Result<(), IggyError> {
-        self.store_consumer_position(position, self.allow_replay)
-            .await
-    }
-
-    async fn store_consumer_position(
-        &self,
-        position: ConsumerPosition,
-        allow_replay: bool,
-    ) -> Result<(), IggyError> {
-        if !allow_replay && self.stored_offset_covers(position) {
-            return Ok(());
-        }
-        self.mutate_offset(OffsetMutation::StorePosition(position))
-            .await?;
-        self.last_stored_offsets
-            .insert(position.partition_id, position);
-        Ok(())
-    }
-
-    async fn mutate_offset(&self, mutation: OffsetMutation) -> Result<(), IggyError> {
-        super::with_client!(self, client => {
-            let deadline = operation_deadline();
-            match self.send_offset_mutation(&client, mutation).await {
-                Err(IggyError::LaneBlocked(op)) => {
-                    if matches!(
-                        client.resolve_operation(op, deadline.into_std()).await,
-                        Err(IggyError::OperationUncertain(_))
-                    ) {
-                        return Err(IggyError::LaneBlocked(op));
-                    }
-                    // The blocker has its own outcome. Re-entering the lane either
-                    // admits this new mutation or reports that it remains blocked.
-                    self.send_offset_mutation(&client, mutation).await
-                }
-                result => result,
-            }
-        })
-    }
-
-    async fn send_offset_mutation(
-        &self,
-        client: &ClientWrapper,
-        mutation: OffsetMutation,
-    ) -> Result<(), IggyError> {
-        match mutation {
-            OffsetMutation::Store {
-                partition_id,
-                offset,
-            } => {
-                client
-                    .store_consumer_offset(
-                        &self.consumer,
-                        &self.stream_id,
-                        &self.topic_id,
-                        Some(partition_id),
-                        offset,
-                    )
-                    .await
-            }
-            OffsetMutation::StorePosition(position) => {
-                client
-                    .store_consumer_position(
-                        &self.consumer,
-                        &self.stream_id,
-                        &self.topic_id,
-                        position,
-                    )
-                    .await
-            }
-            OffsetMutation::Delete { partition_id } => {
-                client
-                    .delete_consumer_offset(
-                        &self.consumer,
-                        &self.stream_id,
-                        &self.topic_id,
-                        partition_id,
-                    )
-                    .await
-            }
-        }
-    }
-
-    /// An uncaptured scalar store cannot prove coverage in any partition history.
-    fn stored_offset_covers(&self, position: ConsumerPosition) -> bool {
-        self.last_stored_offsets
-            .get(&position.partition_id)
-            .is_some_and(|stored| {
-                stored.context.incarnation != 0
-                    && position.context.incarnation != 0
-                    && same_incarnation_and_owner(stored.context, position.context)
-                    && position.offset <= stored.offset
-            })
-    }
-
-    /// The commit tasks have no caller to hand a failure to, so it is logged here. Replay governs
-    /// reading only, so they never move a stored offset back.
-    async fn store_in_background(&self, position: ConsumerPosition) {
-        if let Err(error) = self.store_consumer_position(position, false).await {
-            error!(
-                "Failed to store offset: {} for consumer: {}, partition ID: {}, topic: {}, stream: {}. {error}",
-                position.offset,
-                self.consumer,
-                position.partition_id,
-                self.topic_id,
-                self.stream_id
-            );
-        }
-    }
-
-    fn last_consumed_positions(&self) -> Vec<ConsumerPosition> {
-        self.last_consumed_offsets
-            .iter()
-            .map(|entry| *entry.value())
-            .collect()
-    }
-
-    /// Stores the delivered position of every partition where it differs from the record.
-    ///
-    /// Only for auto-commit-on-poll. The poll commits each whole batch, so the record holds
-    /// the batch end while the application can still be behind it, and the store must bypass
-    /// the skip rule to move the server offset back. `assignment` limits a group member to
-    /// the partitions it still owns; each store also carries the captured partition context.
-    async fn store_delivered_offsets(
-        &self,
-        assignment: Option<&(Arc<ConsumerGroupClientState>, String)>,
-    ) {
-        let owned = assignment.map(|(groups, key)| groups.assignment_snapshot(key).1);
-        for position in self.unstored_delivered_offsets(owned.as_deref()) {
-            if let Err(error) = self.store_consumer_position(position, true).await {
-                error!("Failed to store delivered position: {position:?}. {error}");
-            }
-        }
-    }
-
-    /// The server committed `undelivered`, but the application never took it.
-    /// Recording each commit lets [`Self::store_delivered_offsets`] move the
-    /// offset back to the last delivered message. Without a delivered message,
-    /// a `Next` consumer stores the offset before the first undelivered one,
-    /// because its next run resumes after the stored offset.
-    async fn rewind_undelivered_offsets(
-        &self,
-        undelivered: Vec<PolledMessages>,
-        assignment: Option<&(Arc<ConsumerGroupClientState>, String)>,
-        resumes_next: bool,
-    ) {
-        let owned = assignment.map(|(groups, key)| groups.assignment_snapshot(key).1);
-        let mut rewound = BTreeSet::new();
-        for polled in undelivered {
-            let partition_id = polled.partition_id;
-            let (Some(first), Some(last)) = (polled.messages.first(), polled.messages.last())
-            else {
-                continue;
-            };
-            if owned
-                .as_ref()
-                .is_some_and(|owned| !owned.contains(&partition_id))
-            {
-                continue;
-            }
-            if self.last_consumed_offsets.contains_key(&partition_id) {
-                self.last_stored_offsets.insert(
-                    partition_id,
-                    ConsumerPosition {
-                        partition_id,
-                        offset: last.header.offset,
-                        context: polled.context,
-                    },
-                );
-                continue;
-            }
-            // Batches arrive in offset order, so the first one of a partition
-            // holds the oldest undelivered message.
-            if !resumes_next || !rewound.insert(partition_id) {
-                continue;
-            }
-            let result = match first.header.offset.checked_sub(1) {
-                Some(offset) => {
-                    let position = ConsumerPosition {
-                        partition_id,
-                        offset,
-                        context: polled.context,
-                    };
-                    self.store_consumer_position(position, true).await
-                }
-                None => {
-                    self.last_stored_offsets.remove(&partition_id);
-                    self.mutate_offset(OffsetMutation::Delete {
-                        partition_id: Some(partition_id),
-                    })
-                    .await
-                }
-            };
-            if let Err(error) = result {
-                error!(
-                    "Failed to rewind the offset before undelivered messages in partition: {partition_id}. {error}"
-                );
-            }
-        }
-    }
-
-    fn unstored_delivered_offsets(&self, owned: Option<&[u32]>) -> Vec<ConsumerPosition> {
-        self.last_consumed_positions()
-            .into_iter()
-            .filter(|position| {
-                owned.is_none_or(|owned| owned.contains(&position.partition_id))
-                    && self
-                        .last_stored_offsets
-                        .get(&position.partition_id)
-                        .is_none_or(|stored| *stored != *position)
-            })
-            .collect()
+    /// Counts a checkpoint that the consumer yielded, see [`IggyConsumer::mark`].
+    pub fn mark(&self, checkpoint: Checkpoint) -> Result<(), IggyError> {
+        self.progress.mark(checkpoint)
     }
 }
 
-/// Offsets are comparable only within one partition incarnation and owner. The metadata frontier
-/// is left out: another group's ownership change can advance it between two polls of the same
-/// partition.
-fn same_incarnation_and_owner(left: PartitionContext, right: PartitionContext) -> bool {
-    left.incarnation == right.incarnation && left.owner_generation == right.owner_generation
-}
-
-/// Reads a topic as a stream of messages, using long polling and bounded prefetch.
+/// Reads a topic as a stream of messages through one [`PartitionReader`], with its commit
+/// policy, group create, init retries and a replay filter.
 ///
 /// Call [`Self::init`] before reading and [`Self::shutdown`] before dropping the consumer.
-/// A standalone consumer reads one partition. A group member reads its assignment, and its
-/// assignment refresh continues even when the application stops reading. Fetches share the
-/// client's multiplexed connections with control traffic. At most 16 fetches and readiness
-/// waits run concurrently per consumer, within the client's limit of 64 deferred requests.
-///
-/// A poll returns when at least `min_count` messages are available (one by default),
-/// its byte limit is reached, or `max_wait` expires. `batch_length` is the maximum
-/// message count. The separate request timeout covers routing, waiting and reading.
-/// There is no normal polling interval; errors and reconnects retain retry backoff.
-/// A poll that is still refused when its request timeout ends, for example by a
-/// server at its deferred poll capacity, reaches the application as an error, and
-/// the consumer polls that partition again after
-/// [`polling_retry_interval`](crate::prelude::IggyConsumerBuilder::polling_retry_interval).
+/// A standalone consumer reads the partitions of
+/// [`partitions`](crate::prelude::IggyConsumerBuilder::partitions). A group member reads its
+/// assignment: the reader joins the group, joins again after a session loss, and leaves the group
+/// in [`shutdown`](Self::shutdown). [`PartitionReader`] describes the routing, the prefetch
+/// budget, the errors and the guarantees. The batch that the consumer yields from stays in the
+/// budget until the consumer yields its last message.
 ///
 /// # Positions and commits
 ///
-/// The initial polling strategy is resolved separately for each partition. `Next`
-/// starts after the stored offset, or at zero when no offset exists. Subsequent
-/// fetches use explicit offsets, so a lost reply can be retried without advancing
-/// past unseen messages. Reassignment discards prefetched data. A partition that a
-/// rebalance leaves with its group member continues after the last message that the
-/// application took. The member resolves the starting position again for a partition
-/// that it gets back from another member. After the client starts a new session, the
-/// member resolves it again for every partition. Messages already delivered are
-/// filtered unless replay is enabled. Delivery and durable processing progress are
-/// separate.
+/// [`polling_strategy`](crate::prelude::IggyConsumerBuilder::polling_strategy) is the start of
+/// a partition without a position. `Next` starts after the stored offset, or at zero when no
+/// offset exists. A partition that a rebalance leaves with its group member continues after the
+/// last message that the consumer yielded. Messages already yielded are filtered unless replay is
+/// enabled, or a [`seek`](Self::seek) of their partition asked for them again. Delivery and
+/// durable processing progress are separate.
 ///
-/// The default is [`AutoCommit::Disabled`]. Store offsets only after successfully
-/// processing messages, in partition order. Fetching ahead never commits under this
-/// default. Opting into auto-commit can acknowledge messages before processing.
+/// The [`CommitPolicy`] of [`poll_options`](crate::prelude::IggyConsumerBuilder::poll_options)
+/// decides who stores offsets:
 ///
-/// Under [`AutoCommitWhen::PollingMessages`] the server commits every batch that it
-/// reads, so the consumer does not fetch ahead. It fetches one batch when the
-/// application asks for a message and the buffer is empty. A group member first waits
-/// until one of its partitions holds a message, with one readiness wait per partition
-/// primary. A batch that the server committed reaches the application even after a
-/// reassignment. After its first batch, a partition continues from an explicit offset,
-/// so the consumer retries a lost reply from the same offset. If the consumer shuts down
-/// first, [`shutdown`](Self::shutdown) moves the offset back over the messages that the
-/// application did not take, also for a fetch in flight. A lost reply to the first `Next`
-/// poll of a partition can still skip its batch. So can a lost reply whose partition gets
-/// a new owner before the retry. A new session of the client gives every partition a new
-/// owner.
+/// - `Manual`, the default, stores nothing. Commit [`ReceivedMessage::checkpoint`] with
+///   [`commit`](Self::commit) after processing, in partition order.
+/// - `OnRead` has the server store the offset of each batch that it reads, so the reader reads
+///   only when the application asks for a message and nothing is queued, and then fills its
+///   prefetch budget: one fetch per primary that may wait for messages, and the partitions with
+///   messages left behind spread over the other slots.
+///   [`shutdown`](Self::shutdown) moves the offset back to the last message that the consumer
+///   yielded.
+/// - `Auto` stores the latest counted message of each partition at the interval or the event of
+///   the policy, before a rebalance takes a partition away, and at shutdown. It never stores
+///   the end of a prefetched batch. With [`CommitAfter::Processing`], a message that the stream
+///   yields counts when the stream is polled again or shuts down, and one that
+///   [`consume_messages`](crate::prelude::IggyConsumerMessageExt::consume_messages) hands to its
+///   handler counts once the handler succeeds. With [`CommitAfter::Delivery`] it counts when it
+///   is handed over, and with [`CommitAfter::Marked`] only [`mark`](Self::mark) counts.
 ///
 /// ```rust,no_run
 /// use futures_util::StreamExt;
@@ -502,7 +226,10 @@
 /// # async fn example() -> Result<(), IggyError> {
 /// let client = IggyClient::from_connection_string("iggy://iggy:iggy@localhost:8090")?;
 /// client.connect().await?;
-/// let mut consumer = client.consumer("reader", "stream", "topic", 0)?.build();
+/// let mut consumer = client
+///     .consumer("reader", "stream", "topic")?
+///     .partitions(0..8)
+///     .build();
 /// consumer.init().await?;
 /// while let Some(received) = consumer.next().await {
 ///     let received = match received {
@@ -516,85 +243,81 @@
 ///     if handle(&received.message).await.is_err() {
 ///         break;
 ///     }
-///     consumer.store_position(received.position()).await?;
+///     consumer.commit(received.checkpoint()).await?;
 /// }
 /// consumer.shutdown().await?;
 /// # Ok(())
 /// # }
 /// ```
 ///
-/// # Buffer bounds
-///
-/// Both builders reserve a full possible batch in bytes and messages before sending
-/// each request. The reservation covers in-flight, completed and buffered replies
-/// until the batch is drained or discarded. The byte budget measures encoded poll
-/// replies; decoded message metadata is additionally bounded by the message budget.
-/// Messages retained by application code are outside these SDK buffer limits.
-/// Assignment changes invalidate buffered generations before the next delivery.
-///
 /// # Encryption
 ///
-/// Encryption failures are returned without advancing the fetch position. Storing the
-/// offset of a message that never decrypts, which the log names with its partition,
-/// moves the fetch position past it. Polling
-/// auto-commit with an encryptor is rejected because the server would commit before
-/// decryption. Other auto-commit policies retain their explicit semantics, including
-/// interval commits of delivered messages and `AutoCommitAfter` commits after a
-/// handler returns either success or failure.
+/// A message that does not decrypt comes out as a [`ReadError`] with the continuation after it in
+/// [`skip`](ReadError::skip). The reader reads the message again until [`seek`](Self::seek)
+/// moves past it. `OnRead` with an encryptor is refused, because the server would store the
+/// offset of a batch before it decrypts.
 pub struct IggyConsumer {
-    poll_options: DeferredPollOptions,
-    prefetch_bytes: u32,
-    prefetch_messages: u32,
-    deferred_polls: Option<deferred::DeferredPolls>,
-    fetch_generation: Arc<AtomicU64>,
-    fetch_notify: Arc<Notify>,
-    fetch_demand: Arc<AtomicBool>,
-    buffered_generation: u64,
-    buffered_assignment: Option<AssignmentVersion>,
-    /// The group state changes at which the buffered batch was still current.
-    checked_assignment: Option<u64>,
-    assignment_state: Option<(Arc<ConsumerGroupClientState>, String)>,
-    buffered_capacity: Option<deferred::FetchCapacity>,
-    initialized: bool,
-    uninitialized_poll_warned: bool,
-    shutdown: Arc<AtomicBool>,
-    can_poll: Arc<AtomicBool>,
     client: IggyRwLock<ClientWrapper>,
     consumer_name: String,
     consumer: Arc<Consumer>,
-    is_consumer_group: bool,
-    joined_consumer_group: Arc<AtomicBool>,
-    uncertain_consumer_group_operation: Arc<Mutex<Option<u64>>>,
     stream_id: Arc<Identifier>,
     topic_id: Arc<Identifier>,
-    partition_id: Option<u32>,
+    partitions: Option<Vec<u32>>,
     polling_strategy: PollingStrategy,
-    batch_length: u32,
-    auto_commit: AutoCommit,
-    auto_commit_after_polling: bool,
-    auto_join_consumer_group: bool,
-    create_consumer_group_if_not_exists: bool,
-    state: IggyConsumerState,
-    buffered_messages: VecDeque<IggyMessage>,
-    buffered_context: PartitionContext,
-    buffered_current_offset: u64,
+    poll_options: PollOptions,
+    prefetch_bytes: u32,
+    prefetch_messages: Option<u32>,
+    /// The membership of a group consumer. It outlives a failed `init()`, so the next attempt
+    /// resolves a create or a join of an unknown outcome instead of finding its lane blocked.
+    membership: Option<Arc<Membership>>,
     encryptor: Option<Arc<EncryptorKind>>,
-    /// The latest offset each message trigger asked to commit, per partition. The store task
-    /// drains it, so a burst of triggers costs one round trip per partition instead of one each.
-    pending_commits: Arc<DashMap<u32, ConsumerPosition>>,
-    store_offset_notify: Arc<Notify>,
-    store_offset_task: Option<JoinHandle<()>>,
-    background_commit_task: Option<JoinHandle<()>>,
-    background_commit_notify: Arc<Notify>,
-    events_task: Option<JoinHandle<()>>,
-    store_offset_after_each_message: bool,
-    store_offset_after_all_messages: bool,
-    store_after_every_nth_message: u64,
-    reconnection_retry_interval: NonZeroIggyDuration,
+    polling_retry_interval: NonZeroIggyDuration,
     init_retries: Option<u32>,
     init_retry_interval: NonZeroIggyDuration,
     allow_replay: bool,
     offset_drain_timeout: IggyDuration,
+    state: IggyConsumerState,
+    reader: Option<PartitionReader>,
+    current: Option<CurrentBatch>,
+    initialized: bool,
+    uninitialized_poll_warned: bool,
+    shutdown: AtomicBool,
+    /// Under `CommitAfter::Processing`: the message that the stream yielded last, with whether
+    /// it ends its batch. It counts when the stream is polled again or shuts down.
+    uncounted: Option<(Checkpoint, bool)>,
+}
+
+/// The rest of the batch that the consumer yields one message at a time.
+struct CurrentBatch {
+    messages: vec::IntoIter<IggyMessage>,
+    partition_id: u32,
+    context: PartitionContext,
+    stamp: Option<ReaderStamp>,
+    current_offset: u64,
+    hold: Hold,
+    /// The replay filter skips the messages up to this offset, which the application already
+    /// took in the history of the batch. Offsets rise within a batch, so one read before its
+    /// first message covers the whole batch.
+    delivered_offset: Option<u64>,
+}
+
+impl CurrentBatch {
+    /// `delivered` is the last message that the application took from the partition, `None`
+    /// to hand over every message again.
+    fn new(batch: PolledMessages, hold: Hold, delivered: Option<Checkpoint>) -> Self {
+        let context = batch.context();
+        Self {
+            partition_id: batch.partition_id,
+            context,
+            stamp: batch.stamp(),
+            current_offset: batch.current_offset,
+            messages: batch.messages.into_iter(),
+            hold,
+            delivered_offset: delivered
+                .filter(|delivered| same_incarnation_and_owner(delivered.context(), context))
+                .map(|delivered| delivered.offset()),
+        }
+    }
 }
 
 impl IggyConsumer {
@@ -605,121 +328,83 @@
         consumer: Consumer,
         stream_id: Identifier,
         topic_id: Identifier,
-        partition_id: Option<u32>,
-        poll_options: DeferredPollOptions,
-        prefetch_bytes: u32,
-        prefetch_messages: u32,
+        partitions: Option<Vec<u32>>,
         polling_strategy: PollingStrategy,
-        batch_length: u32,
-        auto_commit: AutoCommit,
-        auto_join_consumer_group: bool,
+        poll_options: PollOptions,
+        prefetch_bytes: u32,
+        prefetch_messages: Option<u32>,
         create_consumer_group_if_not_exists: bool,
         encryptor: Option<Arc<EncryptorKind>>,
-        reconnection_retry_interval: NonZeroIggyDuration,
+        polling_retry_interval: NonZeroIggyDuration,
         init_retries: Option<u32>,
         init_retry_interval: NonZeroIggyDuration,
         allow_replay: bool,
         offset_drain_timeout: IggyDuration,
     ) -> Self {
-        let is_consumer_group = consumer.kind == ConsumerKind::ConsumerGroup;
-        let partition_id = if is_consumer_group && partition_id.is_some() {
-            warn!(
-                "Consumer group member: {consumer_name} ignores the partition set on the builder and reads the server's assignment"
-            );
-            None
-        } else {
-            partition_id
-        };
         let consumer = Arc::new(consumer);
         let stream_id = Arc::new(stream_id);
         let topic_id = Arc::new(topic_id);
         let state = IggyConsumerState::new(
             client.clone(),
-            consumer.clone(),
-            stream_id.clone(),
-            topic_id.clone(),
-            is_consumer_group,
-            allow_replay,
+            Arc::clone(&consumer),
+            Arc::clone(&stream_id),
+            Arc::clone(&topic_id),
+            poll_options.commit,
         );
+        let membership = (consumer.kind == ConsumerKind::ConsumerGroup).then(|| {
+            Arc::new(Membership::new(
+                client.clone(),
+                Arc::clone(&stream_id),
+                Arc::clone(&topic_id),
+                consumer.id.clone(),
+                create_consumer_group_if_not_exists,
+            ))
+        });
         Self {
-            poll_options,
-            prefetch_bytes,
-            prefetch_messages,
-            deferred_polls: None,
-            fetch_generation: Arc::new(AtomicU64::new(0)),
-            fetch_notify: Arc::new(Notify::new()),
-            fetch_demand: Arc::new(AtomicBool::new(false)),
-            buffered_generation: 0,
-            buffered_assignment: None,
-            checked_assignment: None,
-            assignment_state: None,
-            buffered_capacity: None,
-            initialized: false,
-            uninitialized_poll_warned: false,
-            shutdown: Arc::new(AtomicBool::new(false)),
-            is_consumer_group,
-            joined_consumer_group: Arc::new(AtomicBool::new(false)),
-            uncertain_consumer_group_operation: Arc::new(Mutex::new(None)),
-            can_poll: Arc::new(AtomicBool::new(true)),
             client,
             consumer_name,
             consumer,
             stream_id,
             topic_id,
-            partition_id,
+            partitions,
             polling_strategy,
-            state,
-            batch_length,
-            auto_commit,
-            auto_commit_after_polling: matches!(
-                auto_commit,
-                AutoCommit::When(AutoCommitWhen::PollingMessages)
-                    | AutoCommit::IntervalOrWhen(_, AutoCommitWhen::PollingMessages)
-            ),
-            auto_join_consumer_group,
-            create_consumer_group_if_not_exists,
-            buffered_messages: VecDeque::new(),
-            buffered_context: PartitionContext::default(),
-            buffered_current_offset: 0,
+            poll_options,
+            prefetch_bytes,
+            prefetch_messages,
+            membership,
             encryptor,
-            pending_commits: Arc::new(DashMap::new()),
-            store_offset_notify: Arc::new(Notify::new()),
-            store_offset_task: None,
-            background_commit_task: None,
-            background_commit_notify: Arc::new(Notify::new()),
-            events_task: None,
-            store_offset_after_each_message: matches!(
-                auto_commit,
-                AutoCommit::When(AutoCommitWhen::ConsumingEachMessage)
-                    | AutoCommit::IntervalOrWhen(_, AutoCommitWhen::ConsumingEachMessage)
-            ),
-            store_offset_after_all_messages: matches!(
-                auto_commit,
-                AutoCommit::When(AutoCommitWhen::ConsumingAllMessages)
-                    | AutoCommit::IntervalOrWhen(_, AutoCommitWhen::ConsumingAllMessages)
-            ),
-            store_after_every_nth_message: match auto_commit {
-                AutoCommit::When(AutoCommitWhen::ConsumingEveryNthMessage(n))
-                | AutoCommit::IntervalOrWhen(_, AutoCommitWhen::ConsumingEveryNthMessage(n)) => {
-                    n as u64
-                }
-                _ => 0,
-            },
-            reconnection_retry_interval,
+            polling_retry_interval,
             init_retries,
             init_retry_interval,
             allow_replay,
             offset_drain_timeout,
+            state,
+            reader: None,
+            current: None,
+            initialized: false,
+            uninitialized_poll_warned: false,
+            shutdown: AtomicBool::new(false),
+            uncounted: None,
         }
     }
 
-    pub(crate) fn auto_commit(&self) -> AutoCommit {
-        self.auto_commit
-    }
-
     /// Whether the consumer resumes after its stored offsets but never stores one itself.
     pub(crate) fn relies_on_manual_commits(&self) -> bool {
-        self.auto_commit == AutoCommit::Disabled && self.polling_strategy.kind == PollingKind::Next
+        self.poll_options.commit == CommitPolicy::Manual
+            && self.polling_strategy.kind == PollingKind::Next
+    }
+
+    /// When a message counts under `CommitPolicy::Auto`.
+    pub(crate) fn counts_after(&self) -> Option<CommitAfter> {
+        match self.poll_options.commit {
+            CommitPolicy::Auto(auto) => Some(auto.after),
+            CommitPolicy::Manual | CommitPolicy::OnRead => None,
+        }
+    }
+
+    /// Counts the message at `checkpoint` under `CommitPolicy::Auto`.
+    pub(crate) fn count(&self, checkpoint: Checkpoint, ends_batch: bool) {
+        self.state.progress.count(checkpoint, 1, ends_batch);
     }
 
     /// Whether the client is up or comes back without the caller.
@@ -744,12 +429,12 @@
         &self.stream_id
     }
 
-    /// Returns the partition the most recent poll response with messages came from, or `0` before
-    /// the first one.
+    /// Returns the partition of the batch that the consumer yields from, or `0` before the
+    /// first one.
     ///
-    /// For a consumer group the value changes over time, as the server hands different partitions
-    /// to this member. To commit for the partition a message came from, pass
-    /// [`ReceivedMessage::position`] to [`store_position()`](Self::store_position) instead.
+    /// The value changes over time when the consumer reads several partitions. To commit for the
+    /// partition a message came from, pass [`ReceivedMessage::checkpoint`] to
+    /// [`commit()`](Self::commit) instead.
     pub fn partition_id(&self) -> u32 {
         self.state.partition_id()
     }
@@ -760,36 +445,29 @@
     }
 
     /// Stores an offset on the server, marking every message up to and including it as consumed.
+    /// It is the raw counterpart to [`commit()`](Self::commit): it always sends, also an offset
+    /// below the stored one, and it needs no checkpoint.
     ///
-    /// This is the manual counterpart to [`AutoCommit`] and is meant for
-    /// [`AutoCommit::Disabled`].
+    /// Pass `None` as `partition_id` to use [`partition_id()`](Self::partition_id), which can
+    /// already point at another partition. Prefer passing [`ReceivedMessage::partition_id`].
     ///
-    /// Pass `None` as `partition_id` to use [`partition_id()`](Self::partition_id), which for a
-    /// consumer group can already point at another partition. Prefer passing
-    /// [`ReceivedMessage::partition_id`].
+    /// The offset is stored under the context of the latest message consumed from that
+    /// partition, or without a context when nothing was consumed from it. An offset taken from a
+    /// message of an older incarnation or owner is therefore stored under the newer context. A
+    /// refusal of the captured context, such as [`IggyError::HistoryUnavailable`] after the
+    /// partition was recreated, repeats until a message is consumed under the new context.
     ///
-    /// An offset that is not ahead of the last one stored in the same captured context is
-    /// skipped and `Ok(())` is returned without a request, unless the consumer was built with
-    /// [`allow_replay`](crate::prelude::IggyConsumerBuilder::allow_replay). A store without a
-    /// captured context is always sent and cannot suppress a later captured position.
-    ///
-    /// The offset is committed under the context of the latest message consumed from that
-    /// partition, or without a captured context when nothing was consumed from it. An offset taken
-    /// from a message of an older incarnation or owner is therefore committed under the newer
-    /// context. To commit under the exact context of a message, pass [`ReceivedMessage::position`]
-    /// to [`store_position()`](Self::store_position). A refusal of the captured context, such as
-    /// [`IggyError::HistoryUnavailable`] after the partition was recreated, repeats until a message
-    /// is consumed under the new context.
-    ///
-    /// To start over from the first message, delete the stored offset with
-    /// [`delete_offset()`](Self::delete_offset) after [`shutdown()`](Self::shutdown) and build a new
-    /// consumer. A new consumer built with [`PollingStrategy::offset()`] re-reads from any point.
+    /// With [`CommitPolicy::OnRead`], a stored offset also moves the reading position: the
+    /// consumer drops the messages of the partition that it holds and reads on from the message
+    /// after `offset`, as a [`seek`](Self::seek) there does, and the offset counts as the last
+    /// consumed message, so [`shutdown()`](Self::shutdown) keeps it. A skip past a message is
+    /// therefore never delivered again.
     ///
     /// On binary transports, a retained operation blocking this partition is resolved before
     /// the requested store. Recovery and the new store share one operation deadline. The store
     /// goes on after a proved commit or committed rejection of that operation, and the client keeps
     /// the proved outcome for the caller that owns the operation, see
-    /// [`Client::resolve_operation`].
+    /// [`Client::resolve_operation`](crate::prelude::Client::resolve_operation).
     ///
     /// # Errors
     ///
@@ -804,14 +482,34 @@
         self.state.store_offset(offset, partition_id).await
     }
 
-    /// Commit a message's captured partition incarnation and owner using
-    /// [`ReceivedMessage::position`]. A position from a deleted incarnation or a previous owner is
-    /// refused without updating replacement progress.
+    /// Stores the offset of a checkpoint that this consumer yielded, which covers every earlier
+    /// message of its partition. It keeps the partition history that delivered the message, so a
+    /// commit after a recreation or an ownership change is refused instead of landing on the
+    /// newer partition.
+    ///
+    /// A checkpoint of an older generation of its partition, after a [`seek`](Self::seek) or a
+    /// new assignment, or at or below the last commit of its generation, stores nothing.
     ///
     /// # Errors
-    /// Uses the recovery and error contract of [`Self::store_offset`].
-    pub async fn store_position(&self, position: ConsumerPosition) -> Result<(), IggyError> {
-        self.state.store_position(position).await
+    ///
+    /// [`IggyError::InvalidCommand`] with [`CommitPolicy::OnRead`], and for a checkpoint that
+    /// this consumer did not yield. Otherwise the recovery and error contract of
+    /// [`Self::store_offset`].
+    pub async fn commit(&self, checkpoint: Checkpoint) -> Result<(), IggyError> {
+        self.state.commit(checkpoint).await
+    }
+
+    /// Counts a checkpoint that this consumer yielded, under [`CommitPolicy::Auto`]. It stores
+    /// nothing now: the policy stores the latest counted position of the partition. With
+    /// [`CommitAfter::Marked`] only marks count, so messages processed concurrently count once
+    /// they are done. [`IggyConsumerState::mark`] does the same from another task.
+    ///
+    /// # Errors
+    ///
+    /// [`IggyError::InvalidCommand`] without `CommitPolicy::Auto`, and for a checkpoint that
+    /// this consumer did not yield.
+    pub fn mark(&self, checkpoint: Checkpoint) -> Result<(), IggyError> {
+        self.state.mark(checkpoint)
     }
 
     /// Returns the offset of the last message this consumer handed over for the given partition,
@@ -829,8 +527,8 @@
     /// `None` as `partition_id` means [`partition_id()`](Self::partition_id) for a standalone
     /// consumer. A binary consumer group requires an explicit partition. Starting a delete
     /// clears this consumer's stored-offset record, while retaining its consumed position, so
-    /// auto-commit or [`shutdown()`](Self::shutdown) can store that position again. When starting
-    /// over, call it after [`shutdown()`](Self::shutdown).
+    /// a commit policy or [`shutdown()`](Self::shutdown) can store that position again. When
+    /// starting over, call it after [`shutdown()`](Self::shutdown).
     ///
     /// # Errors
     ///
@@ -845,118 +543,80 @@
     /// `None` until this consumer observes a successful commit for the partition.
     ///
     /// The value is this consumer's own record of what it committed, kept in memory rather than
-    /// read back from the server.
-    /// With auto-commit-on-poll, the server can be ahead by the batch that is on its way to
-    /// this consumer.
+    /// read back from the server. With [`CommitPolicy::OnRead`], it is the end of the last
+    /// batch that the consumer took from its reader.
     pub fn get_last_stored_offset(&self, partition_id: u32) -> Option<u64> {
         self.state.get_last_stored_offset(partition_id)
     }
 
+    /// Sets the next read position of `partition`, as [`PartitionReader::seek`] does, and drops
+    /// the rest of the batch of `partition` that the consumer yields from. With
+    /// [`CommitPolicy::OnRead`], a seek to an offset above 0 first stores the offset before it,
+    /// so a restart resumes at the new position. Every other seek stores nothing.
+    ///
+    /// # Errors
+    ///
+    /// [`IggyError::InvalidConfiguration`] before [`init()`](Self::init) and after
+    /// [`shutdown()`](Self::shutdown). Otherwise the errors of [`PartitionReader::seek`].
+    pub async fn seek(&mut self, partition: u32, start: PollingStrategy) -> Result<(), IggyError> {
+        let Some(reader) = self.reader.as_mut() else {
+            return Err(IggyError::InvalidConfiguration);
+        };
+        reader.seek(partition, start).await?;
+        if self
+            .current
+            .as_ref()
+            .is_some_and(|current| current.partition_id == partition)
+        {
+            self.current = None;
+        }
+        Ok(())
+    }
+
     /// Initializes the consumer and makes it ready to poll messages.
     ///
     /// This must be called before the consumer can start polling messages. Calling it again on an
     /// initialized consumer does nothing and returns immediately.
     ///
     /// Initialization ensures that:
+    /// - the configuration can run, checked before anything is sent.
     /// - the consumer's `stream_id` and `topic_id` exist on the server.
     ///   It retries for a number of `init_retries` (defaults to `None`, which is treated as no
     ///   retry) with `init_retry_interval` (defaults to one
     ///   second) time in between retries. Both can be set together through
     ///   [`IggyConsumerBuilder::init_retries`](crate::prelude::IggyConsumerBuilder::init_retries).
-    /// - the consumer subscribes to connection lifecycle events ([`DiagnosticEvent`]) in order to
-    ///   update its state, should it receive a shutdown, connected, disconnected, log in or log out event.
-    /// - if the consumer belongs to a group and `auto_join_consumer_group` is enabled, the group is
-    ///   initialized if it does not exist yet, and the consumer joins that group.
-    /// - the tasks that store the offset on the server are spawned.
-    ///
-    /// # Lifecycle events
-    ///
-    /// Calling init spawns a background task that listens for lifecycle changes ([`DiagnosticEvent`]s) of the
-    /// client connection. It runs until [`shutdown()`](Self::shutdown) or until the client shuts
-    /// down. After every other event, polling is enabled only while the client is signed in at
-    /// that moment, because an event can arrive after one that reports a later state. A client
-    /// that only the caller can bring back is polled anyway, so each poll fails with the reason.
-    /// - [`DiagnosticEvent::Connected`], [`DiagnosticEvent::Disconnected`] and
-    ///   [`DiagnosticEvent::SignedOut`] forget the group membership. A group member whose
-    ///   membership is gone rejoins its group on the next poll, before the request goes out. A
-    ///   failed rejoin is yielded as a poll error and tried again on the poll after.
-    /// - [`DiagnosticEvent::Shutdown`] terminates the background task listening for lifecycle
-    ///   changes, and every later poll fails with [`IggyError::ClientShutdown`]. It does not flush
-    ///   in-flight commits; that only happens when [`shutdown()`](Self::shutdown) itself is called.
-    ///
-    /// # Storing offsets
-    ///
-    /// When the consumer commits is decided by
-    /// [`auto_commit()`](crate::prelude::IggyConsumerBuilder::auto_commit), see
-    /// [Positions and commits](IggyConsumer#positions-and-commits). `init()` spawns the
-    /// tasks behind it:
-    /// - An interval task, only for the variants that carry an interval ([`AutoCommit::Interval`],
-    ///   [`AutoCommit::IntervalOrWhen`], [`AutoCommit::IntervalOrAfter`]). Every tick it stores the
-    ///   reading position of every partition read so far.
-    /// - An offset store task, always. It sends the commits queued by the [`AutoCommitWhen`] and
-    ///   [`AutoCommitAfter`] triggers, keeping only the latest queued offset per partition, and
-    ///   stays idle under [`AutoCommit::Disabled`].
-    ///
-    /// Both skip an offset that is not ahead of this consumer's own record of what it stored
-    /// ([`get_last_stored_offset()`](Self::get_last_stored_offset)) in the same captured context.
-    /// An earlier scalar store without a captured context cannot suppress these commits.
-    /// Combined with auto-commit-on-poll, the interval task instead stores the delivered position
-    /// wherever it differs from that record. This moves the server offset back to the delivered
-    /// position while the rest of the fetched batch is already committed. A group member does this
-    /// only for the partitions of its current assignment.
+    /// - the [`PartitionReader`] of the consumer starts. A group member creates its group if it
+    ///   does not exist yet and
+    ///   [`create_consumer_group_if_not_exists`](crate::prelude::IggyConsumerBuilder::create_consumer_group_if_not_exists)
+    ///   is on, and joins it.
+    /// - under [`CommitPolicy::Auto`], the reader runs the task that stores the counted
+    ///   positions, see [Positions and commits](IggyConsumer#positions-and-commits).
     ///
     /// # Errors
     ///
-    /// - [`IggyError::InvalidConfiguration`] when the consumer has an encryptor and
-    ///   [`auto_commit()`](crate::prelude::IggyConsumerBuilder::auto_commit) is
-    ///   [`AutoCommitWhen::PollingMessages`], checked before anything is sent. See
-    ///   [Encryption](IggyConsumer#encryption).
-    /// - [`IggyError::InvalidConfiguration`] when the prefetch limits cannot hold one reply, and
-    ///   the error of [`DeferredPollOptions::validate`] when the poll options do not fit
-    ///   `batch_length`. Both are logged with the values that failed.
+    /// - The errors of [`ReaderBuilder::build`]: the refusals of the configuration, and of a
+    ///   consumer group over HTTP. They are checked before anything is sent, except the
+    ///   transport check of a group and the join.
     /// - [`IggyError::StreamNameNotFound`], [`IggyError::StreamIdNotFound`] or [`IggyError::TopicNameNotFound`],
     ///   [`IggyError::TopicIdNotFound`] when the stream or the topic still does not exist once the retries are
     ///   exhausted.
     /// - [`IggyError::ConsumerGroupNameNotFound`] when the consumer group does not exist
     ///   and its auto creation is disabled.
-    /// - [`IggyError::FeatureUnavailable`] for a consumer group over HTTP, before the join.
     /// - Any error returned by the server while looking up the stream or the topic, or
     ///   while creating or joining the consumer group. Such an error ends initialization
     ///   immediately instead of consuming a retry.
+    /// - [`IggyError::OperationUncertain`] for a create or a join of the group with an unknown
+    ///   outcome. Call `init()` again: it resolves that operation before it joins.
     pub async fn init(&mut self) -> Result<(), IggyError> {
-        if let Err(error) = self.poll_options.validate(self.batch_length) {
-            error!(
-                "Consumer: {} has poll options: {:?} that do not fit batch length: {}. {error}",
-                self.consumer_name, self.poll_options, self.batch_length
-            );
-            return Err(error);
-        }
-        if self.prefetch_slots() == 0 {
-            error!(
-                "Consumer: {} cannot reserve one reply. Prefetch messages: {} must cover batch length: {}, and prefetch bytes: {} must cover poll max bytes: {}.",
-                self.consumer_name,
-                self.prefetch_messages,
-                self.batch_length,
-                self.prefetch_bytes,
-                self.poll_options.max_bytes
-            );
-            return Err(IggyError::InvalidConfiguration);
-        }
         if self.initialized {
             return Ok(());
         }
+        self.validate()?;
 
         let stream_id = self.stream_id.clone();
         let topic_id = self.topic_id.clone();
         let consumer_name = &self.consumer_name;
 
-        if self.encryptor.is_some() && self.auto_commit_after_polling {
-            error!(
-                "Consumer: {consumer_name} has an encryptor and auto-commit on polling. That commits a batch before it is decrypted, so a batch that fails to decrypt would be lost. Pick another auto-commit setting."
-            );
-            return Err(IggyError::InvalidConfiguration);
-        }
-
         info!(
             "Initializing consumer: {consumer_name} for stream: {stream_id}, topic: {topic_id}..."
         );
@@ -1037,383 +697,52 @@
             }
         }
 
-        // A retried init() after a failed join must not leave the earlier task behind.
-        if let Some(previous) = self.events_task.replace(self.subscribe_events().await) {
-            previous.abort();
-        }
-        // A transport without group state must fail before the join, or the
-        // member would hold partitions that it never reads.
-        let group_state = if self.is_consumer_group {
-            Some(self.client.read().await.deferred_group_state().await?)
-        } else {
-            None
-        };
-        self.init_consumer_group().await?;
-        self.assignment_state = group_state.map(|state| {
-            (
-                state,
-                group_cache_key(&self.stream_id, &self.topic_id, &self.consumer.id),
-            )
-        });
-
-        match self.auto_commit {
-            AutoCommit::Interval(interval)
-            | AutoCommit::IntervalOrWhen(interval, _)
-            | AutoCommit::IntervalOrAfter(interval, _) => {
-                self.background_commit_task = Some(self.store_offsets_in_background(interval));
-            }
-            _ => {}
-        }
-
-        self.store_offset_task = Some(self.store_pending_commits_in_background());
-
+        self.reader = Some(self.reader_builder().build().await?);
         self.initialized = true;
-        self.deferred_polls = Some(deferred::DeferredPolls::start(self));
         info!(
-            "Consumer: {consumer_name} has been initialized for stream: {}, topic: {}.",
-            self.stream_id, self.topic_id
+            "Consumer: {} has been initialized for stream: {}, topic: {}.",
+            self.consumer_name, self.stream_id, self.topic_id
         );
         Ok(())
     }
 
-    fn store_offsets_in_background(&self, interval: NonZeroIggyDuration) -> JoinHandle<()> {
-        let state = self.state.clone();
-        let shutdown = self.shutdown.clone();
-        let notify = self.background_commit_notify.clone();
-        let commit_on_poll = self.auto_commit_after_polling;
-        let assignment = self.assignment_state.clone();
-        tokio::spawn(async move {
-            loop {
-                // Wait for the task until either the interval has passed or
-                // the task is explicitly notified, which happens when shutdown() is called.
-                tokio::select! {
-                    _ = time::sleep(interval.get_duration()) => {}
-                    _ = notify.notified() => {}
-                }
-
-                // Checked before storing: `shutdown()` runs its own final flush as a
-                // group member and then leaves, so a store past this point would hit
-                // a group we've since left. After a bare `Drop` nothing flushes.
-                if shutdown.load(ORDERING) {
-                    trace!("Shutdown signal received, stopping background offset storage");
-                    break;
-                }
-                if commit_on_poll {
-                    state.store_delivered_offsets(assignment.as_ref()).await;
-                    continue;
-                }
-                for position in state.last_consumed_positions() {
-                    state.store_in_background(position).await;
-                }
-            }
-        })
+    /// The checks that need no I/O, so a configuration that cannot run fails before the lookups.
+    fn validate(&self) -> Result<(), IggyError> {
+        self.reader_builder().into_parts()?;
+        Ok(())
     }
 
-    /// Sends the commits queued by the message triggers of `poll_next` and `consume_messages`.
-    /// The interval task and the poll request's own `auto_commit` flag are the other commit paths.
-    fn store_pending_commits_in_background(&self) -> JoinHandle<()> {
-        let state = self.state.clone();
-        let pending_commits = self.pending_commits.clone();
-        let shutdown = self.shutdown.clone();
-        let notify = self.store_offset_notify.clone();
-        tokio::spawn(async move {
-            loop {
-                notify.notified().await;
-                // Keys first, so no map guard is held across a round trip. An offset queued
-                // meanwhile stays in the map and the permit its trigger leaves wakes the next turn.
-                let partitions: Vec<u32> =
-                    pending_commits.iter().map(|entry| *entry.key()).collect();
-                for partition_id in partitions {
-                    let Some((_, position)) = pending_commits.remove(&partition_id) else {
-                        continue;
-                    };
-                    state.store_in_background(position).await;
-                }
-                if shutdown.load(ORDERING) && pending_commits.is_empty() {
-                    break;
-                }
-            }
-        })
-    }
-
-    /// Queues a commit for the store task. A later offset for the same partition replaces a
-    /// queued one that has not been sent yet.
-    pub(crate) fn send_store_offset(&self, position: ConsumerPosition) {
-        if !self.initialized || self.shutdown.load(ORDERING) {
-            error!(
-                ?position,
-                consumer = self.consumer_name,
-                "Offset was not queued because the consumer is not running"
-            );
-            return;
-        }
-        self.pending_commits.insert(position.partition_id, position);
-        self.store_offset_notify.notify_one();
-    }
-
-    async fn init_consumer_group(&self) -> Result<(), IggyError> {
-        if !self.is_consumer_group {
-            return Ok(());
-        }
-
-        if !self.auto_join_consumer_group {
-            warn!("Auto join consumer group is disabled");
-            return Ok(());
-        }
-        tracing::debug!(
-            "Initializing consumer group for stream ID: {}, topic ID: {}, consumer ID: {}",
-            self.stream_id,
-            self.topic_id,
-            self.consumer
-        );
-
-        Self::initialize_consumer_group(
+    fn reader_builder(&self) -> ReaderBuilder {
+        let mut builder = ReaderBuilder::new(
             self.client.clone(),
-            self.create_consumer_group_if_not_exists,
-            self.stream_id.clone(),
-            self.topic_id.clone(),
-            &self.consumer_name,
-            self.joined_consumer_group.clone(),
-            self.uncertain_consumer_group_operation.clone(),
+            Identifier::clone(&self.stream_id),
+            Identifier::clone(&self.topic_id),
+            Consumer::clone(&self.consumer),
+            self.encryptor.clone(),
         )
-        .await
+        .start(self.polling_strategy)
+        .options(self.poll_options)
+        .prefetch_bytes(self.prefetch_bytes)
+        .progress(Arc::clone(&self.state.progress))
+        .retry_interval(self.polling_retry_interval.get_duration());
+        if let Some(membership) = &self.membership {
+            builder = builder.membership(Arc::clone(membership));
+        }
+        if let Some(partitions) = &self.partitions {
+            builder = builder.partitions(partitions.iter().copied());
+        }
+        if let Some(prefetch_messages) = self.prefetch_messages {
+            builder = builder.prefetch_messages(prefetch_messages);
+        }
+        builder
     }
 
-    /// Keeps the polling flags in step with the connection. Joining the group again after a
-    /// reconnect is left to the poll path, which retries it and reports a failure as a poll error.
-    async fn subscribe_events(&self) -> JoinHandle<()> {
-        trace!("Subscribing to diagnostic events");
-        let mut receiver;
-        {
-            let client = self.client.read().await;
-            receiver = client.subscribe_events().await;
-        }
-
-        let client = self.client.clone();
-        let can_poll = self.can_poll.clone();
-        let joined_consumer_group = self.joined_consumer_group.clone();
-        let generation = Arc::clone(&self.fetch_generation);
-        let notify = Arc::clone(&self.fetch_notify);
-
-        tokio::spawn(async move {
-            while let Some(event) = receiver.next().await {
-                trace!("Received diagnostic event: {event}");
-                match event {
-                    DiagnosticEvent::Shutdown => {
-                        warn!("Consumer has been shutdown");
-                        joined_consumer_group.store(false, ORDERING);
-                        can_poll.store(false, ORDERING);
-                        generation.fetch_add(1, ORDERING);
-                        notify.notify_one();
-                        break;
-                    }
-                    DiagnosticEvent::Connected => {
-                        trace!("Connected to the server");
-                        joined_consumer_group.store(false, ORDERING);
-                    }
-                    DiagnosticEvent::Disconnected => {
-                        joined_consumer_group.store(false, ORDERING);
-                        warn!("Disconnected from the server");
-                    }
-                    DiagnosticEvent::SignedIn => {}
-                    DiagnosticEvent::SignedOut => {
-                        joined_consumer_group.store(false, ORDERING);
-                    }
-                }
-                // Links publish from several tasks, so an event can arrive after one that
-                // reports a later state. The state of the client decides instead.
-                let authenticated = client.read().await.is_authenticated().await;
-                can_poll.store(authenticated, ORDERING);
-                generation.fetch_add(1, ORDERING);
-                notify.notify_one();
-            }
-        })
-    }
-
-    async fn initialize_consumer_group(
-        client: IggyRwLock<ClientWrapper>,
-        create_consumer_group_if_not_exists: bool,
-        stream_id: Arc<Identifier>,
-        topic_id: Arc<Identifier>,
-        consumer_name: &str,
-        joined_consumer_group: Arc<AtomicBool>,
-        uncertain_consumer_group_operation: Arc<Mutex<Option<u64>>>,
-    ) -> Result<(), IggyError> {
-        if joined_consumer_group.load(ORDERING) {
-            return Ok(());
-        }
-
-        let mut uncertain_operation = uncertain_consumer_group_operation.lock().await;
-        let client = client.read().await;
-        if let Some(op) = *uncertain_operation {
-            let result = client
-                .resolve_operation(op, operation_deadline().into_std())
-                .await;
-            if matches!(result, Err(IggyError::OperationUncertain(_))) {
-                return result;
-            }
-            *uncertain_operation = None;
-            match result {
-                Ok(())
-                | Err(IggyError::OperationNotRetained(_))
-                | Err(IggyError::ConsumerGroupNameAlreadyExists(_, _)) => {}
-                Err(error) => return Err(error),
-            }
-        }
-        // A proved join can belong to an earlier session, so establish membership again.
-        let consumer_group_id = Identifier::named(consumer_name)?;
-        trace!(
-            "Validating consumer group: {consumer_group_id} for topic: {topic_id}, stream: {stream_id}"
-        );
-        if client
-            .get_consumer_group(&stream_id, &topic_id, &consumer_group_id)
-            .await?
-            .is_none()
-        {
-            if !create_consumer_group_if_not_exists {
-                error!("Consumer group does not exist and auto-creation is disabled.");
-                let topic_identifier = Identifier::from_identifier(&topic_id);
-                return Err(IggyError::ConsumerGroupNameNotFound(
-                    consumer_name.to_owned(),
-                    topic_identifier,
-                ));
-            }
-
-            info!(
-                "Creating consumer group: {consumer_group_id} for topic: {topic_id}, stream: {stream_id}"
-            );
-            match client
-                .create_consumer_group(&stream_id, &topic_id, consumer_name)
-                .await
-            {
-                Ok(_) => {}
-                Err(IggyError::ConsumerGroupNameAlreadyExists(_, _)) => {}
-                Err(error) => {
-                    if let IggyError::OperationUncertain(op) = &error {
-                        *uncertain_operation = Some(*op);
-                    }
-                    error!(
-                        "Failed to create consumer group {consumer_group_id} for topic: {topic_id}, stream: {stream_id}: {error}"
-                    );
-                    return Err(error);
-                }
-            }
-        }
-
-        info!(
-            "Joining consumer group: {consumer_group_id} for topic: {topic_id}, stream: {stream_id}",
-        );
-        if let Err(error) = client
-            .join_consumer_group(&stream_id, &topic_id, &consumer_group_id)
-            .await
-        {
-            joined_consumer_group.store(false, ORDERING);
-            if let IggyError::OperationUncertain(op) = &error {
-                *uncertain_operation = Some(*op);
-            }
-            error!(
-                "Failed to join consumer group: {consumer_group_id} for topic: {topic_id}, stream: {stream_id}: {error}"
-            );
-            return Err(error);
-        }
-
-        joined_consumer_group.store(true, ORDERING);
-        info!(
-            "Joined consumer group: {consumer_group_id} for topic: {topic_id}, stream: {stream_id}"
-        );
-        Ok(())
-    }
-
-    fn prefetch_slots(&self) -> usize {
-        if self.batch_length == 0 || self.poll_options.max_bytes == 0 {
-            return 0;
-        }
-        (self.prefetch_messages / self.batch_length)
-            .min(self.prefetch_bytes / self.poll_options.max_bytes)
-            .min(crate::driver::MAX_DEFERRED_REQUESTS_PER_CONSUMER as u32) as usize
-    }
-
-    /// Locks the group state only after it changed since the buffered batch
-    /// was last found current.
-    fn buffered_assignment_is_current(&mut self) -> bool {
-        let Some((state, key)) = &self.assignment_state else {
-            return true;
-        };
-        let changes = state.assignment_changes();
-        if self.checked_assignment == Some(changes) {
-            return true;
-        }
-        let current = self.buffered_assignment.is_some_and(|assignment| {
-            state.is_current_assignment(key, assignment, self.state.partition_id())
-        });
-        if current {
-            self.checked_assignment = Some(changes);
-        }
-        current
-    }
-}
-
-/// A single message handed over by an [`IggyConsumer`].
-#[derive(Debug)]
-pub struct ReceivedMessage {
-    /// Partition incarnation and owner captured by the poll. Retain it for delayed manual commits.
-    pub context: PartitionContext,
-    /// The message itself, with its payload already decrypted when the client uses an encryptor.
-    ///
-    /// Commit [`Self::position`] with [`IggyConsumer::store_position`] to retain
-    /// the incarnation and owner even if another poll observes a newer context.
-    pub message: IggyMessage,
-    /// The offset of the newest message in the partition at the time it was polled.
-    ///
-    /// Comparing it with `message.header.offset` shows how far this consumer lags behind the end
-    /// of the partition. It is a snapshot taken per request, so it does not change while the
-    /// buffered messages of that request are handed over.
-    pub current_offset: u64,
-    /// The partition this message was read from.
-    ///
-    /// For a consumer group this varies between messages, since the server hands different
-    /// partitions to the same member.
-    pub partition_id: u32,
-}
-
-impl ReceivedMessage {
-    /// The position to commit for this message with [`IggyConsumer::store_position`]. It keeps the
-    /// partition incarnation and owner that delivered the message, so a commit after a recreation
-    /// or an ownership change is refused instead of landing on the newer partition.
-    #[must_use]
-    pub const fn position(&self) -> ConsumerPosition {
-        ConsumerPosition {
-            partition_id: self.partition_id,
-            offset: self.message.header.offset,
-            context: self.context,
-        }
-    }
-
-    /// Creates a received message from a message, the partition head at poll time and the
-    /// partition it was read from.
-    pub fn new(
-        message: IggyMessage,
-        current_offset: u64,
-        partition_id: u32,
-        context: PartitionContext,
-    ) -> Self {
-        Self {
-            context,
-            message,
-            current_offset,
-            partition_id,
-        }
-    }
-}
-
-/// Yields messages one at a time, from the buffer first and from a fresh poll once it is empty.
-///
-/// See [`IggyConsumer`] for how a poll waits, and [Positions and commits](IggyConsumer#positions-and-commits)
-/// for where reading continues after an error.
-impl Stream for IggyConsumer {
-    type Item = Result<ReceivedMessage, IggyError>;
-
-    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
+    /// The next message, with whether it ends its batch. It counts nothing: the stream and
+    /// `consume_messages` count each message as their commit policy says.
+    pub(crate) fn poll_message(
+        &mut self,
+        cx: &mut Context<'_>,
+    ) -> Poll<Option<Result<(ReceivedMessage, bool), ReadError>>> {
         if self.shutdown.load(ORDERING) {
             return Poll::Ready(None);
         }
@@ -1426,2513 +755,225 @@
                     self.consumer_name
                 );
             }
-            return Poll::Ready(Some(Err(IggyError::InvalidConfiguration)));
+            return Poll::Ready(Some(Err(ReadError::new(
+                None,
+                IggyError::InvalidConfiguration,
+            ))));
         }
         loop {
-            // The server committed a batch of a committing fetch, so it stays.
-            if !self.auto_commit_after_polling
-                && !self.buffered_messages.is_empty()
-                && (self.buffered_generation != self.fetch_generation.load(ORDERING)
-                    || !self.buffered_assignment_is_current())
-            {
-                self.buffered_messages.clear();
-                self.buffered_capacity = None;
+            if let Some((received, ends_batch)) = self.yield_current() {
+                return Poll::Ready(Some(Ok((received, ends_batch))));
             }
-            let partition_id = self.state.partition_id();
-            while let Some(message) = self.buffered_messages.pop_front() {
-                let offset = message.header.offset;
-                if !self.allow_replay
-                    && self
-                        .state
-                        .last_consumed_offsets
-                        .get(&partition_id)
-                        .is_some_and(|consumed| {
-                            same_incarnation_and_owner(consumed.context, self.buffered_context)
-                                && offset <= consumed.offset
-                        })
-                {
-                    continue;
-                }
-                self.state.last_consumed_offsets.insert(
-                    partition_id,
-                    ConsumerPosition {
-                        partition_id,
-                        offset,
-                        context: self.buffered_context,
-                    },
-                );
-                let last = self.buffered_messages.is_empty();
-                if self.store_offset_after_each_message
-                    || (self.store_after_every_nth_message > 0
-                        && offset % self.store_after_every_nth_message == 0)
-                    || (self.store_offset_after_all_messages && last)
-                {
-                    self.send_store_offset(ConsumerPosition {
-                        partition_id,
-                        offset: message.header.offset,
-                        context: self.buffered_context,
-                    });
-                }
-                if last {
-                    self.buffered_capacity = None;
-                }
-                return Poll::Ready(Some(Ok(ReceivedMessage::new(
-                    message,
-                    self.buffered_current_offset,
-                    partition_id,
-                    self.buffered_context,
-                ))));
-            }
-            self.buffered_capacity = None;
-            if self.deferred_polls.is_none() {
-                self.deferred_polls = Some(deferred::DeferredPolls::start(&self));
-            }
-            let received = self
-                .deferred_polls
-                .as_mut()
-                .expect("fetch worker initialized")
-                .poll_recv(cx);
-            match received {
-                Poll::Pending => {
-                    if self.auto_commit_after_polling && !self.fetch_demand.swap(true, ORDERING) {
-                        self.fetch_notify.notify_one();
-                    }
-                    return Poll::Pending;
-                }
-                Poll::Ready(None) => {
-                    self.deferred_polls = None;
-                    return Poll::Ready(Some(Err(IggyError::ClientShutdown)));
-                }
-                Poll::Ready(Some(batch)) => {
-                    if batch.generation != self.fetch_generation.load(ORDERING)
-                        && (batch.result.is_err() || !self.auto_commit_after_polling)
-                    {
-                        continue;
-                    }
-                    match batch.result {
-                        Err(error) => return Poll::Ready(Some(Err(error))),
-                        Ok(polled) => {
-                            if polled.messages.is_empty() {
-                                continue;
-                            }
-                            self.state
-                                .current_partition_id
-                                .store(polled.partition_id, ORDERING);
-                            if self.auto_commit_after_polling
-                                && let Some(last) = polled.messages.last()
-                            {
-                                self.state.last_stored_offsets.insert(
-                                    polled.partition_id,
-                                    ConsumerPosition {
-                                        partition_id: polled.partition_id,
-                                        offset: last.header.offset,
-                                        context: polled.context,
-                                    },
-                                );
-                            }
-                            self.buffered_context = polled.context;
-                            self.buffered_current_offset = polled.current_offset;
-                            self.buffered_messages = polled.messages.into();
-                            self.buffered_generation = batch.generation;
-                            self.buffered_assignment = batch.assignment;
-                            self.checked_assignment = None;
-                            self.buffered_capacity = Some(batch.capacity);
-                        }
-                    }
+            let Some(reader) = self.reader.as_mut() else {
+                return Poll::Ready(None);
+            };
+            match reader.poll_held(cx) {
+                Poll::Pending => return Poll::Pending,
+                Poll::Ready(Err(error)) => return Poll::Ready(Some(Err(error))),
+                Poll::Ready(Ok((batch, hold))) => {
+                    self.state
+                        .current_partition_id
+                        .store(batch.partition_id, ORDERING);
+                    let delivered = if self.allow_replay {
+                        None
+                    } else {
+                        self.state.progress.replay_floor(batch.partition_id)
+                    };
+                    self.current = Some(CurrentBatch::new(batch, hold, delivered));
                 }
             }
         }
     }
+
+    /// The next message of the current batch past the replay filter, recorded as delivered. The
+    /// batch leaves the budget with its last message.
+    fn yield_current(&mut self) -> Option<(ReceivedMessage, bool)> {
+        let current = self.current.as_mut()?;
+        // The server stored the offset of an `OnRead` batch, so only a seek drops it, which
+        // the hold also tells.
+        if !current.hold.is_current() {
+            self.current = None;
+            return None;
+        }
+        let yielded = current.messages.find_map(|message| {
+            let offset = message.header.offset;
+            let replayed = current
+                .delivered_offset
+                .is_some_and(|delivered| offset <= delivered);
+            (!replayed).then(|| ReceivedMessage {
+                message,
+                current_offset: current.current_offset,
+                partition_id: current.partition_id,
+                checkpoint: Checkpoint::stamped(
+                    current.partition_id,
+                    offset,
+                    current.context,
+                    current.stamp,
+                ),
+            })
+        });
+        let ends_batch = current.messages.as_slice().is_empty();
+        if ends_batch {
+            self.current = None;
+        }
+        let received = yielded?;
+        if let Some(reader) = &self.reader {
+            reader.delivered(&received.checkpoint);
+        }
+        Some((received, ends_batch))
+    }
+}
+
+/// A single message handed over by an [`IggyConsumer`].
+#[derive(Debug)]
+pub struct ReceivedMessage {
+    /// The message itself, with its payload already decrypted when the client uses an encryptor.
+    ///
+    /// Commit [`Self::checkpoint`] with [`IggyConsumer::commit`] to retain the incarnation and
+    /// owner even if another poll observes a newer context.
+    pub message: IggyMessage,
+    /// The offset of the newest message in the partition at the time it was polled.
+    ///
+    /// Comparing it with `message.header.offset` shows how far this consumer lags behind the end
+    /// of the partition. It is a snapshot taken per request, so it does not change while the
+    /// messages of that request are handed over.
+    pub current_offset: u64,
+    /// The partition this message was read from.
+    ///
+    /// For a consumer that reads several partitions this varies between messages.
+    pub partition_id: u32,
+    checkpoint: Checkpoint,
+}
+
+impl ReceivedMessage {
+    /// The position after this message, to commit it and every earlier message of its partition
+    /// with [`IggyConsumer::commit`]. It keeps the partition incarnation and owner that delivered
+    /// the message, so a commit after a recreation or an ownership change is refused instead of
+    /// landing on the newer partition.
+    #[must_use]
+    pub const fn checkpoint(&self) -> Checkpoint {
+        self.checkpoint
+    }
+
+    /// Creates a received message from a message, the partition head at poll time, the
+    /// partition it was read from and the context of its partition history. No consumer yielded
+    /// it, so [`IggyConsumer::commit`] refuses its checkpoint.
+    pub fn new(
+        message: IggyMessage,
+        current_offset: u64,
+        partition_id: u32,
+        context: PartitionContext,
+    ) -> Self {
+        let checkpoint = Checkpoint::new(partition_id, message.header.offset, context);
+        Self {
+            message,
+            current_offset,
+            partition_id,
+            checkpoint,
+        }
+    }
+}
+
+/// Yields messages one at a time, from the current batch first and from the reader once it is
+/// empty.
+///
+/// See [`IggyConsumer`] for how the reader reads, and [`PartitionReader`] for where reading
+/// continues after an error.
+impl Stream for IggyConsumer {
+    type Item = Result<ReceivedMessage, ReadError>;
+
+    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
+        let consumer = self.get_mut();
+        consumer.count_uncounted();
+        let polled = consumer.poll_message(cx);
+        if let Poll::Ready(Some(Ok((received, ends_batch)))) = &polled {
+            match consumer.counts_after() {
+                Some(CommitAfter::Delivery) => consumer.count(received.checkpoint, *ends_batch),
+                Some(CommitAfter::Processing) => {
+                    consumer.uncounted = Some((received.checkpoint, *ends_batch));
+                }
+                Some(CommitAfter::Marked) | None => {}
+            }
+        }
+        polled.map_ok(|(received, _)| received)
+    }
 }
 
 impl IggyConsumer {
-    /// Shuts the consumer down.
+    /// Shuts the consumer down. Await it before dropping the consumer: a dropped consumer
+    /// stops its reader without storing or rewinding anything and stays in its group until the
+    /// session ends.
     ///
-    /// Specifically, run shutdown and await before dropping the consumer to
-    /// - finish storing the offsets that are currently in-flight.
-    ///   The interval task and the offset store task (see [`init()`](Self::init)) can both have
-    ///   commits in flight. The consumer waits for `offset_drain_timeout` on each in turn before
-    ///   forcing it to abort. Under auto-commit-on-poll, a fetch in flight first gets the same
-    ///   time to hand its batch over. The store below then moves the offset back over it.
-    /// - commit the reading position of every partition where it is ahead of this consumer's own
-    ///   record of what it stored, unless [`auto_commit()`] is [`AutoCommit::Disabled`]. Under
-    ///   auto-commit-on-poll, the poll admitted progress for the whole batch. This
-    ///   store requests the last message handed over, so the next run resumes after it when the
-    ///   store succeeds. On binary transports, final stores and any blocker recovery share one
-    ///   operation deadline. HTTP retains its configured request timeout.
-    /// - leave the consumer group, if this consumer is a group member. This lets the server give its partitions to
-    ///   the remaining members immediately instead of waiting for the connection to time out.
-    /// - stop the task watching the connection lifecycle.
-    ///
-    /// [`auto_commit()`]: crate::prelude::IggyConsumerBuilder::auto_commit
+    /// - Under [`CommitPolicy::Auto`], the message that the stream yielded last counts under
+    ///   [`CommitAfter::Processing`], and the latest counted position of each partition is
+    ///   stored. The stores wait at most
+    ///   [`offset_drain_timeout`](crate::prelude::IggyConsumerBuilder::offset_drain_timeout),
+    ///   and a failure is logged.
+    /// - The reader closes with [`PartitionReader::close`]: with [`CommitPolicy::OnRead`] it
+    ///   moves the offset back to the last message that the consumer yielded, and a group
+    ///   member leaves its group, so the server gives its partitions to the remaining members
+    ///   at once instead of after the connection times out. The close cancels each `OnRead`
+    ///   read in flight and takes its one reply, which tells whether the server stored, so a
+    ///   read that waits on the server ends at once. Over HTTP, which has no cancel, it waits
+    ///   until the deadline of that read.
     ///
     /// # Errors
     ///
-    /// Returns `Ok(())` even when the final commits or the group leave failed, since those
-    /// failures are logged and do not leave anything for the caller to undo. The
-    /// [`Result`] is part of the signature for forward compatibility.
+    /// The errors of [`PartitionReader::close`]: the first failed store of the `OnRead` close,
+    /// or else `PartitionLaneBlocked` for a partition that another call on the client left with
+    /// an unknown outcome. Explicitly abandon that operation on the client before starting a
+    /// replacement consumer. Group-leave failures are logged.
     pub async fn shutdown(&mut self) -> Result<(), IggyError> {
-        // Swap so background tasks see that the consumer got shut down.
         if self.shutdown.swap(true, ORDERING) {
             return Ok(());
         }
-
-        let grace = self
-            .auto_commit_after_polling
-            .then(|| self.offset_drain_timeout.get_duration());
-        let undelivered = match self.deferred_polls.take() {
-            Some(polls) => polls.stop(grace).await,
-            None => Vec::new(),
-        };
-        self.buffered_messages.clear();
-        self.buffered_capacity = None;
-
+        self.current = None;
         info!("Shutting down consumer: {}...", self.consumer_name);
+        self.count_uncounted();
 
-        // Drain the background commit tasks while still a group member, before
-        // leaving below. Otherwise a store they send afterward hits a group
-        // we've already left.
-        self.background_commit_notify.notify_one();
-
-        // A background_commit_task exists, if auto_commit is configured with an interval option.
-        if let Some(mut task) = self.background_commit_task.take()
-            && time::timeout(self.offset_drain_timeout.get_duration(), &mut task)
-                .await
-                .is_err()
-        {
-            // Still running past the bound: abort it rather than leaving it
-            // detached, so it can't send a stale store after we leave below.
-            task.abort();
-            warn!(
-                "Timed out waiting for the background offset-commit task to stop for consumer: {}, aborted",
-                self.consumer_name
-            );
-        }
-
-        // Wakes the store task, which sends what is still queued and exits on the shutdown flag.
-        self.store_offset_notify.notify_one();
-        if let Some(mut task) = self.store_offset_task.take()
-            && time::timeout(self.offset_drain_timeout.get_duration(), &mut task)
-                .await
-                .is_err()
-        {
-            task.abort();
-            warn!(
-                "Timed out draining pending consumer offset stores for consumer: {}, aborted",
-                self.consumer_name
-            );
-        }
-
+        let progress = &self.state.progress;
+        let unstored = progress.unstored_delivered(None);
         if self.relies_on_manual_commits()
-            && self.state.last_stored_offsets.is_empty()
-            && !self.state.last_consumed_offsets.is_empty()
+            && !unstored.is_empty()
+            && unstored
+                .iter()
+                .all(|delivered| progress.last_committed(delivered.partition_id()).is_none())
         {
             warn!(
-                "Consumer: {} delivered messages but stored no offset, so the next run reads them again. Store offsets after processing, or enable auto-commit.",
+                "Consumer: {} delivered messages but stored no offset, so the next run reads them again. Commit checkpoints after processing, or set CommitPolicy::Auto in the poll options.",
                 self.consumer_name
             );
         }
 
-        if self.auto_commit != AutoCommit::Disabled {
-            // Replay governs reading only: this flush must never move a stored offset back.
-            let allow_replay = false;
-            with_deadline(Instant::now() + OPERATION_TIMEOUT, async {
-                if self.auto_commit_after_polling {
-                    let assignment = self.assignment_state.as_ref();
-                    self.state
-                        .rewind_undelivered_offsets(
-                            undelivered,
-                            assignment,
-                            self.polling_strategy.kind == PollingKind::Next,
-                        )
-                        .await;
-                    self.state.store_delivered_offsets(assignment).await;
-                    return;
-                }
-                for position in self.state.last_consumed_positions() {
-                    if let Err(error) = self
-                        .state
-                        .store_consumer_position(position, allow_replay)
-                        .await
-                    {
-                        warn!(?position, %error, "Final consumer checkpoint was not acknowledged");
-                    }
-                }
-            })
-            .await;
-        }
-
-        if self.is_consumer_group && self.joined_consumer_group.load(ORDERING) {
-            let group_id = self.consumer.id.clone();
-            trace!(
-                "Leaving consumer group: {group_id} for stream: {}, topic: {}",
-                self.stream_id, self.topic_id
-            );
-
-            let client = self.client.read().await;
-            // Cleared either way: this consumer is torn down regardless of
-            // whether the broker confirmed the leave.
-            self.joined_consumer_group.store(false, ORDERING);
-            if let Err(error) = client
-                .leave_consumer_group(&self.stream_id, &self.topic_id, &group_id)
-                .await
-            {
-                // Expected on clean teardown after an explicit leave (member
-                // not found) or when the group was deleted underneath the
-                // consumer, so this is debug, not a warning.
-                debug!(
-                    "Failed to leave consumer group: {group_id} for stream: {}, topic: {}. {error}",
-                    self.stream_id, self.topic_id
-                );
-            }
-        }
-
-        if let Some(task) = self.events_task.take() {
-            task.abort();
-        }
-
-        info!("Consumer: {} has been shut down.", self.consumer_name);
-        Ok(())
-    }
-}
-
-/// Stops the background tasks. Commits already queued still go out, nothing else is flushed and
-/// the consumer group is not left. Await [`IggyConsumer::shutdown`] first.
-impl Drop for IggyConsumer {
-    fn drop(&mut self) {
-        self.shutdown.store(true, ORDERING);
-        self.background_commit_notify.notify_one();
-        self.store_offset_notify.notify_one();
-        if let Some(task) = self.events_task.take() {
-            task.abort();
-        }
-        trace!(
-            "Consumer {} has been dropped, shutdown signal sent",
-            self.consumer_name
-        );
-    }
-}
-
-pub(crate) mod deferred;
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-    use crate::client_wrappers::client_wrapper::ClientWrapper;
-    use crate::clients::consumer_builder::IggyConsumerBuilder;
-    use crate::consumer_ext::{IggyConsumerMessageExt, MessageConsumer};
-    use crate::links::ReconnectPolicy;
-    use crate::links::fake_cluster::{
-        Answer, FakeCluster, TEST_BUDGET, connected, connected_with, endpoint, eventually, policy,
-    };
-    use crate::tcp::tcp_client::TcpClient;
-    use iggy_binary_protocol::batch::{BATCH_HEADER_SIZE, BATCH_MESSAGE_HEADER_SIZE, BatchHeader};
-    use iggy_binary_protocol::codes::{
-        CREATE_CONSUMER_GROUP_CODE, DELETE_CONSUMER_OFFSET_CODE, GET_CONSUMER_GROUP_CODE,
-        JOIN_CONSUMER_GROUP_CODE, LOGIN_REGISTER_CODE, POLL_MESSAGES_DEFERRED_CODE,
-        STORE_CONSUMER_OFFSET_CODE, SYNC_CONSUMER_GROUP_CODE,
-    };
-    use iggy_binary_protocol::consensus::EvictionReason;
-    use iggy_binary_protocol::requests::consumer_offsets::{
-        DeleteConsumerOffsetRequest, StoreConsumerOffsetRequest,
-    };
-    use iggy_binary_protocol::responses::consumer_groups::{
-        ConsumerGroupDetailsResponse, ConsumerGroupResponse, SyncConsumerGroupResponse,
-    };
-    use iggy_binary_protocol::responses::messages::{
-        PartitionRouteResponse, PollMessagesResponseHeader,
-    };
-    use iggy_binary_protocol::{PartitionContext, WireDecode, WireEncode, WireName};
-    use iggy_common::locking::IggyRwLockFn;
-    use iggy_common::{
-        Aes256GcmEncryptor, BinaryTransport, TcpClientReconnectionConfig, UserClient,
-    };
-    use std::iter::repeat_n;
-    use std::str::FromStr;
-    use std::task::Waker;
-    use std::time::Duration;
-    use tokio::time::sleep;
-    use tokio::time::timeout;
-    use tracing::field::Field;
-    use tracing::span::{Attributes, Id, Record};
-    use tracing::{Event, Level, Metadata, Subscriber};
-
-    const POLL_RETRY_INTERVAL: Duration = Duration::from_millis(10);
-    const POLL_TIMEOUT: Duration = Duration::from_secs(2);
-
-    struct RejectMessages;
-
-    impl MessageConsumer for RejectMessages {
-        async fn consume(&self, _message: ReceivedMessage) -> Result<(), IggyError> {
-            panic!("a blocked poll must not deliver a message")
-        }
-    }
-
-    /// Records the offset of each message. With a `shutdown` sender, it stops the consumption
-    /// after the first one.
-    struct RecordMessages {
-        offsets: std::sync::Mutex<Vec<u64>>,
-        shutdown: std::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>,
-    }
-
-    impl MessageConsumer for RecordMessages {
-        async fn consume(&self, message: ReceivedMessage) -> Result<(), IggyError> {
-            self.offsets
-                .lock()
-                .unwrap()
-                .push(message.message.header.offset);
-            if let Some(shutdown) = self.shutdown.lock().unwrap().take() {
-                shutdown.send(()).unwrap();
-            }
-            Ok(())
-        }
-    }
-
-    #[tokio::test(start_paused = true)]
-    async fn given_a_blocked_poll_when_consuming_callbacks_should_return_the_retained_operation() {
-        const OPERATION: u64 = 42;
-        let mut consumer = builder().auto_commit(AutoCommit::Disabled).build();
-        consumer.initialized = true;
-        consumer.deferred_polls = Some(deferred::DeferredPolls::with_error(
-            &consumer,
-            IggyError::LaneBlocked(OPERATION),
-        ));
-        let (_shutdown, receiver) = tokio::sync::oneshot::channel();
-
-        let result = timeout(
-            POLL_TIMEOUT,
-            consumer.consume_messages(&RejectMessages, receiver),
-        )
-        .await;
-
-        assert_eq!(result, Ok(Err(IggyError::LaneBlocked(OPERATION))));
-    }
-
-    pub(super) fn builder_for(consumer: Consumer) -> IggyConsumerBuilder {
-        builder_on(TcpClient::default(), consumer)
-    }
-
-    fn builder_on(client: TcpClient, consumer: Consumer) -> IggyConsumerBuilder {
-        IggyConsumerBuilder::new(
-            IggyRwLock::new(ClientWrapper::Tcp(client)),
-            "consumer".to_owned(),
-            consumer,
-            Identifier::numeric(1).unwrap(),
-            Identifier::numeric(1).unwrap(),
-            None,
-            None,
-        )
-    }
-
-    fn builder() -> IggyConsumerBuilder {
-        builder_for(Consumer::new(Identifier::numeric(1).unwrap()))
-    }
-
-    async fn assert_stream_terminates_after_shutdown(consumer: Consumer) {
-        let mut consumer = builder_for(consumer)
-            .partition(Some(1))
-            .batch_length(1)
-            .auto_commit(AutoCommit::Disabled)
-            .build();
-        consumer.initialized = true;
-        consumer.buffered_messages.extend([
-            IggyMessage::from_str("a").unwrap(),
-            IggyMessage::from_str("b").unwrap(),
-        ]);
-        let mut context = Context::from_waker(Waker::noop());
-
-        assert!(matches!(
-            Pin::new(&mut consumer).poll_next(&mut context),
-            Poll::Ready(Some(Ok(_)))
-        ));
-
-        consumer.shutdown().await.unwrap();
-
-        assert!(consumer.buffered_messages.is_empty());
-        assert!(matches!(
-            Pin::new(&mut consumer).poll_next(&mut context),
-            Poll::Ready(None)
-        ));
-    }
-
-    #[tokio::test]
-    async fn standalone_consumer_should_stop_yielding_messages_after_shutdown() {
-        assert_stream_terminates_after_shutdown(Consumer::new(Identifier::numeric(1).unwrap()))
-            .await;
-    }
-
-    #[tokio::test]
-    async fn consumer_group_should_stop_yielding_messages_after_shutdown() {
-        assert_stream_terminates_after_shutdown(Consumer::group(Identifier::numeric(1).unwrap()))
-            .await;
-    }
-
-    #[tokio::test]
-    async fn consumer_group_should_not_create_fetch_worker_after_shutdown() {
-        let mut consumer = builder_for(Consumer::group(Identifier::numeric(1).unwrap()))
-            .auto_commit(AutoCommit::Disabled)
-            .build();
-        let mut context = Context::from_waker(Waker::noop());
-
-        consumer.shutdown().await.unwrap();
-
-        assert!(matches!(
-            Pin::new(&mut consumer).poll_next(&mut context),
-            Poll::Ready(None)
-        ));
-        assert!(consumer.deferred_polls.is_none());
-    }
-
-    #[test]
-    fn group_member_should_ignore_the_partition_set_on_the_builder() {
-        let consumer = builder_for(Consumer::group(Identifier::numeric(1).unwrap()))
-            .partition(Some(1))
-            .build();
-
-        assert_eq!(consumer.partition_id, None);
-    }
-
-    #[test]
-    fn standalone_consumer_should_keep_the_partition_set_on_the_builder() {
-        let consumer = builder().partition(Some(1)).build();
-
-        assert_eq!(consumer.partition_id, Some(1));
-    }
-
-    pub(super) fn message_at(offset: u64) -> IggyMessage {
-        let mut message = IggyMessage::from_str("payload").unwrap();
-        message.header.offset = offset;
-        message
-    }
-
-    /// Hands over `messages` as one buffered batch read from `partition_id`.
-    fn hand_over_batch(consumer: &mut IggyConsumer, partition_id: u32, messages: Vec<IggyMessage>) {
-        consumer.initialized = true;
-        consumer
-            .state
-            .current_partition_id
-            .store(partition_id, ORDERING);
-        consumer.buffered_messages = VecDeque::from(messages);
-        let mut context = Context::from_waker(Waker::noop());
-        while !consumer.buffered_messages.is_empty() {
-            assert!(matches!(
-                Pin::new(&mut *consumer).poll_next(&mut context),
-                Poll::Ready(Some(Ok(_)))
-            ));
-        }
-    }
-
-    #[tokio::test]
-    async fn group_member_should_drop_the_rest_of_its_batch_once_the_assignment_changes() {
-        let mut consumer = builder_for(Consumer::group(Identifier::numeric(1).unwrap()))
-            .auto_commit(AutoCommit::Disabled)
-            .build();
-        let state = Arc::new(ConsumerGroupClientState::new());
-        let key = "1|1|1".to_owned();
-        let identifier = Identifier::numeric(1).unwrap();
-        state.register_group(
-            key.clone(),
-            identifier.clone(),
-            identifier.clone(),
-            identifier,
-        );
-        state.set_assignment(key.clone(), 1, vec![3]);
-        consumer.assignment_state = Some((Arc::clone(&state), key.clone()));
-        consumer.buffered_assignment = Some(AssignmentVersion {
-            session: state.session_generation(),
-            generation: 1,
-        });
-        consumer.initialized = true;
-        consumer.state.current_partition_id.store(3, ORDERING);
-        consumer.buffered_messages =
-            VecDeque::from(vec![message_at(10), message_at(11), message_at(12)]);
-        let mut context = Context::from_waker(Waker::noop());
-        for _ in 0..2 {
-            assert!(matches!(
-                Pin::new(&mut consumer).poll_next(&mut context),
-                Poll::Ready(Some(Ok(_)))
-            ));
-        }
-
-        state.set_assignment(key, 2, vec![3]);
-        assert!(Pin::new(&mut consumer).poll_next(&mut context).is_pending());
-        assert!(consumer.buffered_messages.is_empty());
-        assert_eq!(consumer.get_last_consumed_offset(3), Some(11));
-    }
-
-    #[tokio::test]
-    async fn given_a_committed_batch_when_the_generation_or_assignment_changes_should_hand_over_the_rest()
-     {
-        let mut consumer = builder_for(Consumer::group(Identifier::numeric(1).unwrap()))
-            .auto_commit(AutoCommit::When(AutoCommitWhen::PollingMessages))
-            .build();
-        let state = Arc::new(ConsumerGroupClientState::new());
-        let key = "1|1|1".to_owned();
-        let identifier = Identifier::numeric(1).unwrap();
-        state.register_group(
-            key.clone(),
-            identifier.clone(),
-            identifier.clone(),
-            identifier,
-        );
-        state.set_assignment(key.clone(), 1, vec![3]);
-        consumer.assignment_state = Some((Arc::clone(&state), key.clone()));
-        consumer.buffered_assignment = Some(AssignmentVersion {
-            session: state.session_generation(),
-            generation: 1,
-        });
-        consumer.initialized = true;
-        consumer.state.current_partition_id.store(3, ORDERING);
-        consumer.buffered_messages =
-            VecDeque::from(vec![message_at(10), message_at(11), message_at(12)]);
-        assert_eq!(poll_offset(&mut consumer), Poll::Ready(Some(Ok(10))));
-
-        consumer.fetch_generation.fetch_add(1, ORDERING);
-        assert_eq!(
-            poll_offset(&mut consumer),
-            Poll::Ready(Some(Ok(11))),
-            "the server committed the batch, so a new fetch generation keeps it"
-        );
-        state.set_assignment(key, 2, vec![3]);
-        assert_eq!(
-            poll_offset(&mut consumer),
-            Poll::Ready(Some(Ok(12))),
-            "the server committed the batch, so a new assignment keeps it"
-        );
-    }
-
-    #[test]
-    fn given_a_reread_up_to_the_delivered_offset_when_polled_should_hand_over_only_newer_messages()
-    {
-        const DELIVERED: u64 = 11;
-        let mut consumer = builder_for(Consumer::default())
-            .auto_commit(AutoCommit::When(AutoCommitWhen::PollingMessages))
-            .build();
-        buffer_reread(&mut consumer, DELIVERED);
-
-        assert_eq!(
-            poll_offset(&mut consumer),
-            Poll::Ready(Some(Ok(DELIVERED + 1))),
-            "the application already took the messages up to {DELIVERED}"
-        );
-    }
-
-    #[test]
-    fn given_replay_when_a_reread_up_to_the_delivered_offset_is_polled_should_hand_it_over_again() {
-        const DELIVERED: u64 = 11;
-        let mut consumer = builder_for(Consumer::default())
-            .auto_commit(AutoCommit::When(AutoCommitWhen::PollingMessages))
-            .allow_replay()
-            .build();
-        buffer_reread(&mut consumer, DELIVERED);
-
-        for offset in DELIVERED - 1..=DELIVERED + 1 {
-            assert_eq!(
-                poll_offset(&mut consumer),
-                Poll::Ready(Some(Ok(offset))),
-                "replay hands over every message that the batch read"
-            );
-        }
-    }
-
-    /// Buffers a batch that reads the messages around `delivered` again, in the
-    /// partition history in which the application took `delivered`.
-    fn buffer_reread(consumer: &mut IggyConsumer, delivered: u64) {
-        let position = position_at(delivered, 1);
-        consumer.initialized = true;
-        consumer
-            .state
-            .current_partition_id
-            .store(position.partition_id, ORDERING);
-        consumer
-            .state
-            .last_consumed_offsets
-            .insert(position.partition_id, position);
-        consumer.buffered_context = position.context;
-        consumer.buffered_messages = VecDeque::from(vec![
-            message_at(delivered - 1),
-            message_at(delivered),
-            message_at(delivered + 1),
-        ]);
-    }
-
-    /// The offset of the message that one poll of `consumer` hands over.
-    fn poll_offset(consumer: &mut IggyConsumer) -> Poll<Option<Result<u64, IggyError>>> {
-        Pin::new(consumer)
-            .poll_next(&mut Context::from_waker(Waker::noop()))
-            .map(|polled| {
-                polled.map(|received| received.map(|received| received.message.header.offset))
-            })
-    }
-
-    #[test]
-    fn group_member_tracks_delivered_positions_separately_per_partition() {
-        let mut consumer = builder_for(Consumer::group(Identifier::numeric(1).unwrap()))
-            .polling_strategy(PollingStrategy::first())
-            .auto_commit(AutoCommit::Disabled)
-            .build();
-
-        hand_over_batch(&mut consumer, 3, vec![message_at(10), message_at(11)]);
-        assert_eq!(consumer.get_last_consumed_offset(3), Some(11));
-        assert_eq!(consumer.polling_strategy, PollingStrategy::first());
-
-        hand_over_batch(&mut consumer, 4, vec![message_at(7)]);
-        assert_eq!(consumer.get_last_consumed_offset(4), Some(7));
-        assert_eq!(consumer.get_last_consumed_offset(3), Some(11));
-    }
-
-    #[test]
-    fn default_consumer_does_not_commit_delivered_messages() {
-        let mut consumer = builder_for(Consumer::group(Identifier::numeric(1).unwrap()))
-            .auto_commit(AutoCommit::Disabled)
-            .build();
-
-        hand_over_batch(&mut consumer, 3, vec![message_at(10), message_at(11)]);
-
-        assert_eq!(consumer.auto_commit, AutoCommit::Disabled);
-        assert!(consumer.pending_commits.is_empty());
-        assert_eq!(consumer.get_last_stored_offset(3), None);
-    }
-
-    struct Served(Arc<FakeCluster>);
-
-    impl Served {
-        fn stored_offsets(&self) -> Vec<u64> {
-            self.0
-                .received(STORE_CONSUMER_OFFSET_CODE)
-                .iter()
-                .map(|request| {
-                    StoreConsumerOffsetRequest::decode_from(&request.body)
-                        .unwrap()
-                        .offset
-                })
-                .collect()
-        }
-
-        fn deleted_partitions(&self) -> Vec<u32> {
-            self.0
-                .received(DELETE_CONSUMER_OFFSET_CODE)
-                .iter()
-                .map(|request| {
-                    DeleteConsumerOffsetRequest::decode_from(&request.body)
-                        .unwrap()
-                        .partition_id
-                        .unwrap()
-                })
-                .collect()
-        }
-    }
-
-    async fn connect_to_test_server(
-        partitions: Vec<u32>,
-        poll_refusal: IggyError,
-    ) -> (TcpClient, Served) {
-        let cluster = FakeCluster::new(1);
-        *cluster.route.lock().unwrap() = Some(PartitionRouteResponse {
-            incarnation: 1,
-            metadata_op: 1,
-            view: 0,
-            partitions_count: partitions.iter().copied().max().unwrap_or_default() + 1,
-            primary_replica: 0,
-        });
-        cluster.script(
-            &endpoint(0),
-            SYNC_CONSUMER_GROUP_CODE,
-            [Answer::Body(
-                SyncConsumerGroupResponse {
-                    generation: 1,
-                    partitions,
-                }
-                .to_bytes()
-                .to_vec(),
-            )],
-        );
-        cluster.script(
-            &endpoint(0),
-            POLL_MESSAGES_DEFERRED_CODE,
-            [Answer::Refuse(poll_refusal)],
-        );
-        let links = connected(&cluster, 1).await;
-        (TcpClient::from_links(links), Served(cluster))
-    }
-
-    /// A position delivered at `offset` by a poll that saw the metadata frontier `metadata_op`.
-    fn position_at(offset: u64, metadata_op: u64) -> ConsumerPosition {
-        ConsumerPosition {
-            partition_id: 1,
-            offset,
-            context: PartitionContext {
-                incarnation: 1,
-                metadata_op,
-                ..PartitionContext::default()
-            },
-        }
-    }
-
-    #[tokio::test]
-    async fn store_position_should_skip_an_older_position_from_an_earlier_poll() {
-        let consumer = builder().partition(Some(1)).build();
-        let stored = position_at(19, 2);
-        consumer.state.last_stored_offsets.insert(1, stored);
-
-        // The client is not connected, so only a store skipped before any request succeeds.
-        consumer.store_position(position_at(9, 1)).await.unwrap();
-
-        assert_eq!(consumer.get_last_stored_offset(1), Some(stored.offset));
-    }
-
-    #[tokio::test]
-    async fn shutdown_should_not_move_a_higher_stored_offset_back_under_replay() {
-        let (client, served) = connect_to_test_server(vec![1], IggyError::HistoryUnavailable).await;
-        let mut consumer = builder_on(client, Consumer::new(Identifier::numeric(1).unwrap()))
-            .partition(Some(1))
-            .allow_replay()
-            .auto_commit(AutoCommit::When(AutoCommitWhen::ConsumingAllMessages))
-            .build();
-        let consumed = position_at(9, 1);
-        let stored = position_at(19, 1);
-        consumer.state.last_consumed_offsets.insert(1, consumed);
-        consumer.state.last_stored_offsets.insert(1, stored);
-
-        consumer.shutdown().await.unwrap();
-
-        assert!(served.stored_offsets().is_empty());
-        assert_eq!(consumer.get_last_stored_offset(1), Some(stored.offset));
-    }
-
-    #[tokio::test]
-    async fn store_offset_without_a_consumed_position_should_record_the_stored_offset() {
-        const OFFSET: u64 = 7;
-        let (client, served) = connect_to_test_server(vec![1], IggyError::HistoryUnavailable).await;
-        let consumer = builder_on(client, Consumer::new(Identifier::numeric(1).unwrap()))
-            .partition(Some(1))
-            .auto_commit(AutoCommit::Disabled)
-            .build();
-
-        consumer.store_offset(OFFSET, Some(1)).await.unwrap();
-
-        assert_eq!(served.stored_offsets(), [OFFSET]);
-        assert_eq!(consumer.get_last_stored_offset(1), Some(OFFSET));
-    }
-
-    /// The offset the first store leaves on the server.
-    const STORED_OFFSET: u64 = 100;
-    /// An offset behind [`STORED_OFFSET`] that a second store asks for.
-    const LOWER_OFFSET: u64 = 10;
-
-    /// A position delivered at `offset` under a real partition incarnation. Metadata revisions
-    /// start at 1, so no real partition has the incarnation 0 of an uncaptured context.
-    fn captured_position_at(offset: u64) -> ConsumerPosition {
-        ConsumerPosition {
-            partition_id: 1,
-            offset,
-            context: PartitionContext {
-                incarnation: 1,
-                owner_generation: 0,
-                metadata_op: 1,
-            },
-        }
-    }
-
-    #[tokio::test]
-    async fn store_offset_without_a_consumed_position_should_send_every_offset() {
-        let (client, served) = connect_to_test_server(vec![1], IggyError::HistoryUnavailable).await;
-        let consumer = builder_on(client, Consumer::new(Identifier::numeric(1).unwrap()))
-            .partition(Some(1))
-            .auto_commit(AutoCommit::Disabled)
-            .build();
-
-        consumer.store_offset(STORED_OFFSET, Some(1)).await.unwrap();
-        consumer.store_offset(LOWER_OFFSET, Some(1)).await.unwrap();
-
-        assert_eq!(served.stored_offsets(), [STORED_OFFSET, LOWER_OFFSET]);
-        assert_eq!(consumer.get_last_stored_offset(1), Some(LOWER_OFFSET));
-    }
-
-    #[tokio::test]
-    async fn store_offset_without_a_consumed_position_should_store_a_lower_offset_under_replay() {
-        let (client, served) = connect_to_test_server(vec![1], IggyError::HistoryUnavailable).await;
-        let consumer = builder_on(client, Consumer::new(Identifier::numeric(1).unwrap()))
-            .partition(Some(1))
-            .auto_commit(AutoCommit::Disabled)
-            .allow_replay()
-            .build();
-
-        consumer.store_offset(STORED_OFFSET, Some(1)).await.unwrap();
-        consumer.store_offset(LOWER_OFFSET, Some(1)).await.unwrap();
-
-        assert_eq!(served.stored_offsets(), [STORED_OFFSET, LOWER_OFFSET]);
-        assert_eq!(consumer.get_last_stored_offset(1), Some(LOWER_OFFSET));
-    }
-
-    #[tokio::test]
-    async fn store_position_should_send_after_an_uncaptured_scalar_store() {
-        let (client, served) = connect_to_test_server(vec![1], IggyError::HistoryUnavailable).await;
-        let consumer = builder_on(client, Consumer::new(Identifier::numeric(1).unwrap()))
-            .partition(Some(1))
-            .auto_commit(AutoCommit::Disabled)
-            .build();
-
-        consumer.store_offset(STORED_OFFSET, Some(1)).await.unwrap();
-        consumer
-            .store_position(captured_position_at(LOWER_OFFSET))
-            .await
-            .unwrap();
-
-        assert_eq!(served.stored_offsets(), [STORED_OFFSET, LOWER_OFFSET]);
-        assert_eq!(consumer.get_last_stored_offset(1), Some(LOWER_OFFSET));
-    }
-
-    /// A recreated partition starts again at 0, and the offset stored without a consumed position
-    /// can belong to its earlier incarnation, which the record cannot tell apart.
-    #[tokio::test]
-    async fn store_position_should_send_offset_zero_after_one_stored_without_a_consumed_position() {
-        let (client, served) = connect_to_test_server(vec![1], IggyError::HistoryUnavailable).await;
-        let consumer = builder_on(client, Consumer::new(Identifier::numeric(1).unwrap()))
-            .partition(Some(1))
-            .auto_commit(AutoCommit::Disabled)
-            .build();
-
-        consumer.store_offset(STORED_OFFSET, Some(1)).await.unwrap();
-        consumer
-            .store_position(captured_position_at(0))
-            .await
-            .unwrap();
-
-        assert_eq!(served.stored_offsets(), [STORED_OFFSET, 0]);
-        assert_eq!(consumer.get_last_stored_offset(1), Some(0));
-    }
-
-    #[tokio::test]
-    async fn store_offset_without_a_consumed_position_should_not_compare_a_captured_store() {
-        let (client, served) = connect_to_test_server(vec![1], IggyError::HistoryUnavailable).await;
-        let consumer = builder_on(client, Consumer::new(Identifier::numeric(1).unwrap()))
-            .partition(Some(1))
-            .auto_commit(AutoCommit::Disabled)
-            .build();
-
-        consumer
-            .store_position(captured_position_at(STORED_OFFSET))
-            .await
-            .unwrap();
-        consumer.store_offset(LOWER_OFFSET, Some(1)).await.unwrap();
-
-        assert_eq!(served.stored_offsets(), [STORED_OFFSET, LOWER_OFFSET]);
-        assert_eq!(consumer.get_last_stored_offset(1), Some(LOWER_OFFSET));
-    }
-
-    #[tokio::test]
-    async fn shutdown_should_store_the_captured_position_after_an_uncaptured_scalar_store() {
-        let (client, served) = connect_to_test_server(vec![1], IggyError::HistoryUnavailable).await;
-        let mut consumer = builder_on(client, Consumer::new(Identifier::numeric(1).unwrap()))
-            .partition(Some(1))
-            .auto_commit(AutoCommit::When(AutoCommitWhen::ConsumingAllMessages))
-            .build();
-        consumer.store_offset(STORED_OFFSET, Some(1)).await.unwrap();
-        consumer
-            .state
-            .last_consumed_offsets
-            .insert(1, captured_position_at(LOWER_OFFSET));
-
-        consumer.shutdown().await.unwrap();
-
-        assert_eq!(served.stored_offsets(), [STORED_OFFSET, LOWER_OFFSET]);
-        assert_eq!(consumer.get_last_stored_offset(1), Some(LOWER_OFFSET));
-    }
-
-    #[tokio::test]
-    async fn given_a_deleted_offset_when_storing_the_same_position_should_send_again() {
-        let (client, served) = connect_to_test_server(vec![1], IggyError::HistoryUnavailable).await;
-        let consumer = builder_on(client, Consumer::new(Identifier::numeric(1).unwrap()))
-            .partition(Some(1))
-            .auto_commit(AutoCommit::Disabled)
-            .build();
-        let position = captured_position_at(STORED_OFFSET);
-        consumer.store_position(position).await.unwrap();
-
-        consumer
-            .delete_offset(Some(position.partition_id))
-            .await
-            .unwrap();
-
-        assert_eq!(consumer.get_last_stored_offset(position.partition_id), None);
-        consumer.store_position(position).await.unwrap();
-        assert_eq!(served.deleted_partitions(), [position.partition_id]);
-        assert_eq!(served.stored_offsets(), [STORED_OFFSET, STORED_OFFSET]);
-    }
-
-    #[tokio::test(start_paused = true)]
-    async fn given_an_unconfirmed_delete_when_storing_the_same_position_should_resolve_and_store() {
-        for cancel in [false, true] {
-            let (client, served) =
-                connect_to_test_server(vec![1], IggyError::HistoryUnavailable).await;
-            let consumer = builder_on(client, Consumer::new(Identifier::numeric(1).unwrap()))
-                .partition(Some(1))
-                .auto_commit(AutoCommit::Disabled)
-                .build();
-            let position = captured_position_at(STORED_OFFSET);
-            consumer.store_position(position).await.unwrap();
-            served.0.script(
-                &endpoint(0),
-                DELETE_CONSUMER_OFFSET_CODE,
-                [Answer::Silence, Answer::Commit(2)],
-            );
-
-            if cancel {
-                assert!(
-                    tokio::time::timeout(POLL_RETRY_INTERVAL, consumer.delete_offset(Some(1)))
-                        .await
-                        .is_err()
-                );
-            } else {
-                assert!(matches!(
-                    consumer.delete_offset(Some(1)).await,
-                    Err(IggyError::OperationUncertain(_))
-                ));
-            }
-            assert_eq!(served.0.count(DELETE_CONSUMER_OFFSET_CODE), 1);
-            consumer.store_position(position).await.unwrap();
-
-            let deletes = served.0.received(DELETE_CONSUMER_OFFSET_CODE);
-            assert_eq!(deletes.len(), 2);
-            assert_eq!(deletes[0].header.request, deletes[1].header.request);
-            assert_eq!(served.stored_offsets(), [STORED_OFFSET, STORED_OFFSET]);
-        }
-    }
-
-    #[tokio::test]
-    async fn given_a_rejected_delete_when_storing_the_same_position_should_refresh_coverage() {
-        let (client, served) = connect_to_test_server(vec![1], IggyError::HistoryUnavailable).await;
-        let consumer = builder_on(client, Consumer::new(Identifier::numeric(1).unwrap()))
-            .partition(Some(1))
-            .auto_commit(AutoCommit::Disabled)
-            .build();
-        let position = captured_position_at(STORED_OFFSET);
-        consumer.store_position(position).await.unwrap();
-        served.0.script(
-            &endpoint(0),
-            DELETE_CONSUMER_OFFSET_CODE,
-            [Answer::CommitRejected(2, IggyError::InvalidCommand)],
-        );
-
-        assert_eq!(
-            consumer.delete_offset(Some(1)).await,
-            Err(IggyError::InvalidCommand)
-        );
-        consumer.store_position(position).await.unwrap();
-
-        assert_eq!(consumer.get_last_stored_offset(1), Some(STORED_OFFSET));
-        assert_eq!(served.stored_offsets(), [STORED_OFFSET, STORED_OFFSET]);
-    }
-
-    #[tokio::test]
-    async fn given_a_stored_position_when_its_owner_or_incarnation_changes_should_store_the_lower_offset()
-     {
-        for context in [
-            PartitionContext {
-                owner_generation: 2,
-                ..captured_position_at(LOWER_OFFSET).context
-            },
-            PartitionContext {
-                incarnation: 2,
-                ..captured_position_at(LOWER_OFFSET).context
-            },
-        ] {
-            let (client, served) =
-                connect_to_test_server(vec![1], IggyError::HistoryUnavailable).await;
-            let consumer = builder_on(client, Consumer::group(Identifier::numeric(1).unwrap()))
-                .partition(Some(1))
-                .auto_commit(AutoCommit::Disabled)
-                .build();
-            consumer
-                .store_position(captured_position_at(STORED_OFFSET))
-                .await
-                .unwrap();
-
-            consumer
-                .store_position(ConsumerPosition {
-                    context,
-                    ..captured_position_at(LOWER_OFFSET)
-                })
-                .await
-                .unwrap();
-
-            assert_eq!(served.stored_offsets(), [STORED_OFFSET, LOWER_OFFSET]);
-            let stores = served.0.received(STORE_CONSUMER_OFFSET_CODE);
-            assert_eq!(stores[1].header.partition_incarnation, context.incarnation);
-            assert_eq!(stores[1].header.owner_generation, context.owner_generation);
-        }
-    }
-
-    #[tokio::test(start_paused = true)]
-    async fn given_an_uncertain_store_when_the_blocker_is_rejected_should_store_the_new_position() {
-        let (client, served) = connect_to_test_server(vec![1], IggyError::HistoryUnavailable).await;
-        let consumer = builder_on(client, Consumer::new(Identifier::numeric(1).unwrap()))
-            .partition(Some(1))
-            .auto_commit(AutoCommit::Disabled)
-            .build();
-        served.0.script(
-            &endpoint(0),
-            STORE_CONSUMER_OFFSET_CODE,
-            [
-                Answer::Silence,
-                Answer::CommitRejected(1, IggyError::HistoryUnavailable),
-                Answer::Commit(2),
-            ],
-        );
-        let first = consumer
-            .store_position(captured_position_at(LOWER_OFFSET))
-            .await;
-        let old = served.0.received(STORE_CONSUMER_OFFSET_CODE)[0]
-            .header
-            .request;
-        assert_eq!(first, Err(IggyError::OperationUncertain(old)));
-
-        consumer
-            .store_position(captured_position_at(STORED_OFFSET))
-            .await
-            .unwrap();
-
-        let resolved = consumer
-            .client
-            .read()
-            .await
-            .resolve_operation(old, (Instant::now() + OPERATION_TIMEOUT).into_std())
-            .await;
-        assert_eq!(resolved, Err(IggyError::HistoryUnavailable));
-        let stores = served.0.received(STORE_CONSUMER_OFFSET_CODE);
-        assert_eq!(
-            served.stored_offsets(),
-            [LOWER_OFFSET, LOWER_OFFSET, STORED_OFFSET]
-        );
-        assert_eq!(stores[0].header.request, stores[1].header.request);
-        assert_eq!(stores[0].body, stores[1].body);
-        assert_ne!(stores[1].header.request, stores[2].header.request);
-        assert_eq!(consumer.get_last_stored_offset(1), Some(STORED_OFFSET));
-    }
-
-    #[tokio::test(start_paused = true)]
-    async fn given_an_uncertain_store_when_deleting_should_resolve_before_sending_the_delete() {
-        let (client, served) = connect_to_test_server(vec![1], IggyError::HistoryUnavailable).await;
-        let consumer = builder_on(client, Consumer::new(Identifier::numeric(1).unwrap()))
-            .partition(Some(1))
-            .auto_commit(AutoCommit::Disabled)
-            .build();
-        served.0.script(
-            &endpoint(0),
-            STORE_CONSUMER_OFFSET_CODE,
-            [Answer::Silence, Answer::Commit(1)],
-        );
-        assert!(matches!(
-            consumer
-                .store_position(captured_position_at(LOWER_OFFSET))
-                .await,
-            Err(IggyError::OperationUncertain(_))
-        ));
-
-        consumer.delete_offset(Some(1)).await.unwrap();
-
-        let mutations: Vec<_> = served
-            .0
-            .codes(&endpoint(0))
-            .into_iter()
-            .filter(|code| {
-                matches!(
-                    *code,
-                    STORE_CONSUMER_OFFSET_CODE | DELETE_CONSUMER_OFFSET_CODE
-                )
-            })
-            .collect();
-        assert_eq!(
-            mutations,
-            [
-                STORE_CONSUMER_OFFSET_CODE,
-                STORE_CONSUMER_OFFSET_CODE,
-                DELETE_CONSUMER_OFFSET_CODE
-            ]
-        );
-        assert_eq!(served.deleted_partitions(), [1]);
-        assert_eq!(consumer.get_last_stored_offset(1), None);
-    }
-
-    #[tokio::test(start_paused = true)]
-    async fn given_a_still_uncertain_store_when_storing_again_should_leave_the_new_position_unsent()
-    {
-        let (client, served) = connect_to_test_server(vec![1], IggyError::HistoryUnavailable).await;
-        let consumer = builder_on(client, Consumer::new(Identifier::numeric(1).unwrap()))
-            .partition(Some(1))
-            .auto_commit(AutoCommit::Disabled)
-            .build();
-        served.0.script(
-            &endpoint(0),
-            STORE_CONSUMER_OFFSET_CODE,
-            [Answer::Silence, Answer::Silence],
-        );
-        assert!(matches!(
-            consumer
-                .store_position(captured_position_at(LOWER_OFFSET))
-                .await,
-            Err(IggyError::OperationUncertain(_))
-        ));
-        let old = served.0.received(STORE_CONSUMER_OFFSET_CODE)[0]
-            .header
-            .request;
-        let started = Instant::now();
-
-        let result = consumer
-            .store_position(captured_position_at(STORED_OFFSET))
-            .await;
-
-        assert_eq!(result, Err(IggyError::LaneBlocked(old)));
-        assert!(started.elapsed() <= OPERATION_TIMEOUT);
-        assert_eq!(served.stored_offsets(), [LOWER_OFFSET, LOWER_OFFSET]);
-        assert_eq!(consumer.get_last_stored_offset(1), None);
-    }
-
-    #[tokio::test(start_paused = true)]
-    async fn given_an_outer_deadline_past_the_default_budget_when_a_store_is_unanswered_should_end_at_the_outer_deadline()
-     {
-        const OUTER_DEADLINE: Duration = OPERATION_TIMEOUT.saturating_mul(2);
-        let (client, served) = connect_to_test_server(vec![1], IggyError::HistoryUnavailable).await;
-        let consumer = builder_on(client, Consumer::new(Identifier::numeric(1).unwrap()))
-            .partition(Some(1))
-            .auto_commit(AutoCommit::Disabled)
-            .build();
-        served
-            .0
-            .script(&endpoint(0), STORE_CONSUMER_OFFSET_CODE, [Answer::Silence]);
-        let started = Instant::now();
-
-        let result = with_deadline(
-            started + OUTER_DEADLINE,
-            consumer.store_position(captured_position_at(STORED_OFFSET)),
-        )
-        .await;
-
-        let op = served.0.received(STORE_CONSUMER_OFFSET_CODE)[0]
-            .header
-            .request;
-        assert_eq!(result, Err(IggyError::OperationUncertain(op)));
-        assert_eq!(started.elapsed(), OUTER_DEADLINE);
-    }
-
-    #[tokio::test(start_paused = true)]
-    async fn given_multiple_final_positions_when_shutdown_times_out_should_share_one_deadline() {
-        let (client, served) =
-            connect_to_test_server(vec![1, 2], IggyError::HistoryUnavailable).await;
-        let mut consumer = builder_on(client, Consumer::new(Identifier::numeric(1).unwrap()))
-            .auto_commit(AutoCommit::When(AutoCommitWhen::ConsumingAllMessages))
-            .build();
-        for partition_id in [1, 2] {
-            consumer.state.last_consumed_offsets.insert(
-                partition_id,
-                ConsumerPosition {
-                    partition_id,
-                    ..captured_position_at(LOWER_OFFSET)
-                },
-            );
-        }
-        served.0.script(
-            &endpoint(0),
-            STORE_CONSUMER_OFFSET_CODE,
-            [Answer::Silence, Answer::Silence],
-        );
-        let started = Instant::now();
-
-        consumer.shutdown().await.unwrap();
-
-        assert!(started.elapsed() <= OPERATION_TIMEOUT);
-        assert_eq!(served.stored_offsets(), [LOWER_OFFSET]);
-        assert_eq!(consumer.get_last_stored_offset(1), None);
-        assert_eq!(consumer.get_last_stored_offset(2), None);
-    }
-
-    #[tokio::test(start_paused = true)]
-    async fn given_an_inflight_background_store_when_shutdown_aborts_it_should_resolve_and_flush() {
-        let (client, served) = connect_to_test_server(vec![1], IggyError::HistoryUnavailable).await;
-        let mut consumer = builder_on(client, Consumer::new(Identifier::numeric(1).unwrap()))
-            .offset_drain_timeout(IggyDuration::new(POLL_RETRY_INTERVAL))
-            .auto_commit(AutoCommit::When(AutoCommitWhen::ConsumingAllMessages))
-            .build();
-        consumer
-            .state
-            .last_consumed_offsets
-            .insert(1, captured_position_at(LOWER_OFFSET));
-        served.0.script(
-            &endpoint(0),
-            STORE_CONSUMER_OFFSET_CODE,
-            [Answer::Silence, Answer::Commit(1), Answer::Commit(2)],
-        );
-        consumer.background_commit_task =
-            Some(consumer.store_offsets_in_background(
-                NonZeroIggyDuration::new(POLL_RETRY_INTERVAL).unwrap(),
-            ));
-        eventually("background store reached the primary", || {
-            served.0.count(STORE_CONSUMER_OFFSET_CODE) == 1
-        })
-        .await;
-
-        consumer.shutdown().await.unwrap();
-
-        let stores = served.0.received(STORE_CONSUMER_OFFSET_CODE);
-        assert_eq!(stores.len(), 3);
-        assert_eq!(stores[0].header.request, stores[1].header.request);
-        assert_ne!(stores[1].header.request, stores[2].header.request);
-        assert_eq!(consumer.get_last_stored_offset(1), Some(LOWER_OFFSET));
-    }
-
-    /// Records the events of one level emitted on the test thread. The current-thread runtime
-    /// of `#[tokio::test]` polls the tasks it spawns on that thread, so their events count too.
-    #[derive(Clone)]
-    struct LevelReports {
-        level: Level,
-        events: Arc<std::sync::Mutex<Vec<String>>>,
-    }
-
-    impl Subscriber for LevelReports {
-        fn enabled(&self, metadata: &Metadata<'_>) -> bool {
-            *metadata.level() == self.level
-        }
-
-        fn new_span(&self, _: &Attributes<'_>) -> Id {
-            Id::from_u64(1)
-        }
-
-        fn record(&self, _: &Id, _: &Record<'_>) {}
-
-        fn record_follows_from(&self, _: &Id, _: &Id) {}
-
-        fn event(&self, event: &Event<'_>) {
-            let mut fields = Vec::new();
-            event.record(&mut |field: &Field, value: &dyn Debug| {
-                fields.push(format!("{field}: {value:?}"));
-            });
-            self.events.lock().unwrap().push(fields.join(", "));
-        }
-
-        fn enter(&self, _: &Id) {}
-
-        fn exit(&self, _: &Id) {}
-    }
-
-    impl LevelReports {
-        fn errors() -> Self {
-            Self {
-                level: Level::ERROR,
-                events: Arc::default(),
-            }
-        }
-
-        fn warnings() -> Self {
-            Self {
-                level: Level::WARN,
-                events: Arc::default(),
-            }
-        }
-
-        /// Waits for the first report, or returns none after [`POLL_TIMEOUT`].
-        async fn wait(&self) -> Vec<String> {
-            let first = async {
-                loop {
-                    let reports = self.events.lock().unwrap().clone();
-                    if !reports.is_empty() {
-                        return reports;
-                    }
-                    sleep(POLL_RETRY_INTERVAL).await;
-                }
-            };
-            timeout(POLL_TIMEOUT, first).await.unwrap_or_default()
-        }
-    }
-
-    #[tokio::test]
-    async fn interval_commit_should_report_a_failed_store() {
-        let recorder = LevelReports::errors();
-        let _subscriber = tracing::subscriber::set_default(recorder.clone());
-        let consumer = builder().partition(Some(1)).build();
-        consumer
-            .state
-            .last_consumed_offsets
-            .insert(1, position_at(9, 1));
-
-        // The client is not connected, so every store fails.
-        let task = consumer
-            .store_offsets_in_background(NonZeroIggyDuration::new(POLL_RETRY_INTERVAL).unwrap());
-        let reports = recorder.wait().await;
-        task.abort();
-
-        assert!(
-            !reports.is_empty(),
-            "a failed interval commit must be reported"
-        );
-        let failure = IggyError::Disconnected.to_string();
-        assert!(
-            reports.iter().all(|report| report.contains(&failure)),
-            "{reports:?}"
-        );
-    }
-
-    #[tokio::test]
-    async fn queued_commit_should_report_a_failed_store() {
-        let recorder = LevelReports::errors();
-        let _subscriber = tracing::subscriber::set_default(recorder.clone());
-        let mut consumer = builder().partition(Some(1)).build();
-        consumer.initialized = true;
-        let task = consumer.store_pending_commits_in_background();
-
-        // The client is not connected, so the store fails.
-        consumer.send_store_offset(position_at(9, 1));
-        let reports = recorder.wait().await;
-        task.abort();
-
-        assert_eq!(
-            reports.len(),
-            1,
-            "a failed queued commit must be reported once"
-        );
-        assert!(
-            reports[0].contains(&IggyError::Disconnected.to_string()),
-            "{reports:?}"
-        );
-    }
-
-    #[test]
-    fn commit_on_poll_rewind_should_skip_revoked_and_already_stored_partitions() {
-        let consumer = builder_for(Consumer::group(Identifier::numeric(1).unwrap())).build();
-        for (partition_id, delivered, record) in [(1, 2, 9), (2, 5, 9), (3, 9, 9)] {
-            consumer.state.last_consumed_offsets.insert(
-                partition_id,
-                ConsumerPosition {
-                    partition_id,
-                    ..position_at(delivered, 1)
-                },
-            );
-            consumer.state.last_stored_offsets.insert(
-                partition_id,
-                ConsumerPosition {
-                    partition_id,
-                    ..position_at(record, 1)
-                },
-            );
-        }
-
-        assert_eq!(
-            consumer.state.unstored_delivered_offsets(Some(&[1, 3])),
-            [position_at(2, 1)]
-        );
-        let mut standalone = consumer.state.unstored_delivered_offsets(None);
-        standalone.sort_unstable_by_key(|position| position.partition_id);
-        assert_eq!(
-            standalone,
-            [
-                position_at(2, 1),
-                ConsumerPosition {
-                    partition_id: 2,
-                    ..position_at(5, 1)
-                }
-            ]
-        );
-    }
-
-    #[tokio::test]
-    async fn commit_on_poll_shutdown_should_rewind_over_a_queued_batch() {
-        let consumer = builder_for(Consumer::default()).build();
-        let delivered = position_at(4, 1);
-        consumer.state.last_consumed_offsets.insert(1, delivered);
-        consumer.state.last_stored_offsets.insert(1, delivered);
-        let queued = PolledMessages {
-            partition_id: 1,
-            context: delivered.context,
-            current_offset: 6,
-            count: 2,
-            messages: vec![message_at(5), message_at(6)],
+        let Some(mut reader) = self.reader.take() else {
+            return Ok(());
         };
-
-        consumer
-            .state
-            .rewind_undelivered_offsets(vec![queued], None, true)
-            .await;
-        assert_eq!(
-            consumer.state.unstored_delivered_offsets(None),
-            [delivered],
-            "the server committed offset 6, so the delivered offset 4 is stored again"
-        );
-    }
-
-    /// Polls once as a group member on a client that is not connected. The outcome must be an
-    /// error, never an endless wait for a join.
-    async fn poll_once_as_group_member(
-        builder: IggyConsumerBuilder,
-    ) -> Option<Result<ReceivedMessage, IggyError>> {
-        let mut consumer = builder
-            .polling_retry_interval(NonZeroIggyDuration::new(POLL_RETRY_INTERVAL).unwrap())
-            .build();
-        consumer.initialized = true;
-        timeout(POLL_TIMEOUT, consumer.next())
-            .await
-            .expect("a group member must poll or report an error instead of waiting for a join")
-    }
-
-    #[tokio::test]
-    async fn group_member_without_auto_join_should_poll_instead_of_waiting_for_the_join() {
-        let builder = builder_for(Consumer::group(Identifier::numeric(1).unwrap()))
-            .do_not_auto_join_consumer_group();
-
-        assert!(matches!(
-            poll_once_as_group_member(builder).await,
-            Some(Err(_))
-        ));
-    }
-
-    #[tokio::test]
-    async fn group_member_should_report_a_failed_join_as_a_poll_error() {
-        let builder = builder_for(Consumer::group(Identifier::numeric(1).unwrap()))
-            .auto_join_consumer_group();
-
-        assert!(matches!(
-            poll_once_as_group_member(builder).await,
-            Some(Err(_))
-        ));
-    }
-
-    #[tokio::test]
-    async fn init_should_reject_an_encryptor_with_auto_commit_on_polling() {
-        let encryptor = Arc::new(EncryptorKind::Aes256Gcm(
-            Aes256GcmEncryptor::new(&[1; 32]).unwrap(),
-        ));
-        for auto_commit in [
-            AutoCommit::When(AutoCommitWhen::PollingMessages),
-            AutoCommit::IntervalOrWhen(
-                NonZeroIggyDuration::ONE_SECOND,
-                AutoCommitWhen::PollingMessages,
+        // The stores go out while the member still owns its partitions, before the close leaves
+        // the group.
+        match time::timeout(self.offset_drain_timeout.get_duration(), reader.flush()).await {
+            Ok(Ok(())) => {}
+            Ok(Err(error)) => warn!(
+                "Failed to store the counted offsets of consumer: {}. {error}",
+                self.consumer_name
             ),
-        ] {
-            let mut consumer = builder()
-                .encryptor(encryptor.clone())
-                .auto_commit(auto_commit)
-                .build();
-
-            assert!(
-                matches!(consumer.init().await, Err(IggyError::InvalidConfiguration)),
-                "{auto_commit:?} must be rejected with an encryptor"
-            );
+            Err(_) => warn!(
+                "Timed out storing the counted offsets of consumer: {} after {}.",
+                self.consumer_name, self.offset_drain_timeout
+            ),
         }
-
-        let mut consumer = builder()
-            .encryptor(encryptor)
-            .auto_commit(AutoCommit::When(AutoCommitWhen::ConsumingEachMessage))
-            .build();
-
-        assert!(!matches!(
-            consumer.init().await,
-            Err(IggyError::InvalidConfiguration)
-        ));
-    }
-
-    #[test]
-    fn default_prefetch_should_fit_a_batch_longer_than_the_default() {
-        assert_eq!(builder().batch_length(20_000).build().prefetch_slots(), 1);
-        assert_eq!(
-            builder()
-                .batch_length(20_000)
-                .prefetch_messages(16_000)
-                .build()
-                .prefetch_slots(),
-            0,
-            "an explicit limit stays as configured"
-        );
-    }
-
-    #[test]
-    fn send_store_offset_should_keep_the_latest_offset_per_partition() {
-        let mut consumer = builder().build();
-        consumer.initialized = true;
-
-        consumer.send_store_offset(ConsumerPosition {
-            partition_id: 1,
-            offset: 5,
-            context: PartitionContext::default(),
-        });
-        consumer.send_store_offset(ConsumerPosition {
-            partition_id: 1,
-            offset: 7,
-            context: PartitionContext::default(),
-        });
-        consumer.send_store_offset(ConsumerPosition {
-            partition_id: 2,
-            offset: 3,
-            context: PartitionContext::default(),
-        });
-
-        let mut queued: Vec<(u32, u64)> = consumer
-            .pending_commits
-            .iter()
-            .map(|entry| (*entry.key(), entry.value().offset))
-            .collect();
-        queued.sort_unstable();
-        assert_eq!(queued, vec![(1, 7), (2, 3)]);
-    }
-
-    #[tokio::test]
-    async fn should_accept_every_auto_commit_mode() {
-        for auto_commit in [
-            AutoCommit::Disabled,
-            AutoCommit::Interval(NonZeroIggyDuration::ONE_SECOND),
-            AutoCommit::When(AutoCommitWhen::PollingMessages),
-            AutoCommit::After(AutoCommitAfter::ConsumingAllMessages),
-        ] {
-            let mut consumer = builder().auto_commit(auto_commit).build();
-
-            let error = consumer.init().await.err();
-
-            assert!(
-                !matches!(error, Some(IggyError::InvalidConfiguration)),
-                "{auto_commit:?} must be accepted"
-            );
-        }
-    }
-
-    const POLLED_PARTITION: u32 = 1;
-    const PARTITION_PRIMARY: u8 = 1;
-
-    /// A signed-in client of three ready replicas. Replica 0 is the metadata
-    /// primary, and `PARTITION_PRIMARY` leads every partition.
-    async fn connect_to_three_replicas() -> (TcpClient, Arc<FakeCluster>) {
-        connect_to_three_replicas_with(policy()).await
-    }
-
-    async fn connect_to_three_replicas_with(
-        policy: ReconnectPolicy,
-    ) -> (TcpClient, Arc<FakeCluster>) {
-        let cluster = FakeCluster::new(3);
-        *cluster.route.lock().unwrap() = Some(PartitionRouteResponse {
-            incarnation: 1,
-            metadata_op: 1,
-            view: 0,
-            partitions_count: POLLED_PARTITION + 1,
-            primary_replica: PARTITION_PRIMARY,
-        });
-        let links = connected_with(&cluster, 3, policy).await;
-        (TcpClient::from_links(links), cluster)
-    }
-
-    /// Takes every replica of `cluster` down, which closes the links to them.
-    fn take_every_replica_down(cluster: &FakeCluster) {
-        let endpoints: Vec<String> = cluster
-            .nodes
-            .lock()
-            .unwrap()
-            .iter()
-            .map(|node| endpoint(node.replica_id))
-            .collect();
-        for endpoint in endpoints {
-            cluster.down.lock().unwrap().insert(endpoint.clone());
-            cluster.drop_connections(&endpoint);
-        }
-    }
-
-    /// A consumer of `POLLED_PARTITION` whose polling follows the client
-    /// events, as after `init`.
-    async fn polling_consumer(client: TcpClient, consumer: Consumer) -> IggyConsumer {
-        let mut consumer = builder_on(client, consumer)
-            .partition(Some(POLLED_PARTITION))
-            .polling_strategy(PollingStrategy::first())
-            .auto_commit(AutoCommit::Disabled)
-            .polling_retry_interval(NonZeroIggyDuration::new(POLL_RETRY_INTERVAL).unwrap())
-            .build();
-        consumer.initialized = true;
-        consumer.events_task = Some(consumer.subscribe_events().await);
-        consumer
-    }
-
-    /// Publishes `events` in order on the client of `consumer`.
-    async fn publish(consumer: &IggyConsumer, events: &[DiagnosticEvent]) {
-        let client = consumer.client.read().await;
-        let ClientWrapper::Tcp(client) = &*client else {
-            panic!("a test consumer runs on a TCP client");
-        };
-        for event in events {
-            client.publish_event(*event).await;
-        }
-    }
-
-    /// A poll reply of `POLLED_PARTITION` with `messages_count` messages in `batches`.
-    fn poll_answer(current_offset: u64, messages_count: u32, batches: &[u8]) -> Answer {
-        let header = PollMessagesResponseHeader {
-            partition_id: POLLED_PARTITION,
-            current_offset,
-            messages_count,
-            context: PartitionContext::default(),
-        };
-        let mut body = header.to_bytes().to_vec();
-        body.extend_from_slice(batches);
-        Answer::CommitBody(1, body)
-    }
-
-    fn available_poll() -> Answer {
-        poll_of_one_message(0)
-    }
-
-    /// A poll reply with one empty message at `offset`. A zeroed frame is such a message, and the
-    /// client does not check its checksum.
-    fn poll_of_one_message(offset: u64) -> Answer {
-        let mut batch = vec![0; BATCH_HEADER_SIZE + BATCH_MESSAGE_HEADER_SIZE];
-        let mut header = BatchHeader::new(0, 0, batch.len() as u64, 1);
-        header.base_offset = offset;
-        header.encode_into(&mut batch);
-        poll_answer(offset, 1, &batch)
-    }
-
-    fn existing_consumer_group() -> Answer {
-        Answer::Body(
-            ConsumerGroupDetailsResponse {
-                group: ConsumerGroupResponse {
-                    id: 1,
-                    partitions_count: POLLED_PARTITION + 1,
-                    members_count: 0,
-                    name: WireName::new("consumer").unwrap(),
-                },
-                members: Vec::new(),
-            }
-            .to_bytes()
-            .to_vec(),
-        )
-    }
-
-    #[tokio::test(start_paused = true)]
-    async fn given_an_uncertain_group_mutation_when_polling_again_should_resolve_and_join() {
-        for mutation in [CREATE_CONSUMER_GROUP_CODE, JOIN_CONSUMER_GROUP_CODE] {
-            let (client, cluster) = connect_to_three_replicas().await;
-            let mut consumer =
-                polling_consumer(client, Consumer::group(Identifier::numeric(1).unwrap())).await;
-            let first_group = if mutation == CREATE_CONSUMER_GROUP_CODE {
-                Answer::Body(Vec::new())
-            } else {
-                existing_consumer_group()
-            };
-            cluster.script(
-                &endpoint(0),
-                GET_CONSUMER_GROUP_CODE,
-                [first_group, existing_consumer_group()],
-            );
-            cluster.script(&endpoint(0), mutation, [Answer::Silence, Answer::Commit(1)]);
-            cluster.script(
-                &endpoint(0),
-                SYNC_CONSUMER_GROUP_CODE,
-                [Answer::Body(
-                    SyncConsumerGroupResponse {
-                        generation: 1,
-                        partitions: vec![POLLED_PARTITION],
-                    }
-                    .to_bytes()
-                    .to_vec(),
-                )],
-            );
-            cluster.script(
-                &endpoint(PARTITION_PRIMARY),
-                POLL_MESSAGES_DEFERRED_CODE,
-                [available_poll()],
-            );
-
-            let first = poll_once(&mut consumer).await;
-            let op = cluster.received(mutation)[0].header.request;
-            assert_eq!(first.unwrap_err(), IggyError::OperationUncertain(op));
-
-            let polled = poll_once(&mut consumer).await;
-            assert!(polled.is_ok(), "mutation {mutation}: {polled:?}");
-
-            let requests = cluster.received(mutation);
-            assert_eq!(requests[0].header.request, requests[1].header.request);
-            assert_eq!(requests[0].body, requests[1].body);
-            assert!((1..=2).contains(&cluster.count(POLL_MESSAGES_DEFERRED_CODE)));
-            assert!(consumer.joined_consumer_group.load(ORDERING));
-            assert_eq!(
-                consumer
-                    .client
-                    .read()
-                    .await
-                    .resolve_operation(op, (Instant::now() + OPERATION_TIMEOUT).into_std())
-                    .await,
-                Ok(())
-            );
-        }
-    }
-
-    #[tokio::test(start_paused = true)]
-    async fn given_an_uncertain_join_when_resolution_is_uncertain_should_keep_the_operation() {
-        let (client, cluster) = connect_to_three_replicas().await;
-        let consumer =
-            polling_consumer(client, Consumer::group(Identifier::numeric(1).unwrap())).await;
-        cluster.script(
-            &endpoint(0),
-            GET_CONSUMER_GROUP_CODE,
-            repeat_n(existing_consumer_group(), 2),
-        );
-        cluster.script(
-            &endpoint(0),
-            JOIN_CONSUMER_GROUP_CODE,
-            [Answer::Silence, Answer::Silence, Answer::Commit(1)],
-        );
-        let first = consumer.init_consumer_group().await;
-        let op = cluster.received(JOIN_CONSUMER_GROUP_CODE)[0].header.request;
-        assert_eq!(first, Err(IggyError::OperationUncertain(op)));
-
-        assert_eq!(
-            consumer.init_consumer_group().await,
-            Err(IggyError::OperationUncertain(op))
-        );
-        assert_eq!(cluster.count(GET_CONSUMER_GROUP_CODE), 1);
-        assert!(!consumer.joined_consumer_group.load(ORDERING));
-
-        consumer.init_consumer_group().await.unwrap();
-        let joins = cluster.received(JOIN_CONSUMER_GROUP_CODE);
-        assert_eq!(joins.len(), 4);
-        assert_eq!(joins[1].header.request, op);
-        assert_eq!(joins[2].header.request, op);
-        assert_ne!(joins[3].header.request, op);
-    }
-
-    #[tokio::test(start_paused = true)]
-    async fn given_an_uncertain_join_when_resolution_is_rejected_should_report_and_allow_a_new_join()
-     {
-        let (client, cluster) = connect_to_three_replicas().await;
-        let consumer =
-            polling_consumer(client, Consumer::group(Identifier::numeric(1).unwrap())).await;
-        cluster.script(
-            &endpoint(0),
-            GET_CONSUMER_GROUP_CODE,
-            repeat_n(existing_consumer_group(), 2),
-        );
-        cluster.script(
-            &endpoint(0),
-            JOIN_CONSUMER_GROUP_CODE,
-            [
-                Answer::Silence,
-                Answer::CommitRejected(1, IggyError::Unauthorized),
-            ],
-        );
-        assert!(matches!(
-            consumer.init_consumer_group().await,
-            Err(IggyError::OperationUncertain(_))
-        ));
-
-        assert_eq!(
-            consumer.init_consumer_group().await,
-            Err(IggyError::Unauthorized)
-        );
-        assert!(!consumer.joined_consumer_group.load(ORDERING));
-
-        consumer.init_consumer_group().await.unwrap();
-        assert_eq!(cluster.count(JOIN_CONSUMER_GROUP_CODE), 3);
-    }
-
-    #[tokio::test(start_paused = true)]
-    async fn given_another_callers_uncertain_join_when_initializing_a_group_should_leave_it_retained()
-     {
-        let (client, cluster) = connect_to_three_replicas().await;
-        cluster.script(&endpoint(0), JOIN_CONSUMER_GROUP_CODE, [Answer::Silence]);
-        let first = client
-            .join_consumer_group(
-                &Identifier::numeric(1).unwrap(),
-                &Identifier::numeric(1).unwrap(),
-                &Identifier::numeric(2).unwrap(),
-            )
-            .await;
-        let op = cluster.received(JOIN_CONSUMER_GROUP_CODE)[0].header.request;
-        assert_eq!(first, Err(IggyError::OperationUncertain(op)));
-        let consumer =
-            polling_consumer(client, Consumer::group(Identifier::numeric(1).unwrap())).await;
-        cluster.script(
-            &endpoint(0),
-            GET_CONSUMER_GROUP_CODE,
-            [existing_consumer_group()],
-        );
-
-        assert_eq!(
-            consumer.init_consumer_group().await,
-            Err(IggyError::LaneBlocked(op))
-        );
-
-        assert_eq!(cluster.count(JOIN_CONSUMER_GROUP_CODE), 1);
-        assert!(!consumer.joined_consumer_group.load(ORDERING));
-        consumer
-            .client
-            .read()
-            .await
-            .resolve_operation(op, (Instant::now() + OPERATION_TIMEOUT).into_std())
-            .await
-            .unwrap();
-        assert_eq!(cluster.count(JOIN_CONSUMER_GROUP_CODE), 2);
-    }
-
-    /// Polls one poll after another, as the consumer stream does, until one
-    /// succeeds.
-    async fn poll_once(consumer: &mut IggyConsumer) -> Result<ReceivedMessage, IggyError> {
-        consumer.next().await.ok_or(IggyError::ClientShutdown)?
-    }
-
-    async fn poll_until_success(consumer: &mut IggyConsumer) -> Option<ReceivedMessage> {
-        let mut last_error = None;
-        let polled = async {
-            loop {
-                match poll_once(consumer).await {
-                    Ok(polled) => return polled,
-                    Err(error) => last_error = Some(error),
-                }
-            }
-        };
-        let result = timeout(TEST_BUDGET, polled).await.ok();
-        if result.is_none() {
-            eprintln!(
-                "last poll error: {last_error:?}; joined: {}, can_poll: {}",
-                consumer.joined_consumer_group.load(ORDERING),
-                consumer.can_poll.load(ORDERING)
-            );
-        }
+        let result = reader.close().await;
+        info!("Consumer: {} has been shut down.", self.consumer_name);
         result
     }
 
-    #[tokio::test(start_paused = true)]
-    async fn given_a_live_session_when_a_replica_refuses_a_poll_with_stale_client_should_poll_again_without_a_new_sign_in()
-     {
-        let (client, cluster) = connect_to_three_replicas().await;
-        let mut consumer =
-            polling_consumer(client, Consumer::new(Identifier::numeric(1).unwrap())).await;
-        cluster.script(
-            &endpoint(PARTITION_PRIMARY),
-            POLL_MESSAGES_DEFERRED_CODE,
-            [Answer::Detached, available_poll()],
-        );
-
-        let refused = poll_once(&mut consumer).await;
-
-        assert!(
-            matches!(refused, Err(IggyError::StaleClient)),
-            "{refused:?}"
-        );
-        assert!(poll_until_success(&mut consumer).await.is_some());
-        assert!((2..=3).contains(&cluster.count(POLL_MESSAGES_DEFERRED_CODE)));
-        assert_eq!(cluster.count(LOGIN_REGISTER_CODE), 1);
-    }
-
-    #[tokio::test(start_paused = true)]
-    async fn given_a_lost_route_link_when_a_poll_outlasts_it_should_poll_again_once_the_link_is_back()
-     {
-        let (client, cluster) = connect_to_three_replicas().await;
-        let mut consumer =
-            polling_consumer(client, Consumer::new(Identifier::numeric(1).unwrap())).await;
-        let primary = endpoint(PARTITION_PRIMARY);
-        cluster.down.lock().unwrap().insert(primary.clone());
-        cluster.drop_connections(&primary);
-
-        let lost = timeout(POLL_TIMEOUT, poll_once(&mut consumer)).await;
-        assert!(
-            lost.is_err(),
-            "non-admission retries inside the consumer: {lost:?}"
-        );
-        cluster.down.lock().unwrap().clear();
-        cluster.script(&primary, POLL_MESSAGES_DEFERRED_CODE, [available_poll()]);
-        assert!(poll_until_success(&mut consumer).await.is_some());
-        assert_eq!(cluster.count(LOGIN_REGISTER_CODE), 1);
-    }
-
-    #[tokio::test(start_paused = true)]
-    async fn given_a_delayed_poll_error_when_sign_in_has_completed_should_keep_polling_enabled() {
-        let (client, cluster) = connect_to_three_replicas().await;
-        let mut consumer =
-            polling_consumer(client, Consumer::new(Identifier::numeric(1).unwrap())).await;
-        *cluster.session.lock().unwrap() = None;
-        cluster
-            .held_registers
-            .lock()
-            .unwrap()
-            .insert("iggy".to_owned());
-        cluster.script(
-            &endpoint(PARTITION_PRIMARY),
-            POLL_MESSAGES_DEFERRED_CODE,
-            [Answer::Evict(EvictionReason::NoSession), available_poll()],
-        );
-        let can_poll = Arc::clone(&consumer.can_poll);
-        let mut poll = Box::pin(poll_once(&mut consumer));
-        timeout(TEST_BUDGET, async {
-            while cluster.count(POLL_MESSAGES_DEFERRED_CODE) == 0 {
-                assert!(
-                    poll.as_mut()
-                        .poll(&mut Context::from_waker(Waker::noop()))
-                        .is_pending()
-                );
-                tokio::task::yield_now().await;
-            }
-        })
-        .await
-        .unwrap();
-        eventually("the failed poll triggers sign-in", || {
-            cluster.count(LOGIN_REGISTER_CODE) == 2
-        })
-        .await;
-        eventually("the consumer processes SignedOut", || {
-            !can_poll.load(ORDERING)
-        })
-        .await;
-        cluster.held_registers.lock().unwrap().clear();
-        eventually("the consumer processes SignedIn", || {
-            can_poll.load(ORDERING)
-        })
-        .await;
-
-        assert!(timeout(POLL_TIMEOUT, poll).await.unwrap().is_ok());
-        assert!(consumer.can_poll.load(ORDERING));
-    }
-
-    #[tokio::test(start_paused = true)]
-    async fn given_a_poll_lost_to_a_disconnect_when_consuming_callbacks_should_deliver_the_next_poll()
-     {
-        const OFFSET: u64 = 5;
-        let (client, cluster) = connect_to_three_replicas().await;
-        let mut consumer =
-            polling_consumer(client, Consumer::new(Identifier::numeric(1).unwrap())).await;
-        consumer.deferred_polls = Some(deferred::DeferredPolls::with_error(
-            &consumer,
-            IggyError::Disconnected,
-        ));
-        cluster.script(
-            &endpoint(PARTITION_PRIMARY),
-            POLL_MESSAGES_DEFERRED_CODE,
-            [poll_of_one_message(OFFSET)],
-        );
-        let (shutdown, receiver) = tokio::sync::oneshot::channel();
-        let handler = RecordMessages {
-            offsets: std::sync::Mutex::default(),
-            shutdown: std::sync::Mutex::new(Some(shutdown)),
-        };
-
-        let result = timeout(POLL_TIMEOUT, consumer.consume_messages(&handler, receiver)).await;
-
-        assert_eq!(result, Ok(Ok(())));
-        assert_eq!(*handler.offsets.lock().unwrap(), [OFFSET]);
-    }
-
-    #[tokio::test(start_paused = true)]
-    async fn given_a_lost_commit_reply_when_shutdown_comes_before_the_retry_should_store_the_delivered_offset_again()
-     {
-        const DELIVERED: u64 = 4;
-        let (client, cluster) = connect_to_three_replicas().await;
-        let mut consumer = committing_consumer(client).await;
-        record_delivered(&consumer, DELIVERED);
-        cluster.script(
-            &endpoint(PARTITION_PRIMARY),
-            POLL_MESSAGES_DEFERRED_CODE,
-            [
-                Answer::Refuse(IggyError::HistoryUnavailable),
-                Answer::Refuse(IggyError::TransientNotCommitted),
-            ],
-        );
-
-        let refused = poll_once(&mut consumer).await;
-        assert!(
-            matches!(refused, Err(IggyError::HistoryUnavailable)),
-            "{refused:?}"
-        );
-        assert_eq!(
-            consumer.get_last_stored_offset(POLLED_PARTITION),
-            Some(DELIVERED),
-            "a refused poll commits nothing"
-        );
-        let lost = poll_once(&mut consumer).await;
-        assert!(
-            matches!(lost, Err(IggyError::TransientNotCommitted)),
-            "{lost:?}"
-        );
-        consumer.shutdown().await.unwrap();
-
-        assert_eq!(
-            Served(cluster).stored_offsets(),
-            [DELIVERED],
-            "the server can have committed the batch of the lost reply"
-        );
-    }
-
-    #[tokio::test(start_paused = true)]
-    async fn given_a_committing_fetch_in_flight_when_shutdown_starts_should_rewind_over_its_batch()
-    {
-        const FIRST: u64 = 5;
-        const REPLY_DELAY: Duration = Duration::from_millis(100);
-        let (client, cluster) = connect_to_three_replicas().await;
-        let mut consumer = committing_consumer(client).await;
-        cluster.hold(POLL_MESSAGES_DEFERRED_CODE);
-        cluster.script(
-            &endpoint(PARTITION_PRIMARY),
-            POLL_MESSAGES_DEFERRED_CODE,
-            [poll_of_one_message(FIRST)],
-        );
-        start_fetch(&mut consumer, &cluster).await;
-        let started = Instant::now();
-
-        let (shut_down, ()) = tokio::join!(consumer.shutdown(), async {
-            sleep(REPLY_DELAY).await;
-            cluster.release(POLL_MESSAGES_DEFERRED_CODE);
-        });
-
-        shut_down.unwrap();
-        assert!(
-            started.elapsed() < consumer.offset_drain_timeout.get_duration(),
-            "the stop ends with the fetch, not with the drain timeout"
-        );
-        assert_eq!(
-            Served(cluster).stored_offsets(),
-            [FIRST - 1],
-            "the server committed a batch that the application never got"
-        );
-    }
-
-    #[tokio::test(start_paused = true)]
-    async fn given_a_committing_fetch_that_outlasts_the_drain_timeout_when_shutdown_aborts_it_should_store_the_delivered_offset_again()
-     {
-        const DELIVERED: u64 = 4;
-        let (client, cluster) = connect_to_three_replicas().await;
-        let mut consumer = committing_consumer(client).await;
-        assert!(
-            consumer.offset_drain_timeout.get_duration()
-                < consumer.poll_options.request_timeout.get_duration(),
-            "the drain timeout must end before the poll does"
-        );
-        record_delivered(&consumer, DELIVERED);
-        cluster.script(
-            &endpoint(PARTITION_PRIMARY),
-            POLL_MESSAGES_DEFERRED_CODE,
-            [Answer::Silence],
-        );
-        start_fetch(&mut consumer, &cluster).await;
-
-        consumer.shutdown().await.unwrap();
-
-        assert_eq!(
-            Served(cluster).stored_offsets(),
-            [DELIVERED],
-            "the server can commit the batch of the aborted poll"
-        );
-    }
-
-    #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
-    async fn given_a_shutdown_task_on_the_worker_thread_when_it_aborts_a_committing_fetch_should_store_after_the_fetch_drops()
-     {
-        const DELIVERED: u64 = 4;
-        const DRAIN_TIMEOUT: Duration = Duration::from_millis(50);
-        let (client, cluster) = connect_to_three_replicas().await;
-        let mut consumer = committing_consumer(client).await;
-        consumer.offset_drain_timeout = IggyDuration::from(DRAIN_TIMEOUT);
-        record_delivered(&consumer, DELIVERED);
-        cluster.script(
-            &endpoint(PARTITION_PRIMARY),
-            POLL_MESSAGES_DEFERRED_CODE,
-            [Answer::Silence],
-        );
-        start_fetch(&mut consumer, &cluster).await;
-
-        // The only worker thread runs the woken shutdown task before the
-        // aborted fetch task, which drops its future later.
-        let shut_down = tokio::spawn(async move { consumer.shutdown().await }).await;
-
-        assert!(matches!(shut_down, Ok(Ok(()))), "{shut_down:?}");
-        assert_eq!(
-            Served(cluster).stored_offsets(),
-            [DELIVERED],
-            "the server can commit the batch of the aborted poll"
-        );
-    }
-
-    #[tokio::test(start_paused = true)]
-    async fn given_a_committing_fetch_in_flight_when_the_generation_changes_should_hand_over_its_batch()
-     {
-        const FIRST: u64 = 5;
-        let (client, cluster) = connect_to_three_replicas().await;
-        let mut consumer = committing_consumer(client).await;
-        cluster.hold(POLL_MESSAGES_DEFERRED_CODE);
-        cluster.script(
-            &endpoint(PARTITION_PRIMARY),
-            POLL_MESSAGES_DEFERRED_CODE,
-            [poll_of_one_message(FIRST)],
-        );
-        start_fetch(&mut consumer, &cluster).await;
-
-        // The worker reads the generation only at the top of its loop, so it
-        // hands this batch over with the generation that the fetch started in.
-        consumer.fetch_generation.fetch_add(1, ORDERING);
-        cluster.release(POLL_MESSAGES_DEFERRED_CODE);
-        let polled = timeout(TEST_BUDGET, poll_once(&mut consumer)).await;
-
-        assert!(
-            matches!(&polled, Ok(Ok(received)) if received.message.header.offset == FIRST),
-            "the server committed this batch, so a new fetch generation must not drop it: {polled:?}"
-        );
-    }
-
-    /// Records `offset` of `POLLED_PARTITION` as the last delivered and stored message.
-    fn record_delivered(consumer: &IggyConsumer, offset: u64) {
-        let delivered = position_at(offset, 1);
-        consumer
-            .state
-            .last_consumed_offsets
-            .insert(POLLED_PARTITION, delivered);
-        consumer
-            .state
-            .last_stored_offsets
-            .insert(POLLED_PARTITION, delivered);
-    }
-
-    /// A consumer of `POLLED_PARTITION` whose every poll commits its batch.
-    async fn committing_consumer(client: TcpClient) -> IggyConsumer {
-        let mut consumer = builder_on(client, Consumer::new(Identifier::numeric(1).unwrap()))
-            .partition(Some(POLLED_PARTITION))
-            .auto_commit(AutoCommit::When(AutoCommitWhen::PollingMessages))
-            .polling_retry_interval(NonZeroIggyDuration::new(POLL_RETRY_INTERVAL).unwrap())
-            .build();
-        consumer.initialized = true;
-        consumer.events_task = Some(consumer.subscribe_events().await);
-        consumer
-    }
-
-    /// Polls `consumer` until its fetch reaches `cluster`, then leaves that fetch in flight.
-    async fn start_fetch(consumer: &mut IggyConsumer, cluster: &FakeCluster) {
-        let mut poll = Box::pin(poll_once(consumer));
-        timeout(TEST_BUDGET, async {
-            while cluster.count(POLL_MESSAGES_DEFERRED_CODE) == 0 {
-                assert!(
-                    poll.as_mut()
-                        .poll(&mut Context::from_waker(Waker::noop()))
-                        .is_pending()
-                );
-                tokio::task::yield_now().await;
-            }
-        })
-        .await
-        .unwrap();
-    }
-
-    #[tokio::test(start_paused = true)]
-    async fn given_a_signed_out_client_when_polls_fail_with_unauthenticated_should_back_off_between_attempts()
-     {
-        let (client, cluster) = connect_to_three_replicas().await;
-        client.logout_user().await.unwrap();
-        // No lifecycle task, as for a sign-out that `init` did not see yet.
-        let mut consumer = builder_on(client, Consumer::new(Identifier::numeric(1).unwrap()))
-            .partition(Some(POLLED_PARTITION))
-            .auto_commit(AutoCommit::Disabled)
-            .polling_retry_interval(NonZeroIggyDuration::new(POLL_RETRY_INTERVAL).unwrap())
-            .build();
-        consumer.initialized = true;
-        assert!(matches!(
-            poll_once(&mut consumer).await,
-            Err(IggyError::Unauthenticated)
-        ));
-        let started = Instant::now();
-
-        let polled = poll_once(&mut consumer).await;
-
-        assert!(
-            matches!(polled, Err(IggyError::Unauthenticated)),
-            "{polled:?}"
-        );
-        assert!(started.elapsed() >= POLL_RETRY_INTERVAL);
-        assert_eq!(cluster.count(POLL_MESSAGES_DEFERRED_CODE), 0);
-    }
-
-    #[tokio::test(start_paused = true)]
-    async fn given_a_signed_in_client_when_a_late_disconnected_event_arrives_should_keep_polling() {
-        let (client, cluster) = connect_to_three_replicas().await;
-        let mut consumer =
-            polling_consumer(client, Consumer::new(Identifier::numeric(1).unwrap())).await;
-        cluster.script(
-            &endpoint(PARTITION_PRIMARY),
-            POLL_MESSAGES_DEFERRED_CODE,
-            [available_poll()],
-        );
-
-        publish(
-            &consumer,
-            &[DiagnosticEvent::SignedIn, DiagnosticEvent::Disconnected],
-        )
-        .await;
-        sleep(POLL_RETRY_INTERVAL).await;
-        let polled = timeout(POLL_TIMEOUT, poll_once(&mut consumer)).await;
-
-        assert!(matches!(polled, Ok(Ok(_))), "{polled:?}");
-        assert!((1..=2).contains(&cluster.count(POLL_MESSAGES_DEFERRED_CODE)));
-    }
-
-    #[tokio::test(start_paused = true)]
-    async fn given_an_evicted_poll_when_the_client_signs_in_again_should_wait_for_signed_in() {
-        let (client, cluster) = connect_to_three_replicas().await;
-        let mut consumer =
-            polling_consumer(client, Consumer::new(Identifier::numeric(1).unwrap())).await;
-        *cluster.session.lock().unwrap() = None;
-        cluster
-            .held_registers
-            .lock()
-            .unwrap()
-            .insert("iggy".to_owned());
-        cluster.script(
-            &endpoint(PARTITION_PRIMARY),
-            POLL_MESSAGES_DEFERRED_CODE,
-            [Answer::Evict(EvictionReason::NoSession), available_poll()],
-        );
-
-        let evicted = timeout(POLL_TIMEOUT, poll_once(&mut consumer)).await;
-        assert!(
-            evicted.is_err(),
-            "polling waits for the new session: {evicted:?}"
-        );
-        eventually("the client signs in again", || {
-            cluster.count(LOGIN_REGISTER_CODE) == 2
-        })
-        .await;
-        let parked = timeout(POLL_TIMEOUT, poll_once(&mut consumer)).await;
-        assert!(parked.is_err(), "{parked:?}");
-        assert_eq!(cluster.count(POLL_MESSAGES_DEFERRED_CODE), 1);
-        cluster.held_registers.lock().unwrap().clear();
-        assert!(poll_until_success(&mut consumer).await.is_some());
-    }
-
-    #[tokio::test(start_paused = true)]
-    async fn given_a_group_member_when_a_poll_finds_its_session_ended_should_sign_in_and_join_again()
-     {
-        let (client, cluster) = connect_to_three_replicas().await;
-        let mut consumer =
-            polling_consumer(client, Consumer::group(Identifier::numeric(1).unwrap())).await;
-        let group = ConsumerGroupDetailsResponse {
-            group: ConsumerGroupResponse {
-                id: 1,
-                partitions_count: POLLED_PARTITION + 1,
-                members_count: 0,
-                name: WireName::new("consumer").unwrap(),
-            },
-            members: Vec::new(),
-        };
-        let assignment = SyncConsumerGroupResponse {
-            generation: 1,
-            partitions: vec![POLLED_PARTITION],
-        };
-        cluster.script(
-            &endpoint(0),
-            GET_CONSUMER_GROUP_CODE,
-            repeat_n(Answer::Body(group.to_bytes().to_vec()), 4),
-        );
-        cluster.script(
-            &endpoint(0),
-            SYNC_CONSUMER_GROUP_CODE,
-            repeat_n(Answer::Body(assignment.to_bytes().to_vec()), 4),
-        );
-        cluster.script(
-            &endpoint(PARTITION_PRIMARY),
-            POLL_MESSAGES_DEFERRED_CODE,
-            [Answer::Refuse(IggyError::StaleClient), available_poll()],
-        );
-
-        let refused = poll_once(&mut consumer).await;
-
-        assert!(
-            matches!(refused, Err(IggyError::StaleClient)),
-            "{refused:?}"
-        );
-        assert!(poll_until_success(&mut consumer).await.is_some());
-        assert_eq!(cluster.count(LOGIN_REGISTER_CODE), 2);
-        assert!((2..=3).contains(&cluster.count(JOIN_CONSUMER_GROUP_CODE)));
-    }
-
-    #[tokio::test(start_paused = true)]
-    async fn given_a_last_replica_link_that_gives_up_when_a_keepalive_restarts_the_links_should_pause_polling_and_keep_consuming_callbacks()
-     {
-        const OFFSET: u64 = 5;
-        let one_retry = TcpClientReconnectionConfig {
-            max_retries: Some(1),
-            ..TcpClientReconnectionConfig::default()
-        };
-        let (client, cluster) =
-            connect_to_three_replicas_with(ReconnectPolicy::from(&one_retry)).await;
-        let mut consumer =
-            polling_consumer(client, Consumer::new(Identifier::numeric(1).unwrap())).await;
-        take_every_replica_down(&cluster);
-        eventually("polling pauses once the last replica link gives up", || {
-            !consumer.can_poll.load(ORDERING)
-        })
-        .await;
-        cluster.down.lock().unwrap().clear();
-        cluster.script(
-            &endpoint(PARTITION_PRIMARY),
-            POLL_MESSAGES_DEFERRED_CODE,
-            [poll_of_one_message(OFFSET)],
-        );
-        let (shutdown, receiver) = tokio::sync::oneshot::channel();
-        let handler = RecordMessages {
-            offsets: std::sync::Mutex::default(),
-            shutdown: std::sync::Mutex::new(Some(shutdown)),
-        };
-        let client = consumer.client.clone();
-        let heartbeat = async {
-            sleep(POLL_TIMEOUT).await;
-            client.read().await.keepalive().await
-        };
-
-        let (consumed, keepalive) = tokio::join!(
-            timeout(TEST_BUDGET, consumer.consume_messages(&handler, receiver)),
-            heartbeat
-        );
-
-        assert_eq!(keepalive, Ok(true));
-        assert_eq!(consumed, Ok(Ok(())));
-        assert_eq!(*handler.offsets.lock().unwrap(), [OFFSET]);
-    }
-
-    #[tokio::test(start_paused = true)]
-    async fn given_reconnection_disabled_when_every_replica_link_is_lost_should_end_consuming_callbacks()
-     {
-        let disabled = TcpClientReconnectionConfig {
-            enabled: false,
-            ..TcpClientReconnectionConfig::default()
-        };
-        let (client, cluster) =
-            connect_to_three_replicas_with(ReconnectPolicy::from(&disabled)).await;
-        let mut consumer =
-            polling_consumer(client, Consumer::new(Identifier::numeric(1).unwrap())).await;
-        take_every_replica_down(&cluster);
-        eventually("polling pauses once the client is disconnected", || {
-            !consumer.can_poll.load(ORDERING)
-        })
-        .await;
-        let (_shutdown, receiver) = tokio::sync::oneshot::channel();
-
-        let result = timeout(
-            POLL_TIMEOUT,
-            consumer.consume_messages(&RejectMessages, receiver),
-        )
-        .await;
-
-        assert_eq!(result, Ok(Err(IggyError::Disconnected)));
-    }
-
-    #[tokio::test(start_paused = true)]
-    async fn given_a_shut_down_client_when_consuming_callbacks_should_end_with_client_shutdown() {
-        let (client, _cluster) = connect_to_three_replicas().await;
-        let mut consumer =
-            polling_consumer(client, Consumer::new(Identifier::numeric(1).unwrap())).await;
-        consumer.client.read().await.shutdown().await.unwrap();
-        eventually("polling stops once the client shuts down", || {
-            !consumer.can_poll.load(ORDERING)
-        })
-        .await;
-        let (_shutdown, receiver) = tokio::sync::oneshot::channel();
-
-        let result = timeout(
-            POLL_TIMEOUT,
-            consumer.consume_messages(&RejectMessages, receiver),
-        )
-        .await;
-
-        assert_eq!(result, Ok(Err(IggyError::ClientShutdown)));
-    }
-
-    #[tokio::test]
-    async fn given_an_uninitialized_consumer_when_consuming_callbacks_should_end_at_once() {
-        let mut consumer = builder_for(Consumer::default()).build();
-        let (_shutdown, receiver) = tokio::sync::oneshot::channel();
-
-        let result = timeout(
-            POLL_TIMEOUT,
-            consumer.consume_messages(&RejectMessages, receiver),
-        )
-        .await;
-
-        assert_eq!(result, Ok(Err(IggyError::InvalidConfiguration)));
-    }
-
-    #[tokio::test]
-    async fn given_disabled_auto_commit_and_next_when_consuming_callbacks_should_warn_once() {
-        let warnings = consume_three_messages(AutoCommit::Disabled, PollingStrategy::next()).await;
-
-        assert_eq!(
-            warnings.len(),
-            1,
-            "a run warns once, not per message: {warnings:?}"
-        );
-        assert!(warnings[0].contains("never stores offsets"), "{warnings:?}");
-    }
-
-    #[tokio::test]
-    async fn given_auto_commit_or_a_fixed_start_when_consuming_callbacks_should_not_warn() {
-        for (auto_commit, polling_strategy) in [
-            (
-                AutoCommit::When(AutoCommitWhen::PollingMessages),
-                PollingStrategy::next(),
-            ),
-            (AutoCommit::Disabled, PollingStrategy::first()),
-        ] {
-            let warnings = consume_three_messages(auto_commit, polling_strategy).await;
-
-            assert!(
-                warnings.is_empty(),
-                "{auto_commit:?} with {polling_strategy:?}: {warnings:?}"
-            );
+    /// Under `CommitAfter::Processing`, the message that the stream yielded last counts once the
+    /// stream is polled again or shuts down.
+    fn count_uncounted(&mut self) {
+        if let Some((checkpoint, ends_batch)) = self.uncounted.take() {
+            self.count(checkpoint, ends_batch);
         }
     }
-
-    /// Runs `consume_messages` over three buffered messages, ends it with a blocked poll, and
-    /// returns the warnings that the run logged.
-    async fn consume_three_messages(
-        auto_commit: AutoCommit,
-        polling_strategy: PollingStrategy,
-    ) -> Vec<String> {
-        const OPERATION: u64 = 42;
-        let mut consumer = builder()
-            .partition(Some(1))
-            .auto_commit(auto_commit)
-            .polling_strategy(polling_strategy)
-            .build();
-        consumer.initialized = true;
-        consumer.buffered_messages =
-            VecDeque::from(vec![message_at(1), message_at(2), message_at(3)]);
-        consumer.deferred_polls = Some(deferred::DeferredPolls::with_error(
-            &consumer,
-            IggyError::LaneBlocked(OPERATION),
-        ));
-        let handler = RecordMessages {
-            offsets: std::sync::Mutex::default(),
-            shutdown: std::sync::Mutex::new(None),
-        };
-        let (_shutdown, receiver) = tokio::sync::oneshot::channel();
-        let recorder = LevelReports::warnings();
-        let _subscriber = tracing::subscriber::set_default(recorder.clone());
-
-        let result = timeout(POLL_TIMEOUT, consumer.consume_messages(&handler, receiver)).await;
-
-        assert_eq!(result, Ok(Err(IggyError::LaneBlocked(OPERATION))));
-        assert_eq!(*handler.offsets.lock().unwrap(), [1, 2, 3]);
-        recorder.events.lock().unwrap().clone()
-    }
-
-    #[tokio::test]
-    async fn given_an_uninitialized_consumer_when_polled_should_fail_each_poll_and_warn_once() {
-        let mut consumer = builder_for(Consumer::default()).build();
-        let recorder = LevelReports::warnings();
-        let _subscriber = tracing::subscriber::set_default(recorder.clone());
-
-        let first = poll_once(&mut consumer).await;
-        let second = poll_once(&mut consumer).await;
-
-        for polled in [first, second] {
-            assert!(
-                matches!(polled, Err(IggyError::InvalidConfiguration)),
-                "every poll before init fails: {polled:?}"
-            );
-        }
-        let warnings = recorder.events.lock().unwrap().clone();
-        assert_eq!(
-            warnings.len(),
-            1,
-            "only the first poll before init warns: {warnings:?}"
-        );
-        assert!(
-            warnings[0].contains("was polled before init()"),
-            "{warnings:?}"
-        );
-    }
 }
+
+#[cfg(test)]
+mod tests;
diff --git a/core/sdk/src/clients/consumer/deferred.rs b/core/sdk/src/clients/consumer/deferred.rs
deleted file mode 100644
index d231fe9..0000000
--- a/core/sdk/src/clients/consumer/deferred.rs
+++ /dev/null
@@ -1,1954 +0,0 @@
-// Licensed to the Apache Software Foundation (ASF) under one
-// or more contributor license agreements.  See the NOTICE file
-// distributed with this work for additional information
-// regarding copyright ownership.  The ASF licenses this file
-// to you under the Apache License, Version 2.0 (the
-// "License"); you may not use this file except in compliance
-// with the License.  You may obtain a copy of the License at
-//
-//   http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing,
-// software distributed under the License is distributed on an
-// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-// KIND, either express or implied.  See the License for the
-// specific language governing permissions and limitations
-// under the License.
-
-use std::collections::{BTreeMap, BTreeSet};
-use std::ops::Bound::{Excluded, Unbounded};
-use std::sync::Arc;
-use std::sync::atomic::{AtomicBool, AtomicU64};
-use std::task::{Context, Poll};
-use std::time::Duration;
-
-use bytes::Bytes;
-use dashmap::DashMap;
-use iggy_common::locking::IggyRwLockFn;
-use iggy_common::{
-    AssignmentVersion, ConsumerPosition, EncryptorKind, IggyError, IggyMessage, MessageClient,
-    PolledMessages, PollingKind, PollingStrategy,
-};
-use tokio::sync::{Notify, OwnedSemaphorePermit, Semaphore, mpsc};
-use tokio::task::{AbortHandle, Id, JoinHandle, JoinSet};
-use tokio::time::{Instant, MissedTickBehavior};
-use tracing::error;
-
-use super::{IggyConsumer, ORDERING, same_incarnation_and_owner};
-use crate::driver::{
-    DEFERRED_CONSUMER_CAPACITY, MAX_DEFERRED_REQUESTS_PER_CONSUMER, with_deadline,
-};
-
-const ASSIGNMENT_REFRESH_INTERVAL: Duration = Duration::from_secs(1);
-pub(crate) const DEFAULT_PREFETCH_BYTES: u32 = 16 * 1024 * 1024;
-pub(crate) const DEFAULT_PREFETCH_MESSAGES: u32 = 16_000;
-
-/// A reservation covers the whole possible reply, including work still in flight.
-/// Keep it until the batch is drained: message slices can share its backing allocation.
-pub(super) struct FetchCapacity {
-    permit: Option<OwnedSemaphorePermit>,
-    wake: Arc<Notify>,
-}
-
-impl Drop for FetchCapacity {
-    fn drop(&mut self) {
-        drop(self.permit.take());
-        self.wake.notify_one();
-    }
-}
-
-pub(super) struct PrefetchedBatch {
-    pub generation: u64,
-    pub assignment: Option<AssignmentVersion>,
-    pub result: Result<PolledMessages, IggyError>,
-    pub capacity: FetchCapacity,
-}
-
-pub(super) struct DeferredPolls {
-    receiver: mpsc::Receiver<PrefetchedBatch>,
-    wake: Arc<Notify>,
-    task: JoinHandle<()>,
-}
-
-impl DeferredPolls {
-    pub(super) fn start(consumer: &IggyConsumer) -> Self {
-        let slots = consumer.prefetch_slots();
-        let (sender, receiver) = mpsc::channel(slots);
-        let worker = Worker::new(consumer, sender);
-        Self {
-            receiver,
-            wake: Arc::clone(&worker.wake),
-            task: tokio::spawn(worker.run()),
-        }
-    }
-
-    #[cfg(test)]
-    pub(super) fn with_error(consumer: &IggyConsumer, error: IggyError) -> Self {
-        let (sender, receiver) = mpsc::channel(consumer.prefetch_slots());
-        let worker = Worker::new(consumer, sender.clone());
-        let capacity = worker.reserve().unwrap();
-        let generation = worker.epoch;
-        sender
-            .try_send(PrefetchedBatch {
-                generation,
-                assignment: None,
-                result: Err(error),
-                capacity,
-            })
-            .unwrap_or_else(|_| panic!("the test queue is empty"));
-        Self {
-            receiver,
-            wake: Arc::clone(&worker.wake),
-            task: tokio::spawn(async move {
-                worker.wake.notified().await;
-                worker.run().await;
-            }),
-        }
-    }
-
-    pub(super) fn poll_recv(&mut self, cx: &mut Context<'_>) -> Poll<Option<PrefetchedBatch>> {
-        self.receiver.poll_recv(cx)
-    }
-
-    /// Stops fetching and returns the polled batches that the application did not take.
-    ///
-    /// The server can commit the batch of a committing fetch in flight. With a `grace`, the
-    /// woken worker sees the shutdown flag of the consumer and lets its fetches hand their
-    /// batches over first. After the grace, a fetch still in flight is aborted, and its
-    /// pending commit forgets the stored offset.
-    pub(super) async fn stop(mut self, grace: Option<Duration>) -> Vec<PolledMessages> {
-        let ended = match grace {
-            Some(grace) => {
-                self.wake.notify_one();
-                tokio::time::timeout(grace, &mut self.task).await.is_ok()
-            }
-            None => false,
-        };
-        if !ended {
-            self.task.abort();
-        }
-        // The channel closes once the worker and every pending commit are gone.
-        // The drain then holds every batch, and each aborted fetch already
-        // forgot its stored offset.
-        let mut undelivered = Vec::new();
-        while let Some(batch) = self.receiver.recv().await {
-            if let Ok(polled) = batch.result {
-                undelivered.push(polled);
-            }
-        }
-        undelivered
-    }
-}
-
-impl Drop for DeferredPolls {
-    fn drop(&mut self) {
-        self.task.abort();
-    }
-}
-
-struct CompletedPoll {
-    /// The fetch state that started the fetch.
-    epoch: u64,
-    partition: u32,
-    /// The position that the fetch read from.
-    strategy: PollingStrategy,
-    result: Result<PolledMessages, IggyError>,
-    capacity: FetchCapacity,
-    /// Set for a fetch that commits its batch.
-    commit: Option<PendingCommit>,
-}
-
-/// The server can commit the batch of a committing fetch whose reply never
-/// reaches the application. Unless the fetch settles, dropping this forgets
-/// the stored offset of its partition. A shutdown then stores the last
-/// delivered message again, which moves the server offset back to it.
-struct PendingCommit {
-    partition: u32,
-    consumed: Arc<DashMap<u32, ConsumerPosition>>,
-    stored: Arc<DashMap<u32, ConsumerPosition>>,
-    /// An aborted worker leaves the drop of a fetch in flight to the task of that
-    /// fetch. Until then, this keeps the batch channel open for `stop` to wait on.
-    _batches: mpsc::Sender<PrefetchedBatch>,
-    settled: bool,
-}
-
-impl PendingCommit {
-    fn settle(mut self) {
-        self.settled = true;
-    }
-}
-
-impl Drop for PendingCommit {
-    fn drop(&mut self) {
-        if self.settled {
-            return;
-        }
-        // A record that differs from the delivered message makes a shutdown
-        // store that message anyway. Such a record can also be an offset that
-        // the application stored to skip a message.
-        let consumed = self
-            .consumed
-            .get(&self.partition)
-            .map(|entry| *entry.value());
-        self.stored
-            .remove_if(&self.partition, |_, stored| consumed == Some(*stored));
-    }
-}
-
-struct CompletedWait {
-    partitions: Vec<u32>,
-    result: Result<Vec<u32>, IggyError>,
-}
-
-/// The assignment that a refresh read.
-struct Refreshed {
-    /// The fetch state that started the refresh.
-    epoch: u64,
-    assignment: AssignmentVersion,
-    partitions: Vec<u32>,
-    /// Set when readiness waits cover the partitions.
-    groups: Option<Vec<Vec<u32>>>,
-}
-
-/// Readiness state of the fetches that start only once a partition holds a
-/// message: every fetch of `AutoCommitWhen::PollingMessages`, where each poll
-/// commits its batch on the server, and the fetches of a group member with
-/// more partitions than fetch slots.
-struct OnDemand {
-    /// Set while the application waits for a message and its buffer is empty.
-    demand: Arc<AtomicBool>,
-    /// One readiness wait per group that has idle partitions.
-    waits: JoinSet<CompletedWait>,
-    /// The assigned partitions, grouped by primary endpoint and split to fit
-    /// one wait request each.
-    groups: Vec<Vec<u32>>,
-    /// Partitions that hold a message for their next poll.
-    ready: BTreeSet<u32>,
-    /// The wait that covers each waiting partition.
-    waiting: BTreeMap<u32, AbortHandle>,
-    /// The offset after the newest message that a `Timestamp` poll skipped.
-    floors: BTreeMap<u32, u64>,
-}
-
-impl OnDemand {
-    fn clear(&mut self) {
-        self.waits = JoinSet::new();
-        self.groups.clear();
-        self.ready.clear();
-        self.waiting.clear();
-        self.floors.clear();
-    }
-
-    /// A wait can end before the restart that replaces it cancels it. Only
-    /// the wait that covers the partitions now reports them.
-    fn covers(&self, wait: Id, partitions: &[u32]) -> bool {
-        partitions.iter().all(|partition| {
-            self.waiting
-                .get(partition)
-                .is_some_and(|running| running.id() == wait)
-        })
-    }
-}
-
-struct Worker {
-    client: iggy_common::locking::IggyRwLock<crate::client_wrappers::client_wrapper::ClientWrapper>,
-    stream: Arc<iggy_common::Identifier>,
-    topic: Arc<iggy_common::Identifier>,
-    consumer: Arc<iggy_common::Consumer>,
-    name: String,
-    standalone_partition: u32,
-    is_group: bool,
-    auto_join: bool,
-    create_group: bool,
-    joined: Arc<std::sync::atomic::AtomicBool>,
-    uncertain_group_operation: Arc<tokio::sync::Mutex<Option<u64>>>,
-    can_poll: Arc<std::sync::atomic::AtomicBool>,
-    shutdown: Arc<AtomicBool>,
-    generation: Arc<AtomicU64>,
-    wake: Arc<Notify>,
-    strategy: PollingStrategy,
-    options: iggy_common::DeferredPollOptions,
-    count: u32,
-    auto_commit: bool,
-    encryptor: Option<Arc<iggy_common::EncryptorKind>>,
-    retry_interval: Duration,
-    sender: mpsc::Sender<PrefetchedBatch>,
-    capacity: Arc<Semaphore>,
-    request_capacity: Arc<Semaphore>,
-    /// The permits of `capacity`.
-    slots: usize,
-    tasks: JoinSet<CompletedPoll>,
-    /// At most one refresh, apart from the fetches, so a slow one never holds
-    /// their replies back.
-    refreshing: JoinSet<Result<Refreshed, IggyError>>,
-    active: BTreeSet<u32>,
-    /// Partitions whose next record exceeds `max_bytes`.
-    oversized: BTreeSet<u32>,
-    positions: BTreeMap<u32, PollingStrategy>,
-    /// The last message that the application took from each partition.
-    consumed: Arc<DashMap<u32, ConsumerPosition>>,
-    /// The last offset that the application stored for each partition.
-    stored: Arc<DashMap<u32, ConsumerPosition>>,
-    retry_at: BTreeMap<u32, Instant>,
-    partitions: Vec<u32>,
-    assignment: Option<AssignmentVersion>,
-    pending_error: Option<IggyError>,
-    epoch: u64,
-    cursor: Option<u32>,
-    on_demand: OnDemand,
-}
-
-impl Worker {
-    fn new(consumer: &IggyConsumer, sender: mpsc::Sender<PrefetchedBatch>) -> Self {
-        let slots = consumer.prefetch_slots();
-        Self {
-            client: consumer.client.clone(),
-            stream: Arc::clone(&consumer.stream_id),
-            topic: Arc::clone(&consumer.topic_id),
-            consumer: Arc::clone(&consumer.consumer),
-            name: consumer.consumer_name.clone(),
-            standalone_partition: consumer.partition_id.unwrap_or_default(),
-            is_group: consumer.is_consumer_group,
-            auto_join: consumer.auto_join_consumer_group,
-            create_group: consumer.create_consumer_group_if_not_exists,
-            joined: Arc::clone(&consumer.joined_consumer_group),
-            uncertain_group_operation: Arc::clone(&consumer.uncertain_consumer_group_operation),
-            can_poll: Arc::clone(&consumer.can_poll),
-            shutdown: Arc::clone(&consumer.shutdown),
-            generation: Arc::clone(&consumer.fetch_generation),
-            wake: Arc::clone(&consumer.fetch_notify),
-            strategy: consumer.polling_strategy,
-            options: consumer.poll_options,
-            count: consumer.batch_length,
-            auto_commit: consumer.auto_commit_after_polling,
-            encryptor: consumer.encryptor.clone(),
-            retry_interval: consumer.reconnection_retry_interval.get_duration(),
-            sender,
-            capacity: Arc::new(Semaphore::new(slots)),
-            request_capacity: Arc::new(Semaphore::new(MAX_DEFERRED_REQUESTS_PER_CONSUMER)),
-            slots,
-            tasks: JoinSet::new(),
-            refreshing: JoinSet::new(),
-            active: BTreeSet::new(),
-            oversized: BTreeSet::new(),
-            positions: BTreeMap::new(),
-            consumed: Arc::clone(&consumer.state.last_consumed_offsets),
-            stored: Arc::clone(&consumer.state.last_stored_offsets),
-            retry_at: BTreeMap::new(),
-            partitions: Vec::new(),
-            assignment: None,
-            pending_error: None,
-            epoch: consumer.fetch_generation.load(ORDERING),
-            cursor: None,
-            on_demand: OnDemand {
-                demand: Arc::clone(&consumer.fetch_demand),
-                waits: JoinSet::new(),
-                groups: Vec::new(),
-                ready: BTreeSet::new(),
-                waiting: BTreeMap::new(),
-                floors: BTreeMap::new(),
-            },
-        }
-    }
-
-    async fn run(mut self) {
-        let mut refresh = tokio::time::interval(ASSIGNMENT_REFRESH_INTERVAL);
-        refresh.set_missed_tick_behavior(MissedTickBehavior::Delay);
-        loop {
-            if self.sender.is_closed() {
-                return;
-            }
-            if self.shutdown.load(ORDERING) {
-                self.finish_fetches().await;
-                return;
-            }
-            if self.epoch != self.generation.load(ORDERING) {
-                self.rewind();
-                self.epoch = self.generation.load(ORDERING);
-                refresh.reset_immediately();
-            }
-            let enabled =
-                self.can_poll.load(ORDERING) || !self.client.read().await.can_recover().await;
-            self.flush_error();
-            if enabled {
-                self.schedule();
-            }
-            let retry_at = self
-                .retry_at
-                .values()
-                .min()
-                .copied()
-                .unwrap_or_else(|| Instant::now() + ASSIGNMENT_REFRESH_INTERVAL);
-            tokio::select! {
-                _ = self.sender.closed() => return,
-                _ = self.wake.notified() => {},
-                _ = tokio::time::sleep_until(retry_at), if !self.retry_at.is_empty() => {
-                    self.retry_at.retain(|_, deadline| *deadline > Instant::now());
-                },
-                _ = refresh.tick() => {
-                    if enabled && self.refreshing.is_empty() {
-                        let deadline = Instant::now() + self.options.request_timeout.get_duration();
-                        self.refreshing.spawn(with_deadline(deadline, self.read_assignment()));
-                    }
-                },
-                Some(refreshed) = self.refreshing.join_next(), if !self.refreshing.is_empty() => {
-                    match refreshed {
-                        Ok(Ok(refreshed)) => {
-                            if self.apply(refreshed) {
-                                refresh.reset();
-                            } else {
-                                refresh.reset_immediately();
-                            }
-                        }
-                        Ok(Err(error)) => {
-                            self.report(error);
-                            refresh.reset_after(self.retry_interval);
-                        }
-                        Err(_) => {
-                            self.invalidate();
-                            self.report(IggyError::ClientShutdown);
-                            return;
-                        }
-                    }
-                },
-                Some(completed) = self.tasks.join_next(), if !self.tasks.is_empty() => {
-                    match completed {
-                        Ok(completed) => {
-                            self.complete(completed);
-                            if self.partitions.is_empty() {
-                                refresh.reset_after(self.retry_interval);
-                            }
-                        },
-                        Err(_) => {
-                            self.invalidate();
-                            self.report(IggyError::ClientShutdown);
-                            return;
-                        }
-                    }
-                }
-                Some(joined) = self.on_demand.waits.join_next_with_id(), if !self.on_demand.waits.is_empty() => {
-                    match joined {
-                        Ok((wait, completed)) => {
-                            if self.on_demand.covers(wait, &completed.partitions) {
-                                self.complete_wait(completed);
-                            }
-                        }
-                        // A restart cancelled the wait.
-                        Err(error) if error.is_cancelled() => {}
-                        Err(_) => {
-                            self.invalidate();
-                            self.report(IggyError::ClientShutdown);
-                            return;
-                        }
-                    }
-                    if self.partitions.is_empty() {
-                        refresh.reset_after(self.retry_interval);
-                    }
-                }
-            }
-        }
-    }
-
-    /// The server can commit the batch of a committing fetch in flight, so the
-    /// worker ends once its fetches hand their batches over. Nothing new starts.
-    async fn finish_fetches(&mut self) {
-        while let Some(Ok(completed)) = self.tasks.join_next().await {
-            self.complete(completed);
-        }
-    }
-
-    /// A new fetch generation discards every batch that the application did
-    /// not take, so each partition continues after its last delivered message.
-    /// The server already committed the batch of a committing fetch, so that
-    /// fetch finishes, delivers it and moves its position. Nothing is fetched
-    /// until the next refresh confirms the assignment.
-    fn rewind(&mut self) {
-        if !self.auto_commit {
-            self.tasks = JoinSet::new();
-            self.active.clear();
-            let consumed = &self.consumed;
-            // A partition without a delivered message in the incarnation of
-            // its position restarts from the initial strategy.
-            self.positions.retain(|partition, position| {
-                let Some(consumed) = consumed.get(partition).map(|entry| *entry.value()) else {
-                    return false;
-                };
-                let same_incarnation = position
-                    .context
-                    .is_some_and(|context| same_incarnation_and_owner(context, consumed.context));
-                match consumed.offset.checked_add(1) {
-                    Some(next) if same_incarnation => {
-                        *position = PollingStrategy::offset(next).with_context(consumed.context);
-                        true
-                    }
-                    _ => false,
-                }
-            });
-        }
-        self.on_demand.clear();
-        self.oversized.clear();
-        self.retry_at.clear();
-        self.partitions.clear();
-        self.pending_error = None;
-    }
-
-    fn invalidate(&mut self) {
-        self.epoch = self.generation.fetch_add(1, ORDERING) + 1;
-        self.rewind();
-    }
-
-    fn read_assignment(
-        &self,
-    ) -> impl Future<Output = Result<Refreshed, IggyError>> + Send + 'static {
-        let client = self.client.clone();
-        let stream = Arc::clone(&self.stream);
-        let topic = Arc::clone(&self.topic);
-        let consumer = Arc::clone(&self.consumer);
-        let name = self.name.clone();
-        let joined = Arc::clone(&self.joined);
-        let uncertain_group_operation = Arc::clone(&self.uncertain_group_operation);
-        let join = self.is_group && self.auto_join;
-        let create_group = self.create_group;
-        let is_group = self.is_group;
-        let standalone_partition = self.standalone_partition;
-        let (auto_commit, slots) = (self.auto_commit, self.slots);
-        let epoch = self.epoch;
-        async move {
-            if join && !joined.load(ORDERING) {
-                IggyConsumer::initialize_consumer_group(
-                    client.clone(),
-                    create_group,
-                    Arc::clone(&stream),
-                    Arc::clone(&topic),
-                    &name,
-                    joined,
-                    uncertain_group_operation,
-                )
-                .await?;
-            }
-            let (assignment, mut partitions) = if is_group {
-                client
-                    .read()
-                    .await
-                    .deferred_poll_partitions(&stream, &topic, &consumer.id)
-                    .await?
-            } else {
-                (AssignmentVersion::default(), vec![standalone_partition])
-            };
-            partitions.sort_unstable();
-            let groups = if waits_for_readiness(is_group, auto_commit, partitions.len(), slots)
-                && !partitions.is_empty()
-            {
-                Some(
-                    client
-                        .read()
-                        .await
-                        .readiness_groups(&stream, &topic, &partitions)
-                        .await?,
-                )
-            } else {
-                None
-            };
-            Ok(Refreshed {
-                epoch,
-                assignment,
-                partitions,
-                groups,
-            })
-        }
-    }
-
-    /// Returns false for a refresh that started before the current fetch
-    /// state, which only a new refresh replaces.
-    fn apply(&mut self, refreshed: Refreshed) -> bool {
-        if refreshed.epoch != self.epoch || self.epoch != self.generation.load(ORDERING) {
-            return false;
-        }
-        if self.assignment != Some(refreshed.assignment) {
-            self.invalidate();
-            // The server keeps the owner of a partition that stays with its
-            // member, so that partition continues from its position. Every
-            // partition has a new owner in a new session. A partition that
-            // moved away and back between two refreshes has one too: the
-            // server refuses a poll from its old position, and the worker
-            // then drops that position.
-            if self
-                .assignment
-                .is_some_and(|assignment| assignment.session == refreshed.assignment.session)
-            {
-                self.positions
-                    .retain(|partition, _| refreshed.partitions.binary_search(partition).is_ok());
-            } else {
-                self.positions.clear();
-            }
-            self.assignment = Some(refreshed.assignment);
-        }
-        self.partitions = refreshed.partitions;
-        if let Some(groups) = refreshed.groups
-            && groups != self.on_demand.groups
-        {
-            // A restart cancels the wait of its own group only, so no wait
-            // may cover partitions of two groups.
-            self.on_demand.waits = JoinSet::new();
-            self.on_demand.waiting.clear();
-            self.on_demand.groups = groups;
-        }
-        true
-    }
-
-    fn reserve(&self) -> Option<FetchCapacity> {
-        Arc::clone(&self.capacity)
-            .try_acquire_owned()
-            .ok()
-            .map(|permit| FetchCapacity {
-                permit: Some(permit),
-                wake: Arc::clone(&self.wake),
-            })
-    }
-
-    fn report(&mut self, error: IggyError) {
-        if matches!(error, IggyError::ConsumerGroupMemberNotFound(..)) {
-            self.joined.store(false, ORDERING);
-            if self.auto_join {
-                return;
-            }
-        }
-        if matches!(
-            error,
-            IggyError::Disconnected | IggyError::Unauthenticated | IggyError::StaleClient
-        ) {
-            self.joined.store(false, ORDERING);
-        }
-        self.pending_error = Some(error);
-        self.flush_error();
-    }
-
-    fn flush_error(&mut self) {
-        let Some(error) = self.pending_error.take() else {
-            return;
-        };
-        let Some(capacity) = self.reserve() else {
-            self.pending_error = Some(error);
-            return;
-        };
-        self.deliver(PrefetchedBatch {
-            generation: self.epoch,
-            assignment: self.assignment,
-            result: Err(error),
-            capacity,
-        });
-    }
-
-    /// The application takes one item per demand and asks again for the next.
-    fn deliver(&self, batch: PrefetchedBatch) {
-        self.on_demand.demand.store(false, ORDERING);
-        let _ = self.sender.try_send(batch);
-    }
-
-    fn schedule(&mut self) {
-        if self.partitions.is_empty() {
-            return;
-        }
-        if self.auto_commit {
-            self.schedule_on_demand();
-            return;
-        }
-        if self.waits_for_readiness() {
-            self.schedule_ready();
-            return;
-        }
-        let start = self.cursor.map_or(0, |cursor| {
-            self.partitions.partition_point(|id| *id <= cursor)
-        });
-        for index in 0..self.partitions.len() {
-            if self.tasks.len() >= MAX_DEFERRED_REQUESTS_PER_CONSUMER {
-                break;
-            }
-            let partition = self.partitions[(start + index) % self.partitions.len()];
-            if self.active.contains(&partition) || self.retry_at.contains_key(&partition) {
-                continue;
-            }
-            let Some(capacity) = self.reserve() else {
-                break;
-            };
-            self.spawn_fetch(partition, capacity);
-        }
-    }
-
-    fn waits_for_readiness(&self) -> bool {
-        waits_for_readiness(
-            self.is_group,
-            self.auto_commit,
-            self.partitions.len(),
-            self.slots,
-        )
-    }
-
-    fn schedule_ready(&mut self) {
-        while let Some(partition) = self.next_ready() {
-            let Some(capacity) = self.reserve() else {
-                break;
-            };
-            self.spawn_fetch(partition, capacity);
-        }
-        self.start_waits();
-    }
-
-    /// Every poll of this mode commits its batch. The worker fetches one batch
-    /// per demand, so it never holds a committed batch that the application
-    /// did not ask for. A wait commits nothing, so the waits run without
-    /// demand.
-    fn schedule_on_demand(&mut self) {
-        if self.on_demand.demand.load(ORDERING) && self.tasks.is_empty() {
-            let partition = if self.is_group {
-                self.next_ready()
-            } else {
-                self.partitions.first().copied()
-            };
-            if let Some(partition) =
-                partition.filter(|partition| !self.retry_at.contains_key(partition))
-                && let Some(capacity) = self.reserve()
-            {
-                self.spawn_fetch(partition, capacity);
-            }
-        }
-        self.start_waits();
-    }
-
-    fn next_ready(&self) -> Option<u32> {
-        let after = self.cursor.map_or(Unbounded, Excluded);
-        self.on_demand
-            .ready
-            .range((after, Unbounded))
-            .chain(&self.on_demand.ready)
-            .copied()
-            .find(|partition| {
-                !self.retry_at.contains_key(partition) && !self.active.contains(partition)
-            })
-    }
-
-    /// One readiness wait per group covers the idle partitions of the group:
-    /// those that are not ready, not fetched and not retrying. A wait takes
-    /// no partitions after it starts, so a partition that becomes idle
-    /// restarts the wait of its group. A second wait would hold one more of
-    /// the deferred requests that the fetches of the consumer share.
-    fn start_waits(&mut self) {
-        let idle = |partition: &&u32| {
-            !self.on_demand.ready.contains(*partition)
-                && !self.retry_at.contains_key(*partition)
-                && !self.active.contains(*partition)
-        };
-        for group in &self.on_demand.groups {
-            if group
-                .iter()
-                .filter(idle)
-                .all(|partition| self.on_demand.waiting.contains_key(partition))
-            {
-                continue;
-            }
-            for partition in group {
-                if let Some(wait) = self.on_demand.waiting.remove(partition) {
-                    wait.abort();
-                }
-            }
-            let targets: Vec<_> = group
-                .iter()
-                .filter(idle)
-                .map(|partition| (*partition, self.wait_target(*partition)))
-                .collect();
-            let partitions: Vec<_> = targets.iter().map(|(partition, _)| *partition).collect();
-            let client = self.client.clone();
-            let stream = Arc::clone(&self.stream);
-            let topic = Arc::clone(&self.topic);
-            let consumer = Arc::clone(&self.consumer);
-            let options = self.options;
-            let wait = self.on_demand.waits.spawn(DEFERRED_CONSUMER_CAPACITY.scope(
-                Arc::clone(&self.request_capacity),
-                async move {
-                    let result =
-                        tokio::time::timeout(options.request_timeout.get_duration(), async {
-                            let client = client.read().await;
-                            client
-                                .wait_for_messages(&stream, &topic, &targets, &consumer, options)
-                                .await
-                        })
-                        .await
-                        .unwrap_or(Err(IggyError::TransientNotAccepted))
-                        // A wait changes nothing on the server, so a lost reply only retries.
-                        .map_err(|error| match error {
-                            IggyError::TransientNotCommitted => IggyError::TransientNotAccepted,
-                            error => error,
-                        });
-                    CompletedWait {
-                        partitions: targets
-                            .into_iter()
-                            .map(|(partition, _)| partition)
-                            .collect(),
-                        result,
-                    }
-                },
-            ));
-            self.on_demand.waiting.extend(
-                partitions
-                    .into_iter()
-                    .map(|partition| (partition, wait.clone())),
-            );
-        }
-    }
-
-    /// A stored offset at or after the position moves it past that offset, so
-    /// the application can skip a message that never reads, such as one that
-    /// does not decrypt.
-    fn position(&self, partition: u32) -> PollingStrategy {
-        let position = self
-            .positions
-            .get(&partition)
-            .copied()
-            .unwrap_or(self.strategy);
-        let stored = self.stored.get(&partition).map(|entry| *entry.value());
-        match (position.kind, position.context, stored) {
-            (PollingKind::Offset, Some(context), Some(stored))
-                if same_incarnation_and_owner(context, stored.context)
-                    && stored.offset >= position.value =>
-            {
-                PollingStrategy::offset(stored.offset.saturating_add(1)).with_context(context)
-            }
-            _ => position,
-        }
-    }
-
-    /// A readiness wait takes an offset or `Next`. Before the first batch, a
-    /// `First`, `Last` or `Timestamp` start waits for any message after the
-    /// ones that a timestamp skipped.
-    fn wait_target(&self, partition: u32) -> PollingStrategy {
-        let strategy = self.position(partition);
-        match strategy.kind {
-            PollingKind::Next | PollingKind::Offset => strategy,
-            _ => PollingStrategy::offset(
-                self.on_demand
-                    .floors
-                    .get(&partition)
-                    .copied()
-                    .unwrap_or_default(),
-            ),
-        }
-    }
-
-    fn spawn_fetch(&mut self, partition: u32, capacity: FetchCapacity) {
-        let client = self.client.clone();
-        let stream = Arc::clone(&self.stream);
-        let topic = Arc::clone(&self.topic);
-        let consumer = Arc::clone(&self.consumer);
-        let strategy = self.position(partition);
-        // The record alone, without a byte limit, as a plain poll reads it.
-        let (count, options) = if self.oversized.contains(&partition) {
-            let options = iggy_common::DeferredPollOptions {
-                min_count: 1,
-                max_bytes: u32::MAX,
-                ..self.options
-            };
-            (1, options)
-        } else {
-            (self.count, self.options)
-        };
-        let auto_commit = self.auto_commit;
-        let commit = auto_commit.then(|| PendingCommit {
-            partition,
-            consumed: Arc::clone(&self.consumed),
-            stored: Arc::clone(&self.stored),
-            _batches: self.sender.clone(),
-            settled: false,
-        });
-        let encryptor = self.encryptor.clone();
-        let epoch = self.epoch;
-        self.tasks.spawn(DEFERRED_CONSUMER_CAPACITY.scope(
-            Arc::clone(&self.request_capacity),
-            async move {
-            let started = Instant::now();
-            let result = tokio::time::timeout(options.request_timeout.get_duration(), async {
-                let client = client.read().await;
-                let mut result = client
-                    .poll_messages_deferred(
-                        &stream,
-                        &topic,
-                        Some(partition),
-                        &consumer,
-                        &strategy,
-                        count,
-                        auto_commit,
-                        options.remaining(started.elapsed())?,
-                    )
-                    .await?;
-                if result.count > count || result.messages.len() > count as usize {
-                    return Err(IggyError::InvalidMessagesCount);
-                }
-                if let Some(encryptor) = encryptor {
-                    for message in &mut result.messages {
-                        let offset = message.header.offset;
-                        decrypt(&encryptor, message).inspect_err(|error| {
-                            error!(
-                                "Failed to decrypt the message at offset: {offset}, partition ID: {partition}. Storing its offset skips it. {error}"
-                            );
-                        })?;
-                    }
-                }
-                Ok(result)
-            })
-            .await
-            // A fetch that commits nothing leaves nothing behind its timeout.
-            .unwrap_or(Err(if auto_commit {
-                IggyError::TransientNotCommitted
-            } else {
-                IggyError::TransientNotAccepted
-            }));
-            CompletedPoll {
-                epoch,
-                partition,
-                strategy,
-                result,
-                capacity,
-                commit,
-            }
-            },
-        ));
-        self.active.insert(partition);
-        self.cursor = Some(partition);
-    }
-
-    fn complete(&mut self, mut completed: CompletedPoll) {
-        self.active.remove(&completed.partition);
-        // A reply that never arrived can commit a batch that never reaches the
-        // application. So can a reply that the worker drops for holding more
-        // messages than it asked for.
-        if !matches!(
-            completed.result,
-            Err(IggyError::TransientNotCommitted | IggyError::InvalidMessagesCount)
-        ) && let Some(commit) = completed.commit.take()
-        {
-            commit.settle();
-        }
-        // A record above `max_bytes` would stall its partition, so the next
-        // fetch reads it alone. Only a failure of that fetch is reported.
-        if !self.oversized.remove(&completed.partition)
-            && matches!(completed.result, Err(IggyError::InvalidSizeBytes))
-            && completed.epoch == self.epoch
-            && self.epoch == self.generation.load(ORDERING)
-        {
-            self.oversized.insert(completed.partition);
-            return;
-        }
-        if self.auto_commit {
-            self.complete_on_demand(completed);
-            return;
-        }
-        if self.epoch != self.generation.load(ORDERING) {
-            return;
-        }
-        self.positions
-            .insert(completed.partition, completed.strategy);
-        if let Ok(result) = &completed.result {
-            if result.partition_id == iggy_common::RESYNC_REQUIRED_PARTITION_SENTINEL {
-                self.invalidate();
-                return;
-            }
-            self.track_readiness(completed.partition, completed.strategy, result);
-            if let Some(last) = result.messages.last() {
-                match last.header.offset.checked_add(1) {
-                    Some(next) => {
-                        self.positions.insert(
-                            completed.partition,
-                            PollingStrategy::offset(next).with_context(result.context),
-                        );
-                    }
-                    None => {
-                        completed.result = Err(IggyError::InvalidOffset(last.header.offset));
-                    }
-                }
-            } else {
-                return;
-            }
-        }
-        if let Err(error) = completed.result {
-            if matches!(
-                error,
-                IggyError::HistoryUnavailable | IggyError::ConsumerGroupPartitionNotOwned(..)
-            ) {
-                self.positions.remove(&completed.partition);
-            }
-            if matches!(
-                error,
-                IggyError::ConsumerGroupMemberNotFound(..)
-                    | IggyError::Disconnected
-                    | IggyError::Unauthenticated
-                    | IggyError::StaleClient
-            ) {
-                if self.is_group {
-                    self.invalidate();
-                }
-                self.retry_at
-                    .insert(completed.partition, Instant::now() + self.retry_interval);
-                self.report(error);
-                return;
-            }
-            self.retry_at
-                .insert(completed.partition, Instant::now() + self.retry_interval);
-            completed.result = Err(error);
-        }
-        self.deliver(PrefetchedBatch {
-            generation: self.epoch,
-            assignment: self.assignment,
-            result: completed.result,
-            capacity: completed.capacity,
-        });
-    }
-
-    /// The server committed a batch that holds messages, so the batch reaches
-    /// the application even after the fetch state was cleared.
-    fn complete_on_demand(&mut self, completed: CompletedPoll) {
-        let current = completed.epoch == self.epoch && self.epoch == self.generation.load(ORDERING);
-        let partition = completed.partition;
-        let polled = match completed.result {
-            Ok(polled) => polled,
-            Err(error) => {
-                if current {
-                    if matches!(
-                        error,
-                        IggyError::HistoryUnavailable
-                            | IggyError::ConsumerGroupPartitionNotOwned(..)
-                    ) {
-                        self.positions.remove(&partition);
-                    }
-                    self.fail_on_demand(&[partition], error);
-                }
-                return;
-            }
-        };
-        let Some(last) = polled.messages.last().map(|message| message.header.offset) else {
-            if current {
-                if polled.partition_id == iggy_common::RESYNC_REQUIRED_PARTITION_SENTINEL {
-                    self.invalidate();
-                } else {
-                    self.track_readiness(partition, completed.strategy, &polled);
-                }
-            }
-            return;
-        };
-        let mut overflow = None;
-        // A new assignment replaces the position that the fetch started from.
-        // `Next` continues from an explicit offset too, because the interval
-        // commit can move the server offset back to the delivered message.
-        if completed.strategy == self.position(partition) {
-            match last.checked_add(1) {
-                Some(next) => {
-                    self.positions.insert(
-                        partition,
-                        PollingStrategy::offset(next).with_context(polled.context),
-                    );
-                }
-                None => overflow = Some(IggyError::InvalidOffset(last)),
-            }
-        }
-        if current {
-            self.track_readiness(partition, completed.strategy, &polled);
-        }
-        self.deliver(PrefetchedBatch {
-            generation: self.epoch,
-            assignment: self.assignment,
-            result: Ok(polled),
-            capacity: completed.capacity,
-        });
-        if let Some(error) = overflow {
-            self.fail_on_demand(&[partition], error);
-        }
-    }
-
-    /// The reply carries the commit offset, so a partition that holds more
-    /// messages stays ready without another wait.
-    fn track_readiness(
-        &mut self,
-        partition: u32,
-        strategy: PollingStrategy,
-        polled: &PolledMessages,
-    ) {
-        match polled.messages.last() {
-            Some(last) => {
-                self.on_demand.floors.remove(&partition);
-                if last.header.offset >= polled.current_offset {
-                    self.on_demand.ready.remove(&partition);
-                }
-            }
-            None => {
-                self.on_demand.ready.remove(&partition);
-                if strategy.kind == PollingKind::Timestamp {
-                    self.on_demand
-                        .floors
-                        .insert(partition, polled.current_offset.saturating_add(1));
-                }
-            }
-        }
-    }
-
-    fn complete_wait(&mut self, completed: CompletedWait) {
-        for partition in &completed.partitions {
-            self.on_demand.waiting.remove(partition);
-        }
-        match completed.result {
-            Ok(ready) => self.on_demand.ready.extend(
-                ready
-                    .into_iter()
-                    .filter(|partition| completed.partitions.contains(partition)),
-            ),
-            // A wait does not name the partition whose history is gone, so
-            // each partition fetches alone and its own poll tells.
-            Err(IggyError::HistoryUnavailable) => {
-                self.on_demand.ready.extend(&completed.partitions);
-            }
-            Err(error) => self.fail_on_demand(&completed.partitions, error),
-        }
-    }
-
-    /// Membership and connection errors clear the fetch state. Other errors
-    /// make the partitions retry later, and the application sees them.
-    fn fail_on_demand(&mut self, partitions: &[u32], error: IggyError) {
-        match error {
-            IggyError::ConsumerGroupPartitionNotOwned(..) => self.invalidate(),
-            IggyError::ConsumerGroupMemberNotFound(..)
-            | IggyError::Disconnected
-            | IggyError::Unauthenticated
-            | IggyError::StaleClient => {
-                self.invalidate();
-                self.report(error);
-            }
-            error => {
-                let retry_at = Instant::now() + self.retry_interval;
-                for partition in partitions {
-                    self.retry_at.insert(*partition, retry_at);
-                }
-                self.report(error);
-            }
-        }
-    }
-}
-
-fn decrypt(encryptor: &EncryptorKind, message: &mut IggyMessage) -> Result<(), IggyError> {
-    message.payload = Bytes::from(encryptor.decrypt(&message.payload)?);
-    message.header.payload_length = message.payload.len() as u32;
-    if let Some(headers) = &message.user_headers {
-        let headers = encryptor.decrypt(headers)?;
-        message.header.user_headers_length = headers.len() as u32;
-        message.user_headers = Some(Bytes::from(headers));
-    }
-    Ok(())
-}
-
-/// A partition without a known message waits in a readiness wait when every
-/// poll commits, or when idle partitions could hold every fetch slot for
-/// `max_wait`.
-fn waits_for_readiness(is_group: bool, auto_commit: bool, partitions: usize, slots: usize) -> bool {
-    is_group && (auto_commit || partitions > slots)
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-    use crate::client_wrappers::client_wrapper::ClientWrapper;
-    use crate::clients::consumer::tests::{builder_for, message_at};
-    use crate::clients::consumer::{AutoCommit, AutoCommitWhen};
-    use crate::clients::consumer_builder::IggyConsumerBuilder;
-    use crate::tcp::tcp_client::TcpClient;
-    use iggy_common::locking::{IggyRwLock, IggyRwLockFn};
-    use iggy_common::{Consumer, DeferredPollOptions, Identifier, IggyTimestamp, PartitionContext};
-
-    #[tokio::test(start_paused = true)]
-    async fn byte_and_message_reservations_cover_inflight_and_queued_replies() {
-        const BATCH_COUNT: u32 = 10;
-        const REPLY_BYTES: u32 = 1024;
-        for (bytes, messages) in [
-            (REPLY_BYTES * 2, BATCH_COUNT * 10),
-            (REPLY_BYTES * 10, BATCH_COUNT * 2),
-        ] {
-            let consumer = IggyConsumerBuilder::new(
-                IggyRwLock::new(ClientWrapper::Tcp(TcpClient::default())),
-                "reader".into(),
-                Consumer::default(),
-                Identifier::numeric(1).unwrap(),
-                Identifier::numeric(1).unwrap(),
-                None,
-                None,
-            )
-            .batch_length(BATCH_COUNT)
-            .poll_options(DeferredPollOptions {
-                max_bytes: REPLY_BYTES,
-                ..Default::default()
-            })
-            .prefetch_bytes(bytes)
-            .prefetch_messages(messages)
-            .build();
-            let client = consumer.client.clone();
-            let _blocked_connection = client.write().await;
-            let (sender, mut receiver) = mpsc::channel(consumer.prefetch_slots());
-            let mut worker = Worker::new(&consumer, sender);
-            worker.partitions = vec![0, 1, 2, 3];
-            worker.schedule();
-            assert_eq!(worker.tasks.len(), 2, "reserve before sending");
-            assert_eq!(worker.capacity.available_permits(), 0);
-            while let Some(completed) = worker.tasks.join_next().await {
-                let mut completed = completed.unwrap();
-                assert!(
-                    matches!(completed.result, Err(IggyError::TransientNotAccepted)),
-                    "a timed-out fetch that commits nothing was not accepted"
-                );
-                completed.result = Err(IggyError::CannotReadMessage);
-                worker.complete(completed);
-            }
-            assert_eq!(
-                receiver.len(),
-                2,
-                "completed failures retain their bounded queue reservations"
-            );
-            worker.schedule();
-            assert!(
-                worker.tasks.is_empty(),
-                "a stalled application stops new fetches"
-            );
-            drop(receiver.recv().await.unwrap());
-            worker.retry_at.clear();
-            worker.schedule();
-            assert_eq!(
-                worker.tasks.len(),
-                1,
-                "draining releases exactly one full-batch reservation"
-            );
-            while let Some(completed) = worker.tasks.join_next().await {
-                let mut completed = completed.unwrap();
-                completed.result = Err(IggyError::CannotReadMessage);
-                worker.complete(completed);
-            }
-            worker.invalidate();
-            worker.report(IggyError::Disconnected);
-            assert!(
-                worker.pending_error.is_some(),
-                "retain control errors under backpressure"
-            );
-            drop(receiver.recv().await.unwrap());
-            worker.flush_error();
-            assert_ne!(receiver.recv().await.unwrap().generation, worker.epoch);
-            let error = receiver.recv().await.unwrap();
-            assert_eq!(error.generation, worker.epoch);
-            assert!(matches!(error.result, Err(IggyError::Disconnected)));
-        }
-    }
-
-    #[tokio::test]
-    async fn refused_polls_reach_the_application_and_retry_later() {
-        let consumer = IggyConsumerBuilder::new(
-            IggyRwLock::new(ClientWrapper::Tcp(TcpClient::default())),
-            "reader".into(),
-            Consumer::default(),
-            Identifier::numeric(1).unwrap(),
-            Identifier::numeric(1).unwrap(),
-            None,
-            None,
-        )
-        .build();
-        let (sender, mut receiver) = mpsc::channel(consumer.prefetch_slots());
-        let mut worker = Worker::new(&consumer, sender);
-        for error in [
-            IggyError::TransientNotAccepted,
-            IggyError::DeferredPollCapacityExceeded,
-        ] {
-            let capacity = worker.reserve().unwrap();
-            worker.complete(CompletedPoll {
-                epoch: worker.epoch,
-                partition: 0,
-                strategy: worker.strategy,
-                result: Err(error.clone()),
-                capacity,
-                commit: None,
-            });
-            assert!(worker.retry_at.contains_key(&0), "the partition retries");
-            assert!(
-                matches!(receiver.try_recv().unwrap().result, Err(ref fetched) if *fetched == error),
-                "a refused fetch reaches the application"
-            );
-            worker.report(error.clone());
-            assert!(
-                matches!(receiver.try_recv().unwrap().result, Err(ref reported) if *reported == error),
-                "a refused wait or refresh reaches the application"
-            );
-        }
-    }
-
-    #[tokio::test(start_paused = true)]
-    async fn committing_fetch_waits_for_demand_and_survives_invalidation() {
-        let (mut worker, mut receiver) =
-            on_demand_worker(Consumer::default(), PollingStrategy::next());
-        let client = worker.client.clone();
-        let _blocked_connection = client.write().await;
-        worker.partitions = vec![0];
-        worker.schedule();
-        assert!(
-            worker.tasks.is_empty(),
-            "no fetch before the application asks"
-        );
-        worker.on_demand.demand.store(true, ORDERING);
-        worker.schedule();
-        worker.schedule();
-        assert_eq!(worker.tasks.len(), 1, "one committing fetch at a time");
-        let started = worker.epoch;
-        worker.invalidate();
-        let completed = worker.tasks.join_next().await.unwrap().unwrap();
-        assert_eq!(completed.epoch, started, "the fetch survives invalidation");
-        assert!(matches!(
-            completed.result,
-            Err(IggyError::TransientNotCommitted)
-        ));
-        worker.complete(completed);
-        assert!(receiver.is_empty(), "an invalidated fetch drops its error");
-        assert!(worker.retry_at.is_empty());
-
-        worker.complete(fetched(
-            &worker,
-            started,
-            PollingStrategy::next(),
-            0,
-            [3, 4],
-            9,
-        ));
-        let batch = receiver.try_recv().unwrap();
-        assert_eq!(
-            batch.result.unwrap().messages.len(),
-            2,
-            "the server committed the batch, so the application gets it"
-        );
-        assert!(
-            !worker.on_demand.demand.load(ORDERING),
-            "the batch answers the demand"
-        );
-
-        let position = worker.position(0);
-        assert_eq!(
-            position,
-            PollingStrategy::offset(5).with_context(Default::default()),
-            "an interval commit that moves the server offset back is not read again"
-        );
-        worker.complete(fetched(&worker, worker.epoch, position, 0, [5], 9));
-        assert!(receiver.try_recv().is_ok());
-        assert_eq!(
-            worker.position(0),
-            PollingStrategy::offset(6).with_context(Default::default())
-        );
-    }
-
-    #[tokio::test]
-    async fn readiness_follows_the_commit_offset_and_the_timestamp_floor() {
-        let start = PollingStrategy::timestamp(IggyTimestamp::from(1));
-        let (mut worker, _receiver) =
-            on_demand_worker(Consumer::group(Identifier::numeric(1).unwrap()), start);
-        worker.partitions = vec![0, 1];
-        worker.on_demand.ready.extend([0, 1]);
-        let epoch = worker.epoch;
-
-        worker.complete(fetched(&worker, epoch, start, 0, [5, 6], 9));
-        assert_eq!(
-            worker.positions[&0],
-            PollingStrategy::offset(7).with_context(Default::default())
-        );
-        assert!(
-            worker.on_demand.ready.contains(&0),
-            "offsets 7 to 9 are committed and not read yet"
-        );
-        worker.complete(fetched(&worker, epoch, worker.position(0), 0, [7, 8, 9], 9));
-        assert!(!worker.on_demand.ready.contains(&0));
-        assert_eq!(
-            worker.wait_target(0),
-            PollingStrategy::offset(10).with_context(Default::default())
-        );
-
-        worker.complete(fetched(&worker, epoch, start, 1, [], 4));
-        assert!(!worker.on_demand.ready.contains(&1));
-        assert_eq!(
-            worker.wait_target(1),
-            PollingStrategy::offset(5),
-            "wait for a message after the ones that the timestamp skipped"
-        );
-        worker.complete(fetched(&worker, epoch, start, 1, [5], 5));
-        assert!(worker.on_demand.floors.is_empty());
-        assert_eq!(
-            worker.wait_target(1),
-            PollingStrategy::offset(6).with_context(Default::default())
-        );
-
-        let mut fenced = fetched(&worker, epoch, PollingStrategy::offset(6), 1, [], 5);
-        fenced.result.as_mut().unwrap().partition_id =
-            iggy_common::RESYNC_REQUIRED_PARTITION_SENTINEL;
-        worker.complete(fenced);
-        assert_ne!(worker.epoch, epoch, "a fenced assignment refreshes");
-        assert!(
-            worker.partitions.is_empty(),
-            "nothing is fetched before the refresh confirms the assignment"
-        );
-    }
-
-    #[tokio::test]
-    async fn oversized_record_is_fetched_alone_before_its_error_is_reported() {
-        for auto_commit in [
-            AutoCommit::Disabled,
-            AutoCommit::When(AutoCommitWhen::PollingMessages),
-        ] {
-            let consumer = builder_for(Consumer::default())
-                .auto_commit(auto_commit)
-                .build();
-            let (sender, mut receiver) = mpsc::channel(consumer.prefetch_slots());
-            let mut worker = Worker::new(&consumer, sender);
-            for reported in [false, true] {
-                worker.complete(CompletedPoll {
-                    epoch: worker.epoch,
-                    partition: 0,
-                    strategy: worker.strategy,
-                    result: Err(IggyError::InvalidSizeBytes),
-                    capacity: worker.reserve().unwrap(),
-                    commit: None,
-                });
-                assert_eq!(worker.oversized.contains(&0), !reported);
-                assert_eq!(worker.retry_at.contains_key(&0), reported);
-                assert_eq!(
-                    receiver.try_recv().is_ok(),
-                    reported,
-                    "only the fetch without a byte limit reports the error"
-                );
-            }
-        }
-    }
-
-    #[tokio::test]
-    async fn new_generation_resumes_after_the_last_delivered_message() {
-        let consumer = builder_for(Consumer::default())
-            .polling_strategy(PollingStrategy::first())
-            .auto_commit(AutoCommit::Disabled)
-            .build();
-        let (sender, mut receiver) = mpsc::channel(consumer.prefetch_slots());
-        let mut worker = Worker::new(&consumer, sender);
-        assert!(worker.apply(worker.read_assignment().await.unwrap()));
-        let partition = worker.partitions[0];
-        for (delivered, resumed) in [
-            (None, PollingStrategy::first()),
-            (
-                Some(0),
-                PollingStrategy::offset(1).with_context(Default::default()),
-            ),
-        ] {
-            worker.complete(fetched(
-                &worker,
-                worker.epoch,
-                PollingStrategy::first(),
-                partition,
-                [0, 1, 2],
-                9,
-            ));
-            assert!(receiver.try_recv().is_ok());
-            if let Some(offset) = delivered {
-                consumer.state.last_consumed_offsets.insert(
-                    partition,
-                    ConsumerPosition {
-                        partition_id: partition,
-                        offset,
-                        context: Default::default(),
-                    },
-                );
-            }
-            let stale = worker.read_assignment().await.unwrap();
-            worker.invalidate();
-            assert!(
-                !worker.apply(stale),
-                "a refresh from before the new generation is read again"
-            );
-            assert!(worker.partitions.is_empty());
-            assert!(worker.apply(worker.read_assignment().await.unwrap()));
-            assert_eq!(worker.partitions, [partition]);
-            assert_eq!(
-                worker.position(partition),
-                resumed,
-                "the batch that the application did not take is fetched again"
-            );
-        }
-    }
-
-    #[tokio::test]
-    async fn rebalance_keeps_the_position_of_a_partition_that_stays_with_the_member() {
-        let consumer = builder_for(Consumer::group(Identifier::numeric(1).unwrap()))
-            .polling_strategy(PollingStrategy::first())
-            .auto_commit(AutoCommit::Disabled)
-            .build();
-        let (sender, _receiver) = mpsc::channel(consumer.prefetch_slots());
-        let mut worker = Worker::new(&consumer, sender);
-        assert!(worker.apply(assigned(&worker, 1, 1, [0, 1, 2])));
-        for partition in [0, 2] {
-            worker.complete(fetched(
-                &worker,
-                worker.epoch,
-                PollingStrategy::first(),
-                partition,
-                [0, 1, 2],
-                9,
-            ));
-            consumer.state.last_consumed_offsets.insert(
-                partition,
-                ConsumerPosition {
-                    partition_id: partition,
-                    offset: 0,
-                    context: Default::default(),
-                },
-            );
-        }
-
-        assert!(worker.apply(assigned(&worker, 1, 2, [0, 1])));
-        assert_eq!(
-            worker.position(0),
-            PollingStrategy::offset(1).with_context(Default::default()),
-            "the partition continues after its last delivered message"
-        );
-        assert!(
-            !worker.positions.contains_key(&2),
-            "a partition that left starts over when it comes back"
-        );
-
-        assert!(worker.apply(assigned(&worker, 2, 2, [0, 1])));
-        assert_eq!(
-            worker.position(0),
-            PollingStrategy::first(),
-            "a new session owns every partition anew"
-        );
-    }
-
-    #[tokio::test]
-    async fn committing_member_retries_a_lost_reply_until_the_partition_changes_owner() {
-        let (mut worker, _receiver) = on_demand_worker(
-            Consumer::group(Identifier::numeric(1).unwrap()),
-            PollingStrategy::next(),
-        );
-        assert!(worker.apply(assigned(&worker, 1, 1, [0, 1])));
-        worker.complete(fetched(
-            &worker,
-            worker.epoch,
-            PollingStrategy::next(),
-            0,
-            [3, 4],
-            9,
-        ));
-        let retried = worker.position(0);
-        worker.complete(CompletedPoll {
-            epoch: worker.epoch,
-            partition: 0,
-            strategy: retried,
-            result: Err(IggyError::TransientNotCommitted),
-            capacity: worker.reserve().unwrap(),
-            commit: None,
-        });
-        assert!(worker.retry_at.contains_key(&0));
-
-        assert!(worker.apply(assigned(&worker, 1, 2, [0])));
-        assert_eq!(
-            worker.position(0),
-            retried,
-            "`Next` would start after the batch that the server may have committed"
-        );
-
-        worker.complete(CompletedPoll {
-            epoch: worker.epoch,
-            partition: 0,
-            strategy: retried,
-            result: Err(IggyError::ConsumerGroupPartitionNotOwned(1, 0)),
-            capacity: worker.reserve().unwrap(),
-            commit: None,
-        });
-        assert!(worker.apply(assigned(&worker, 1, 2, [0])));
-        assert_eq!(
-            worker.position(0),
-            PollingStrategy::next(),
-            "a partition that moved away and back resolves its position again"
-        );
-    }
-
-    #[tokio::test]
-    async fn stored_offset_moves_the_fetch_position_past_a_message_that_never_reads() {
-        let consumer = builder_for(Consumer::default())
-            .auto_commit(AutoCommit::Disabled)
-            .build();
-        let (sender, _receiver) = mpsc::channel(consumer.prefetch_slots());
-        let mut worker = Worker::new(&consumer, sender);
-        let context = PartitionContext {
-            incarnation: 7,
-            ..Default::default()
-        };
-        let stuck = PollingStrategy::offset(5).with_context(context);
-        worker.positions.insert(0, stuck);
-        let store = |offset, context| {
-            consumer.state.last_stored_offsets.insert(
-                0,
-                ConsumerPosition {
-                    partition_id: 0,
-                    offset,
-                    context,
-                },
-            );
-        };
-
-        store(4, context);
-        assert_eq!(worker.position(0), stuck, "a commit behind the position");
-        store(
-            9,
-            PartitionContext {
-                incarnation: 8,
-                ..Default::default()
-            },
-        );
-        assert_eq!(worker.position(0), stuck, "a commit in another incarnation");
-        store(5, context);
-        assert_eq!(
-            worker.position(0),
-            PollingStrategy::offset(6).with_context(context)
-        );
-    }
-
-    #[tokio::test]
-    async fn readiness_waits_cover_each_endpoint_once_and_retry_after_errors() {
-        let (mut worker, mut receiver) = on_demand_worker(
-            Consumer::group(Identifier::numeric(1).unwrap()),
-            PollingStrategy::next(),
-        );
-        let client = worker.client.clone();
-        let _blocked_connection = client.write().await;
-        worker.partitions = vec![0, 1, 2];
-        worker.on_demand.groups = vec![vec![0, 2], vec![1]];
-        worker.on_demand.demand.store(true, ORDERING);
-        worker.schedule();
-        worker.schedule();
-        assert_eq!(
-            worker.on_demand.waits.len(),
-            2,
-            "one wait per primary endpoint"
-        );
-        assert!(worker.tasks.is_empty(), "no partition is known to be ready");
-
-        worker.complete_wait(CompletedWait {
-            partitions: vec![0, 2],
-            result: Ok(vec![2, 5]),
-        });
-        assert_eq!(
-            worker.on_demand.ready,
-            BTreeSet::from([2]),
-            "a reply marks only the partitions that the wait named"
-        );
-        worker.complete_wait(CompletedWait {
-            partitions: vec![1],
-            result: Err(IggyError::TransientNotAccepted),
-        });
-        assert!(worker.retry_at.contains_key(&1), "the partition retries");
-        assert!(worker.pending_error.is_none());
-        assert!(
-            matches!(
-                receiver.try_recv().unwrap().result,
-                Err(IggyError::TransientNotAccepted)
-            ),
-            "the application sees a refused wait"
-        );
-        // The application asks again after it took the error.
-        worker.on_demand.demand.store(true, ORDERING);
-
-        worker.schedule();
-        assert!(
-            worker.active.contains(&2),
-            "a ready partition is fetched without another wait"
-        );
-
-        worker.complete_wait(CompletedWait {
-            partitions: vec![0],
-            result: Err(IggyError::InvalidCommand),
-        });
-        assert!(matches!(
-            receiver.try_recv().unwrap().result,
-            Err(IggyError::InvalidCommand)
-        ));
-
-        let epoch = worker.epoch;
-        worker.complete_wait(CompletedWait {
-            partitions: vec![0, 2],
-            result: Err(IggyError::ConsumerGroupPartitionNotOwned(1, 0)),
-        });
-        assert_ne!(
-            worker.epoch, epoch,
-            "a moved partition refreshes the assignment"
-        );
-        assert!(worker.on_demand.groups.is_empty());
-        assert!(worker.on_demand.waits.is_empty());
-        assert!(receiver.is_empty());
-    }
-
-    #[tokio::test]
-    async fn committing_member_watches_idle_partitions_while_it_fetches_a_backlog() {
-        let (mut worker, _receiver) = on_demand_worker(
-            Consumer::group(Identifier::numeric(1).unwrap()),
-            PollingStrategy::next(),
-        );
-        let client = worker.client.clone();
-        let _blocked_connection = client.write().await;
-        worker.partitions = vec![0, 1];
-        worker.on_demand.groups = vec![vec![0, 1]];
-        worker.on_demand.ready.insert(0);
-        worker.on_demand.demand.store(true, ORDERING);
-        worker.schedule();
-        assert!(worker.active.contains(&0));
-        let watching = worker.on_demand.waiting.get(&1).map(AbortHandle::id);
-        assert!(
-            watching.is_some(),
-            "a backlog on one partition does not hide new messages on another"
-        );
-
-        let epoch = worker.epoch;
-        worker.complete(fetched(
-            &worker,
-            epoch,
-            PollingStrategy::next(),
-            0,
-            [5, 6],
-            9,
-        ));
-        worker.schedule();
-        assert_eq!(
-            worker.on_demand.waiting.get(&1).map(AbortHandle::id),
-            watching,
-            "the wait runs on while no partition becomes idle"
-        );
-
-        worker.complete(fetched(&worker, epoch, worker.position(0), 0, [7, 8, 9], 9));
-        worker.schedule();
-        let restarted = worker.on_demand.waiting.get(&0).map(AbortHandle::id);
-        assert!(
-            restarted.is_some_and(|wait| worker.on_demand.covers(wait, &[0, 1])),
-            "a drained partition restarts the wait of its group before the application asks"
-        );
-        let replaced = worker.on_demand.waits.join_next_with_id().await.unwrap();
-        assert!(
-            replaced.is_err_and(|error| error.is_cancelled() && Some(error.id()) == watching),
-            "the restart cancels the wait that it replaces"
-        );
-        assert_eq!(worker.on_demand.waits.len(), 1, "one wait per group");
-        assert!(
-            watching.is_some_and(|wait| !worker.on_demand.covers(wait, &[1])),
-            "a reply of the replaced wait is stale"
-        );
-    }
-
-    #[tokio::test]
-    async fn changed_readiness_groups_restart_every_wait() {
-        let (mut worker, _receiver) = on_demand_worker(
-            Consumer::group(Identifier::numeric(1).unwrap()),
-            PollingStrategy::next(),
-        );
-        let client = worker.client.clone();
-        let _blocked_connection = client.write().await;
-        let (epoch, assignment) = (worker.epoch, AssignmentVersion::default());
-        worker.assignment = Some(assignment);
-        let refreshed = |groups| Refreshed {
-            epoch,
-            assignment,
-            partitions: vec![0, 1],
-            groups: Some(groups),
-        };
-        assert!(worker.apply(refreshed(vec![vec![0, 1]])));
-        worker.schedule();
-        let watching = worker.on_demand.waiting.get(&0).map(AbortHandle::id);
-        assert!(worker.apply(refreshed(vec![vec![0, 1]])));
-        worker.schedule();
-        assert!(
-            watching.is_some_and(|wait| worker.on_demand.covers(wait, &[0, 1])),
-            "a refresh that keeps the groups keeps the waits"
-        );
-
-        assert!(worker.apply(refreshed(vec![vec![0], vec![1]])));
-        worker.schedule();
-        let waits: BTreeSet<_> = worker
-            .on_demand
-            .waiting
-            .values()
-            .map(AbortHandle::id)
-            .collect();
-        assert_eq!(
-            (worker.on_demand.waiting.len(), waits.len()),
-            (2, 2),
-            "each wait covers one group, so a restart of one group leaves the other watched"
-        );
-    }
-
-    #[tokio::test]
-    async fn more_partitions_than_fetch_slots_fetch_only_partitions_that_hold_messages() {
-        let consumer = builder_for(Consumer::group(Identifier::numeric(1).unwrap()))
-            .auto_commit(AutoCommit::Disabled)
-            .build();
-        let (sender, _receiver) = mpsc::channel(consumer.prefetch_slots());
-        let mut worker = Worker::new(&consumer, sender);
-        let client = worker.client.clone();
-        let _blocked_connection = client.write().await;
-        let partitions: Vec<u32> = (0..=u32::try_from(worker.slots).unwrap()).collect();
-        worker.partitions = partitions.clone();
-        worker.on_demand.groups = vec![partitions.clone()];
-        worker.schedule();
-        assert!(worker.tasks.is_empty(), "an idle partition holds no slot");
-        assert_eq!(worker.on_demand.waits.len(), 1);
-
-        worker.complete_wait(CompletedWait {
-            partitions: partitions.clone(),
-            result: Ok(vec![3]),
-        });
-        worker.schedule();
-        assert_eq!(worker.active, BTreeSet::from([3]));
-        assert_eq!(
-            worker.on_demand.waiting.len(),
-            partitions.len() - 1,
-            "the next wait covers every other partition"
-        );
-
-        let epoch = worker.epoch;
-        worker.complete(fetched(
-            &worker,
-            epoch,
-            PollingStrategy::next(),
-            3,
-            [5, 6],
-            9,
-        ));
-        worker.schedule();
-        assert!(
-            worker.active.contains(&3),
-            "a partition that holds more messages is fetched again without a wait"
-        );
-        worker.complete(fetched(&worker, epoch, worker.position(3), 3, [7, 8, 9], 9));
-        assert!(!worker.on_demand.ready.contains(&3));
-        worker.schedule();
-        let restarted = worker.on_demand.waiting.get(&3).map(AbortHandle::id);
-        assert!(
-            restarted.is_some_and(|wait| worker.on_demand.covers(wait, &partitions)),
-            "a drained partition restarts the wait of its group at once"
-        );
-    }
-
-    #[tokio::test]
-    async fn group_member_should_drop_only_the_continuation_of_the_refused_partition() {
-        for error in [
-            IggyError::HistoryUnavailable,
-            IggyError::ConsumerGroupPartitionNotOwned(1, 3),
-        ] {
-            let consumer = builder_for(Consumer::group(Identifier::numeric(1).unwrap()))
-                .auto_commit(AutoCommit::Disabled)
-                .build();
-            let (sender, mut receiver) = mpsc::channel(consumer.prefetch_slots());
-            let mut worker = Worker::new(&consumer, sender);
-            worker.positions.insert(3, PollingStrategy::offset(11));
-            worker.positions.insert(4, PollingStrategy::offset(21));
-            worker.complete(CompletedPoll {
-                epoch: worker.epoch,
-                partition: 3,
-                strategy: PollingStrategy::offset(11),
-                result: Err(error.clone()),
-                capacity: worker.reserve().unwrap(),
-                commit: None,
-            });
-            assert_eq!(receiver.recv().await.unwrap().result.unwrap_err(), error);
-            assert!(!worker.positions.contains_key(&3));
-            assert_eq!(worker.positions.get(&4), Some(&PollingStrategy::offset(21)));
-        }
-    }
-
-    #[tokio::test]
-    async fn committing_worker_should_drop_only_the_continuation_of_a_partition_without_history() {
-        let (mut worker, mut receiver) = on_demand_worker(
-            Consumer::new(Identifier::numeric(1).unwrap()),
-            PollingStrategy::first(),
-        );
-        worker.positions.insert(3, PollingStrategy::offset(11));
-        worker.positions.insert(4, PollingStrategy::offset(21));
-        worker.complete(CompletedPoll {
-            epoch: worker.epoch,
-            partition: 3,
-            strategy: PollingStrategy::offset(11),
-            result: Err(IggyError::HistoryUnavailable),
-            capacity: worker.reserve().unwrap(),
-            commit: None,
-        });
-        assert!(matches!(
-            receiver.recv().await.unwrap().result,
-            Err(IggyError::HistoryUnavailable)
-        ));
-        assert!(!worker.positions.contains_key(&3));
-        assert!(worker.retry_at.contains_key(&3));
-
-        worker.complete_wait(CompletedWait {
-            partitions: vec![4, 5],
-            result: Err(IggyError::HistoryUnavailable),
-        });
-        assert_eq!(
-            worker.on_demand.ready,
-            BTreeSet::from([4, 5]),
-            "each partition of the wait fetches alone"
-        );
-        assert!(receiver.is_empty(), "only a fetch reports the lost history");
-        assert_eq!(worker.positions.get(&4), Some(&PollingStrategy::offset(21)));
-    }
-
-    fn on_demand_worker(
-        consumer: Consumer,
-        strategy: PollingStrategy,
-    ) -> (Worker, mpsc::Receiver<PrefetchedBatch>) {
-        let consumer = builder_for(consumer)
-            .polling_strategy(strategy)
-            .auto_commit(AutoCommit::When(AutoCommitWhen::PollingMessages))
-            .build();
-        let (sender, receiver) = mpsc::channel(consumer.prefetch_slots());
-        (Worker::new(&consumer, sender), receiver)
-    }
-
-    /// A refresh in the current fetch state that read `partitions`.
-    fn assigned(
-        worker: &Worker,
-        session: u64,
-        generation: u64,
-        partitions: impl Into<Vec<u32>>,
-    ) -> Refreshed {
-        Refreshed {
-            epoch: worker.epoch,
-            assignment: AssignmentVersion {
-                session,
-                generation,
-            },
-            partitions: partitions.into(),
-            groups: None,
-        }
-    }
-
-    /// A fetch of `partition` that the worker started in `epoch`.
-    fn fetched(
-        worker: &Worker,
-        epoch: u64,
-        strategy: PollingStrategy,
-        partition: u32,
-        offsets: impl IntoIterator<Item = u64>,
-        current_offset: u64,
-    ) -> CompletedPoll {
-        let messages: Vec<_> = offsets.into_iter().map(message_at).collect();
-        CompletedPoll {
-            epoch,
-            partition,
-            strategy,
-            result: Ok(PolledMessages {
-                partition_id: partition,
-                context: Default::default(),
-                current_offset,
-                count: messages.len() as u32,
-                messages,
-            }),
-            capacity: worker.reserve().unwrap(),
-            commit: None,
-        }
-    }
-}
diff --git a/core/sdk/src/clients/consumer/tests.rs b/core/sdk/src/clients/consumer/tests.rs
new file mode 100644
index 0000000..eb1dfe7
--- /dev/null
+++ b/core/sdk/src/clients/consumer/tests.rs
@@ -0,0 +1,347 @@
+// 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.
+
+//! Fixtures that the tests of the consumer share.
+
+mod commits;
+mod polling;
+
+use std::fmt::Debug;
+use std::sync::Arc;
+use std::time::Duration;
+
+use futures::StreamExt;
+use iggy_binary_protocol::codes::{
+    DELETE_CONSUMER_OFFSET_CODE, FETCH_MESSAGES_CODE, GET_STREAM_CODE, GET_TOPIC_CODE,
+    POLL_MESSAGES_DEFERRED_CODE, STORE_CONSUMER_OFFSET_CODE,
+};
+use iggy_binary_protocol::requests::consumer_offsets::{
+    DeleteConsumerOffsetRequest, StoreConsumerOffsetRequest,
+};
+use iggy_binary_protocol::responses::consumer_groups::{
+    ConsumerGroupDetailsResponse, ConsumerGroupResponse, SyncConsumerGroupResponse,
+};
+use iggy_binary_protocol::responses::messages::{
+    PartitionRouteResponse, PollMessagesResponseHeader,
+};
+use iggy_binary_protocol::responses::streams::{GetStreamResponse, StreamResponse};
+use iggy_binary_protocol::{WireDecode, WireEncode, WireName, WireOptions};
+use iggy_common::locking::{IggyRwLock, IggyRwLockFn};
+use iggy_common::{
+    CommitPolicy, Consumer, Identifier, IggyDuration, NonZeroIggyDuration, PollOptions,
+    PollingStrategy,
+};
+
+use crate::client_wrappers::client_wrapper::ClientWrapper;
+use crate::clients::consumer::{IggyConsumer, ReceivedMessage};
+use crate::clients::consumer_builder::IggyConsumerBuilder;
+use crate::clients::reader::fixtures::{Section, fetch_body, served};
+use crate::links::ReconnectPolicy;
+use crate::links::fake_cluster::{
+    Answer, FakeCluster, TEST_BUDGET, connected_with, endpoint, eventually, policy, topic_body,
+};
+use crate::tcp::tcp_client::TcpClient;
+use tokio::task::yield_now;
+use tokio::time::timeout;
+use tracing::field::Field;
+use tracing::span::{Attributes, Id, Record};
+use tracing::{Event, Level, Metadata, Subscriber};
+
+const POLLED_PARTITION: u32 = 1;
+/// The replica that leads every partition of `connect_to_three_replicas`.
+const PARTITION_PRIMARY: u8 = 1;
+/// The incarnation of every partition in the route of `connect_to_three_replicas`.
+const ROUTE_INCARNATION: u64 = 1;
+const POLL_RETRY_INTERVAL: Duration = Duration::from_millis(10);
+const POLL_TIMEOUT: Duration = Duration::from_secs(2);
+
+fn standalone() -> Consumer {
+    Consumer::new(Identifier::numeric(1).unwrap())
+}
+
+fn group() -> Consumer {
+    Consumer::group(Identifier::numeric(1).unwrap())
+}
+
+/// A consumer of stream 1 and topic 1 on `client`, which retries a refused partition after
+/// `POLL_RETRY_INTERVAL`.
+fn builder_on(client: TcpClient, consumer: Consumer) -> IggyConsumerBuilder {
+    IggyConsumerBuilder::new(
+        IggyRwLock::new(ClientWrapper::Tcp(client)),
+        "consumer".to_owned(),
+        consumer,
+        Identifier::numeric(1).unwrap(),
+        Identifier::numeric(1).unwrap(),
+        None,
+    )
+    .polling_retry_interval(NonZeroIggyDuration::new(POLL_RETRY_INTERVAL).unwrap())
+}
+
+/// A consumer on a client that never connects.
+fn builder_for(consumer: Consumer) -> IggyConsumerBuilder {
+    builder_on(TcpClient::default(), consumer)
+}
+
+/// A signed-in client of three ready replicas. Replica 0 is the metadata primary, and
+/// `PARTITION_PRIMARY` leads every partition.
+async fn connect_to_three_replicas() -> (TcpClient, Arc<FakeCluster>) {
+    connect_to_three_replicas_with(policy()).await
+}
+
+async fn connect_to_three_replicas_with(policy: ReconnectPolicy) -> (TcpClient, Arc<FakeCluster>) {
+    let cluster = FakeCluster::new(3);
+    *cluster.route.lock().unwrap() = Some(PartitionRouteResponse {
+        incarnation: ROUTE_INCARNATION,
+        metadata_op: 1,
+        view: 0,
+        partitions_count: POLLED_PARTITION + 1,
+        primary_replica: PARTITION_PRIMARY,
+    });
+    let links = connected_with(&cluster, 3, policy).await;
+    (TcpClient::from_links(links), cluster)
+}
+
+/// Answers the stream and topic lookups of one `init`.
+fn answer_lookups(cluster: &FakeCluster) {
+    let stream = GetStreamResponse {
+        stream: StreamResponse {
+            id: 1,
+            created_at: 0,
+            topics_count: 0,
+            size_bytes: 0,
+            messages_count: 0,
+            name: WireName::new("stream").unwrap(),
+            options: WireOptions::empty(),
+            derived_options: WireOptions::empty(),
+        },
+        topics: Vec::new(),
+    };
+    cluster.script(
+        &endpoint(0),
+        GET_STREAM_CODE,
+        [Answer::Body(stream.to_bytes().to_vec())],
+    );
+    cluster.script(
+        &endpoint(0),
+        GET_TOPIC_CODE,
+        [Answer::Body(topic_body(POLLED_PARTITION + 1))],
+    );
+}
+
+/// The reply of a fetch that serves `served`.
+fn fetch_of(served: &[Section]) -> Answer {
+    Answer::Body(fetch_body(served).to_vec())
+}
+
+/// The reply of a fetch that serves `offsets` of `POLLED_PARTITION`, which continues after them.
+fn poll_reply_of(offsets: &[u64]) -> Answer {
+    let (section, records) = served(POLLED_PARTITION, offsets, offsets.last().unwrap() + 1);
+    let mut body = PollMessagesResponseHeader {
+        partition_id: POLLED_PARTITION,
+        current_offset: section.current_offset,
+        messages_count: section.messages_count,
+        context: section.context,
+    }
+    .to_bytes()
+    .to_vec();
+    body.extend(records);
+    Answer::Body(body)
+}
+
+fn reply_of(offsets: &[u64]) -> Answer {
+    let next_offset = offsets.last().unwrap() + 1;
+    fetch_of(&[served(POLLED_PARTITION, offsets, next_offset)])
+}
+
+/// Queues `answers` for the next requests of `code` to the primary of every partition.
+fn script_primary(cluster: &FakeCluster, code: u32, answers: impl IntoIterator<Item = Answer>) {
+    cluster.script(&endpoint(PARTITION_PRIMARY), code, answers);
+}
+
+async fn next_message(consumer: &mut IggyConsumer) -> ReceivedMessage {
+    timeout(TEST_BUDGET, consumer.next())
+        .await
+        .unwrap()
+        .unwrap()
+        .unwrap()
+}
+
+/// Polls `consumer` until its next fetch reaches `cluster`, and leaves that fetch in flight.
+async fn start_fetch(consumer: &mut IggyConsumer, cluster: &FakeCluster) {
+    let sent = cluster.count(FETCH_MESSAGES_CODE);
+    let mut next = consumer.next();
+    timeout(TEST_BUDGET, async {
+        while cluster.count(FETCH_MESSAGES_CODE) == sent {
+            assert!(futures::poll!(&mut next).is_pending());
+            yield_now().await;
+        }
+    })
+    .await
+    .unwrap();
+}
+
+/// An initialized consumer of `POLLED_PARTITION` under `commit`, which reads from the first
+/// offset. Its fetches serve `replies`, and the fetch after them stays unanswered, so the reader
+/// reads nothing more. It returns once that fetch is in flight, so every reply waits in the
+/// reader.
+async fn reading_consumer(
+    commit: CommitPolicy,
+    replies: &[&[u64]],
+) -> (IggyConsumer, Arc<FakeCluster>) {
+    let (client, cluster) = connect_to_three_replicas().await;
+    answer_lookups(&cluster);
+    script_primary(
+        &cluster,
+        FETCH_MESSAGES_CODE,
+        replies
+            .iter()
+            .map(|offsets| reply_of(offsets))
+            .chain([Answer::Silence]),
+    );
+    let mut consumer = builder_on(client, standalone())
+        .partitions([POLLED_PARTITION])
+        .polling_strategy(PollingStrategy::first())
+        .poll_options(PollOptions {
+            commit,
+            ..PollOptions::wait(IggyDuration::ONE_SECOND)
+        })
+        .build();
+    consumer.init().await.unwrap();
+    eventually("the reader fetched every reply", || {
+        cluster.count(FETCH_MESSAGES_CODE) == replies.len() + 1
+    })
+    .await;
+    (consumer, cluster)
+}
+
+/// An initialized consumer of `POLLED_PARTITION` whose every fetch commits its batch. It reads
+/// only when it is asked for a message, so a test scripts the fetches after `init`.
+async fn committing_consumer(first_offset: u64) -> (IggyConsumer, Arc<FakeCluster>) {
+    let (client, cluster) = connect_to_three_replicas().await;
+    answer_lookups(&cluster);
+    script_primary(
+        &cluster,
+        POLL_MESSAGES_DEFERRED_CODE,
+        [poll_reply_of(&[first_offset])],
+    );
+    let mut consumer = builder_on(client, standalone())
+        .partitions([POLLED_PARTITION])
+        .poll_options(PollOptions {
+            commit: CommitPolicy::OnRead,
+            ..PollOptions::default()
+        })
+        .build();
+    consumer.init().await.unwrap();
+    (consumer, cluster)
+}
+
+/// The assignment of a group member in `generation`.
+fn assignment(generation: u64, partitions: Vec<u32>) -> Answer {
+    Answer::Body(
+        SyncConsumerGroupResponse {
+            generation,
+            partitions,
+        }
+        .to_bytes()
+        .to_vec(),
+    )
+}
+
+fn existing_consumer_group() -> Answer {
+    Answer::Body(
+        ConsumerGroupDetailsResponse {
+            group: ConsumerGroupResponse {
+                id: 1,
+                partitions_count: POLLED_PARTITION + 1,
+                members_count: 0,
+                name: WireName::new("consumer").unwrap(),
+            },
+            members: Vec::new(),
+        }
+        .to_bytes()
+        .to_vec(),
+    )
+}
+
+/// The partition and the offset of each store that reached `cluster`, in arrival order.
+fn stored_offsets(cluster: &FakeCluster) -> Vec<(u32, u64)> {
+    cluster
+        .received(STORE_CONSUMER_OFFSET_CODE)
+        .iter()
+        .map(|request| {
+            let store = StoreConsumerOffsetRequest::decode_from(&request.body).unwrap();
+            (store.partition_id.unwrap(), store.offset)
+        })
+        .collect()
+}
+
+/// The partition of each delete that reached `cluster`, in arrival order.
+fn deleted_partitions(cluster: &FakeCluster) -> Vec<u32> {
+    cluster
+        .received(DELETE_CONSUMER_OFFSET_CODE)
+        .iter()
+        .map(|request| {
+            DeleteConsumerOffsetRequest::decode_from(&request.body)
+                .unwrap()
+                .partition_id
+                .unwrap()
+        })
+        .collect()
+}
+
+/// Records the events of one level emitted on the test thread. The current-thread runtime of
+/// `#[tokio::test]` polls the tasks it spawns on that thread, so their events count too.
+#[derive(Clone)]
+struct LevelReports {
+    level: Level,
+    events: Arc<std::sync::Mutex<Vec<String>>>,
+}
+
+impl Subscriber for LevelReports {
+    fn enabled(&self, metadata: &Metadata<'_>) -> bool {
+        *metadata.level() == self.level
+    }
+
+    fn new_span(&self, _: &Attributes<'_>) -> Id {
+        Id::from_u64(1)
+    }
+
+    fn record(&self, _: &Id, _: &Record<'_>) {}
+
+    fn record_follows_from(&self, _: &Id, _: &Id) {}
+
+    fn event(&self, event: &Event<'_>) {
+        let mut fields = Vec::new();
+        event.record(&mut |field: &Field, value: &dyn Debug| {
+            fields.push(format!("{field}: {value:?}"));
+        });
+        self.events.lock().unwrap().push(fields.join(", "));
+    }
+
+    fn enter(&self, _: &Id) {}
+
+    fn exit(&self, _: &Id) {}
+}
+
+impl LevelReports {
+    fn warnings() -> Self {
+        Self {
+            level: Level::WARN,
+            events: Arc::default(),
+        }
+    }
+}
diff --git a/core/sdk/src/clients/consumer/tests/commits.rs b/core/sdk/src/clients/consumer/tests/commits.rs
new file mode 100644
index 0000000..15fccd9
--- /dev/null
+++ b/core/sdk/src/clients/consumer/tests/commits.rs
@@ -0,0 +1,983 @@
+// 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.
+
+//! The commit and shutdown tests of the consumer.
+
+use std::convert::identity;
+use std::sync::{Arc, Mutex};
+use std::time::Duration;
+
+use futures::StreamExt;
+use iggy_binary_protocol::codes::{
+    CANCEL_DEFERRED_REQUEST_CODE, DELETE_CONSUMER_OFFSET_CODE, FETCH_MESSAGES_CODE,
+    POLL_MESSAGES_DEFERRED_CODE, STORE_CONSUMER_OFFSET_CODE,
+};
+use iggy_binary_protocol::requests::messages::FetchMessagesRequest;
+use iggy_binary_protocol::{PartitionContext, WireDecode, WirePollingStrategy};
+use iggy_common::{
+    AutoCommit, Checkpoint, CommitAfter, CommitEvery, CommitPolicy, IggyDuration, IggyError,
+    NonZeroIggyDuration, PollOptions, PollingStrategy,
+};
+use tokio::sync::oneshot;
+use tokio::time::{Instant, sleep, timeout};
+
+use super::{
+    PARTITION_PRIMARY, POLL_RETRY_INTERVAL, POLLED_PARTITION, ROUTE_INCARNATION, answer_lookups,
+    builder_on, committing_consumer, connect_to_three_replicas, deleted_partitions, next_message,
+    poll_reply_of, reading_consumer, reply_of, script_primary, standalone, start_fetch,
+    stored_offsets,
+};
+use crate::clients::consumer::{IggyConsumer, IggyConsumerState, ReceivedMessage};
+use crate::clients::consumer_builder::IggyConsumerBuilder;
+use crate::clients::reader::ReadError;
+use crate::consumer_ext::{IggyConsumerMessageExt, MessageConsumer};
+use crate::links::fake_cluster::{Answer, FakeCluster, TEST_BUDGET, endpoint, eventually};
+
+/// The offset the first store leaves on the server.
+const STORED_OFFSET: u64 = 100;
+/// An offset behind [`STORED_OFFSET`] that a second store asks for.
+const LOWER_OFFSET: u64 = 10;
+/// The message that a consumer yielded before the read under test.
+const DELIVERED: u64 = 4;
+/// A position of a partition under a real incarnation. Metadata revisions start at 1, so no
+/// real partition has the incarnation 0 of an uncaptured context. An uncaptured offset write
+/// takes the incarnation of the route, so only the route incarnation puts captured and
+/// uncaptured writes of a partition on one lane, which the order checks need.
+const CAPTURED: PartitionContext = PartitionContext {
+    incarnation: ROUTE_INCARNATION,
+    owner_generation: 0,
+    metadata_op: 1,
+};
+/// The commits with which a replica answers the offset writes of one test, in order.
+const FIRST_COMMIT: u64 = 1;
+const SECOND_COMMIT: u64 = 2;
+/// A policy that stores each message that it counts.
+const EACH_MESSAGE: CommitPolicy = CommitPolicy::Auto(AutoCommit {
+    interval: None,
+    every: Some(CommitEvery::Message),
+    after: CommitAfter::Delivery,
+});
+/// Longer than any test, so only the close or the shutdown stores.
+const NEVER: Duration = Duration::from_secs(24 * 60 * 60);
+/// The batch that the counting tests yield.
+const BATCH: [u64; 3] = [0, 1, 2];
+
+#[tokio::test(start_paused = true)]
+async fn default_consumer_does_not_commit_delivered_messages() {
+    let (mut consumer, cluster) = reading_consumer(CommitPolicy::Manual, &[&BATCH]).await;
+    for offset in BATCH {
+        assert_eq!(
+            next_message(&mut consumer).await.message.header.offset,
+            offset
+        );
+    }
+
+    consumer.shutdown().await.unwrap();
+
+    assert_eq!(
+        consumer.get_last_consumed_offset(POLLED_PARTITION),
+        Some(BATCH[2])
+    );
+    assert_eq!(consumer.get_last_stored_offset(POLLED_PARTITION), None);
+    assert!(stored_offsets(&cluster).is_empty());
+}
+
+#[tokio::test(start_paused = true)]
+async fn flush_should_not_move_a_higher_stored_offset_back_under_replay() {
+    const CONSUMED: u64 = 9;
+    const STORED: u64 = 19;
+    let (consumer, cluster) = consumer_without_reader(&[POLLED_PARTITION], |builder| {
+        builder
+            .allow_replay()
+            .poll_options(auto_options(EACH_MESSAGE))
+    })
+    .await;
+    let progress = &consumer.state.progress;
+    progress.count(
+        captured_checkpoint(&consumer, POLLED_PARTITION, CONSUMED),
+        1,
+        false,
+    );
+    progress.record_commit(captured_checkpoint(&consumer, POLLED_PARTITION, STORED));
+
+    progress.flush_counted().await.unwrap();
+
+    assert!(stored_offsets(&cluster).is_empty());
+    assert_eq!(
+        consumer.get_last_stored_offset(POLLED_PARTITION),
+        Some(STORED)
+    );
+}
+
+#[tokio::test(start_paused = true)]
+async fn store_offset_without_a_consumed_position_should_record_the_stored_offset() {
+    const OFFSET: u64 = 7;
+    let (consumer, cluster) = consumer_without_reader(&[POLLED_PARTITION], identity).await;
+
+    consumer
+        .store_offset(OFFSET, Some(POLLED_PARTITION))
+        .await
+        .unwrap();
+
+    assert_eq!(stored_offsets(&cluster), [(POLLED_PARTITION, OFFSET)]);
+    assert_eq!(
+        consumer.get_last_stored_offset(POLLED_PARTITION),
+        Some(OFFSET)
+    );
+}
+
+#[tokio::test(start_paused = true)]
+async fn store_offset_without_a_consumed_position_should_send_every_offset() {
+    let (consumer, cluster) = consumer_without_reader(&[POLLED_PARTITION], identity).await;
+
+    consumer
+        .store_offset(STORED_OFFSET, Some(POLLED_PARTITION))
+        .await
+        .unwrap();
+    consumer
+        .store_offset(LOWER_OFFSET, Some(POLLED_PARTITION))
+        .await
+        .unwrap();
+
+    assert_eq!(
+        stored_offsets(&cluster),
+        [
+            (POLLED_PARTITION, STORED_OFFSET),
+            (POLLED_PARTITION, LOWER_OFFSET)
+        ]
+    );
+    assert_eq!(
+        consumer.get_last_stored_offset(POLLED_PARTITION),
+        Some(LOWER_OFFSET)
+    );
+}
+
+#[tokio::test(start_paused = true)]
+async fn store_offset_without_a_consumed_position_should_store_a_lower_offset_under_replay() {
+    let (consumer, cluster) =
+        consumer_without_reader(&[POLLED_PARTITION], IggyConsumerBuilder::allow_replay).await;
+
+    consumer
+        .store_offset(STORED_OFFSET, Some(POLLED_PARTITION))
+        .await
+        .unwrap();
+    consumer
+        .store_offset(LOWER_OFFSET, Some(POLLED_PARTITION))
+        .await
+        .unwrap();
+
+    assert_eq!(
+        stored_offsets(&cluster),
+        [
+            (POLLED_PARTITION, STORED_OFFSET),
+            (POLLED_PARTITION, LOWER_OFFSET)
+        ]
+    );
+    assert_eq!(
+        consumer.get_last_stored_offset(POLLED_PARTITION),
+        Some(LOWER_OFFSET)
+    );
+}
+
+#[tokio::test(start_paused = true)]
+async fn commit_should_send_after_an_uncaptured_scalar_store() {
+    let (consumer, cluster) = consumer_without_reader(&[POLLED_PARTITION], identity).await;
+
+    consumer
+        .store_offset(STORED_OFFSET, Some(POLLED_PARTITION))
+        .await
+        .unwrap();
+    consumer
+        .commit(captured_checkpoint(
+            &consumer,
+            POLLED_PARTITION,
+            LOWER_OFFSET,
+        ))
+        .await
+        .unwrap();
+
+    assert_eq!(
+        stored_offsets(&cluster),
+        [
+            (POLLED_PARTITION, STORED_OFFSET),
+            (POLLED_PARTITION, LOWER_OFFSET)
+        ]
+    );
+    assert_eq!(
+        consumer.get_last_stored_offset(POLLED_PARTITION),
+        Some(LOWER_OFFSET)
+    );
+}
+
+/// A recreated partition starts again at 0, and the offset stored without a consumed position
+/// can belong to its earlier incarnation, which the record cannot tell apart.
+#[tokio::test(start_paused = true)]
+async fn commit_should_send_offset_zero_after_one_stored_without_a_consumed_position() {
+    const FIRST_OFFSET: u64 = 0;
+    let (consumer, cluster) = consumer_without_reader(&[POLLED_PARTITION], identity).await;
+
+    consumer
+        .store_offset(STORED_OFFSET, Some(POLLED_PARTITION))
+        .await
+        .unwrap();
+    consumer
+        .commit(captured_checkpoint(
+            &consumer,
+            POLLED_PARTITION,
+            FIRST_OFFSET,
+        ))
+        .await
+        .unwrap();
+
+    assert_eq!(
+        stored_offsets(&cluster),
+        [
+            (POLLED_PARTITION, STORED_OFFSET),
+            (POLLED_PARTITION, FIRST_OFFSET)
+        ]
+    );
+    assert_eq!(
+        consumer.get_last_stored_offset(POLLED_PARTITION),
+        Some(FIRST_OFFSET)
+    );
+}
+
+#[tokio::test(start_paused = true)]
+async fn store_offset_without_a_consumed_position_should_not_compare_a_captured_store() {
+    let (consumer, cluster) = consumer_without_reader(&[POLLED_PARTITION], identity).await;
+
+    consumer
+        .commit(captured_checkpoint(
+            &consumer,
+            POLLED_PARTITION,
+            STORED_OFFSET,
+        ))
+        .await
+        .unwrap();
+    consumer
+        .store_offset(LOWER_OFFSET, Some(POLLED_PARTITION))
+        .await
+        .unwrap();
+
+    assert_eq!(
+        stored_offsets(&cluster),
+        [
+            (POLLED_PARTITION, STORED_OFFSET),
+            (POLLED_PARTITION, LOWER_OFFSET)
+        ]
+    );
+    assert_eq!(
+        consumer.get_last_stored_offset(POLLED_PARTITION),
+        Some(LOWER_OFFSET)
+    );
+}
+
+#[tokio::test(start_paused = true)]
+async fn flush_should_store_the_captured_position_after_an_uncaptured_scalar_store() {
+    let (consumer, cluster) = consumer_without_reader(&[POLLED_PARTITION], |builder| {
+        builder.poll_options(auto_options(EACH_MESSAGE))
+    })
+    .await;
+    consumer
+        .store_offset(STORED_OFFSET, Some(POLLED_PARTITION))
+        .await
+        .unwrap();
+    consumer.state.progress.count(
+        captured_checkpoint(&consumer, POLLED_PARTITION, LOWER_OFFSET),
+        1,
+        false,
+    );
+
+    consumer.state.progress.flush_counted().await.unwrap();
+
+    assert_eq!(
+        stored_offsets(&cluster),
+        [
+            (POLLED_PARTITION, STORED_OFFSET),
+            (POLLED_PARTITION, LOWER_OFFSET)
+        ]
+    );
+    assert_eq!(
+        consumer.get_last_stored_offset(POLLED_PARTITION),
+        Some(LOWER_OFFSET)
+    );
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_a_deleted_offset_when_committing_the_same_position_should_send_again() {
+    let (consumer, cluster) = consumer_without_reader(&[POLLED_PARTITION], identity).await;
+    let checkpoint = captured_checkpoint(&consumer, POLLED_PARTITION, STORED_OFFSET);
+    consumer.commit(checkpoint).await.unwrap();
+
+    consumer
+        .delete_offset(Some(POLLED_PARTITION))
+        .await
+        .unwrap();
+
+    assert_eq!(consumer.get_last_stored_offset(POLLED_PARTITION), None);
+    consumer.commit(checkpoint).await.unwrap();
+    assert_eq!(deleted_partitions(&cluster), [POLLED_PARTITION]);
+    assert_eq!(
+        stored_offsets(&cluster),
+        [
+            (POLLED_PARTITION, STORED_OFFSET),
+            (POLLED_PARTITION, STORED_OFFSET)
+        ]
+    );
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_a_rejected_delete_when_committing_the_same_position_should_refresh_coverage() {
+    let (consumer, cluster) = consumer_without_reader(&[POLLED_PARTITION], identity).await;
+    let checkpoint = captured_checkpoint(&consumer, POLLED_PARTITION, STORED_OFFSET);
+    consumer.commit(checkpoint).await.unwrap();
+    script_primary(
+        &cluster,
+        DELETE_CONSUMER_OFFSET_CODE,
+        [Answer::CommitRejected(
+            SECOND_COMMIT,
+            IggyError::InvalidCommand,
+        )],
+    );
+
+    assert_eq!(
+        consumer.delete_offset(Some(POLLED_PARTITION)).await,
+        Err(IggyError::InvalidCommand)
+    );
+    consumer.commit(checkpoint).await.unwrap();
+
+    assert_eq!(
+        consumer.get_last_stored_offset(POLLED_PARTITION),
+        Some(STORED_OFFSET)
+    );
+    assert_eq!(
+        stored_offsets(&cluster),
+        [
+            (POLLED_PARTITION, STORED_OFFSET),
+            (POLLED_PARTITION, STORED_OFFSET)
+        ]
+    );
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_an_uncertain_store_when_deleting_should_resolve_before_sending_the_delete() {
+    let (consumer, cluster) = consumer_without_reader(&[POLLED_PARTITION], identity).await;
+    script_primary(
+        &cluster,
+        STORE_CONSUMER_OFFSET_CODE,
+        [Answer::Silence, Answer::Commit(FIRST_COMMIT)],
+    );
+    assert!(matches!(
+        consumer
+            .commit(captured_checkpoint(
+                &consumer,
+                POLLED_PARTITION,
+                LOWER_OFFSET
+            ))
+            .await,
+        Err(IggyError::OperationUncertain(_))
+    ));
+
+    consumer
+        .delete_offset(Some(POLLED_PARTITION))
+        .await
+        .unwrap();
+
+    let mutations: Vec<_> = cluster
+        .codes(&endpoint(PARTITION_PRIMARY))
+        .into_iter()
+        .filter(|code| {
+            matches!(
+                *code,
+                STORE_CONSUMER_OFFSET_CODE | DELETE_CONSUMER_OFFSET_CODE
+            )
+        })
+        .collect();
+    assert_eq!(
+        mutations,
+        [
+            STORE_CONSUMER_OFFSET_CODE,
+            STORE_CONSUMER_OFFSET_CODE,
+            DELETE_CONSUMER_OFFSET_CODE
+        ]
+    );
+    assert_eq!(deleted_partitions(&cluster), [POLLED_PARTITION]);
+    assert_eq!(consumer.get_last_stored_offset(POLLED_PARTITION), None);
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_auto_commit_when_the_stream_yields_should_count_each_message_as_its_policy_says() {
+    for (after, counted) in [
+        (CommitAfter::Delivery, Some(BATCH[2])),
+        (CommitAfter::Processing, Some(BATCH[1])),
+        (CommitAfter::Marked, None),
+    ] {
+        let (mut consumer, _cluster) = reading_consumer(auto(after), &[&BATCH]).await;
+
+        for offset in BATCH {
+            assert_eq!(
+                next_message(&mut consumer).await.message.header.offset,
+                offset
+            );
+        }
+
+        assert_eq!(
+            counted_offset(&consumer.state()),
+            counted,
+            "{after:?}: delivery counts at the yield, processing when the stream is polled again"
+        );
+    }
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_auto_commit_when_consuming_should_count_before_the_handler_on_delivery_and_after_it_on_processing()
+ {
+    for (after, counted_in_handler) in [
+        (
+            CommitAfter::Delivery,
+            [Some(BATCH[0]), Some(BATCH[1]), Some(BATCH[2])],
+        ),
+        (
+            CommitAfter::Processing,
+            [None, Some(BATCH[0]), Some(BATCH[1])],
+        ),
+    ] {
+        let (mut consumer, _cluster) = reading_consumer(auto(after), &[&BATCH]).await;
+        let (shutdown, receiver) = oneshot::channel();
+        let handler = RecordCounted {
+            state: consumer.state(),
+            last: BATCH[2],
+            counted: Mutex::default(),
+            shutdown: Mutex::new(Some(shutdown)),
+        };
+
+        timeout(TEST_BUDGET, consumer.consume_messages(&handler, receiver))
+            .await
+            .unwrap()
+            .unwrap();
+
+        assert_eq!(
+            *handler.counted.lock().unwrap(),
+            counted_in_handler,
+            "{after:?}"
+        );
+        assert_eq!(
+            counted_offset(&consumer.state()),
+            Some(BATCH[2]),
+            "{after:?}"
+        );
+    }
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_auto_commit_after_processing_when_a_handler_fails_should_end_the_consumption_without_counting_its_message()
+ {
+    let (mut consumer, _cluster) = reading_consumer(auto(CommitAfter::Processing), &[&BATCH]).await;
+    let (_shutdown, receiver) = oneshot::channel();
+
+    let consumed = timeout(
+        TEST_BUDGET,
+        consumer.consume_messages(&FailAt(BATCH[1]), receiver),
+    )
+    .await
+    .unwrap();
+
+    assert_eq!(
+        consumed,
+        Err(ReadError::new(Some(POLLED_PARTITION), IggyError::Error))
+    );
+    assert_eq!(
+        counted_offset(&consumer.state()),
+        Some(BATCH[0]),
+        "the failed message stays uncounted, so the next run reads it again"
+    );
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_marked_auto_commit_when_messages_are_marked_should_count_only_the_marks_forward() {
+    let (mut consumer, _cluster) = reading_consumer(auto(CommitAfter::Marked), &[&BATCH]).await;
+    let mut checkpoints = Vec::new();
+    for _ in BATCH {
+        checkpoints.push(next_message(&mut consumer).await.checkpoint());
+    }
+    assert_eq!(
+        counted_offset(&consumer.state()),
+        None,
+        "a yield counts nothing"
+    );
+
+    consumer.mark(checkpoints[1]).unwrap();
+    consumer.state().mark(checkpoints[0]).unwrap();
+
+    assert_eq!(counted_offset(&consumer.state()), Some(BATCH[1]));
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_manual_or_on_read_when_marking_should_refuse_it() {
+    for commit in [CommitPolicy::Manual, CommitPolicy::OnRead] {
+        let (consumer, _cluster) = consumer_without_reader(&[POLLED_PARTITION], |builder| {
+            builder.poll_options(auto_options(commit))
+        })
+        .await;
+        let checkpoint = captured_checkpoint(&consumer, POLLED_PARTITION, DELIVERED);
+
+        assert_eq!(
+            consumer.mark(checkpoint),
+            Err(IggyError::InvalidCommand),
+            "{commit:?}"
+        );
+    }
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_auto_commit_after_processing_when_shutting_down_should_store_the_last_yielded_message()
+ {
+    let (mut consumer, cluster) = reading_consumer(auto(CommitAfter::Processing), &[&BATCH]).await;
+    for _ in BATCH {
+        next_message(&mut consumer).await;
+    }
+
+    consumer.shutdown().await.unwrap();
+
+    assert_eq!(stored_offsets(&cluster), [(POLLED_PARTITION, BATCH[2])]);
+    assert_eq!(
+        consumer.get_last_stored_offset(POLLED_PARTITION),
+        Some(BATCH[2])
+    );
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_auto_commit_on_each_message_when_the_stream_yields_should_store_the_yielded_message()
+{
+    let (mut consumer, cluster) = reading_consumer(EACH_MESSAGE, &[&BATCH]).await;
+
+    let yielded = next_message(&mut consumer).await;
+
+    eventually("the store task stores the counted message", || {
+        stored_offsets(&cluster) == [(POLLED_PARTITION, yielded.message.header.offset)]
+    })
+    .await;
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_on_read_when_shutting_down_after_a_partial_yield_should_move_the_offset_back_to_the_last_yielded_message()
+ {
+    const READ: [u64; 4] = [0, 1, 2, 3];
+    const LAST_YIELDED: u64 = 1;
+    let (mut consumer, cluster) = committing_consumer(READ[0]).await;
+    script_primary(&cluster, FETCH_MESSAGES_CODE, [reply_of(&READ)]);
+    for offset in 0..=LAST_YIELDED {
+        assert_eq!(
+            next_message(&mut consumer).await.message.header.offset,
+            offset
+        );
+    }
+    assert_eq!(
+        consumer.get_last_stored_offset(POLLED_PARTITION),
+        Some(READ[3]),
+        "the server stored the end of the read"
+    );
+
+    consumer.shutdown().await.unwrap();
+
+    assert_eq!(stored_offsets(&cluster), [(POLLED_PARTITION, LAST_YIELDED)]);
+    assert_eq!(
+        consumer.get_last_stored_offset(POLLED_PARTITION),
+        Some(LAST_YIELDED)
+    );
+}
+
+/// A close cancels a committing fetch that waits on the server, and the one reply to the cancel
+/// tells whether the server stored.
+#[tokio::test(start_paused = true)]
+async fn given_on_read_when_shutting_down_with_a_parked_fetch_should_cancel_it_and_rewind_what_it_stored()
+ {
+    const FIRST: u64 = 5;
+    const MAX_WAIT: Duration = Duration::from_secs(30);
+    for (case, answer, stored) in [
+        (
+            "cancel won",
+            Answer::Refuse(IggyError::TransientNotAccepted),
+            Vec::new(),
+        ),
+        (
+            "store won",
+            reply_of(&[FIRST, FIRST + 1]),
+            vec![(POLLED_PARTITION, FIRST - 1)],
+        ),
+    ] {
+        let (client, cluster) = connect_to_three_replicas().await;
+        answer_lookups(&cluster);
+        script_primary(
+            &cluster,
+            POLL_MESSAGES_DEFERRED_CODE,
+            [poll_reply_of(&[FIRST])],
+        );
+        let mut consumer = builder_on(client, standalone())
+            .partitions([POLLED_PARTITION])
+            .poll_options(PollOptions {
+                commit: CommitPolicy::OnRead,
+                ..PollOptions::wait(IggyDuration::from(MAX_WAIT))
+            })
+            .build();
+        consumer.init().await.unwrap();
+        script_primary(&cluster, FETCH_MESSAGES_CODE, [answer]);
+        cluster.hold(FETCH_MESSAGES_CODE);
+        start_fetch(&mut consumer, &cluster).await;
+        let started = Instant::now();
+        let shutdown = tokio::spawn(async move { consumer.shutdown().await });
+
+        eventually("the close cancelled the parked fetch", || {
+            cluster.count(CANCEL_DEFERRED_REQUEST_CODE) == 1
+        })
+        .await;
+        cluster.release(FETCH_MESSAGES_CODE);
+        shutdown.await.unwrap().unwrap();
+
+        assert!(
+            started.elapsed() < MAX_WAIT,
+            "{case}: the close took {:?}",
+            started.elapsed()
+        );
+        assert_eq!(stored_offsets(&cluster), stored, "{case}");
+    }
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_on_read_when_the_application_stores_an_offset_ahead_should_read_on_after_it_and_keep_it_at_shutdown()
+ {
+    const READ: [u64; 2] = [0, 1];
+    const SKIPPED_TO: u64 = 7;
+    let (mut consumer, cluster) = committing_consumer(READ[0]).await;
+    script_primary(
+        &cluster,
+        FETCH_MESSAGES_CODE,
+        [reply_of(&READ), reply_of(&[SKIPPED_TO + 1, SKIPPED_TO + 2])],
+    );
+    assert_eq!(
+        next_message(&mut consumer).await.message.header.offset,
+        READ[0]
+    );
+
+    consumer
+        .store_offset(SKIPPED_TO, Some(POLLED_PARTITION))
+        .await
+        .unwrap();
+
+    assert_eq!(
+        next_message(&mut consumer).await.message.header.offset,
+        SKIPPED_TO + 1,
+        "the messages up to the stored offset are never delivered"
+    );
+    let fetches = cluster.received(FETCH_MESSAGES_CODE);
+    let after_store = FetchMessagesRequest::decode_from(&fetches.last().unwrap().body).unwrap();
+    assert_eq!(
+        after_store.targets[0].strategy,
+        WirePollingStrategy::offset(SKIPPED_TO + 1),
+        "the read after the store starts after the stored offset"
+    );
+    consumer.shutdown().await.unwrap();
+    assert_eq!(
+        stored_offsets(&cluster),
+        [
+            (POLLED_PARTITION, SKIPPED_TO),
+            (POLLED_PARTITION, SKIPPED_TO + 1)
+        ],
+        "the close moves the offset back to the last yielded message, after the store"
+    );
+}
+
+/// A restart resumes at the sought offset, because the seek stored the one before it.
+#[tokio::test(start_paused = true)]
+async fn given_on_read_when_seeking_to_an_offset_should_store_the_offset_before_it_and_read_from_it()
+ {
+    const SOUGHT: u64 = 7;
+    let (mut consumer, cluster) = committing_consumer(0).await;
+    script_primary(
+        &cluster,
+        FETCH_MESSAGES_CODE,
+        [reply_of(&[0, 1]), reply_of(&[SOUGHT, SOUGHT + 1])],
+    );
+    assert_eq!(next_message(&mut consumer).await.message.header.offset, 0);
+
+    consumer
+        .seek(POLLED_PARTITION, PollingStrategy::offset(SOUGHT))
+        .await
+        .unwrap();
+
+    assert_eq!(
+        stored_offsets(&cluster),
+        [(POLLED_PARTITION, SOUGHT - 1)],
+        "the seek stores the offset before its start"
+    );
+    assert_eq!(
+        next_message(&mut consumer).await.message.header.offset,
+        SOUGHT,
+        "the rest of the held batch is dropped"
+    );
+    let fetches = cluster.received(FETCH_MESSAGES_CODE);
+    let after_seek = FetchMessagesRequest::decode_from(&fetches.last().unwrap().body).unwrap();
+    assert_eq!(
+        after_seek.targets[0].strategy,
+        WirePollingStrategy::offset(SOUGHT),
+        "the read after the seek starts at the sought offset"
+    );
+    consumer.shutdown().await.unwrap();
+    assert_eq!(
+        stored_offsets(&cluster),
+        [(POLLED_PARTITION, SOUGHT - 1), (POLLED_PARTITION, SOUGHT)],
+        "the close moves the offset back to the last yielded message"
+    );
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_on_read_when_a_seek_store_is_refused_should_fail_the_seek_and_keep_the_position() {
+    let (mut consumer, cluster) = committing_consumer(0).await;
+    script_primary(
+        &cluster,
+        FETCH_MESSAGES_CODE,
+        [reply_of(&[0, 1]), reply_of(&[0])],
+    );
+    assert_eq!(next_message(&mut consumer).await.message.header.offset, 0);
+
+    script_primary(
+        &cluster,
+        STORE_CONSUMER_OFFSET_CODE,
+        [Answer::Refuse(IggyError::Unauthorized)],
+    );
+    assert_eq!(
+        consumer
+            .seek(POLLED_PARTITION, PollingStrategy::offset(7))
+            .await,
+        Err(IggyError::Unauthorized),
+        "a refused store fails the seek"
+    );
+    assert_eq!(
+        next_message(&mut consumer).await.message.header.offset,
+        1,
+        "a failed seek keeps the held batch and the position"
+    );
+
+    consumer
+        .seek(POLLED_PARTITION, PollingStrategy::first())
+        .await
+        .unwrap();
+    assert_eq!(
+        stored_offsets(&cluster).len(),
+        1,
+        "a seek to the first offset stores nothing"
+    );
+    assert_eq!(
+        next_message(&mut consumer).await.message.header.offset,
+        0,
+        "the read after the seek starts at the first offset"
+    );
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_on_read_when_shutting_down_before_a_yield_should_rewind_before_the_queued_batch() {
+    const PARTITION_START: u64 = 0;
+    const LATER_START: u64 = 5;
+    for first in [LATER_START, PARTITION_START] {
+        let (mut consumer, cluster) = committing_consumer(first).await;
+        script_primary(
+            &cluster,
+            FETCH_MESSAGES_CODE,
+            [reply_of(&[first, first + 1])],
+        );
+        start_fetch(&mut consumer, &cluster).await;
+
+        consumer.shutdown().await.unwrap();
+
+        match first.checked_sub(1) {
+            Some(before) => {
+                assert_eq!(stored_offsets(&cluster), [(POLLED_PARTITION, before)]);
+                assert!(deleted_partitions(&cluster).is_empty());
+            }
+            None => {
+                assert!(stored_offsets(&cluster).is_empty());
+                assert_eq!(
+                    deleted_partitions(&cluster),
+                    [POLLED_PARTITION],
+                    "no offset comes before the start of the partition"
+                );
+            }
+        }
+    }
+}
+
+/// The server can have committed the batch of a lost reply, so the shutdown stores the
+/// delivered offset again, and the lost reply ends nothing.
+#[tokio::test(start_paused = true)]
+async fn given_a_lost_committing_fetch_when_shutting_down_should_store_the_delivered_offset_again()
+{
+    let (mut consumer, cluster) = committing_consumer(DELIVERED).await;
+    script_primary(
+        &cluster,
+        FETCH_MESSAGES_CODE,
+        [
+            reply_of(&[DELIVERED]),
+            Answer::Refuse(IggyError::TransientNotCommitted),
+        ],
+    );
+    assert_eq!(
+        next_message(&mut consumer).await.message.header.offset,
+        DELIVERED
+    );
+    let read_on = timeout(TEST_BUDGET, consumer.next()).await;
+    assert!(read_on.is_err(), "a lost reply is no error: {read_on:?}");
+    assert!(cluster.count(FETCH_MESSAGES_CODE) >= 2);
+
+    consumer.shutdown().await.unwrap();
+
+    assert_eq!(stored_offsets(&cluster), [(POLLED_PARTITION, DELIVERED)]);
+    assert!(deleted_partitions(&cluster).is_empty());
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_a_committing_fetch_that_outlasts_the_drain_timeout_when_shutting_down_should_wait_for_it_and_store_the_delivered_offset_again()
+ {
+    const REPLY_DELAY: Duration = Duration::from_millis(100);
+    let (mut consumer, cluster) = committing_consumer(DELIVERED).await;
+    consumer.offset_drain_timeout = IggyDuration::new(POLL_RETRY_INTERVAL);
+    assert!(REPLY_DELAY > consumer.offset_drain_timeout.get_duration());
+    script_primary(
+        &cluster,
+        FETCH_MESSAGES_CODE,
+        [reply_of(&[DELIVERED]), reply_of(&[DELIVERED + 1])],
+    );
+    assert_eq!(
+        next_message(&mut consumer).await.message.header.offset,
+        DELIVERED
+    );
+    cluster.hold(FETCH_MESSAGES_CODE);
+    start_fetch(&mut consumer, &cluster).await;
+    let started = Instant::now();
+
+    let (shut_down, ()) = tokio::join!(consumer.shutdown(), async {
+        sleep(REPLY_DELAY).await;
+        cluster.release(FETCH_MESSAGES_CODE);
+    });
+
+    shut_down.unwrap();
+    assert!(started.elapsed() >= REPLY_DELAY);
+    assert_eq!(
+        stored_offsets(&cluster),
+        [(POLLED_PARTITION, DELIVERED)],
+        "the server committed a batch that the application never got"
+    );
+    assert_eq!(
+        consumer.get_last_stored_offset(POLLED_PARTITION),
+        Some(DELIVERED)
+    );
+}
+
+/// Records the counted offset of its partition when its handler starts, and stops the
+/// consumption after `last`.
+struct RecordCounted {
+    state: IggyConsumerState,
+    last: u64,
+    counted: Mutex<Vec<Option<u64>>>,
+    shutdown: Mutex<Option<oneshot::Sender<()>>>,
+}
+
+impl MessageConsumer for RecordCounted {
+    async fn consume(&self, received: ReceivedMessage) -> Result<(), IggyError> {
+        self.counted
+            .lock()
+            .unwrap()
+            .push(counted_offset(&self.state));
+        if received.message.header.offset == self.last {
+            self.shutdown
+                .lock()
+                .unwrap()
+                .take()
+                .unwrap()
+                .send(())
+                .unwrap();
+        }
+        Ok(())
+    }
+}
+
+/// Fails the handler of the message at its offset.
+struct FailAt(u64);
+
+impl MessageConsumer for FailAt {
+    async fn consume(&self, received: ReceivedMessage) -> Result<(), IggyError> {
+        if received.message.header.offset == self.0 {
+            return Err(IggyError::Error);
+        }
+        Ok(())
+    }
+}
+
+/// A consumer of `partitions` on a client of `connect_to_three_replicas`, without a reader. Each
+/// partition has a generation, as a reader of it gives it, so the consumer commits its own
+/// checkpoints. `partitions` is sorted.
+async fn consumer_without_reader(
+    partitions: &[u32],
+    configure: impl FnOnce(IggyConsumerBuilder) -> IggyConsumerBuilder,
+) -> (IggyConsumer, Arc<FakeCluster>) {
+    let (client, cluster) = connect_to_three_replicas().await;
+    let consumer =
+        configure(builder_on(client, standalone()).partitions(partitions.iter().copied())).build();
+    start_generations(&consumer, partitions);
+    (consumer, cluster)
+}
+
+/// Starts a generation of each of the sorted `partitions`, as a reader of them does.
+fn start_generations(consumer: &IggyConsumer, partitions: &[u32]) {
+    consumer.state.progress.assign(partitions, &[]);
+}
+
+/// A checkpoint at `offset` that `consumer` delivered in the current generation of `partition`.
+fn captured_checkpoint(consumer: &IggyConsumer, partition: u32, offset: u64) -> Checkpoint {
+    let progress = &consumer.state.progress;
+    let generation = progress.generation(partition).unwrap();
+    Checkpoint::stamped(
+        partition,
+        offset,
+        CAPTURED,
+        Some(progress.stamp(generation)),
+    )
+}
+
+/// `CommitPolicy::Auto` that counts `after`, and stores only at the close or the shutdown.
+fn auto(after: CommitAfter) -> CommitPolicy {
+    CommitPolicy::Auto(AutoCommit {
+        interval: NonZeroIggyDuration::new(NEVER).ok(),
+        every: None,
+        after,
+    })
+}
+
+/// The default options of a consumer with `commit`.
+fn auto_options(commit: CommitPolicy) -> PollOptions {
+    PollOptions {
+        commit,
+        ..PollOptions::wait(IggyDuration::ONE_SECOND)
+    }
+}
+
+/// The offset of the latest counted message of `POLLED_PARTITION`.
+fn counted_offset(state: &IggyConsumerState) -> Option<u64> {
+    state
+        .progress
+        .last_counted(POLLED_PARTITION)
+        .map(|checkpoint| checkpoint.offset())
+}
diff --git a/core/sdk/src/clients/consumer/tests/polling.rs b/core/sdk/src/clients/consumer/tests/polling.rs
new file mode 100644
index 0000000..9bae90e
--- /dev/null
+++ b/core/sdk/src/clients/consumer/tests/polling.rs
@@ -0,0 +1,1259 @@
+// 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.
+
+//! The polling and lifecycle tests of the consumer.
+
+use std::convert::identity;
+use std::iter::repeat_n;
+use std::num::NonZeroU32;
+use std::pin::Pin;
+use std::sync::{Arc, Mutex};
+use std::task::{Context, Poll, Waker};
+
+use bytes::Bytes;
+use futures::{Stream, StreamExt};
+use iggy_binary_protocol::codes::{
+    FETCH_MESSAGES_CODE, GET_CONSUMER_GROUP_CODE, GET_STREAM_CODE, JOIN_CONSUMER_GROUP_CODE,
+    POLL_MESSAGES_DEFERRED_CODE, STORE_CONSUMER_OFFSET_CODE, SYNC_CONSUMER_GROUP_CODE,
+};
+use iggy_binary_protocol::primitives::polling_strategy::WirePollingStrategy;
+use iggy_binary_protocol::requests::messages::{DeferredPollMessagesRequest, FetchMessagesRequest};
+use iggy_binary_protocol::{PartitionContext, WireDecode};
+use iggy_common::locking::IggyRwLockFn;
+use iggy_common::{
+    Aes256GcmEncryptor, AutoCommit, BinaryTransport, Checkpoint, Client, CommitAfter, CommitEvery,
+    CommitPolicy, Consumer, ConsumerGroupClient, ConsumerOffsetClient, DiagnosticEvent,
+    EncryptorKind, Identifier, IggyError, NonZeroIggyDuration, PollOptions, PollingKind,
+    PollingStrategy, TcpClientReconnectionConfig, group_cache_key,
+};
+use tokio::sync::oneshot;
+use tokio::time::{Instant, sleep, timeout};
+
+use super::{
+    LevelReports, POLL_RETRY_INTERVAL, POLL_TIMEOUT, POLLED_PARTITION, answer_lookups, assignment,
+    builder_for, builder_on, committing_consumer, connect_to_three_replicas,
+    connect_to_three_replicas_with, existing_consumer_group, fetch_of, group, next_message,
+    poll_reply_of, reading_consumer, reply_of, script_primary, standalone,
+};
+use crate::client_wrappers::client_wrapper::ClientWrapper;
+use crate::clients::consumer::{IggyConsumer, ReceivedMessage};
+use crate::clients::consumer_builder::IggyConsumerBuilder;
+use crate::clients::reader::ReadError;
+use crate::clients::reader::fixtures::{refused, served};
+use crate::consumer_ext::{IggyConsumerMessageExt, MessageConsumer};
+use crate::links::fake_cluster::{Answer, FakeCluster, TEST_BUDGET, endpoint, eventually};
+use crate::links::{OPERATION_TIMEOUT, ReconnectPolicy};
+use crate::tcp::tcp_client::TcpClient;
+
+const METADATA_PRIMARY: u8 = 0;
+/// The other partition of the topic of `connect_to_three_replicas`.
+const OTHER_PARTITION: u32 = 0;
+const OTHER_GROUP: u32 = 2;
+const FIRST_GENERATION: u64 = 1;
+const NEXT_GENERATION: u64 = 2;
+/// More assignment refreshes than a test lives through.
+const GROUP_REFRESHES: usize = 64;
+/// The commit of each scripted metadata reply.
+const COMMIT_OP: u64 = 1;
+/// A prefetch below one batch, which leaves the reader one slot.
+const ONE_SLOT: u32 = 1;
+const OFFSET: u64 = 5;
+/// The message that the consumer yielded before the read under test.
+const DELIVERED: u64 = 11;
+const BATCH: [u64; 3] = [10, 11, 12];
+const STORED_OFFSET: u64 = 4;
+const CONTINUED_AT: u64 = 3;
+const NTH: u32 = 2;
+const ENCRYPTION_KEY: [u8; 32] = [1; 32];
+/// The stream lookups of an `init` that stops before any request, and of one that reaches the
+/// server.
+const NO_LOOKUP: usize = 0;
+const ONE_LOOKUP: usize = 1;
+
+/// Fails the test on any message.
+struct RejectMessages;
+
+impl MessageConsumer for RejectMessages {
+    async fn consume(&self, _: ReceivedMessage) -> Result<(), IggyError> {
+        panic!("no message may reach the handler")
+    }
+}
+
+/// Records the offset of each message. Built with [`Self::until`], it stops the consumption
+/// after the message at `last`.
+#[derive(Default)]
+struct RecordMessages {
+    offsets: Mutex<Vec<u64>>,
+    stop_after: Mutex<Option<(u64, oneshot::Sender<()>)>>,
+}
+
+impl RecordMessages {
+    fn until(last: u64) -> (Self, oneshot::Receiver<()>) {
+        let (shutdown, receiver) = oneshot::channel();
+        let handler = Self {
+            offsets: Mutex::default(),
+            stop_after: Mutex::new(Some((last, shutdown))),
+        };
+        (handler, receiver)
+    }
+
+    fn offsets(&self) -> Vec<u64> {
+        self.offsets.lock().unwrap().clone()
+    }
+}
+
+impl MessageConsumer for RecordMessages {
+    async fn consume(&self, received: ReceivedMessage) -> Result<(), IggyError> {
+        let offset = received.message.header.offset;
+        self.offsets.lock().unwrap().push(offset);
+        let mut stop_after = self.stop_after.lock().unwrap();
+        if stop_after.as_ref().is_some_and(|(last, _)| *last == offset) {
+            let (_, shutdown) = stop_after.take().unwrap();
+            shutdown.send(()).unwrap();
+        }
+        Ok(())
+    }
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_a_blocked_poll_when_consuming_callbacks_should_return_the_retained_operation() {
+    let (mut consumer, cluster) = committing_consumer(DELIVERED).await;
+    script_primary(
+        &cluster,
+        STORE_CONSUMER_OFFSET_CODE,
+        [
+            Answer::Refuse(IggyError::TransientNotCommitted),
+            Answer::Refuse(IggyError::RequestTooOld),
+        ],
+    );
+    let stored = consumer
+        .client
+        .read()
+        .await
+        .store_consumer_offset(
+            &standalone(),
+            consumer.stream(),
+            consumer.topic(),
+            Some(POLLED_PARTITION),
+            STORED_OFFSET,
+        )
+        .await;
+    let store = cluster.received(STORE_CONSUMER_OFFSET_CODE)[0]
+        .header
+        .request;
+    assert_eq!(stored, Err(IggyError::OperationUncertain(store)));
+    let (_shutdown, receiver) = oneshot::channel();
+
+    let blocked = timeout(
+        TEST_BUDGET,
+        consumer.consume_messages(&RejectMessages, receiver),
+    )
+    .await
+    .unwrap();
+
+    assert_eq!(
+        blocked,
+        Err(ReadError::new(
+            Some(POLLED_PARTITION),
+            IggyError::PartitionLaneBlocked(POLLED_PARTITION, store)
+        ))
+    );
+
+    let (mut consumer, cluster) = committing_consumer(DELIVERED).await;
+    script_primary(
+        &cluster,
+        FETCH_MESSAGES_CODE,
+        [
+            reply_of(&[DELIVERED]),
+            Answer::Refuse(IggyError::TransientNotCommitted),
+        ],
+    );
+    let handler = RecordMessages::default();
+    let (_shutdown, receiver) = oneshot::channel();
+
+    let lost = timeout(TEST_BUDGET, consumer.consume_messages(&handler, receiver)).await;
+
+    assert!(
+        lost.is_err(),
+        "the reader reads on after a lost reply, so the loop goes on: {lost:?}"
+    );
+    assert!(cluster.count(FETCH_MESSAGES_CODE) >= 2);
+    assert_eq!(handler.offsets(), [DELIVERED]);
+}
+
+/// `HistoryUnavailable` stops its partition until a `seek`, which the loop cannot make.
+#[tokio::test(start_paused = true)]
+async fn given_history_unavailable_when_consuming_callbacks_should_end_with_its_partition() {
+    let (client, cluster) = connect_to_three_replicas().await;
+    answer_lookups(&cluster);
+    script_primary(
+        &cluster,
+        FETCH_MESSAGES_CODE,
+        [
+            reply_of(&BATCH),
+            fetch_of(&[refused(POLLED_PARTITION, &IggyError::HistoryUnavailable)]),
+        ],
+    );
+    let mut consumer = builder_on(client, standalone())
+        .partitions([POLLED_PARTITION])
+        .polling_strategy(PollingStrategy::first())
+        .build();
+    consumer.init().await.unwrap();
+    let handler = RecordMessages::default();
+    let (_shutdown, receiver) = oneshot::channel();
+
+    let consumed = timeout(TEST_BUDGET, consumer.consume_messages(&handler, receiver)).await;
+
+    assert_eq!(
+        consumed,
+        Ok(Err(ReadError::new(
+            Some(POLLED_PARTITION),
+            IggyError::HistoryUnavailable
+        )))
+    );
+    assert_eq!(handler.offsets(), BATCH);
+}
+
+/// The reader reads a message that does not decode again until a `seek` to its skip, which the
+/// loop cannot make.
+#[tokio::test(start_paused = true)]
+async fn given_a_message_that_does_not_decode_when_consuming_callbacks_should_end_with_its_skip() {
+    let undecodable = BATCH[BATCH.len() - 1] + 1;
+    let (client, cluster) = connect_to_three_replicas().await;
+    answer_lookups(&cluster);
+    let (mut header, _) = served(POLLED_PARTITION, &[undecodable], undecodable + 1);
+    header.records_len = 1;
+    script_primary(
+        &cluster,
+        FETCH_MESSAGES_CODE,
+        [reply_of(&BATCH), fetch_of(&[(header, vec![0])])],
+    );
+    let mut consumer = builder_on(client, standalone())
+        .partitions([POLLED_PARTITION])
+        .polling_strategy(PollingStrategy::first())
+        .build();
+    consumer.init().await.unwrap();
+    let handler = RecordMessages::default();
+    let (_shutdown, receiver) = oneshot::channel();
+
+    let consumed = timeout(TEST_BUDGET, consumer.consume_messages(&handler, receiver)).await;
+
+    let skip = PollingStrategy::continuation(
+        POLLED_PARTITION,
+        PollingKind::Offset,
+        undecodable + 1,
+        PartitionContext::default(),
+    );
+    assert_eq!(
+        consumed,
+        Ok(Err(ReadError {
+            partition_id: Some(POLLED_PARTITION),
+            error: IggyError::InvalidMessagesCount,
+            skip: Some(skip),
+            offset: None,
+            record: Some(Bytes::from_static(b"\0"))
+        }))
+    );
+    assert_eq!(handler.offsets(), BATCH);
+}
+
+/// With `OnRead`, the server stores the offset of a read whose reply does not decode. A read from
+/// `Next` then has no known start, so its partition stays stopped until a `seek`, which the loop
+/// cannot make.
+#[tokio::test(start_paused = true)]
+async fn given_a_committing_next_continuation_that_does_not_decode_when_consuming_callbacks_should_end_with_a_skip()
+ {
+    let (mut consumer, cluster) = committing_consumer(DELIVERED).await;
+    // A record above `max_bytes` makes the reader poll it alone.
+    let oversized = refused(POLLED_PARTITION, &IggyError::InvalidSizeBytes);
+    script_primary(&cluster, FETCH_MESSAGES_CODE, [fetch_of(&[oversized])]);
+    // Shorter than the reply header.
+    script_primary(
+        &cluster,
+        POLL_MESSAGES_DEFERRED_CODE,
+        [Answer::Body(vec![0])],
+    );
+    let (_shutdown, receiver) = oneshot::channel();
+
+    let consumed = timeout(
+        TEST_BUDGET,
+        consumer.consume_messages(&RejectMessages, receiver),
+    )
+    .await;
+
+    let poll = DeferredPollMessagesRequest::decode_from(
+        &cluster.received(POLL_MESSAGES_DEFERRED_CODE)[1].body,
+    )
+    .unwrap()
+    .poll;
+    assert_eq!(
+        (poll.strategy, poll.auto_commit),
+        (
+            WirePollingStrategy {
+                value: DELIVERED,
+                ..WirePollingStrategy::next()
+            },
+            true
+        )
+    );
+    assert_eq!(
+        consumed,
+        Ok(Err(ReadError {
+            partition_id: Some(POLLED_PARTITION),
+            error: IggyError::InvalidNumberEncoding,
+            skip: Some(PollingStrategy::continuation(
+                POLLED_PARTITION,
+                PollingKind::Offset,
+                DELIVERED + 1,
+                PartitionContext::default()
+            )),
+            offset: None,
+            record: None,
+        }))
+    );
+}
+
+/// Each run stops inside the batch, so a loop that picked a ready message over the signal would
+/// hand one over in about half of the runs.
+#[tokio::test(start_paused = true)]
+async fn given_a_shutdown_signal_inside_a_batch_when_consuming_callbacks_should_hand_over_no_further_message()
+ {
+    const RUNS: u64 = 16;
+    let offsets: Vec<u64> = (0..RUNS).collect();
+    let (mut consumer, _cluster) =
+        reading_consumer(CommitPolicy::Manual, &[offsets.as_slice()]).await;
+    for offset in offsets {
+        let (handler, receiver) = RecordMessages::until(offset);
+
+        let consumed = timeout(TEST_BUDGET, consumer.consume_messages(&handler, receiver)).await;
+
+        assert_eq!(consumed, Ok(Ok(())));
+        assert_eq!(handler.offsets(), [offset]);
+    }
+}
+
+#[tokio::test(start_paused = true)]
+async fn standalone_consumer_should_stop_yielding_messages_after_shutdown() {
+    let (consumer, _cluster) = reading_consumer(CommitPolicy::Manual, &[&BATCH]).await;
+
+    assert_stream_ends_after_shutdown(consumer).await;
+}
+
+#[tokio::test(start_paused = true)]
+async fn consumer_group_should_stop_yielding_messages_after_shutdown() {
+    let (consumer, _cluster) = reading_member(&[POLLED_PARTITION], [reply_of(&BATCH)]).await;
+
+    assert_stream_ends_after_shutdown(consumer).await;
+}
+
+#[tokio::test]
+async fn consumer_group_should_not_create_fetch_worker_after_shutdown() {
+    let mut consumer = builder_for(group()).build();
+
+    consumer.shutdown().await.unwrap();
+
+    assert!(matches!(poll_now(&mut consumer), Poll::Ready(None)));
+    assert!(consumer.reader.is_none());
+}
+
+#[tokio::test(start_paused = true)]
+async fn group_member_should_refuse_the_partitions_set_on_the_builder() {
+    let initialized =
+        init_without_stream(group(), |builder| builder.partitions([POLLED_PARTITION])).await;
+
+    assert_eq!(initialized, (Err(IggyError::InvalidCommand), NO_LOOKUP));
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_a_standalone_consumer_without_partitions_or_with_a_continuation_start_when_initializing_should_refuse_it_before_any_request()
+ {
+    let continuation = PollingStrategy::continuation(
+        POLLED_PARTITION,
+        PollingKind::Offset,
+        CONTINUED_AT,
+        PartitionContext::default(),
+    );
+
+    let without_partitions = init_without_stream(standalone(), identity).await;
+    let continued = init_without_stream(standalone(), |builder| {
+        builder
+            .partitions([POLLED_PARTITION])
+            .polling_strategy(continuation)
+    })
+    .await;
+
+    assert_eq!(
+        without_partitions,
+        (Err(IggyError::InvalidCommand), NO_LOOKUP)
+    );
+    assert_eq!(continued, (Err(IggyError::InvalidCommand), NO_LOOKUP));
+}
+
+#[tokio::test(start_paused = true)]
+async fn standalone_consumer_should_keep_the_partition_set_on_the_builder() {
+    let (consumer, cluster) = reading_consumer(CommitPolicy::Manual, &[]).await;
+
+    assert_eq!(
+        consumer.reader.as_ref().unwrap().partitions(),
+        [POLLED_PARTITION]
+    );
+    let fetch =
+        FetchMessagesRequest::decode_from(&cluster.received(FETCH_MESSAGES_CODE)[0].body).unwrap();
+    let targets: Vec<u32> = fetch
+        .targets
+        .iter()
+        .map(|target| target.partition_id)
+        .collect();
+    assert_eq!(targets, [POLLED_PARTITION]);
+}
+
+#[tokio::test(start_paused = true)]
+async fn group_member_should_drop_the_rest_of_its_batch_once_the_assignment_changes() {
+    let (mut consumer, _cluster) = reading_member(&[POLLED_PARTITION], [reply_of(&BATCH)]).await;
+    for offset in &BATCH[..2] {
+        assert_eq!(
+            next_message(&mut consumer).await.message.header.offset,
+            *offset
+        );
+    }
+
+    reassign(&consumer, NEXT_GENERATION).await;
+
+    assert!(poll_now(&mut consumer).is_pending());
+    assert!(consumer.current.is_none());
+    assert_eq!(
+        consumer.get_last_consumed_offset(POLLED_PARTITION),
+        Some(BATCH[1])
+    );
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_a_committed_batch_when_the_generation_or_assignment_changes_should_hand_over_the_rest()
+ {
+    let (client, cluster) = connect_to_three_replicas().await;
+    answer_lookups(&cluster);
+    script_group(&cluster, &[POLLED_PARTITION]);
+    let mut consumer = builder_on(client, group()).poll_options(on_read()).build();
+    consumer.init().await.unwrap();
+    script_primary(
+        &cluster,
+        POLL_MESSAGES_DEFERRED_CODE,
+        [poll_reply_of(&BATCH[..1])],
+    );
+    script_primary(&cluster, FETCH_MESSAGES_CODE, [reply_of(&BATCH)]);
+    assert_eq!(
+        next_message(&mut consumer).await.message.header.offset,
+        BATCH[0]
+    );
+
+    publish(&consumer, &[DiagnosticEvent::Disconnected]).await;
+    assert_eq!(
+        next_message(&mut consumer).await.message.header.offset,
+        BATCH[1],
+        "the server committed the batch, so a new epoch keeps it"
+    );
+    reassign(&consumer, NEXT_GENERATION).await;
+    assert_eq!(
+        next_message(&mut consumer).await.message.header.offset,
+        BATCH[2],
+        "the server committed the batch, so a new assignment keeps it"
+    );
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_a_reread_up_to_the_delivered_offset_when_polled_should_hand_over_only_newer_messages()
+ {
+    let (mut consumer, _cluster) = rereading_consumer().await;
+
+    assert_eq!(
+        next_message(&mut consumer).await.message.header.offset,
+        DELIVERED + 1,
+        "the application already took the messages up to {DELIVERED}"
+    );
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_replay_when_a_reread_up_to_the_delivered_offset_is_polled_should_hand_it_over_again()
+{
+    let (mut consumer, _cluster) = rereading_consumer().await;
+    consumer.allow_replay = true;
+
+    for offset in DELIVERED - 1..=DELIVERED + 1 {
+        assert_eq!(
+            next_message(&mut consumer).await.message.header.offset,
+            offset,
+            "replay hands over every message that the batch read"
+        );
+    }
+}
+
+#[tokio::test(start_paused = true)]
+async fn group_member_tracks_delivered_positions_separately_per_partition() {
+    const OTHER_OFFSET: u64 = 7;
+    let reply = fetch_of(&[
+        served(POLLED_PARTITION, &BATCH[..2], BATCH[2]),
+        served(OTHER_PARTITION, &[OTHER_OFFSET], OTHER_OFFSET + 1),
+    ]);
+    let (mut consumer, _cluster) =
+        reading_member(&[OTHER_PARTITION, POLLED_PARTITION], [reply]).await;
+
+    for _ in 0..3 {
+        next_message(&mut consumer).await;
+    }
+
+    assert_eq!(
+        consumer.get_last_consumed_offset(POLLED_PARTITION),
+        Some(BATCH[1])
+    );
+    assert_eq!(
+        consumer.get_last_consumed_offset(OTHER_PARTITION),
+        Some(OTHER_OFFSET)
+    );
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_a_reply_of_several_partitions_when_polling_should_hand_over_every_batch_in_reply_order()
+ {
+    const OTHER_OFFSET: u64 = 7;
+    const HANDED_OVER: [(u32, u64); 3] = [
+        (POLLED_PARTITION, 0),
+        (POLLED_PARTITION, 1),
+        (OTHER_PARTITION, OTHER_OFFSET),
+    ];
+    let (client, cluster) = connect_to_three_replicas().await;
+    answer_lookups(&cluster);
+    let reply = fetch_of(&[
+        served(POLLED_PARTITION, &[HANDED_OVER[0].1, HANDED_OVER[1].1], 2),
+        served(OTHER_PARTITION, &[OTHER_OFFSET], OTHER_OFFSET + 1),
+    ]);
+    script_primary(&cluster, FETCH_MESSAGES_CODE, [reply, Answer::Silence]);
+    let mut consumer = builder_on(client, standalone())
+        .partitions([OTHER_PARTITION, POLLED_PARTITION])
+        .polling_strategy(PollingStrategy::first())
+        .prefetch_messages(ONE_SLOT)
+        .build();
+    consumer.init().await.unwrap();
+
+    let mut handed_over = Vec::new();
+    for _ in 1..HANDED_OVER.len() {
+        let received = next_message(&mut consumer).await;
+        handed_over.push((received.partition_id, received.message.header.offset));
+        sleep(POLL_RETRY_INTERVAL).await;
+        assert_eq!(
+            cluster.count(FETCH_MESSAGES_CODE),
+            1,
+            "the reply holds the only slot until its last message"
+        );
+    }
+    let last = next_message(&mut consumer).await;
+    handed_over.push((last.partition_id, last.message.header.offset));
+
+    assert_eq!(handed_over, HANDED_OVER);
+    eventually("the last message returns the slot", || {
+        cluster.count(FETCH_MESSAGES_CODE) == 2
+    })
+    .await;
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_a_failed_join_when_initializing_a_group_member_should_return_it_as_the_init_error() {
+    let (client, cluster) = connect_to_three_replicas().await;
+    answer_lookups(&cluster);
+    cluster.script(
+        &endpoint(METADATA_PRIMARY),
+        GET_CONSUMER_GROUP_CODE,
+        [existing_consumer_group()],
+    );
+    cluster.script(
+        &endpoint(METADATA_PRIMARY),
+        JOIN_CONSUMER_GROUP_CODE,
+        [Answer::CommitRejected(COMMIT_OP, IggyError::Unauthorized)],
+    );
+    let mut consumer = builder_on(client, group()).build();
+
+    let initialized = consumer.init().await;
+
+    assert_eq!(initialized, Err(IggyError::Unauthorized));
+    assert!(!consumer.initialized);
+    assert!(consumer.reader.is_none());
+}
+
+#[tokio::test(start_paused = true)]
+async fn init_should_refuse_an_encryptor_with_on_read() {
+    let encryptor = Arc::new(EncryptorKind::Aes256Gcm(
+        Aes256GcmEncryptor::new(&ENCRYPTION_KEY).unwrap(),
+    ));
+    let committing = Arc::clone(&encryptor);
+
+    let refused = init_without_stream(standalone(), |builder| {
+        builder
+            .partitions([POLLED_PARTITION])
+            .encryptor(committing)
+            .poll_options(on_read())
+    })
+    .await;
+    let accepted = init_without_stream(standalone(), |builder| {
+        builder.partitions([POLLED_PARTITION]).encryptor(encryptor)
+    })
+    .await;
+
+    assert_eq!(refused, (Err(IggyError::InvalidConfiguration), NO_LOOKUP));
+    assert_reaches_the_server(accepted, "an encryptor without OnRead");
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_each_commit_policy_when_initializing_should_refuse_only_the_policies_that_cannot_run()
+ {
+    for commit in commit_policies() {
+        let initialized = init_without_stream(standalone(), |builder| {
+            builder
+                .partitions([POLLED_PARTITION])
+                .poll_options(PollOptions {
+                    commit,
+                    ..PollOptions::default()
+                })
+        })
+        .await;
+
+        assert_reaches_the_server(initialized, &format!("{commit:?}"));
+    }
+
+    let never_commits = init_without_stream(standalone(), |builder| {
+        builder
+            .partitions([POLLED_PARTITION])
+            .poll_options(PollOptions {
+                commit: CommitPolicy::Auto(AutoCommit {
+                    interval: None,
+                    every: None,
+                    after: CommitAfter::Processing,
+                }),
+                ..PollOptions::default()
+            })
+    })
+    .await;
+
+    assert_eq!(
+        never_commits,
+        (Err(IggyError::InvalidConfiguration), NO_LOOKUP),
+        "auto commit without an interval or an event would store only at shutdown"
+    );
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_an_uncertain_join_when_resolution_is_uncertain_should_keep_the_operation() {
+    let (client, cluster) = connect_to_three_replicas().await;
+    cluster.script(
+        &endpoint(METADATA_PRIMARY),
+        GET_CONSUMER_GROUP_CODE,
+        repeat_n(existing_consumer_group(), 2),
+    );
+    cluster.script(
+        &endpoint(METADATA_PRIMARY),
+        JOIN_CONSUMER_GROUP_CODE,
+        [Answer::Silence, Answer::Silence, Answer::Commit(COMMIT_OP)],
+    );
+    let mut consumer = builder_on(client, group()).build();
+    answer_lookups(&cluster);
+    let first = consumer.init().await;
+    let op = cluster.received(JOIN_CONSUMER_GROUP_CODE)[0].header.request;
+    assert_eq!(first, Err(IggyError::OperationUncertain(op)));
+
+    answer_lookups(&cluster);
+    assert_eq!(
+        consumer.init().await,
+        Err(IggyError::OperationUncertain(op))
+    );
+    assert_eq!(cluster.count(GET_CONSUMER_GROUP_CODE), 1);
+    assert!(consumer.reader.is_none());
+
+    answer_lookups(&cluster);
+    consumer.init().await.unwrap();
+    let joins = cluster.received(JOIN_CONSUMER_GROUP_CODE);
+    assert_eq!(joins.len(), 4);
+    assert_eq!(joins[1].header.request, op);
+    assert_eq!(joins[2].header.request, op);
+    assert_ne!(joins[3].header.request, op);
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_an_uncertain_join_when_resolution_is_rejected_should_report_and_allow_a_new_join() {
+    let (client, cluster) = connect_to_three_replicas().await;
+    cluster.script(
+        &endpoint(METADATA_PRIMARY),
+        GET_CONSUMER_GROUP_CODE,
+        repeat_n(existing_consumer_group(), 2),
+    );
+    cluster.script(
+        &endpoint(METADATA_PRIMARY),
+        JOIN_CONSUMER_GROUP_CODE,
+        [
+            Answer::Silence,
+            Answer::CommitRejected(COMMIT_OP, IggyError::Unauthorized),
+        ],
+    );
+    let mut consumer = builder_on(client, group()).build();
+    answer_lookups(&cluster);
+    assert!(matches!(
+        consumer.init().await,
+        Err(IggyError::OperationUncertain(_))
+    ));
+
+    answer_lookups(&cluster);
+    assert_eq!(consumer.init().await, Err(IggyError::Unauthorized));
+    assert!(consumer.reader.is_none());
+
+    answer_lookups(&cluster);
+    consumer.init().await.unwrap();
+    assert_eq!(cluster.count(JOIN_CONSUMER_GROUP_CODE), 3);
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_another_callers_uncertain_join_when_initializing_a_group_should_leave_it_retained() {
+    let (client, cluster) = connect_to_three_replicas().await;
+    let mut consumer = builder_on(client, group()).build();
+    cluster.script(
+        &endpoint(METADATA_PRIMARY),
+        JOIN_CONSUMER_GROUP_CODE,
+        [Answer::Silence],
+    );
+    let first = consumer
+        .client
+        .read()
+        .await
+        .join_consumer_group(
+            consumer.stream(),
+            consumer.topic(),
+            &Identifier::numeric(OTHER_GROUP).unwrap(),
+        )
+        .await;
+    let op = cluster.received(JOIN_CONSUMER_GROUP_CODE)[0].header.request;
+    assert_eq!(first, Err(IggyError::OperationUncertain(op)));
+    answer_lookups(&cluster);
+    cluster.script(
+        &endpoint(METADATA_PRIMARY),
+        GET_CONSUMER_GROUP_CODE,
+        [existing_consumer_group()],
+    );
+
+    assert_eq!(consumer.init().await, Err(IggyError::LaneBlocked(op)));
+
+    assert_eq!(cluster.count(JOIN_CONSUMER_GROUP_CODE), 1);
+    assert!(consumer.reader.is_none());
+    consumer
+        .client
+        .read()
+        .await
+        .resolve_operation(op, (Instant::now() + OPERATION_TIMEOUT).into_std())
+        .await
+        .unwrap();
+    assert_eq!(cluster.count(JOIN_CONSUMER_GROUP_CODE), 2);
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_a_poll_lost_to_a_disconnect_when_consuming_callbacks_should_deliver_the_next_poll() {
+    let (client, cluster) = connect_to_three_replicas().await;
+    answer_lookups(&cluster);
+    script_primary(
+        &cluster,
+        FETCH_MESSAGES_CODE,
+        [Answer::HangUp, reply_of(&[OFFSET]), Answer::Silence],
+    );
+    let mut consumer = standalone_reader(client).build();
+    consumer.init().await.unwrap();
+    let (handler, receiver) = RecordMessages::until(OFFSET);
+
+    let consumed = timeout(TEST_BUDGET, consumer.consume_messages(&handler, receiver)).await;
+
+    assert_eq!(consumed, Ok(Ok(())));
+    assert_eq!(handler.offsets(), [OFFSET]);
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_a_last_replica_link_that_gives_up_when_a_keepalive_restarts_the_links_should_pause_polling_and_keep_consuming_callbacks()
+ {
+    let one_retry = TcpClientReconnectionConfig {
+        max_retries: Some(1),
+        ..TcpClientReconnectionConfig::default()
+    };
+    let (mut consumer, cluster) = disconnected_consumer(ReconnectPolicy::from(&one_retry)).await;
+    cluster.down.lock().unwrap().clear();
+    script_primary(
+        &cluster,
+        FETCH_MESSAGES_CODE,
+        [reply_of(&[OFFSET]), Answer::Silence],
+    );
+    let (handler, receiver) = RecordMessages::until(OFFSET);
+    let client = consumer.client.clone();
+    let heartbeat = async {
+        sleep(POLL_TIMEOUT).await;
+        client.read().await.keepalive().await
+    };
+
+    let (consumed, keepalive) = tokio::join!(
+        timeout(TEST_BUDGET, consumer.consume_messages(&handler, receiver)),
+        heartbeat
+    );
+
+    assert_eq!(keepalive, Ok(true));
+    assert_eq!(consumed, Ok(Ok(())));
+    assert_eq!(handler.offsets(), [OFFSET]);
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_reconnection_disabled_when_every_replica_link_is_lost_should_end_consuming_callbacks()
+ {
+    let disabled = TcpClientReconnectionConfig {
+        enabled: false,
+        ..TcpClientReconnectionConfig::default()
+    };
+    let (mut consumer, _cluster) = disconnected_consumer(ReconnectPolicy::from(&disabled)).await;
+    let (_shutdown, receiver) = oneshot::channel();
+
+    let consumed = timeout(
+        POLL_TIMEOUT,
+        consumer.consume_messages(&RejectMessages, receiver),
+    )
+    .await;
+
+    // The read finds no link, or it loses its link, depending on when the links went down.
+    assert!(
+        matches!(
+            consumed,
+            Ok(Err(ReadError {
+                error: IggyError::Disconnected | IggyError::NotConnected,
+                ..
+            }))
+        ),
+        "{consumed:?}"
+    );
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_a_shut_down_client_when_consuming_callbacks_should_end_with_client_shutdown() {
+    let (mut consumer, _cluster) = reading_consumer(CommitPolicy::Manual, &[]).await;
+    consumer.client.read().await.shutdown().await.unwrap();
+    let (_shutdown, receiver) = oneshot::channel();
+
+    let consumed = timeout(
+        POLL_TIMEOUT,
+        consumer.consume_messages(&RejectMessages, receiver),
+    )
+    .await;
+
+    assert!(
+        matches!(
+            consumed,
+            Ok(Err(ReadError {
+                error: IggyError::ClientShutdown,
+                ..
+            }))
+        ),
+        "{consumed:?}"
+    );
+}
+
+#[tokio::test]
+async fn given_an_uninitialized_consumer_when_consuming_callbacks_should_end_at_once() {
+    let mut consumer = builder_for(standalone()).build();
+    let (_shutdown, receiver) = oneshot::channel();
+
+    let consumed = timeout(
+        POLL_TIMEOUT,
+        consumer.consume_messages(&RejectMessages, receiver),
+    )
+    .await;
+
+    assert_eq!(
+        consumed,
+        Ok(Err(ReadError::new(None, IggyError::InvalidConfiguration)))
+    );
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_the_default_client_commit_and_next_when_consuming_callbacks_should_warn_once() {
+    let warnings = consume_three_messages(identity).await;
+
+    assert_eq!(
+        warnings.len(),
+        1,
+        "a run warns once, not per message: {warnings:?}"
+    );
+    assert!(warnings[0].contains("never stores offsets"), "{warnings:?}");
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_a_commit_policy_or_a_fixed_start_when_consuming_callbacks_should_not_warn() {
+    let auto_commit = consume_three_messages(|builder| {
+        builder.poll_options(PollOptions {
+            commit: CommitPolicy::Auto(AutoCommit::default()),
+            ..PollOptions::default()
+        })
+    })
+    .await;
+    let on_read = consume_three_messages(|builder| builder.poll_options(on_read())).await;
+    let fixed_start =
+        consume_three_messages(|builder| builder.polling_strategy(PollingStrategy::first())).await;
+
+    for (case, warnings) in [
+        ("auto commit", auto_commit),
+        ("OnRead", on_read),
+        ("a fixed start", fixed_start),
+    ] {
+        assert!(warnings.is_empty(), "{case}: {warnings:?}");
+    }
+}
+
+#[tokio::test]
+async fn given_an_uninitialized_consumer_when_polled_should_fail_each_poll_and_warn_once() {
+    let mut consumer = builder_for(standalone()).build();
+    let recorder = LevelReports::warnings();
+    let _subscriber = tracing::subscriber::set_default(recorder.clone());
+
+    let first = consumer.next().await;
+    let second = consumer.next().await;
+
+    for polled in [first, second] {
+        assert!(
+            matches!(
+                polled,
+                Some(Err(ReadError {
+                    error: IggyError::InvalidConfiguration,
+                    ..
+                }))
+            ),
+            "every poll before init fails: {polled:?}"
+        );
+    }
+    let warnings = recorder.events.lock().unwrap().clone();
+    assert_eq!(
+        warnings.len(),
+        1,
+        "only the first poll before init warns: {warnings:?}"
+    );
+    assert!(
+        warnings[0].contains("was polled before init()"),
+        "{warnings:?}"
+    );
+}
+
+#[tokio::test]
+async fn given_an_uninitialized_consumer_when_seeking_should_refuse_with_invalid_configuration() {
+    let mut consumer = builder_for(standalone())
+        .partitions([POLLED_PARTITION])
+        .build();
+
+    let sought = consumer
+        .seek(POLLED_PARTITION, PollingStrategy::offset(OFFSET))
+        .await;
+
+    assert_eq!(sought, Err(IggyError::InvalidConfiguration));
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_a_seek_when_polled_should_drop_the_rest_of_the_batch_and_read_from_the_new_start() {
+    const SOUGHT: u64 = 30;
+    let (client, cluster) = connect_to_three_replicas().await;
+    answer_lookups(&cluster);
+    script_primary(
+        &cluster,
+        FETCH_MESSAGES_CODE,
+        [reply_of(&BATCH), reply_of(&[SOUGHT]), Answer::Silence],
+    );
+    // One slot, so the reader reads nothing while the batch is current, and the seek starts
+    // the next read.
+    let mut consumer = standalone_reader(client)
+        .prefetch_messages(ONE_SLOT)
+        .build();
+    consumer.init().await.unwrap();
+    assert_eq!(
+        next_message(&mut consumer).await.message.header.offset,
+        BATCH[0]
+    );
+
+    consumer
+        .seek(POLLED_PARTITION, PollingStrategy::offset(SOUGHT))
+        .await
+        .unwrap();
+
+    assert!(consumer.current.is_none());
+    assert_eq!(
+        next_message(&mut consumer).await.message.header.offset,
+        SOUGHT,
+        "the rest of the batch before the seek is dropped"
+    );
+    let fetch =
+        FetchMessagesRequest::decode_from(&cluster.received(FETCH_MESSAGES_CODE)[1].body).unwrap();
+    assert_eq!(
+        fetch.targets[0].strategy,
+        WirePollingStrategy::offset(SOUGHT)
+    );
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_a_backward_seek_when_polled_should_hand_over_the_earlier_messages_again() {
+    let (client, cluster) = connect_to_three_replicas().await;
+    answer_lookups(&cluster);
+    script_primary(
+        &cluster,
+        FETCH_MESSAGES_CODE,
+        [reply_of(&BATCH), reply_of(&BATCH), Answer::Silence],
+    );
+    let mut consumer = standalone_reader(client)
+        .prefetch_messages(ONE_SLOT)
+        .build();
+    consumer.init().await.unwrap();
+    for offset in BATCH {
+        assert_eq!(
+            next_message(&mut consumer).await.message.header.offset,
+            offset
+        );
+    }
+
+    consumer
+        .seek(POLLED_PARTITION, PollingStrategy::offset(BATCH[0]))
+        .await
+        .unwrap();
+
+    for offset in BATCH {
+        assert_eq!(
+            next_message(&mut consumer).await.message.header.offset,
+            offset,
+            "a seek back reads the messages that the consumer already yielded again"
+        );
+    }
+}
+
+async fn assert_stream_ends_after_shutdown(mut consumer: IggyConsumer) {
+    next_message(&mut consumer).await;
+    assert!(consumer.current.is_some(), "the rest of the batch waits");
+
+    consumer.shutdown().await.unwrap();
+
+    assert!(consumer.current.is_none());
+    assert!(matches!(poll_now(&mut consumer), Poll::Ready(None)));
+}
+
+/// One poll of `consumer` that does not wait.
+fn poll_now(consumer: &mut IggyConsumer) -> Poll<Option<Result<ReceivedMessage, ReadError>>> {
+    Pin::new(consumer).poll_next(&mut Context::from_waker(Waker::noop()))
+}
+
+/// The outcome of `init` for a consumer that `configure` sets up, on replicas that know no
+/// stream, with the stream lookups that reached them.
+async fn init_without_stream(
+    consumer: Consumer,
+    configure: impl FnOnce(IggyConsumerBuilder) -> IggyConsumerBuilder,
+) -> (Result<(), IggyError>, usize) {
+    let (client, cluster) = connect_to_three_replicas().await;
+    let mut consumer = configure(builder_on(client, consumer)).build();
+    let initialized = consumer.init().await;
+    (initialized, cluster.count(GET_STREAM_CODE))
+}
+
+/// Asserts that `init` accepted the configuration of `case`: it looked the stream up and found
+/// none.
+fn assert_reaches_the_server(initialized: (Result<(), IggyError>, usize), case: &str) {
+    assert!(
+        matches!(
+            initialized,
+            (Err(IggyError::StreamIdNotFound(_)), ONE_LOOKUP)
+        ),
+        "{case}: {initialized:?}"
+    );
+}
+
+/// Every commit policy that a consumer runs, with each event and each counting point.
+fn commit_policies() -> [CommitPolicy; 6] {
+    let auto = |interval, every, after| {
+        CommitPolicy::Auto(AutoCommit {
+            interval,
+            every,
+            after,
+        })
+    };
+    let interval = Some(NonZeroIggyDuration::ONE_SECOND);
+    let every_nth = CommitEvery::Messages(NonZeroU32::new(NTH).unwrap());
+    [
+        CommitPolicy::Manual,
+        CommitPolicy::OnRead,
+        CommitPolicy::Auto(AutoCommit::default()),
+        auto(interval, None, CommitAfter::Delivery),
+        auto(None, Some(every_nth), CommitAfter::Processing),
+        auto(interval, Some(CommitEvery::Batch), CommitAfter::Marked),
+    ]
+}
+
+fn on_read() -> PollOptions {
+    PollOptions {
+        commit: CommitPolicy::OnRead,
+        ..PollOptions::default()
+    }
+}
+
+/// A consumer of `POLLED_PARTITION` on `client` that reads from the first offset.
+fn standalone_reader(client: TcpClient) -> IggyConsumerBuilder {
+    builder_on(client, standalone())
+        .partitions([POLLED_PARTITION])
+        .polling_strategy(PollingStrategy::first())
+}
+
+/// An initialized group member assigned `partitions`, whose fetches serve `replies`. The fetch
+/// after them stays unanswered.
+async fn reading_member(
+    partitions: &[u32],
+    replies: impl IntoIterator<Item = Answer>,
+) -> (IggyConsumer, Arc<FakeCluster>) {
+    let (client, cluster) = connect_to_three_replicas().await;
+    answer_lookups(&cluster);
+    script_group(&cluster, partitions);
+    script_primary(
+        &cluster,
+        FETCH_MESSAGES_CODE,
+        replies.into_iter().chain([Answer::Silence]),
+    );
+    let mut consumer = builder_on(client, group()).build();
+    consumer.init().await.unwrap();
+    (consumer, cluster)
+}
+
+/// Scripts a group that exists and assigns `partitions` at each refresh.
+fn script_group(cluster: &FakeCluster, partitions: &[u32]) {
+    let metadata_primary = endpoint(METADATA_PRIMARY);
+    cluster.script(
+        &metadata_primary,
+        GET_CONSUMER_GROUP_CODE,
+        repeat_n(existing_consumer_group(), GROUP_REFRESHES),
+    );
+    cluster.script(
+        &metadata_primary,
+        SYNC_CONSUMER_GROUP_CODE,
+        repeat_n(
+            assignment(FIRST_GENERATION, partitions.to_vec()),
+            GROUP_REFRESHES,
+        ),
+    );
+}
+
+/// Moves the assignment of the group member `consumer` to `generation`, as a rebalance does.
+async fn reassign(consumer: &IggyConsumer, generation: u64) {
+    let state = consumer
+        .client
+        .read()
+        .await
+        .deferred_group_state()
+        .await
+        .unwrap();
+    let key = group_cache_key(consumer.stream(), consumer.topic(), &consumer.consumer.id);
+    state.set_assignment(key, generation, vec![POLLED_PARTITION]);
+}
+
+/// A consumer whose reader holds a batch that reads the messages around [`DELIVERED`] again,
+/// in the partition history in which the application took [`DELIVERED`].
+async fn rereading_consumer() -> (IggyConsumer, Arc<FakeCluster>) {
+    let (consumer, cluster) = reading_consumer(
+        CommitPolicy::Manual,
+        &[&[DELIVERED - 1, DELIVERED, DELIVERED + 1]],
+    )
+    .await;
+    // `served` writes the default context into each section.
+    consumer.state.progress.deliver(&Checkpoint::new(
+        POLLED_PARTITION,
+        DELIVERED,
+        PartitionContext::default(),
+    ));
+    (consumer, cluster)
+}
+
+/// An initialized consumer on a client under `policy` whose replicas went down after its first
+/// fetch. It returns once the client reports the loss of its last link.
+async fn disconnected_consumer(policy: ReconnectPolicy) -> (IggyConsumer, Arc<FakeCluster>) {
+    let (client, cluster) = connect_to_three_replicas_with(policy).await;
+    answer_lookups(&cluster);
+    script_primary(&cluster, FETCH_MESSAGES_CODE, [Answer::Silence]);
+    let mut consumer = standalone_reader(client).build();
+    consumer.init().await.unwrap();
+    eventually("the consumer reads", || {
+        cluster.count(FETCH_MESSAGES_CODE) == 1
+    })
+    .await;
+    let mut events = consumer.client.read().await.subscribe_events().await;
+    take_every_replica_down(&cluster);
+    timeout(TEST_BUDGET, async {
+        while events.recv().await.unwrap() != DiagnosticEvent::Disconnected {}
+    })
+    .await
+    .unwrap();
+    (consumer, cluster)
+}
+
+/// Takes every replica of `cluster` down, which closes the links to them.
+fn take_every_replica_down(cluster: &FakeCluster) {
+    let endpoints: Vec<String> = cluster
+        .nodes
+        .lock()
+        .unwrap()
+        .iter()
+        .map(|node| endpoint(node.replica_id))
+        .collect();
+    for endpoint in endpoints {
+        cluster.down.lock().unwrap().insert(endpoint.clone());
+        cluster.drop_connections(&endpoint);
+    }
+}
+
+/// Publishes `events` in order on the client of `consumer`.
+async fn publish(consumer: &IggyConsumer, events: &[DiagnosticEvent]) {
+    let client = consumer.client.read().await;
+    let ClientWrapper::Tcp(client) = &*client else {
+        panic!("a test consumer runs on a TCP client");
+    };
+    for event in events {
+        client.publish_event(*event).await;
+    }
+}
+
+/// Runs `consume_messages` over three messages of a consumer of `POLLED_PARTITION` that
+/// `configure` sets up, and returns the warnings that the run logged.
+async fn consume_three_messages(
+    configure: impl FnOnce(IggyConsumerBuilder) -> IggyConsumerBuilder,
+) -> Vec<String> {
+    const OFFSETS: [u64; 3] = [1, 2, 3];
+    let (client, cluster) = connect_to_three_replicas().await;
+    answer_lookups(&cluster);
+    script_primary(
+        &cluster,
+        POLL_MESSAGES_DEFERRED_CODE,
+        [poll_reply_of(&OFFSETS[..1])],
+    );
+    script_primary(
+        &cluster,
+        FETCH_MESSAGES_CODE,
+        [reply_of(&OFFSETS), Answer::Silence],
+    );
+    let mut consumer =
+        configure(builder_on(client, standalone()).partitions([POLLED_PARTITION])).build();
+    consumer.init().await.unwrap();
+    let (handler, receiver) = RecordMessages::until(OFFSETS[2]);
+    let recorder = LevelReports::warnings();
+    let _subscriber = tracing::subscriber::set_default(recorder.clone());
+
+    let consumed = timeout(TEST_BUDGET, consumer.consume_messages(&handler, receiver)).await;
+
+    assert_eq!(consumed, Ok(Ok(())));
+    assert_eq!(handler.offsets(), OFFSETS);
+    recorder.events.lock().unwrap().clone()
+}
diff --git a/core/sdk/src/clients/consumer_builder.rs b/core/sdk/src/clients/consumer_builder.rs
index 3086327..c1896b5 100644
--- a/core/sdk/src/clients/consumer_builder.rs
+++ b/core/sdk/src/clients/consumer_builder.rs
@@ -16,14 +16,17 @@
 // under the License.
 
 use crate::client_wrappers::client_wrapper::ClientWrapper;
-use crate::prelude::{AutoCommit, IggyConsumer};
+use crate::clients::reader_builder::{DEFAULT_MAX_WAIT, DEFAULT_PREFETCH_BYTES};
+use crate::prelude::IggyConsumer;
 use iggy_common::locking::IggyRwLock;
 use iggy_common::{
-    Consumer, DeferredPollOptions, EncryptorKind, Identifier, IggyDuration, NonZeroIggyDuration,
+    Consumer, EncryptorKind, Identifier, IggyDuration, NonZeroIggyDuration, PollOptions,
     PollingStrategy,
 };
 use std::sync::Arc;
 
+const DEFAULT_OFFSET_DRAIN_TIMEOUT_SECS: u64 = 5;
+
 #[derive(Debug)]
 pub struct IggyConsumerBuilder {
     client: IggyRwLock<ClientWrapper>,
@@ -31,14 +34,11 @@
     consumer: Consumer,
     stream: Identifier,
     topic: Identifier,
-    partition: Option<u32>,
+    partitions: Option<Vec<u32>>,
     polling_strategy: PollingStrategy,
-    batch_length: u32,
-    poll_options: DeferredPollOptions,
+    poll_options: PollOptions,
     prefetch_bytes: u32,
     prefetch_messages: Option<u32>,
-    auto_commit: AutoCommit,
-    auto_join_consumer_group: bool,
     create_consumer_group_if_not_exists: bool,
     encryptor: Option<Arc<EncryptorKind>>,
     polling_retry_interval: NonZeroIggyDuration,
@@ -49,14 +49,12 @@
 }
 
 impl IggyConsumerBuilder {
-    #[allow(clippy::too_many_arguments)]
     pub(crate) fn new(
         client: IggyRwLock<ClientWrapper>,
         consumer_name: String,
         consumer: Consumer,
         stream_id: Identifier,
         topic_id: Identifier,
-        partition_id: Option<u32>,
         encryptor: Option<Arc<EncryptorKind>>,
     ) -> Self {
         Self {
@@ -65,21 +63,18 @@
             consumer,
             stream: stream_id,
             topic: topic_id,
-            partition: partition_id,
+            partitions: None,
             polling_strategy: PollingStrategy::next(),
-            batch_length: 1000,
-            poll_options: DeferredPollOptions::default(),
-            prefetch_bytes: super::consumer::deferred::DEFAULT_PREFETCH_BYTES,
+            poll_options: PollOptions::wait(DEFAULT_MAX_WAIT.into()),
+            prefetch_bytes: DEFAULT_PREFETCH_BYTES,
             prefetch_messages: None,
-            auto_commit: AutoCommit::Disabled,
-            auto_join_consumer_group: true,
             create_consumer_group_if_not_exists: true,
             encryptor,
             polling_retry_interval: NonZeroIggyDuration::ONE_SECOND,
             init_retries: None,
             init_retry_interval: NonZeroIggyDuration::ONE_SECOND,
             allow_replay: false,
-            offset_drain_timeout: IggyDuration::new_from_secs(5),
+            offset_drain_timeout: IggyDuration::new_from_secs(DEFAULT_OFFSET_DRAIN_TIMEOUT_SECS),
         }
     }
 
@@ -93,14 +88,18 @@
         Self { topic, ..self }
     }
 
-    /// Sets the partition to read. `None` lets a consumer group read its assigned partitions and
-    /// makes the server read partition `0` for a standalone consumer. `Some(n)` is for standalone
-    /// consumers. A group member ignores it with a warning and reads its assignment.
-    pub fn partition(self, partition: Option<u32>) -> Self {
-        Self { partition, ..self }
+    /// Sets the partitions of a standalone consumer, which requires them. A consumer group reads
+    /// the assignment of its member, so `init()` refuses them for a group.
+    pub fn partitions(self, partitions: impl IntoIterator<Item = u32>) -> Self {
+        Self {
+            partitions: Some(partitions.into_iter().collect()),
+            ..self
+        }
     }
 
-    /// Sets the polling strategy.
+    /// Sets the start of a partition without a position. Defaults to
+    /// [`PollingStrategy::next()`]. `init()` refuses a continuation, because it belongs to one
+    /// partition.
     pub fn polling_strategy(self, polling_strategy: PollingStrategy) -> Self {
         Self {
             polling_strategy,
@@ -108,18 +107,11 @@
         }
     }
 
-    /// Sets how many messages one poll request fetches at most. Defaults to 1000.
-    pub fn batch_length(self, batch_length: u32) -> Self {
-        Self {
-            batch_length,
-            ..self
-        }
-    }
-
-    /// Sets readiness, response-byte and overall request limits. Defaults to one
-    /// message, a 1 MiB reply, a one-second wait and an eleven-second request timeout.
-    /// A message larger than the reply limit is fetched alone, without that limit.
-    pub fn poll_options(self, poll_options: DeferredPollOptions) -> Self {
+    /// Sets the limits of each request and the [`CommitPolicy`](iggy_common::CommitPolicy) of
+    /// the consumer. Defaults to [`PollOptions::wait`] of five seconds: at most 1000 messages and
+    /// a 1 MiB reply, and the application commits. A message larger than the reply limit comes
+    /// alone in its reply.
+    pub fn poll_options(self, poll_options: PollOptions) -> Self {
         Self {
             poll_options,
             ..self
@@ -127,7 +119,6 @@
     }
 
     /// Caps encoded bytes reserved for in-flight and buffered replies. Default: 16 MiB.
-    /// Must fit at least one complete reply configured by `poll_options`.
     pub fn prefetch_bytes(self, prefetch_bytes: u32) -> Self {
         Self {
             prefetch_bytes,
@@ -135,8 +126,8 @@
         }
     }
 
-    /// Caps messages reserved for in-flight and buffered replies. Default: 16,000, or
-    /// `batch_length` when that is larger. Must fit at least `batch_length` messages.
+    /// Caps messages reserved for in-flight and buffered replies. Default: 16,000, or the
+    /// `count` of the poll options when that is larger.
     pub fn prefetch_messages(self, prefetch_messages: u32) -> Self {
         Self {
             prefetch_messages: Some(prefetch_messages),
@@ -144,37 +135,8 @@
         }
     }
 
-    /// Sets the auto-commit configuration for storing the offset on the server.
-    /// Defaults to [`AutoCommit::Disabled`]: the consumer stores no offset, so a
-    /// restart with `Next` reads every message after the last offset stored with
-    /// `store_offset` or `store_position` again.
-    pub fn auto_commit(self, auto_commit: AutoCommit) -> Self {
-        Self {
-            auto_commit,
-            ..self
-        }
-    }
-
-    /// Joins the consumer group during `init()` and again after the membership was lost, for
-    /// example after a reconnect. On by default.
-    pub fn auto_join_consumer_group(self) -> Self {
-        Self {
-            auto_join_consumer_group: true,
-            ..self
-        }
-    }
-
-    /// Leaves joining the consumer group to the caller. The member polls as soon as `init()`
-    /// returns, and a poll without a membership fails with
-    /// [`IggyError::ConsumerGroupMemberNotFound`](iggy_common::IggyError::ConsumerGroupMemberNotFound).
-    pub fn do_not_auto_join_consumer_group(self) -> Self {
-        Self {
-            auto_join_consumer_group: false,
-            ..self
-        }
-    }
-
-    /// Automatically creates the consumer group if it does not exist.
+    /// Automatically creates the consumer group if it does not exist, also when the member
+    /// joins again after a session loss. On by default.
     pub fn create_consumer_group_if_not_exists(self) -> Self {
         Self {
             create_consumer_group_if_not_exists: true,
@@ -206,9 +168,8 @@
         }
     }
 
-    /// Sets how long a poll waits before the next attempt while it is blocked: after a
-    /// disconnect, after a failed group join, or while the group member holds no partitions.
-    /// One second by default.
+    /// Sets how long a refused partition waits before the next read: after a transient
+    /// refusal, or a message that does not decode. One second by default.
     pub fn polling_retry_interval(self, interval: NonZeroIggyDuration) -> Self {
         Self {
             polling_retry_interval: interval,
@@ -235,9 +196,8 @@
         }
     }
 
-    /// Sets how long `shutdown()` waits for each background auto-commit task to
-    /// drain before leaving the consumer group. Under auto-commit-on-poll, a fetch
-    /// in flight gets the same time to hand its batch over. 5 seconds by default.
+    /// Sets how long `shutdown()` waits for the stores of `CommitPolicy::Auto` before the reader
+    /// closes. 5 seconds by default.
     pub fn offset_drain_timeout(self, timeout: IggyDuration) -> Self {
         Self {
             offset_drain_timeout: timeout,
@@ -255,16 +215,11 @@
             self.consumer,
             self.stream,
             self.topic,
-            self.partition,
+            self.partitions,
+            self.polling_strategy,
             self.poll_options,
             self.prefetch_bytes,
-            self.prefetch_messages.unwrap_or(
-                super::consumer::deferred::DEFAULT_PREFETCH_MESSAGES.max(self.batch_length),
-            ),
-            self.polling_strategy,
-            self.batch_length,
-            self.auto_commit,
-            self.auto_join_consumer_group,
+            self.prefetch_messages,
             self.create_consumer_group_if_not_exists,
             self.encryptor,
             self.polling_retry_interval,
diff --git a/core/sdk/src/clients/mod.rs b/core/sdk/src/clients/mod.rs
index f832676..74c72ae 100644
--- a/core/sdk/src/clients/mod.rs
+++ b/core/sdk/src/clients/mod.rs
@@ -16,8 +16,11 @@
 // under the License.
 
 macro_rules! with_client {
-    ($self:expr, $client:ident => $body:expr) => {{
-        let deadline = iggy_common::binary_operation_deadline();
+    ($self:expr, $client:ident => $body:expr) => {
+        $crate::clients::with_client!($self, iggy_common::binary_operation_deadline(), $client => $body)
+    };
+    ($self:expr, $deadline:expr, $client:ident => $body:expr) => {{
+        let deadline = $deadline;
         iggy_common::with_binary_deadline(deadline, async {
             let $client = tokio::time::timeout_at(deadline, $self.client.read())
                 .await
@@ -51,6 +54,8 @@
 pub mod producer_dispatcher;
 pub mod producer_error_callback;
 pub mod producer_sharding;
+pub mod reader;
+pub mod reader_builder;
 
 const ORDERING: std::sync::atomic::Ordering = std::sync::atomic::Ordering::SeqCst;
 
diff --git a/core/sdk/src/clients/reader.rs b/core/sdk/src/clients/reader.rs
new file mode 100644
index 0000000..1695a36
--- /dev/null
+++ b/core/sdk/src/clients/reader.rs
@@ -0,0 +1,1973 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::collections::VecDeque;
+use std::error::Error;
+use std::fmt::{self, Debug, Display, Formatter};
+use std::future::{pending, poll_fn};
+use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
+use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
+use std::task::{Context, Poll};
+use std::time::Duration;
+
+use async_broadcast::Receiver;
+use bytes::Bytes;
+use iggy_common::locking::IggyRwLock;
+use iggy_common::{
+    AssignmentVersion, AutoCommit, Checkpoint, CommitAfter, CommitPolicy, Consumer,
+    ConsumerGroupClientState, ConsumerKind, DiagnosticEvent, EncryptorKind, Identifier, IggyError,
+    NonZeroIggyDuration, PollOptions, PolledMessages, PollingKind, PollingStrategy,
+};
+use tokio::sync::{Notify, OwnedSemaphorePermit, mpsc};
+use tokio::task::JoinHandle;
+use tokio::time::{Instant, Interval, MissedTickBehavior, interval_at};
+
+use crate::client_wrappers::client_wrapper::ClientWrapper;
+use crate::driver::with_deadline;
+use crate::links::OPERATION_TIMEOUT;
+use membership::Membership;
+use progress::Progress;
+use worker::Worker;
+pub(crate) use worker::lasting_refusal;
+
+#[cfg(test)]
+mod cluster_tests;
+#[cfg(test)]
+pub(crate) mod fixtures;
+pub(crate) mod membership;
+pub(crate) mod progress;
+mod worker;
+
+const ORDERING: Ordering = Ordering::SeqCst;
+
+/// Reads the partitions of a topic in batches, with routing, prefetch and the positions of each
+/// partition inside. Build one with [`IggyClient::reader`](crate::prelude::IggyClient::reader).
+///
+/// - A standalone consumer reads the partitions of its builder. A consumer group reads the
+///   assignment of its member, and the reader joins the group, joins again after a session loss
+///   and leaves it in [`close`](Self::close).
+/// - [`next`](Self::next) returns the next batch of one partition, in offset order within one
+///   generation of the partition: from its first read, from each [`seek`](Self::seek), and from
+///   each new assignment to this member.
+/// - The [`CommitPolicy`] of the options decides who stores offsets. With `Manual`,
+///   [`commit`](Self::commit) stores the offset of a checkpoint. With `OnRead`, the server stores
+///   the offset of each reply, the reader reads only while [`next`](Self::next) waits and then
+///   fills its budget (one fetch per primary that may wait for messages, the partitions with
+///   messages left behind spread over the other slots), a [`seek`](Self::seek) to an offset
+///   stores the offset before it, and [`close`](Self::close) moves the offset back to the last
+///   delivered message. With `Auto`, the reader counts each batch as its
+///   [`CommitAfter`] says, or each [`mark`](Self::mark), and stores the
+///   latest counted position at the interval or the event of the policy, before a rebalance
+///   takes a partition away, and in [`close`](Self::close).
+/// - The reader stays usable after a [`ReadError`] and retries by itself. `HistoryUnavailable`
+///   stops its partition until a `seek` when the position came from an earlier read of a history
+///   that is gone. A refusal that a quick retry does not change, such as `Unauthorized`,
+///   `PartitionNotFound`, or `ConsumerGroupPartitionNotOwned` again after the refresh that each
+///   one starts, is retried after a wait that doubles each time, and after six in a row it stops
+///   its partition until a `seek` with one error.
+/// - With `OnRead`, the reader reads each partition from its stored offset after a delivery, so
+///   the server refuses the commit of an older read once the offset moved. A read whose reply is
+///   lost can still have stored its offset. The reader then abandons its operation, and while it
+///   holds that lost read, a next read that starts past the last delivered message makes it read
+///   the skipped messages again by offset without a commit and hand them over first. Without a
+///   lost read, such a read goes on from where it starts, because the application or another
+///   session stored that offset. Messages that a lost read committed are lost only when a
+///   rebalance moves their partition to another member before that.
+/// - Dropping the reader stops its tasks without waiting, and it moves no offset back.
+pub struct PartitionReader {
+    config: Arc<Config>,
+    shared: Arc<Shared>,
+    receiver: mpsc::UnboundedReceiver<Reply>,
+    /// The batches of the received replies, one partition each, in arrival order.
+    queue: VecDeque<Entry>,
+    task: Option<JoinHandle<()>>,
+    /// With `CommitAfter::Processing`: the last batch that `next()` returned, which counts once
+    /// the application asks for the next one or closes the reader.
+    uncounted: Option<(Checkpoint, u32)>,
+    /// Stores the counted positions under `CommitPolicy::Auto`.
+    committer: Option<Committer>,
+}
+
+/// The store task of a reader under `CommitPolicy::Auto`. It sends one store at a time, so the
+/// stores of one partition go out in order.
+struct Committer {
+    stop: Arc<Notify>,
+    task: JoinHandle<()>,
+}
+
+/// An error of a read. The reader stays usable after it and retries by itself.
+#[derive(Debug, Clone, PartialEq)]
+pub struct ReadError {
+    /// The partition of the error. `None` for an error of the whole reader.
+    pub partition_id: Option<u32>,
+    pub error: IggyError,
+    /// For a message that does not decode: the continuation after it. The reader reads the
+    /// message again until a [`seek`](PartitionReader::seek) to this continuation skips it. The
+    /// messages before it arrive in a batch before this error.
+    pub skip: Option<PollingStrategy>,
+    /// For a message that does not decode: its offset, when its frame decoded and a later step
+    /// failed. `None` when the framing is malformed, so no record can be isolated.
+    pub offset: Option<u64>,
+    /// For a message that does not decode: the record as the reply carried it, its frame
+    /// header, payload and user headers before any decryption. For malformed framing, the rest
+    /// of the records from where decoding stopped. For an HTTP reply that is not JSON, the
+    /// response body. `None` for a message that does not decrypt over HTTP, which carries no
+    /// records.
+    pub record: Option<Bytes>,
+}
+
+/// The fixed configuration of a reader.
+pub(crate) struct Config {
+    pub(super) client: IggyRwLock<ClientWrapper>,
+    pub(super) stream: Arc<Identifier>,
+    pub(super) topic: Arc<Identifier>,
+    pub(super) consumer: Arc<Consumer>,
+    /// The partitions of a standalone consumer. Empty for a group.
+    pub(super) partitions: Vec<u32>,
+    /// The start of a partition without a position. `First` is `Offset(0)`.
+    pub(super) start: PollingStrategy,
+    pub(super) options: PollOptions,
+    /// The replies that the budget holds: reads in flight, queued replies and errors.
+    pub(super) slots: usize,
+    pub(super) encryptor: Option<Arc<EncryptorKind>>,
+    pub(super) retry_interval: Duration,
+}
+
+/// The state that the fetch loop shares with the reader handle.
+pub(crate) struct Shared {
+    pub(super) progress: Arc<Progress>,
+    /// A change of the connection moves it. Without `OnRead`, a reply of an older epoch is stale.
+    pub(super) epoch: AtomicU64,
+    pub(super) wake: Arc<Notify>,
+    /// With `OnRead`: a `next()` waits, and the queue and the channel are empty.
+    pub(super) demand: AtomicBool,
+    pub(super) closing: AtomicBool,
+    pub(super) can_poll: AtomicBool,
+    /// The partitions that the reader reads, for `partitions()` and `seek`.
+    pub(super) partitions: Mutex<Vec<u32>>,
+    /// Shared with `IggyConsumer`, which keeps an uncertain create or join across builds.
+    pub(super) membership: Option<Arc<Membership>>,
+    /// The group state and the key of a group reader.
+    pub(super) assignment_state: Option<(Arc<ConsumerGroupClientState>, String)>,
+}
+
+/// One slot of the budget, which covers the whole possible reply. Keep it until the batch is
+/// handed over: message slices can share its backing allocation.
+struct FetchCapacity {
+    permit: Option<OwnedSemaphorePermit>,
+    wake: Arc<Notify>,
+}
+
+/// One item of the channel from the fetch loop: the batches of one reply, each of one
+/// partition, in reply order, or an error.
+struct Reply {
+    epoch: u64,
+    assignment: Option<AssignmentVersion>,
+    result: Result<Vec<PolledMessages>, Refusal>,
+    capacity: Arc<FetchCapacity>,
+}
+
+/// An error with the generation of its partition when the reader reported it. An error of a
+/// partition counts until a new generation, so the stop of a partition is never lost. An error
+/// of the whole reader counts within its epoch.
+struct Refusal {
+    error: ReadError,
+    generation: Option<u64>,
+}
+
+struct Entry {
+    epoch: u64,
+    assignment: Option<AssignmentVersion>,
+    result: Result<PolledMessages, Refusal>,
+    /// The slot returns when the last batch of its reply leaves the reader.
+    capacity: Arc<FetchCapacity>,
+}
+
+/// Keeps a batch in the budget of its reader until the last message is handed over.
+pub(crate) struct Hold {
+    _capacity: Arc<FetchCapacity>,
+    shared: Arc<Shared>,
+    on_read: bool,
+    epoch: u64,
+    assignment: Option<AssignmentVersion>,
+    partition: u32,
+    generation: u64,
+    /// The generation changes of the reader when the generation was last found current.
+    generation_checked: Option<u64>,
+    /// The assignment changes of the group state when the assignment was last found current.
+    checked: Option<u64>,
+}
+
+impl PartitionReader {
+    pub(super) fn start(
+        config: Arc<Config>,
+        shared: Arc<Shared>,
+        events: Option<Receiver<DiagnosticEvent>>,
+    ) -> Self {
+        let (receiver, task) = spawn_worker(&config, &shared, events);
+        let committer = config
+            .auto()
+            .map(|auto| Committer::spawn(Arc::clone(&shared.progress), auto.interval));
+        Self {
+            config,
+            shared,
+            receiver,
+            queue: VecDeque::new(),
+            task: Some(task),
+            uncounted: None,
+            committer,
+        }
+    }
+
+    /// The next batch of one partition. Never empty, and it has no time limit. Cancel safe: a
+    /// dropped call leaves its batch in the reader.
+    // The design fixes this public signature. An error is rare next to the batches, so its
+    // size does not matter.
+    #[allow(clippy::result_large_err)]
+    pub async fn next(&mut self) -> Result<PolledMessages, ReadError> {
+        self.count_uncounted();
+        let (batch, hold) = poll_fn(|cx| self.poll_held(cx)).await?;
+        if let Some(checkpoint) = batch.checkpoint() {
+            self.delivered(&checkpoint);
+            let messages = u32::try_from(batch.messages.len()).unwrap_or(u32::MAX);
+            match self.config.auto().map(|auto| auto.after) {
+                Some(CommitAfter::Delivery) => self.count(checkpoint, messages, true),
+                Some(CommitAfter::Processing) => self.uncounted = Some((checkpoint, messages)),
+                Some(CommitAfter::Marked) | None => {}
+            }
+        }
+        drop(hold);
+        Ok(batch)
+    }
+
+    /// The next batch with its budget slot, which returns when the hold drops. It records no
+    /// delivery: the caller reports each message that it hands over with
+    /// [`delivered`](Self::delivered).
+    pub(crate) fn poll_held(
+        &mut self,
+        cx: &mut Context<'_>,
+    ) -> Poll<Result<(PolledMessages, Hold), ReadError>> {
+        loop {
+            if let Some(entry) = self.queue.pop_front() {
+                if let Some(ready) = self.hand_over(entry) {
+                    return Poll::Ready(ready);
+                }
+                continue;
+            }
+            match self.receiver.poll_recv(cx) {
+                Poll::Pending => {
+                    if self.config.on_read() && !self.shared.demand.swap(true, ORDERING) {
+                        self.shared.wake.notify_one();
+                    }
+                    return Poll::Pending;
+                }
+                Poll::Ready(None) => {
+                    let (receiver, task) = spawn_worker(&self.config, &self.shared, None);
+                    self.receiver = receiver;
+                    self.task = Some(task);
+                    return Poll::Ready(Err(ReadError::new(None, IggyError::ClientShutdown)));
+                }
+                Poll::Ready(Some(reply)) => self.split(reply),
+            }
+        }
+    }
+
+    /// Records the last message that the application took from its partition. With `OnRead`,
+    /// [`close`](Self::close) moves the offset back to it.
+    pub(crate) fn delivered(&self, checkpoint: &Checkpoint) {
+        self.shared.progress.deliver(checkpoint);
+    }
+
+    /// Counts `messages` up to `checkpoint` under `CommitPolicy::Auto`. `ends_batch` marks the
+    /// last message of a batch.
+    pub(crate) fn count(&self, checkpoint: Checkpoint, messages: u32, ends_batch: bool) {
+        self.shared.progress.count(checkpoint, messages, ends_batch);
+    }
+
+    /// Counts a checkpoint that this reader delivered, under `CommitPolicy::Auto`. It stores
+    /// nothing now: the policy stores the latest counted position of the partition. With
+    /// `CommitAfter::Marked` only marks count, so batches processed concurrently count once they
+    /// are done.
+    ///
+    /// # Errors
+    ///
+    /// [`IggyError::InvalidCommand`] without `CommitPolicy::Auto`, and for a checkpoint that
+    /// this reader did not deliver.
+    pub fn mark(&self, checkpoint: Checkpoint) -> Result<(), IggyError> {
+        self.shared.progress.mark(checkpoint)
+    }
+
+    /// Stores the offset of a checkpoint that this reader delivered. The commits of one
+    /// partition go out in order when the caller awaits each one.
+    ///
+    /// A checkpoint of an older generation of its partition, or at or below the last commit of
+    /// its generation, stores nothing.
+    ///
+    /// # Errors
+    ///
+    /// [`IggyError::InvalidCommand`] with `CommitPolicy::OnRead`, and for a checkpoint that this
+    /// reader did not deliver. The errors of the offset store.
+    pub async fn commit(&self, checkpoint: Checkpoint) -> Result<(), IggyError> {
+        if self.config.on_read() {
+            return Err(IggyError::InvalidCommand);
+        }
+        self.shared.progress.commit(checkpoint).await
+    }
+
+    /// Sets the next read position of `partition` and starts a new generation of it. It drops
+    /// the queued batches of the partition, ignores its late replies and resumes a stopped
+    /// partition.
+    ///
+    /// With `OnRead`, a seek to an offset above 0 first stores the offset before it, under the
+    /// history of a continuation or else of the last delivered message, so a restart resumes at
+    /// the new position. A failed store fails the seek and nothing moves. Every other seek stores
+    /// nothing. The new position waits for the end of the committing read in flight, whose
+    /// reply the new generation drops, and the first read from it commits as usual.
+    ///
+    /// # Errors
+    ///
+    /// - [`IggyError::InvalidCommand`] for a partition that the reader does not read, and for a
+    ///   continuation of another partition. A group reader reads no partition until the first
+    ///   refresh of its assignment after `build()`, so it refuses every partition before it.
+    /// - [`IggyError::PartitionLaneBlocked`] with `OnRead` while another call on the client left
+    ///   a committing read of the partition with an unknown outcome. Call `abandon_operation`
+    ///   with its operation first.
+    /// - The errors of the offset store with `OnRead`.
+    pub async fn seek(&mut self, partition: u32, start: PollingStrategy) -> Result<(), IggyError> {
+        start.validate_partition(partition)?;
+        if !self.shared.partitions().contains(&partition) {
+            return Err(IggyError::InvalidCommand);
+        }
+        let progress = &self.shared.progress;
+        progress.unblock_abandoned().await;
+        if self.config.on_read()
+            && let Some(operation) = progress.blocked(partition)
+        {
+            return Err(IggyError::PartitionLaneBlocked(partition, operation));
+        }
+        let start = first_as_offset(start);
+        let stored = self.store_before(partition, start).await?;
+        while let Ok(reply) = self.receiver.try_recv() {
+            self.split(reply);
+        }
+        self.queue
+            .retain(|entry| entry.partition() != Some(partition));
+        match stored {
+            Some(stored) => self.shared.progress.resume_after(stored, start),
+            None => {
+                self.shared.progress.seek(partition, start);
+            }
+        }
+        self.shared.wake.notify_one();
+        Ok(())
+    }
+
+    /// With `OnRead`, a seek to an offset above 0 stores the offset before it. The store carries
+    /// the history of a continuation, else that of the last delivered message, else none.
+    async fn store_before(
+        &self,
+        partition: u32,
+        start: PollingStrategy,
+    ) -> Result<Option<Checkpoint>, IggyError> {
+        if !self.config.on_read() || start.kind != PollingKind::Offset {
+            return Ok(None);
+        }
+        let Some(before) = start.value.checked_sub(1) else {
+            return Ok(None);
+        };
+        let progress = &self.shared.progress;
+        let context = start.context().or_else(|| {
+            progress
+                .last_delivered(partition)
+                .map(|delivered| delivered.context())
+        });
+        let stored = Checkpoint::new(partition, before, context.unwrap_or_default());
+        match context {
+            Some(_) => progress.store(stored).await?,
+            None => progress.store_offset(partition, before).await?,
+        }
+        Ok(Some(stored))
+    }
+
+    /// The partitions that the reader reads: those of a standalone consumer, or the current
+    /// assignment of a group member. A group reader holds no partition until the first refresh
+    /// of its assignment after `build()`.
+    pub fn partitions(&self) -> Vec<u32> {
+        self.shared.partitions()
+    }
+
+    /// Stops the reader. With `OnRead`, it cancels each read in flight and takes its one reply,
+    /// which tells what the server stored, at most until the deadline of the read. With
+    /// `Manual` it commits nothing. With `Auto`, it counts the last batch under
+    /// `CommitAfter::Processing` and stores the latest counted position of each partition.
+    ///
+    /// With `OnRead`, it moves the offset of each partition with undelivered batches, or with a
+    /// read whose reply was lost, back to its last delivered message. A partition that delivered
+    /// nothing and started at `Next` gets the offset before its first undelivered message, or no
+    /// offset at offset 0. A group reader then leaves its group.
+    ///
+    /// # Errors
+    ///
+    /// The first failed store of the `OnRead` or the `Auto` close. Otherwise
+    /// [`IggyError::PartitionLaneBlocked`] for a partition with an unknown outcome: abandon its
+    /// operation on the client before a new reader reads the partition.
+    pub async fn close(mut self) -> Result<(), IggyError> {
+        self.shared.closing.store(true, ORDERING);
+        self.shared.wake.notify_one();
+        if let Some(task) = self.task.take() {
+            // A worker that failed already ended, which is all that the close waits for.
+            let _ = task.await;
+        }
+        while let Ok(reply) = self.receiver.try_recv() {
+            self.split(reply);
+        }
+        let undelivered: Vec<PolledMessages> = self
+            .queue
+            .drain(..)
+            .filter_map(|entry| entry.result.ok())
+            .collect();
+        // The worker lifts the block of an abandoned operation only at its next turn.
+        self.shared.progress.unblock_abandoned().await;
+        let mut result = Ok(());
+        if self.config.on_read() {
+            let owned = self.shared.owned_partitions();
+            let resumes_next = self.config.start.kind == PollingKind::Next;
+            result = with_deadline(
+                Instant::now() + OPERATION_TIMEOUT,
+                self.shared
+                    .progress
+                    .rewind(undelivered, owned.as_deref(), resumes_next),
+            )
+            .await;
+        }
+        self.count_uncounted();
+        if let Some(committer) = self.committer.take() {
+            committer.stop().await;
+            result = with_deadline(
+                Instant::now() + OPERATION_TIMEOUT,
+                self.shared.progress.flush_counted(),
+            )
+            .await;
+        }
+        // The server refuses a store from a member that left, so the rewind goes first.
+        if let Some(membership) = &self.shared.membership {
+            membership.leave().await;
+        }
+        result?;
+        match self.shared.progress.blocks().first() {
+            Some(&(partition, operation)) => {
+                Err(IggyError::PartitionLaneBlocked(partition, operation))
+            }
+            None => Ok(()),
+        }
+    }
+
+    /// Under `CommitPolicy::Auto`, stops the store task once its store in flight ends and
+    /// stores the latest counted position of each partition. The reader stores nothing more
+    /// after it, also not in [`close`](Self::close).
+    pub(crate) async fn flush(&mut self) -> Result<(), IggyError> {
+        let Some(committer) = self.committer.take() else {
+            return Ok(());
+        };
+        committer.stop().await;
+        self.shared.progress.flush_counted().await
+    }
+
+    /// Under `CommitAfter::Processing`, the batch that the application took last counts once
+    /// it asks for the next one.
+    fn count_uncounted(&mut self) {
+        if let Some((checkpoint, messages)) = self.uncounted.take() {
+            self.count(checkpoint, messages, true);
+        }
+    }
+
+    fn hand_over(&self, entry: Entry) -> Option<Result<(PolledMessages, Hold), ReadError>> {
+        let batch = match entry.result {
+            Ok(batch) => batch,
+            Err(Refusal { error, generation }) => {
+                let current = match (error.partition_id, generation) {
+                    (Some(partition), Some(generation)) => {
+                        self.shared
+                            .progress
+                            .keeps(partition, generation, self.config.on_read())
+                    }
+                    _ => entry.epoch == self.shared.epoch.load(ORDERING),
+                };
+                return current.then_some(Err(error));
+            }
+        };
+        let stamp = batch.stamp()?;
+        let mut hold = Hold {
+            _capacity: entry.capacity,
+            shared: Arc::clone(&self.shared),
+            on_read: self.config.on_read(),
+            epoch: entry.epoch,
+            assignment: entry.assignment,
+            partition: batch.partition_id,
+            generation: stamp.generation,
+            generation_checked: None,
+            checked: None,
+        };
+        if !hold.is_current() {
+            return None;
+        }
+        if hold.on_read
+            && let Some(checkpoint) = batch.checkpoint()
+        {
+            self.shared.progress.record_commit(checkpoint);
+        }
+        Some(Ok((batch, hold)))
+    }
+
+    fn split(&mut self, reply: Reply) {
+        let Reply {
+            epoch,
+            assignment,
+            result,
+            capacity,
+        } = reply;
+        match result {
+            Err(error) => self.queue.push_back(Entry {
+                epoch,
+                assignment,
+                result: Err(error),
+                capacity,
+            }),
+            Ok(batches) => self.queue.extend(batches.into_iter().map(|batch| Entry {
+                epoch,
+                assignment,
+                result: Ok(batch),
+                capacity: Arc::clone(&capacity),
+            })),
+        }
+    }
+}
+
+impl Debug for PartitionReader {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+        f.debug_struct("PartitionReader")
+            .field("stream", &self.config.stream)
+            .field("topic", &self.config.topic)
+            .field("consumer", &self.config.consumer)
+            .field("options", &self.config.options)
+            .field("queued", &self.queue.len())
+            .finish_non_exhaustive()
+    }
+}
+
+impl Drop for PartitionReader {
+    fn drop(&mut self) {
+        if let Some(task) = &self.task {
+            task.abort();
+        }
+        if let Some(committer) = &self.committer {
+            committer.task.abort();
+        }
+    }
+}
+
+impl Committer {
+    fn spawn(progress: Arc<Progress>, interval: Option<NonZeroIggyDuration>) -> Self {
+        let stop = Arc::new(Notify::new());
+        let stopped = Arc::clone(&stop);
+        let task = tokio::spawn(async move {
+            let mut ticks = interval.map(|interval| {
+                let period = interval.get_duration();
+                let mut ticks = interval_at(Instant::now() + period, period);
+                ticks.set_missed_tick_behavior(MissedTickBehavior::Delay);
+                ticks
+            });
+            loop {
+                tokio::select! {
+                    () = stopped.notified() => return,
+                    () = progress.store_due() => {}
+                    () = next_tick(&mut ticks) => {}
+                }
+                // The flush logs each failure, and the next one stores the position again.
+                let _ = progress.flush_counted().await;
+            }
+        });
+        Self { stop, task }
+    }
+
+    /// Lets a flush in flight finish, so its stores never race the final one.
+    async fn stop(self) {
+        self.stop.notify_one();
+        // A task that panicked stored what it could, and the final flush stores the rest.
+        let _ = self.task.await;
+    }
+}
+
+impl ReadError {
+    pub(crate) fn new(partition_id: Option<u32>, error: IggyError) -> Self {
+        Self {
+            partition_id,
+            error,
+            skip: None,
+            offset: None,
+            record: None,
+        }
+    }
+}
+
+impl Display for ReadError {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+        match self.partition_id {
+            Some(partition_id) => write!(f, "partition ID: {partition_id}: {}", self.error),
+            None => Display::fmt(&self.error, f),
+        }
+    }
+}
+
+impl Error for ReadError {
+    fn source(&self) -> Option<&(dyn Error + 'static)> {
+        Some(&self.error)
+    }
+}
+
+impl From<ReadError> for IggyError {
+    fn from(error: ReadError) -> Self {
+        error.error
+    }
+}
+
+impl Config {
+    pub(crate) fn is_group(&self) -> bool {
+        self.consumer.kind == ConsumerKind::ConsumerGroup
+    }
+
+    pub(crate) fn on_read(&self) -> bool {
+        self.options.commit == CommitPolicy::OnRead
+    }
+
+    pub(crate) fn auto(&self) -> Option<AutoCommit> {
+        match self.options.commit {
+            CommitPolicy::Auto(auto) => Some(auto),
+            CommitPolicy::Manual | CommitPolicy::OnRead => None,
+        }
+    }
+}
+
+impl Shared {
+    fn partitions(&self) -> Vec<u32> {
+        self.partitions_guard().clone()
+    }
+
+    fn set_partitions(&self, partitions: &[u32]) {
+        partitions.clone_into(&mut self.partitions_guard());
+    }
+
+    /// The partitions that a group member owns now. `None` for a standalone reader.
+    fn owned_partitions(&self) -> Option<Vec<u32>> {
+        self.assignment_state
+            .as_ref()
+            .map(|(state, key)| state.assignment_snapshot(key).1)
+    }
+
+    fn partitions_guard(&self) -> MutexGuard<'_, Vec<u32>> {
+        self.partitions
+            .lock()
+            .unwrap_or_else(PoisonError::into_inner)
+    }
+}
+
+impl Drop for FetchCapacity {
+    fn drop(&mut self) {
+        drop(self.permit.take());
+        self.wake.notify_one();
+    }
+}
+
+impl Entry {
+    fn partition(&self) -> Option<u32> {
+        match &self.result {
+            Ok(batch) => Some(batch.partition_id),
+            Err(refusal) => refusal.error.partition_id,
+        }
+    }
+}
+
+impl Hold {
+    /// Whether the batch still counts: the generation of its partition, and without `OnRead`
+    /// also the epoch and the assignment of a group member.
+    pub(crate) fn is_current(&mut self) -> bool {
+        let progress = &self.shared.progress;
+        // Lock the generations only after a seek or an assignment changed them.
+        let generation_changes = progress.generation_changes();
+        if self.generation_checked != Some(generation_changes) {
+            if !progress.keeps(self.partition, self.generation, self.on_read) {
+                return false;
+            }
+            self.generation_checked = Some(generation_changes);
+        }
+        if self.on_read {
+            return true;
+        }
+        if self.epoch != self.shared.epoch.load(ORDERING) {
+            return false;
+        }
+        let Some((state, key)) = &self.shared.assignment_state else {
+            return true;
+        };
+        // Lock the group state only after it changed since the last check.
+        let changes = state.assignment_changes();
+        if self.checked == Some(changes) {
+            return true;
+        }
+        let current = self
+            .assignment
+            .is_some_and(|assignment| state.is_current_assignment(key, assignment, self.partition));
+        if current {
+            self.checked = Some(changes);
+        }
+        current
+    }
+}
+
+/// The server reads `First` from offset 0, and a group fetch takes only offsets and `Next`.
+pub(super) fn first_as_offset(start: PollingStrategy) -> PollingStrategy {
+    if start.kind == PollingKind::First {
+        PollingStrategy::offset(0)
+    } else {
+        start
+    }
+}
+
+async fn next_tick(ticks: &mut Option<Interval>) {
+    match ticks {
+        Some(ticks) => {
+            ticks.tick().await;
+        }
+        None => pending().await,
+    }
+}
+
+fn spawn_worker(
+    config: &Arc<Config>,
+    shared: &Arc<Shared>,
+    events: Option<Receiver<DiagnosticEvent>>,
+) -> (mpsc::UnboundedReceiver<Reply>, JoinHandle<()>) {
+    let (sender, receiver) = mpsc::unbounded_channel();
+    let worker = Worker::new(Arc::clone(config), Arc::clone(shared), sender, events);
+    (receiver, tokio::spawn(worker.run()))
+}
+
+#[cfg(test)]
+mod tests {
+    use std::collections::VecDeque;
+    use std::future::poll_fn;
+    use std::num::NonZeroU32;
+    use std::ops::RangeInclusive;
+    use std::sync::Arc;
+    use std::time::Duration;
+
+    use futures::FutureExt;
+    use iggy_binary_protocol::WireDecode;
+    use iggy_binary_protocol::codes::{
+        DELETE_CONSUMER_OFFSET_CODE, POLL_MESSAGES_DEFERRED_CODE, STORE_CONSUMER_OFFSET_CODE,
+    };
+    use iggy_binary_protocol::primitives::partition_history::PartitionContext;
+    use iggy_binary_protocol::requests::consumer_offsets::DeleteConsumerOffsetRequest;
+    use iggy_common::locking::{IggyRwLock, IggyRwLockFn};
+    use iggy_common::{
+        Aes256GcmEncryptor, AutoCommit, Checkpoint, Client, CommitAfter, CommitEvery, CommitPolicy,
+        Consumer, EncryptorKind, IggyError, MessageClient, NonZeroIggyDuration, PollOptions,
+        PolledMessages, PollingKind, PollingStrategy, ReaderStamp,
+    };
+    use tokio::sync::{Semaphore, mpsc};
+    use tokio::time::{Instant, timeout};
+
+    use super::fixtures::{
+        CONSUMER_ID, CONTEXT, GROUP_ID, PRIMARY, STREAM_ID, TOPIC_ID, builder_for, builder_on,
+        committing, connect_to_one_replica, group, identifier, message_at, partition_and_offsets,
+        stored_offsets,
+    };
+    use super::progress::Progress;
+    use super::{Committer, FetchCapacity, ORDERING, PartitionReader, ReadError, Refusal, Reply};
+    use crate::client_wrappers::client_wrapper::ClientWrapper;
+    use crate::clients::reader_builder::{DEFAULT_PREFETCH_MESSAGES, ReaderBuilder};
+    use crate::driver::with_deadline;
+    use crate::http::http_client::HttpClient;
+    use crate::links::OPERATION_TIMEOUT;
+    use crate::links::fake_cluster::{Answer, FakeCluster, endpoint};
+    use crate::tcp::tcp_client::TcpClient;
+
+    const PARTITION: u32 = 1;
+    const OTHER_PARTITION: u32 = 2;
+    const THIRD_PARTITION: u32 = 3;
+    const FIRST_COMMIT: u64 = 1;
+    const SECOND_COMMIT: u64 = 2;
+    const KEY: [u8; 32] = [1; 32];
+    const UNREACHABLE_API_URL: &str = "http://127.0.0.1:1";
+    /// Ends a call long before any operation deadline.
+    const CANCEL_AFTER: Duration = Duration::from_millis(10);
+    const NEW_OWNER: PartitionContext = PartitionContext {
+        owner_generation: 2,
+        ..CONTEXT
+    };
+    const RECREATED: PartitionContext = PartitionContext {
+        incarnation: 2,
+        ..CONTEXT
+    };
+    /// The offset that a first commit stores.
+    const STORED_OFFSET: u64 = 100;
+    /// An offset below [`STORED_OFFSET`] that a second commit asks for.
+    const LOWER_OFFSET: u64 = 10;
+    const DELIVERED_OFFSET: u64 = 4;
+    /// The messages after the delivered one.
+    const BATCH: RangeInclusive<u64> = DELIVERED_OFFSET + 1..=DELIVERED_OFFSET + 3;
+    /// The first offset of a partition. A rewind before it deletes the offset.
+    const START_OFFSET: u64 = 0;
+    const SEEK_OFFSET: u64 = 50;
+    /// Longer than any test, so only an event or the close stores.
+    const NEVER: Duration = Duration::from_secs(24 * 60 * 60);
+
+    #[tokio::test]
+    async fn given_a_commit_of_a_later_poll_when_committing_at_or_below_it_should_store_nothing() {
+        let fed = FedReader::new(standalone([PARTITION]));
+        let later_poll = PartitionContext {
+            metadata_op: CONTEXT.metadata_op + 1,
+            ..CONTEXT
+        };
+        let committed = fed.checkpoint(PARTITION, STORED_OFFSET, later_poll);
+        fed.progress().record_commit(committed);
+
+        for offset in [LOWER_OFFSET, STORED_OFFSET] {
+            fed.reader
+                .commit(fed.checkpoint(PARTITION, offset, CONTEXT))
+                .await
+                .unwrap();
+        }
+
+        // The client never connects, so only a commit that stores nothing succeeds.
+        assert_eq!(
+            fed.reader
+                .commit(fed.checkpoint(PARTITION, STORED_OFFSET + 1, CONTEXT))
+                .await,
+            Err(IggyError::Disconnected)
+        );
+        assert_eq!(fed.progress().last_committed(PARTITION), Some(committed));
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_auto_commit_when_closing_should_store_only_counted_positions_of_the_current_generation_ahead_of_the_last_store()
+     {
+        let (client, cluster) = connect_to_one_replica().await;
+        let mut fed = FedReader::new(
+            standalone_on(client, [PARTITION, OTHER_PARTITION, THIRD_PARTITION])
+                .options(auto(None, CommitAfter::Delivery)),
+        );
+        fed.reader.count(
+            fed.checkpoint(PARTITION, DELIVERED_OFFSET, CONTEXT),
+            1,
+            true,
+        );
+        fed.progress()
+            .record_commit(fed.checkpoint(OTHER_PARTITION, STORED_OFFSET, CONTEXT));
+        fed.reader.count(
+            fed.checkpoint(OTHER_PARTITION, LOWER_OFFSET, CONTEXT),
+            1,
+            true,
+        );
+        fed.reader.count(
+            fed.checkpoint(THIRD_PARTITION, LOWER_OFFSET, CONTEXT),
+            1,
+            true,
+        );
+        fed.reader
+            .seek(THIRD_PARTITION, PollingStrategy::offset(SEEK_OFFSET))
+            .await
+            .unwrap();
+
+        fed.reader.close().await.unwrap();
+
+        assert_eq!(
+            stored_offsets(&cluster),
+            [DELIVERED_OFFSET],
+            "a store behind the last one and a count of the generation before a seek are dropped"
+        );
+    }
+
+    #[tokio::test]
+    async fn given_auto_commit_every_n_messages_when_counting_should_make_a_store_due_after_n_messages_of_one_partition()
+     {
+        const EVERY: u32 = 3;
+        let every = CommitEvery::Messages(NonZeroU32::new(EVERY).unwrap());
+        let (_, shared) = standalone([PARTITION, OTHER_PARTITION])
+            .options(auto(Some(every), CommitAfter::Processing))
+            .into_parts()
+            .unwrap();
+        let progress = shared.progress;
+        progress.assign(&[PARTITION, OTHER_PARTITION], &[]);
+        let at = |partition, offset| {
+            let generation = progress.generation(partition).unwrap();
+            Checkpoint::stamped(partition, offset, CONTEXT, Some(progress.stamp(generation)))
+        };
+        let due = || progress.store_due().now_or_never().is_some();
+
+        progress.count(at(PARTITION, 10), EVERY - 1, false);
+        progress.count(at(OTHER_PARTITION, 10), EVERY - 1, false);
+        assert!(!due(), "each partition counted fewer than {EVERY} messages");
+
+        progress.count(at(PARTITION, 11), 1, false);
+        assert!(
+            due(),
+            "{EVERY} counted messages of one partition make a store due"
+        );
+    }
+
+    #[tokio::test]
+    async fn given_a_checkpoint_of_the_generation_before_a_seek_when_committing_should_store_nothing()
+     {
+        let mut fed = FedReader::new(standalone([PARTITION]));
+        let before_seek = fed.checkpoint(PARTITION, STORED_OFFSET, CONTEXT);
+        fed.reader
+            .seek(PARTITION, PollingStrategy::offset(SEEK_OFFSET))
+            .await
+            .unwrap();
+
+        fed.reader.commit(before_seek).await.unwrap();
+
+        // The client never connects, so only a commit that stores nothing succeeds.
+        assert_eq!(
+            fed.reader
+                .commit(fed.checkpoint(PARTITION, STORED_OFFSET, CONTEXT))
+                .await,
+            Err(IggyError::Disconnected)
+        );
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_an_unconfirmed_commit_when_committing_the_same_checkpoint_should_resolve_it_and_store()
+     {
+        for cancel in [false, true] {
+            let (client, cluster) = connect_to_one_replica().await;
+            let fed = FedReader::new(standalone_on(client, [PARTITION]));
+            let checkpoint = fed.checkpoint(PARTITION, STORED_OFFSET, CONTEXT);
+            cluster.script(
+                &endpoint(PRIMARY),
+                STORE_CONSUMER_OFFSET_CODE,
+                [Answer::Silence, Answer::Commit(SECOND_COMMIT)],
+            );
+
+            if cancel {
+                assert!(
+                    timeout(CANCEL_AFTER, fed.reader.commit(checkpoint))
+                        .await
+                        .is_err()
+                );
+            } else {
+                assert!(matches!(
+                    fed.reader.commit(checkpoint).await,
+                    Err(IggyError::OperationUncertain(_))
+                ));
+            }
+            assert_eq!(cluster.count(STORE_CONSUMER_OFFSET_CODE), 1);
+            fed.reader.commit(checkpoint).await.unwrap();
+
+            let stores = cluster.received(STORE_CONSUMER_OFFSET_CODE);
+            assert_eq!(
+                stored_offsets(&cluster),
+                [STORED_OFFSET, STORED_OFFSET, STORED_OFFSET]
+            );
+            assert_eq!(
+                stores[0].header.request, stores[1].header.request,
+                "the commit resolves the old request first"
+            );
+            assert_eq!(stores[0].body, stores[1].body);
+            assert_ne!(stores[1].header.request, stores[2].header.request);
+            assert_eq!(fed.progress().last_committed(PARTITION), Some(checkpoint));
+        }
+    }
+
+    #[tokio::test]
+    async fn given_a_commit_when_its_owner_or_incarnation_changes_should_store_a_lower_offset_under_the_new_history()
+     {
+        for changed in [NEW_OWNER, RECREATED] {
+            let (client, cluster) = connect_to_one_replica().await;
+            let fed = FedReader::new(builder_on(client, group()));
+            fed.assign(&[PARTITION]);
+            fed.reader
+                .commit(fed.checkpoint(PARTITION, STORED_OFFSET, CONTEXT))
+                .await
+                .unwrap();
+
+            fed.reader
+                .commit(fed.checkpoint(PARTITION, LOWER_OFFSET, changed))
+                .await
+                .unwrap();
+
+            assert_eq!(stored_offsets(&cluster), [STORED_OFFSET, LOWER_OFFSET]);
+            assert_eq!(
+                histories(&cluster, STORE_CONSUMER_OFFSET_CODE),
+                [history(CONTEXT), history(changed)]
+            );
+        }
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_an_uncertain_commit_when_the_blocker_is_rejected_should_store_the_new_checkpoint()
+     {
+        let (client, cluster) = connect_to_one_replica().await;
+        let fed = FedReader::new(standalone_on(client, [PARTITION]));
+        cluster.script(
+            &endpoint(PRIMARY),
+            STORE_CONSUMER_OFFSET_CODE,
+            [
+                Answer::Silence,
+                Answer::CommitRejected(FIRST_COMMIT, IggyError::HistoryUnavailable),
+                Answer::Commit(SECOND_COMMIT),
+            ],
+        );
+        let first = fed
+            .reader
+            .commit(fed.checkpoint(PARTITION, LOWER_OFFSET, CONTEXT))
+            .await;
+        let old = cluster.received(STORE_CONSUMER_OFFSET_CODE)[0]
+            .header
+            .request;
+        assert_eq!(first, Err(IggyError::OperationUncertain(old)));
+
+        let checkpoint = fed.checkpoint(PARTITION, STORED_OFFSET, CONTEXT);
+        fed.reader.commit(checkpoint).await.unwrap();
+
+        let resolved = fed
+            .reader
+            .config
+            .client
+            .read()
+            .await
+            .resolve_operation(old, (Instant::now() + OPERATION_TIMEOUT).into_std())
+            .await;
+        assert_eq!(resolved, Err(IggyError::HistoryUnavailable));
+        let stores = cluster.received(STORE_CONSUMER_OFFSET_CODE);
+        assert_eq!(
+            stored_offsets(&cluster),
+            [LOWER_OFFSET, LOWER_OFFSET, STORED_OFFSET]
+        );
+        assert_eq!(stores[0].header.request, stores[1].header.request);
+        assert_eq!(stores[0].body, stores[1].body);
+        assert_ne!(stores[1].header.request, stores[2].header.request);
+        assert_eq!(fed.progress().last_committed(PARTITION), Some(checkpoint));
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_a_still_uncertain_commit_when_committing_again_should_leave_the_new_checkpoint_unsent()
+     {
+        let (client, cluster) = connect_to_one_replica().await;
+        let fed = FedReader::new(standalone_on(client, [PARTITION]));
+        cluster.script(
+            &endpoint(PRIMARY),
+            STORE_CONSUMER_OFFSET_CODE,
+            [Answer::Silence, Answer::Silence],
+        );
+        assert!(matches!(
+            fed.reader
+                .commit(fed.checkpoint(PARTITION, LOWER_OFFSET, CONTEXT))
+                .await,
+            Err(IggyError::OperationUncertain(_))
+        ));
+        let old = cluster.received(STORE_CONSUMER_OFFSET_CODE)[0]
+            .header
+            .request;
+        let started = Instant::now();
+
+        let result = fed
+            .reader
+            .commit(fed.checkpoint(PARTITION, STORED_OFFSET, CONTEXT))
+            .await;
+
+        assert_eq!(result, Err(IggyError::LaneBlocked(old)));
+        assert!(started.elapsed() <= OPERATION_TIMEOUT);
+        assert_eq!(stored_offsets(&cluster), [LOWER_OFFSET, LOWER_OFFSET]);
+        assert_eq!(fed.progress().last_committed(PARTITION), None);
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_an_outer_deadline_past_the_default_budget_when_a_commit_is_unanswered_should_end_at_the_outer_deadline()
+     {
+        const OUTER_DEADLINE: Duration = OPERATION_TIMEOUT.saturating_mul(2);
+        let (client, cluster) = connect_to_one_replica().await;
+        let fed = FedReader::new(standalone_on(client, [PARTITION]));
+        cluster.script(
+            &endpoint(PRIMARY),
+            STORE_CONSUMER_OFFSET_CODE,
+            [Answer::Silence],
+        );
+        let started = Instant::now();
+
+        let result = with_deadline(
+            started + OUTER_DEADLINE,
+            fed.reader
+                .commit(fed.checkpoint(PARTITION, STORED_OFFSET, CONTEXT)),
+        )
+        .await;
+
+        let op = cluster.received(STORE_CONSUMER_OFFSET_CODE)[0]
+            .header
+            .request;
+        assert_eq!(result, Err(IggyError::OperationUncertain(op)));
+        assert_eq!(started.elapsed(), OUTER_DEADLINE);
+    }
+
+    #[tokio::test]
+    async fn given_a_checkpoint_inside_a_batch_when_committing_should_store_its_message_and_skip_a_lower_one()
+     {
+        const INSIDE: usize = 1;
+        let (client, cluster) = connect_to_one_replica().await;
+        let mut fed = FedReader::new(standalone_on(client, [PARTITION]));
+        fed.send(Ok(vec![fed.batch(PARTITION, BATCH, CONTEXT)]));
+        let batch = fed.reader.next().await.unwrap();
+
+        fed.reader
+            .commit(batch.checkpoint_at(INSIDE).unwrap())
+            .await
+            .unwrap();
+        fed.reader
+            .commit(batch.checkpoint_at(0).unwrap())
+            .await
+            .unwrap();
+
+        assert_eq!(
+            stored_offsets(&cluster),
+            [batch.messages[INSIDE].header.offset]
+        );
+    }
+
+    #[tokio::test]
+    async fn given_a_checkpoint_of_another_reader_or_without_a_stamp_when_committing_should_refuse_it()
+     {
+        let fed = FedReader::new(standalone([PARTITION]));
+        let other = FedReader::new(standalone([PARTITION]));
+
+        for checkpoint in [
+            other.checkpoint(PARTITION, LOWER_OFFSET, CONTEXT),
+            Checkpoint::new(PARTITION, LOWER_OFFSET, CONTEXT),
+        ] {
+            assert_eq!(
+                fed.reader.commit(checkpoint).await,
+                Err(IggyError::InvalidCommand)
+            );
+        }
+    }
+
+    #[tokio::test]
+    async fn given_a_server_commit_on_read_when_committing_should_refuse_it() {
+        let fed = FedReader::new(committing(standalone([PARTITION])));
+
+        assert_eq!(
+            fed.reader
+                .commit(fed.checkpoint(PARTITION, LOWER_OFFSET, CONTEXT))
+                .await,
+            Err(IggyError::InvalidCommand)
+        );
+    }
+
+    #[test]
+    fn given_revoked_and_stored_partitions_when_listing_unstored_deliveries_should_skip_them() {
+        const BEHIND: u32 = PARTITION;
+        const REVOKED: u32 = OTHER_PARTITION;
+        const STORED: u32 = THIRD_PARTITION;
+        let fed = FedReader::new(standalone([BEHIND, REVOKED, STORED]));
+        for (partition, delivered) in [
+            (BEHIND, LOWER_OFFSET),
+            (REVOKED, LOWER_OFFSET),
+            (STORED, STORED_OFFSET),
+        ] {
+            fed.progress()
+                .deliver(&fed.checkpoint(partition, delivered, CONTEXT));
+            fed.progress()
+                .record_commit(fed.checkpoint(partition, STORED_OFFSET, CONTEXT));
+        }
+        let behind = fed.checkpoint(BEHIND, LOWER_OFFSET, CONTEXT);
+
+        assert_eq!(
+            fed.progress().unstored_delivered(Some(&[BEHIND, STORED])),
+            [behind]
+        );
+        let mut unstored = fed.progress().unstored_delivered(None);
+        unstored.sort_unstable_by_key(Checkpoint::partition_id);
+        assert_eq!(
+            unstored,
+            [behind, fed.checkpoint(REVOKED, LOWER_OFFSET, CONTEXT)]
+        );
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_blocked_fetch_progress_when_closing_should_skip_its_rewind_and_report_the_block()
+    {
+        let (client, cluster) = connect_to_one_replica().await;
+        let fed = FedReader::new(committing(standalone_on(client, [PARTITION])));
+        let operation = lose_committing_poll(&fed, &cluster, PARTITION).await;
+        fed.progress()
+            .deliver(&fed.checkpoint(PARTITION, DELIVERED_OFFSET, CONTEXT));
+        fed.progress()
+            .record_commit(fed.checkpoint(PARTITION, DELIVERED_OFFSET + 1, CONTEXT));
+        fed.send(Ok(vec![fed.batch(PARTITION, BATCH, CONTEXT)]));
+        let shared = Arc::clone(&fed.reader.shared);
+
+        let closed = fed.reader.close().await;
+
+        assert_eq!(
+            closed,
+            Err(IggyError::PartitionLaneBlocked(PARTITION, operation))
+        );
+        assert!(
+            stored_offsets(&cluster).is_empty(),
+            "the server can store the offset of the lost read after any store of the close"
+        );
+        assert!(shared.closing.load(ORDERING));
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_an_abandoned_block_when_closing_should_lift_it_and_rewind_its_partition() {
+        let (client, cluster) = connect_to_one_replica().await;
+        let fed = FedReader::new(committing(standalone_on(client, [PARTITION])));
+        let operation = lose_committing_poll(&fed, &cluster, PARTITION).await;
+        fed.progress()
+            .deliver(&fed.checkpoint(PARTITION, DELIVERED_OFFSET, CONTEXT));
+        fed.progress()
+            .record_commit(fed.checkpoint(PARTITION, DELIVERED_OFFSET + 1, CONTEXT));
+        fed.send(Ok(vec![fed.batch(PARTITION, BATCH, CONTEXT)]));
+        let abandoned = fed
+            .reader
+            .config
+            .client
+            .read()
+            .await
+            .abandon_operation(operation)
+            .await;
+
+        let closed = fed.reader.close().await;
+
+        assert_eq!(abandoned, Ok(()));
+        assert_eq!(closed, Ok(()), "the caller abandoned the only block");
+        assert_eq!(
+            stored_offsets(&cluster),
+            [DELIVERED_OFFSET],
+            "the close moves the offset back over the queued batch"
+        );
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_undecoded_healthy_and_blocked_sections_when_closing_should_rewind_only_the_healthy_one()
+     {
+        const BLOCKED: u32 = OTHER_PARTITION;
+        const UNDECODED_OFFSET: u64 = 7;
+        let (client, cluster) = connect_to_one_replica().await;
+        let fed = FedReader::new(committing(standalone_on(client, [PARTITION, BLOCKED])));
+        let progress = Arc::clone(&fed.reader.shared.progress);
+        for partition in [PARTITION, BLOCKED] {
+            progress.remember_undecoded(Checkpoint::new(partition, UNDECODED_OFFSET, CONTEXT));
+        }
+        let operation = lose_committing_poll(&fed, &cluster, BLOCKED).await;
+
+        assert_eq!(
+            fed.reader.close().await,
+            Err(IggyError::PartitionLaneBlocked(BLOCKED, operation))
+        );
+        assert_eq!(stored_offsets(&cluster), [UNDECODED_OFFSET - 1]);
+        assert_eq!(
+            histories(&cluster, STORE_CONSUMER_OFFSET_CODE),
+            [history(CONTEXT)]
+        );
+        assert_eq!(progress.undecoded(PARTITION), None);
+    }
+
+    #[tokio::test]
+    async fn given_a_queued_batch_of_the_delivered_history_when_closing_should_store_the_delivered_offset_again()
+     {
+        let (client, cluster) = connect_to_one_replica().await;
+        let mut fed = FedReader::new(committing(standalone_on(client, [PARTITION])));
+        fed.send(Ok(vec![fed.batch(
+            PARTITION,
+            DELIVERED_OFFSET..=DELIVERED_OFFSET,
+            CONTEXT,
+        )]));
+        fed.reader.next().await.unwrap();
+        fed.send(Ok(vec![fed.batch(PARTITION, BATCH, CONTEXT)]));
+
+        fed.reader.close().await.unwrap();
+
+        assert_eq!(
+            stored_offsets(&cluster),
+            [DELIVERED_OFFSET],
+            "the server stored the offset of the queued batch, so the delivered offset is stored again"
+        );
+    }
+
+    #[tokio::test]
+    async fn given_queued_batches_of_a_reply_when_closing_should_rewind_over_them() {
+        let (client, cluster) = connect_to_one_replica().await;
+        let mut fed = FedReader::new(committing(standalone_on(
+            client,
+            [PARTITION, OTHER_PARTITION],
+        )));
+        fed.send(Ok(vec![
+            fed.batch(PARTITION, DELIVERED_OFFSET..=DELIVERED_OFFSET + 1, CONTEXT),
+            fed.batch(OTHER_PARTITION, START_OFFSET..=START_OFFSET + 1, CONTEXT),
+        ]));
+        let (batch, hold) = poll_fn(|cx| fed.reader.poll_held(cx)).await.unwrap();
+        fed.reader.delivered(&batch.checkpoint_at(0).unwrap());
+        drop(hold);
+
+        fed.reader.close().await.unwrap();
+
+        assert_eq!(
+            stored_offsets(&cluster),
+            [DELIVERED_OFFSET],
+            "the server stored the offset of the whole batch, so the delivered offset is stored again"
+        );
+        assert_eq!(
+            deleted_partitions(&cluster),
+            [Some(OTHER_PARTITION)],
+            "nothing of the queued batch was delivered, so the offset goes back before it"
+        );
+    }
+
+    #[tokio::test]
+    async fn given_a_batch_of_a_newer_history_than_the_delivered_message_when_closing_should_store_before_it_and_skip_the_delivered_message()
+     {
+        const RECREATED_OFFSET: u64 = 2;
+        let (client, cluster) = connect_to_one_replica().await;
+        let mut fed = FedReader::new(committing(standalone_on(client, [PARTITION])));
+        fed.send(Ok(vec![fed.batch(
+            PARTITION,
+            DELIVERED_OFFSET..=DELIVERED_OFFSET,
+            CONTEXT,
+        )]));
+        fed.reader.next().await.unwrap();
+        fed.send(Ok(vec![fed.batch(
+            PARTITION,
+            RECREATED_OFFSET..=RECREATED_OFFSET + 1,
+            RECREATED,
+        )]));
+
+        fed.reader.close().await.unwrap();
+
+        assert_eq!(stored_offsets(&cluster), [RECREATED_OFFSET - 1]);
+        assert_eq!(
+            histories(&cluster, STORE_CONSUMER_OFFSET_CODE),
+            [history(RECREATED)],
+            "a server of the new history refuses the delivered message of the old one, so the close skips it"
+        );
+    }
+
+    #[tokio::test]
+    async fn given_undelivered_batches_of_two_histories_when_closing_should_rewind_each_under_its_context()
+     {
+        let recreated = PartitionContext {
+            owner_generation: NEW_OWNER.owner_generation,
+            ..RECREATED
+        };
+        let (client, cluster) = connect_to_one_replica().await;
+        let fed = FedReader::new(committing(builder_on(client, group())));
+        fed.assign(&[PARTITION]);
+        fed.send(Ok(vec![fed.batch(PARTITION, BATCH, CONTEXT)]));
+        fed.send(Ok(vec![fed.batch(
+            PARTITION,
+            START_OFFSET..=START_OFFSET + 1,
+            recreated,
+        )]));
+
+        fed.reader.close().await.unwrap();
+
+        assert_eq!(stored_offsets(&cluster), [BATCH.start() - 1]);
+        assert_eq!(
+            histories(&cluster, STORE_CONSUMER_OFFSET_CODE),
+            [history(CONTEXT)]
+        );
+        assert_eq!(
+            histories(&cluster, DELETE_CONSUMER_OFFSET_CODE),
+            [history(recreated)],
+            "the delete carries the history of its batch, not the one of the route"
+        );
+    }
+
+    #[tokio::test]
+    async fn given_a_reply_of_several_partitions_when_reading_should_hand_over_every_batch_in_reply_order()
+     {
+        let replied = [OTHER_PARTITION, THIRD_PARTITION, PARTITION];
+        let mut fed = FedReader::new(standalone(replied));
+        let free = fed.slots.available_permits();
+        fed.send(Ok(replied
+            .iter()
+            .map(|&partition| fed.batch(partition, BATCH, CONTEXT))
+            .collect()));
+
+        for partition in replied {
+            assert_eq!(
+                fed.slots.available_permits(),
+                free - 1,
+                "the reply holds its slot until its last batch leaves"
+            );
+            assert_eq!(fed.reader.next().await.unwrap().partition_id, partition);
+        }
+        assert_eq!(fed.slots.available_permits(), free);
+    }
+
+    #[tokio::test]
+    async fn given_a_seek_when_reading_should_drop_the_queued_and_late_batches_of_the_old_generation()
+     {
+        let mut fed = FedReader::new(standalone([PARTITION, OTHER_PARTITION]));
+        let free = fed.slots.available_permits();
+        let late = fed.batch(PARTITION, BATCH, CONTEXT);
+        fed.send(Ok(vec![fed.batch(PARTITION, BATCH, CONTEXT)]));
+        fed.send(Ok(vec![fed.batch(OTHER_PARTITION, BATCH, CONTEXT)]));
+
+        fed.reader
+            .seek(PARTITION, PollingStrategy::offset(SEEK_OFFSET))
+            .await
+            .unwrap();
+        assert_eq!(
+            fed.slots.available_permits(),
+            free - 1,
+            "the dropped batch returns its slot"
+        );
+        fed.send(Ok(vec![late]));
+        fed.send(Ok(vec![fed.batch(
+            PARTITION,
+            SEEK_OFFSET..=SEEK_OFFSET,
+            CONTEXT,
+        )]));
+
+        assert_eq!(
+            partition_and_offsets(&fed.reader.next().await.unwrap()),
+            (OTHER_PARTITION, BATCH.collect())
+        );
+        assert_eq!(
+            partition_and_offsets(&fed.reader.next().await.unwrap()),
+            (PARTITION, vec![SEEK_OFFSET])
+        );
+        assert_eq!(fed.slots.available_permits(), free);
+    }
+
+    #[tokio::test]
+    async fn given_a_held_batch_when_a_seek_or_an_assignment_starts_a_new_generation_should_stop_counting_it()
+     {
+        for reassign in [false, true] {
+            let mut fed = FedReader::new(builder_for(group()));
+            fed.assign(&[PARTITION, OTHER_PARTITION]);
+            fed.send(Ok(vec![fed.batch(PARTITION, BATCH, CONTEXT)]));
+            let (_, mut hold) = poll_fn(|cx| fed.reader.poll_held(cx)).await.unwrap();
+            fed.progress()
+                .seek(OTHER_PARTITION, PollingStrategy::offset(SEEK_OFFSET));
+            assert!(
+                hold.is_current(),
+                "a new generation of another partition keeps the batch"
+            );
+
+            if reassign {
+                fed.assign(&[OTHER_PARTITION]);
+            } else {
+                fed.progress()
+                    .seek(PARTITION, PollingStrategy::offset(SEEK_OFFSET));
+            }
+
+            assert!(!hold.is_current(), "reassign: {reassign}");
+        }
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_on_read_when_a_partition_leaves_with_an_undelivered_batch_should_still_hand_it_over()
+     {
+        let mut fed = FedReader::new(committing(builder_for(group())));
+        fed.assign(&[PARTITION, OTHER_PARTITION]);
+        fed.send(Ok(vec![
+            fed.batch(OTHER_PARTITION, BATCH, CONTEXT),
+            fed.batch(PARTITION, BATCH, CONTEXT),
+        ]));
+        assert_eq!(
+            fed.reader.next().await.unwrap().partition_id,
+            OTHER_PARTITION
+        );
+
+        fed.assign(&[OTHER_PARTITION]);
+
+        let batch = timeout(CANCEL_AFTER, fed.reader.next())
+            .await
+            .expect("the server stored the batch of the partition that left")
+            .unwrap();
+        assert_eq!(partition_and_offsets(&batch), (PARTITION, BATCH.collect()));
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_on_read_when_a_new_session_keeps_the_partition_should_still_hand_over_its_undelivered_batch()
+     {
+        // A new session starts a new generation of every partition, and the batch
+        // that the server stored under the old one was dropped.
+        let mut fed = FedReader::new(committing(builder_for(group())));
+        fed.assign(&[PARTITION, OTHER_PARTITION]);
+        fed.send(Ok(vec![
+            fed.batch(OTHER_PARTITION, BATCH, CONTEXT),
+            fed.batch(PARTITION, BATCH, CONTEXT),
+        ]));
+        assert_eq!(
+            fed.reader.next().await.unwrap().partition_id,
+            OTHER_PARTITION
+        );
+
+        fed.assign(&[PARTITION, OTHER_PARTITION]);
+
+        let batch = timeout(CANCEL_AFTER, fed.reader.next())
+            .await
+            .expect("the server stored the batch under the old session")
+            .unwrap();
+        assert_eq!(partition_and_offsets(&batch), (PARTITION, BATCH.collect()));
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_on_read_when_a_seek_follows_an_undelivered_batch_should_drop_it() {
+        let mut fed = FedReader::new(committing(builder_for(group())));
+        fed.assign(&[PARTITION, OTHER_PARTITION]);
+        fed.send(Ok(vec![
+            fed.batch(OTHER_PARTITION, BATCH, CONTEXT),
+            fed.batch(PARTITION, BATCH, CONTEXT),
+        ]));
+        assert_eq!(
+            fed.reader.next().await.unwrap().partition_id,
+            OTHER_PARTITION
+        );
+
+        fed.progress()
+            .seek(PARTITION, PollingStrategy::offset(SEEK_OFFSET));
+
+        assert!(
+            timeout(CANCEL_AFTER, fed.reader.next()).await.is_err(),
+            "a seek reads from its own start, not what came before it"
+        );
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_a_cancelled_read_when_a_reply_arrives_should_hand_over_the_whole_batch() {
+        let mut fed = FedReader::new(standalone([PARTITION]));
+        assert!(timeout(CANCEL_AFTER, fed.reader.next()).await.is_err());
+
+        fed.send(Ok(vec![fed.batch(PARTITION, BATCH, CONTEXT)]));
+
+        assert_eq!(
+            partition_and_offsets(&fed.reader.next().await.unwrap()),
+            (PARTITION, BATCH.collect())
+        );
+    }
+
+    #[tokio::test]
+    async fn given_a_healthy_batch_beside_a_failed_partition_when_reading_should_return_the_batch_then_the_error()
+     {
+        let mut fed = FedReader::new(standalone([PARTITION, OTHER_PARTITION]));
+        fed.send(Ok(vec![fed.batch(PARTITION, BATCH, CONTEXT)]));
+        fed.send(Err(
+            fed.refusal(OTHER_PARTITION, IggyError::HistoryUnavailable)
+        ));
+
+        assert_eq!(fed.reader.next().await.unwrap().partition_id, PARTITION);
+        assert_eq!(
+            fed.reader.next().await.unwrap_err(),
+            ReadError::new(Some(OTHER_PARTITION), IggyError::HistoryUnavailable)
+        );
+    }
+
+    #[tokio::test]
+    async fn given_errors_of_an_older_generation_or_epoch_when_reading_should_drop_them() {
+        let mut fed = FedReader::new(standalone([PARTITION]));
+        let old_generation = fed.refusal(PARTITION, IggyError::HistoryUnavailable);
+        fed.reader
+            .seek(PARTITION, PollingStrategy::offset(SEEK_OFFSET))
+            .await
+            .unwrap();
+        let old_epoch = fed.reply(Err(reader_refusal(IggyError::Disconnected)));
+        fed.reader.shared.epoch.fetch_add(1, ORDERING);
+
+        fed.send(Err(old_generation));
+        fed.sender.send(old_epoch).unwrap();
+        fed.send(Err(reader_refusal(IggyError::StaleClient)));
+
+        assert_eq!(
+            fed.reader.next().await.unwrap_err(),
+            ReadError::new(None, IggyError::StaleClient)
+        );
+    }
+
+    #[tokio::test]
+    async fn given_a_message_that_does_not_decode_when_reading_should_return_its_prefix_then_a_skip_to_seek()
+     {
+        let mut fed = FedReader::new(standalone([PARTITION]));
+        let undecoded = BATCH.end() + 1;
+        let skip =
+            PollingStrategy::continuation(PARTITION, PollingKind::Offset, undecoded + 1, CONTEXT);
+        fed.send(Ok(vec![fed.batch(PARTITION, BATCH, CONTEXT)]));
+        fed.send(Err(Refusal {
+            error: ReadError {
+                partition_id: Some(PARTITION),
+                error: IggyError::CannotDecryptData,
+                skip: Some(skip),
+                offset: None,
+                record: None,
+            },
+            generation: fed.progress().generation(PARTITION),
+        }));
+
+        assert_eq!(
+            partition_and_offsets(&fed.reader.next().await.unwrap()),
+            (PARTITION, BATCH.collect())
+        );
+        assert_eq!(fed.reader.next().await.unwrap_err().skip, Some(skip));
+        fed.reader.seek(PARTITION, skip).await.unwrap();
+        assert_eq!(
+            fed.progress().take_seeks(),
+            [(
+                PARTITION,
+                skip,
+                fed.progress().generation(PARTITION).unwrap()
+            )]
+        );
+    }
+
+    #[tokio::test]
+    async fn given_a_partition_the_reader_does_not_read_when_seeking_should_refuse_it() {
+        let mut fed = FedReader::new(standalone([PARTITION]));
+        let generation = fed.progress().generation(PARTITION);
+        let continuation_of_other = PollingStrategy::continuation(
+            OTHER_PARTITION,
+            PollingKind::Offset,
+            SEEK_OFFSET,
+            CONTEXT,
+        );
+
+        assert_eq!(
+            fed.reader
+                .seek(OTHER_PARTITION, PollingStrategy::next())
+                .await,
+            Err(IggyError::InvalidCommand)
+        );
+        assert_eq!(
+            fed.reader.seek(PARTITION, continuation_of_other).await,
+            Err(IggyError::InvalidCommand)
+        );
+        assert_eq!(fed.progress().generation(PARTITION), generation);
+    }
+
+    #[test]
+    fn given_an_invalid_configuration_when_building_should_refuse_it() {
+        let continuation =
+            PollingStrategy::continuation(PARTITION, PollingKind::Offset, SEEK_OFFSET, CONTEXT);
+        let no_messages = PollOptions {
+            count: 0,
+            ..PollOptions::default()
+        };
+
+        for (builder, error) in [
+            (
+                builder_for(Consumer::external_group(identifier(GROUP_ID))),
+                IggyError::FeatureUnavailable,
+            ),
+            (
+                builder_for(group()).partitions([PARTITION]),
+                IggyError::InvalidCommand,
+            ),
+            (builder_for(consumer()), IggyError::InvalidCommand),
+            (standalone([]), IggyError::InvalidCommand),
+            (
+                standalone([PARTITION]).start(continuation),
+                IggyError::InvalidCommand,
+            ),
+            (
+                standalone([PARTITION]).options(no_messages),
+                IggyError::InvalidMessagesCount,
+            ),
+        ] {
+            let described = format!("{builder:?}");
+            assert_eq!(builder.into_parts().err(), Some(error), "{described}");
+        }
+    }
+
+    #[test]
+    fn given_an_encryptor_when_building_should_refuse_only_a_server_commit_on_read() {
+        let encrypted = || {
+            let encryptor = EncryptorKind::Aes256Gcm(Aes256GcmEncryptor::new(&KEY).unwrap());
+            builder_with(
+                ClientWrapper::Tcp(TcpClient::default()),
+                consumer(),
+                Some(Arc::new(encryptor)),
+            )
+            .partitions([PARTITION])
+        };
+
+        assert_eq!(
+            committing(encrypted()).into_parts().err(),
+            Some(IggyError::InvalidConfiguration)
+        );
+        assert!(encrypted().into_parts().is_ok());
+    }
+
+    #[test]
+    fn given_duplicate_partitions_when_building_should_read_each_once_in_order() {
+        let fed = FedReader::new(standalone([OTHER_PARTITION, PARTITION, OTHER_PARTITION]));
+
+        assert_eq!(fed.reader.partitions(), [PARTITION, OTHER_PARTITION]);
+    }
+
+    #[tokio::test]
+    async fn given_a_group_reader_on_http_when_building_should_refuse_it() {
+        let client = ClientWrapper::Http(HttpClient::new(UNREACHABLE_API_URL).unwrap());
+
+        let built = builder_with(client, group(), None).build().await;
+
+        assert_eq!(built.err(), Some(IggyError::FeatureUnavailable));
+    }
+
+    #[test]
+    fn given_a_count_above_the_default_prefetch_when_building_should_fit_one_reply() {
+        let long_batches = PollOptions {
+            count: DEFAULT_PREFETCH_MESSAGES + 1,
+            ..PollOptions::default()
+        };
+
+        for builder in [
+            standalone([PARTITION]).options(long_batches),
+            standalone([PARTITION])
+                .options(long_batches)
+                .prefetch_messages(DEFAULT_PREFETCH_MESSAGES),
+        ] {
+            let described = format!("{builder:?}");
+            let (config, _) = builder.into_parts().unwrap();
+            assert_eq!(config.slots, 1, "{described}");
+        }
+    }
+
+    /// A reader without its fetch loop. The test sends the replies of the loop, each with a slot
+    /// of `slots`. `sender` stays open, because a closed channel starts a real fetch loop.
+    struct FedReader {
+        reader: PartitionReader,
+        sender: mpsc::UnboundedSender<Reply>,
+        slots: Arc<Semaphore>,
+    }
+
+    impl FedReader {
+        fn new(builder: ReaderBuilder) -> Self {
+            let (config, shared) = builder.into_parts().unwrap();
+            // The fetch loop of a standalone reader starts its generations at once.
+            shared.progress.assign(&config.partitions, &[]);
+            let (sender, receiver) = mpsc::unbounded_channel();
+            let slots = Arc::new(Semaphore::new(config.slots));
+            let config = Arc::new(config);
+            let shared = Arc::new(shared);
+            let committer = config
+                .auto()
+                .map(|auto| Committer::spawn(Arc::clone(&shared.progress), auto.interval));
+            let reader = PartitionReader {
+                config,
+                shared,
+                receiver,
+                queue: VecDeque::new(),
+                task: None,
+                uncounted: None,
+                committer,
+            };
+            Self {
+                reader,
+                sender,
+                slots,
+            }
+        }
+
+        /// Assigns `partitions` to a group member, as a refresh of its fetch loop does.
+        fn assign(&self, partitions: &[u32]) {
+            self.progress().assign(partitions, &[]);
+            self.reader.shared.set_partitions(partitions);
+        }
+
+        fn progress(&self) -> &Progress {
+            &self.reader.shared.progress
+        }
+
+        fn stamp(&self, partition: u32) -> ReaderStamp {
+            let progress = self.progress();
+            progress.stamp(progress.generation(partition).unwrap())
+        }
+
+        /// A checkpoint of the current generation of `partition`.
+        fn checkpoint(&self, partition: u32, offset: u64, context: PartitionContext) -> Checkpoint {
+            Checkpoint::stamped(partition, offset, context, Some(self.stamp(partition)))
+        }
+
+        /// A batch of the current generation of `partition` with one message at each offset.
+        fn batch(
+            &self,
+            partition: u32,
+            offsets: RangeInclusive<u64>,
+            context: PartitionContext,
+        ) -> PolledMessages {
+            let current_offset = *offsets.end();
+            let messages: Vec<_> = offsets.map(message_at).collect();
+            let mut batch = PolledMessages::new(
+                context,
+                partition,
+                current_offset,
+                u32::try_from(messages.len()).unwrap(),
+                messages,
+            );
+            batch.set_stamp(self.stamp(partition));
+            batch
+        }
+
+        /// A refusal of `partition` in its current generation.
+        fn refusal(&self, partition: u32, error: IggyError) -> Refusal {
+            Refusal {
+                error: ReadError::new(Some(partition), error),
+                generation: self.progress().generation(partition),
+            }
+        }
+
+        /// A reply of the current epoch that holds a slot.
+        fn reply(&self, result: Result<Vec<PolledMessages>, Refusal>) -> Reply {
+            Reply {
+                epoch: self.reader.shared.epoch.load(ORDERING),
+                assignment: None,
+                result,
+                capacity: Arc::new(FetchCapacity {
+                    permit: Some(Arc::clone(&self.slots).try_acquire_owned().unwrap()),
+                    wake: Arc::clone(&self.reader.shared.wake),
+                }),
+            }
+        }
+
+        fn send(&self, result: Result<Vec<PolledMessages>, Refusal>) {
+            self.sender.send(self.reply(result)).unwrap();
+        }
+    }
+
+    fn reader_refusal(error: IggyError) -> Refusal {
+        Refusal {
+            error: ReadError::new(None, error),
+            generation: None,
+        }
+    }
+
+    fn consumer() -> Consumer {
+        Consumer::new(identifier(CONSUMER_ID))
+    }
+
+    /// A standalone reader of `partitions` on a client that never connects.
+    /// `CommitPolicy::Auto` that stores on `every` and at the close, never on an interval.
+    fn auto(every: Option<CommitEvery>, after: CommitAfter) -> PollOptions {
+        let interval = (every.is_none()).then_some(NonZeroIggyDuration::new(NEVER).unwrap());
+        PollOptions {
+            commit: CommitPolicy::Auto(AutoCommit {
+                interval,
+                every,
+                after,
+            }),
+            ..PollOptions::default()
+        }
+    }
+
+    fn standalone(partitions: impl IntoIterator<Item = u32>) -> ReaderBuilder {
+        builder_for(consumer()).partitions(partitions)
+    }
+
+    fn standalone_on(
+        client: TcpClient,
+        partitions: impl IntoIterator<Item = u32>,
+    ) -> ReaderBuilder {
+        builder_on(client, consumer()).partitions(partitions)
+    }
+
+    /// A reader on `client`, as `IggyClient::reader` builds it.
+    fn builder_with(
+        client: ClientWrapper,
+        consumer: Consumer,
+        encryptor: Option<Arc<EncryptorKind>>,
+    ) -> ReaderBuilder {
+        ReaderBuilder::new(
+            IggyRwLock::new(client),
+            identifier(STREAM_ID),
+            identifier(TOPIC_ID),
+            consumer,
+            encryptor,
+        )
+    }
+
+    /// Loses the reply of a committing poll of `partition`, so the client of `fed` retains its
+    /// operation, and blocks the partition with it, as the fetch loop does.
+    async fn lose_committing_poll(fed: &FedReader, cluster: &FakeCluster, partition: u32) -> u64 {
+        cluster.script(
+            &endpoint(PRIMARY),
+            POLL_MESSAGES_DEFERRED_CODE,
+            [Answer::Silence],
+        );
+        let config = &fed.reader.config;
+        let lost = config
+            .client
+            .read()
+            .await
+            .poll_messages(
+                &config.stream,
+                &config.topic,
+                partition,
+                &config.consumer,
+                &PollingStrategy::next(),
+                PollOptions {
+                    commit: CommitPolicy::OnRead,
+                    ..PollOptions::default()
+                },
+            )
+            .await;
+        let operation = cluster
+            .received(POLL_MESSAGES_DEFERRED_CODE)
+            .last()
+            .unwrap()
+            .header
+            .request;
+        assert_eq!(
+            lost.unwrap_err(),
+            IggyError::FetchProgressUncertain(operation)
+        );
+        fed.progress().block(partition, operation);
+        operation
+    }
+
+    fn deleted_partitions(cluster: &FakeCluster) -> Vec<Option<u32>> {
+        cluster
+            .received(DELETE_CONSUMER_OFFSET_CODE)
+            .iter()
+            .map(|delete| {
+                DeleteConsumerOffsetRequest::decode_from(&delete.body)
+                    .unwrap()
+                    .partition_id
+            })
+            .collect()
+    }
+
+    /// The history that each request of `code` names.
+    fn histories(cluster: &FakeCluster, code: u32) -> Vec<(u64, u64)> {
+        cluster
+            .received(code)
+            .iter()
+            .map(|request| {
+                (
+                    request.header.partition_incarnation,
+                    request.header.owner_generation,
+                )
+            })
+            .collect()
+    }
+
+    fn history(context: PartitionContext) -> (u64, u64) {
+        (context.incarnation, context.owner_generation)
+    }
+}
diff --git a/core/sdk/src/clients/reader/cluster_tests.rs b/core/sdk/src/clients/reader/cluster_tests.rs
new file mode 100644
index 0000000..5aad8f1
--- /dev/null
+++ b/core/sdk/src/clients/reader/cluster_tests.rs
@@ -0,0 +1,1357 @@
+// 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.
+
+//! End-to-end tests of the reader on fake replicas.
+
+use std::iter::{once, repeat_n};
+use std::sync::Arc;
+use std::task::{Context, Waker};
+use std::time::Duration;
+
+use iggy_binary_protocol::codes::{
+    CREATE_CONSUMER_GROUP_CODE, FETCH_MESSAGES_CODE, GET_CONSUMER_GROUP_CODE,
+    GET_PARTITION_ROUTE_CODE, JOIN_CONSUMER_GROUP_CODE, LOGIN_REGISTER_CODE,
+    POLL_MESSAGES_DEFERRED_CODE, STORE_CONSUMER_OFFSET_CODE, SYNC_CONSUMER_GROUP_CODE,
+};
+use iggy_binary_protocol::consensus::EvictionReason;
+use iggy_binary_protocol::primitives::polling_strategy::WirePollingStrategy;
+use iggy_binary_protocol::requests::messages::{DeferredPollMessagesRequest, FetchMessagesRequest};
+use iggy_binary_protocol::responses::consumer_groups::{
+    ConsumerGroupDetailsResponse, ConsumerGroupResponse, SyncConsumerGroupResponse,
+};
+use iggy_binary_protocol::responses::messages::{
+    FetchMessagesResponse, PartitionRouteResponse, PollMessagesResponseHeader,
+};
+use iggy_binary_protocol::{WireDecode, WireEncode, WireName};
+use iggy_common::locking::{IggyRwLock, IggyRwLockFn};
+use iggy_common::{
+    BinaryTransport, Client, CommitPolicy, Consumer, ConsumerOffsetClient, DiagnosticEvent,
+    Identifier, IggyDuration, IggyError, PollOptions, PolledMessages, PollingStrategy, TopicClient,
+    UserClient,
+};
+use tokio::task::yield_now;
+use tokio::time::{Instant, sleep, timeout};
+
+use super::fixtures::{
+    CONSUMER_ID, GROUP_ID, STREAM_ID, Section, TOPIC_ID, builder_on, committing, fetch_body, group,
+    identifier, partition_and_offsets, refused, served, stored_offsets,
+};
+use super::{ORDERING, PartitionReader, ReadError};
+use crate::client_wrappers::client_wrapper::ClientWrapper;
+use crate::clients::reader_builder::ReaderBuilder;
+use crate::links::OPERATION_TIMEOUT;
+use crate::links::fake_cluster::{
+    Answer, FakeCluster, Received, TEST_BUDGET, connected, endpoint, eventually,
+};
+use crate::tcp::tcp_client::TcpClient;
+
+const REPLICAS: u8 = 3;
+const METADATA_PRIMARY: u8 = 0;
+/// The primary of every partition until a test moves one.
+const PRIMARY: u8 = 1;
+const MOVED_PRIMARY: u8 = 2;
+const FIRST_VIEW: u32 = 0;
+const MOVED_VIEW: u32 = 1;
+const INCARNATION: u64 = 1;
+const METADATA_OP: u64 = 1;
+const PARTITION: u32 = 1;
+const OTHER_PARTITION: u32 = 0;
+const PARTITIONS_COUNT: u32 = PARTITION + 1;
+const GROUP_NAME: &str = "group";
+const GROUP_GENERATION: u64 = 1;
+const USERNAME: &str = "iggy";
+/// The commit of each scripted metadata reply.
+const COMMIT_OP: u64 = 1;
+const RETRY_INTERVAL: Duration = Duration::from_millis(10);
+/// Long enough for every refresh and retry of a reader, and below the deadline of a read.
+const POLL_TIMEOUT: Duration = Duration::from_secs(2);
+/// The unanswered reads after the scripted ones. A reader that reads ahead then waits instead
+/// of spinning on the empty replies of the fake replicas, which stops the paused clock.
+const SILENT_READS: usize = 8;
+const GROUP_REFRESHES: usize = 16;
+
+#[tokio::test(start_paused = true)]
+async fn given_reader_options_when_reading_should_send_them_in_each_request() {
+    const COUNT: u32 = 10;
+    const MIN_COUNT: u32 = 3;
+    const MAX_WAIT: Duration = Duration::from_secs(2);
+    const MAX_BYTES: u32 = 256 * 1024;
+    let options = PollOptions {
+        count: COUNT,
+        max_wait: IggyDuration::from(MAX_WAIT),
+        min_count: MIN_COUNT,
+        max_bytes: MAX_BYTES,
+        commit: CommitPolicy::Manual,
+    };
+    let wait_us = options.max_wait.as_micros();
+    let request_timeout_us = options.request_timeout().as_micros();
+    let fetch_records_limit =
+        MAX_BYTES - u32::try_from(FetchMessagesResponse::head_size(1)).unwrap();
+    for (start, code, max_bytes) in [
+        (
+            PollingStrategy::next(),
+            FETCH_MESSAGES_CODE,
+            fetch_records_limit,
+        ),
+        (
+            PollingStrategy::last(),
+            POLL_MESSAGES_DEFERRED_CODE,
+            MAX_BYTES,
+        ),
+    ] {
+        let (client, cluster) = connect_to_three_replicas().await;
+        cluster.script(
+            &endpoint(PRIMARY),
+            code,
+            repeat_n(Answer::Silence, SILENT_READS),
+        );
+        let _reader = standalone(client, [PARTITION])
+            .start(start)
+            .options(options)
+            .build()
+            .await
+            .unwrap();
+
+        eventually("the first read arrives", || cluster.count(code) == 1).await;
+
+        let body = &cluster.received(code)[0].body;
+        let sent = if code == FETCH_MESSAGES_CODE {
+            let (request, _) = FetchMessagesRequest::decode(body).unwrap();
+            assert!(
+                request
+                    .targets
+                    .iter()
+                    .all(|target| target.max_bytes == max_bytes),
+                "the whole reply fits the reservation of one reply"
+            );
+            (
+                request.count,
+                request.wait_us,
+                request.min_count,
+                request.max_bytes,
+                request.request_timeout_us,
+            )
+        } else {
+            let (request, _) = DeferredPollMessagesRequest::decode(body).unwrap();
+            (
+                request.poll.count,
+                request.wait_us,
+                request.min_count,
+                request.max_bytes,
+                request.request_timeout_us,
+            )
+        };
+        assert_eq!(
+            sent,
+            (COUNT, wait_us, MIN_COUNT, max_bytes, request_timeout_us),
+            "{start:?}"
+        );
+    }
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_a_committing_group_member_when_reading_should_fetch_the_partitions_of_a_primary_with_one_request()
+ {
+    const OFFSET: u64 = 7;
+    let (client, cluster) = connect_to_three_replicas().await;
+    script_group(&cluster, &[OTHER_PARTITION, PARTITION]);
+    script_fetches(&cluster, [served_answer(PARTITION, OFFSET)]);
+    script_initial_next(&cluster, &[(OTHER_PARTITION, OFFSET), (PARTITION, OFFSET)]);
+    let mut reader = committing(member(client)).build().await.unwrap();
+
+    let batch = timeout(POLL_TIMEOUT, reader.next()).await.unwrap().unwrap();
+
+    assert_eq!(partition_and_offsets(&batch), (PARTITION, vec![OFFSET]));
+    let fetches = cluster.received(FETCH_MESSAGES_CODE);
+    assert_eq!(fetches.len(), 1, "one committing fetch per primary");
+    let request = fetch_request(&fetches[0]);
+    assert!(request.auto_commit);
+    assert_eq!(request.count, reader.config.options.count);
+    assert_eq!(partitions_of(&fetches[0]), [OTHER_PARTITION, PARTITION]);
+    let records_limit = reader.config.options.max_bytes
+        - u32::try_from(FetchMessagesResponse::head_size(request.targets.len())).unwrap();
+    assert_eq!(request.max_bytes, records_limit);
+    assert!(
+        request
+            .targets
+            .iter()
+            .all(|target| target.max_bytes == records_limit),
+        "the whole reply fits the reservation of one reply"
+    );
+    assert_eq!(cluster.count(POLL_MESSAGES_DEFERRED_CODE), 2);
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_an_uncertain_store_of_a_second_target_when_a_committing_member_fetches_should_leave_its_partition_out()
+ {
+    const OFFSET: u64 = 7;
+    let (client, cluster) = connect_to_three_replicas().await;
+    script_group(&cluster, &[OTHER_PARTITION, PARTITION]);
+    cluster.script(
+        &endpoint(PRIMARY),
+        STORE_CONSUMER_OFFSET_CODE,
+        [
+            Answer::Refuse(IggyError::TransientNotCommitted),
+            Answer::Refuse(IggyError::RequestTooOld),
+        ],
+    );
+    script_fetches(&cluster, [served_answer(OTHER_PARTITION, OFFSET)]);
+    let stored = client
+        .store_consumer_offset(
+            &group(),
+            &identifier(STREAM_ID),
+            &identifier(TOPIC_ID),
+            Some(PARTITION),
+            OFFSET,
+        )
+        .await;
+    let store = cluster.received(STORE_CONSUMER_OFFSET_CODE)[0]
+        .header
+        .request;
+    assert_eq!(stored, Err(IggyError::OperationUncertain(store)));
+    script_initial_next(
+        &cluster,
+        &[(OTHER_PARTITION, OFFSET), (PARTITION, OFFSET + 1)],
+    );
+    let mut reader = committing(member(client)).build().await.unwrap();
+
+    // The lane refuses the first fetch of both targets before it leaves, so the refusal of the
+    // blocked partition arrives before the batch of the other one.
+    let blocked = timeout(POLL_TIMEOUT, reader.next()).await.unwrap();
+    let batch = timeout(POLL_TIMEOUT, reader.next()).await.unwrap().unwrap();
+
+    assert_eq!(
+        partition_and_offsets(&batch),
+        (OTHER_PARTITION, vec![OFFSET]),
+        "the other target of the group fetches on"
+    );
+    assert_eq!(
+        blocked.unwrap_err(),
+        ReadError::new(
+            Some(PARTITION),
+            IggyError::PartitionLaneBlocked(PARTITION, store)
+        ),
+        "a replay of the store after a commit of its partition would move the offset back"
+    );
+    let fetches = cluster.received(FETCH_MESSAGES_CODE);
+    assert!(
+        !fetches.is_empty()
+            && fetches
+                .iter()
+                .all(|fetch| partitions_of(fetch) == [OTHER_PARTITION]),
+        "no fetch reads the blocked partition"
+    );
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_a_missing_group_when_building_should_return_the_join_error() {
+    let (client, _cluster) = connect_to_three_replicas().await;
+
+    let built = member(client).build().await;
+
+    assert!(
+        matches!(built, Err(IggyError::ConsumerGroupNameNotFound(..))),
+        "{built:?}"
+    );
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_an_uncertain_join_when_building_again_should_keep_it_until_the_caller_resolves_it() {
+    // Each build looks the group up, the blocked one too.
+    const BUILDS: usize = 3;
+    let (client, cluster) = connect_to_three_replicas().await;
+    let metadata_primary = endpoint(METADATA_PRIMARY);
+    cluster.script(
+        &metadata_primary,
+        GET_CONSUMER_GROUP_CODE,
+        repeat_n(existing_group(), BUILDS),
+    );
+    cluster.script(
+        &metadata_primary,
+        JOIN_CONSUMER_GROUP_CODE,
+        [Answer::Silence, Answer::Commit(COMMIT_OP)],
+    );
+    let client = IggyRwLock::new(ClientWrapper::Tcp(client));
+    let reader = || {
+        ReaderBuilder::new(
+            client.clone(),
+            identifier(STREAM_ID),
+            identifier(TOPIC_ID),
+            group(),
+            None,
+        )
+    };
+
+    let lost = reader().build().await.err();
+    let op = cluster.received(JOIN_CONSUMER_GROUP_CODE)[0].header.request;
+    assert_eq!(lost, Some(IggyError::OperationUncertain(op)));
+    assert_eq!(
+        reader().build().await.err(),
+        Some(IggyError::LaneBlocked(op)),
+        "the client keeps the join of the failed build"
+    );
+    assert_eq!(cluster.count(JOIN_CONSUMER_GROUP_CODE), 1);
+
+    client
+        .read()
+        .await
+        .resolve_operation(op, (Instant::now() + OPERATION_TIMEOUT).into_std())
+        .await
+        .unwrap();
+    reader().build().await.unwrap();
+
+    let joins = cluster.received(JOIN_CONSUMER_GROUP_CODE);
+    assert_eq!(joins.len(), 3);
+    assert_eq!(
+        joins[1].header.request, op,
+        "the resolution replays the lost join"
+    );
+    assert_ne!(joins[2].header.request, op);
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_a_lost_membership_when_the_join_fails_should_report_it_for_the_whole_reader() {
+    let (client, cluster) = connect_to_three_replicas().await;
+    // Only the join of `build()` finds the group.
+    cluster.script(
+        &endpoint(METADATA_PRIMARY),
+        GET_CONSUMER_GROUP_CODE,
+        [existing_group()],
+    );
+    let mut reader = member(client).build().await.unwrap();
+    forget_membership(&reader);
+
+    let refused = timeout(POLL_TIMEOUT, reader.next()).await.unwrap();
+
+    assert!(
+        matches!(
+            refused,
+            Err(ReadError {
+                partition_id: None,
+                error: IggyError::ConsumerGroupNameNotFound(..),
+                ..
+            })
+        ),
+        "{refused:?}"
+    );
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_a_failed_refresh_when_the_caller_asks_again_at_once_should_wait_its_retry_interval()
+{
+    const CALLS: u32 = 3;
+    let (client, cluster) = connect_to_three_replicas().await;
+    // Only the join of `build()` finds the group.
+    cluster.script(
+        &endpoint(METADATA_PRIMARY),
+        GET_CONSUMER_GROUP_CODE,
+        [existing_group()],
+    );
+    let mut reader = member(client).build().await.unwrap();
+    forget_membership(&reader);
+    timeout(POLL_TIMEOUT, reader.next())
+        .await
+        .unwrap()
+        .unwrap_err();
+    let refreshes = cluster.count(GET_CONSUMER_GROUP_CODE);
+    let started = Instant::now();
+
+    for _ in 0..CALLS {
+        timeout(POLL_TIMEOUT, reader.next())
+            .await
+            .unwrap()
+            .unwrap_err();
+    }
+
+    assert!(started.elapsed() >= RETRY_INTERVAL * CALLS);
+    assert_eq!(
+        cluster.count(GET_CONSUMER_GROUP_CODE),
+        refreshes + usize::try_from(CALLS).unwrap(),
+        "one refresh per failed call"
+    );
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_a_member_unknown_to_the_server_when_refreshing_should_join_again_without_an_error() {
+    const OFFSET: u64 = 2;
+    let (client, cluster) = connect_to_three_replicas().await;
+    cluster.script(
+        &endpoint(METADATA_PRIMARY),
+        GET_CONSUMER_GROUP_CODE,
+        repeat_n(existing_group(), GROUP_REFRESHES),
+    );
+    let not_member = IggyError::ConsumerGroupMemberNotFound(
+        u32::default(),
+        identifier(GROUP_ID),
+        identifier(TOPIC_ID),
+    );
+    cluster.script(
+        &endpoint(METADATA_PRIMARY),
+        SYNC_CONSUMER_GROUP_CODE,
+        once(Answer::Refuse(not_member)).chain(repeat_n(assignment(&[PARTITION]), GROUP_REFRESHES)),
+    );
+    script_fetches(&cluster, [served_answer(PARTITION, OFFSET)]);
+    let mut reader = member(client).build().await.unwrap();
+
+    let read = timeout(TEST_BUDGET, reader.next()).await.unwrap();
+
+    assert!(
+        matches!(&read, Ok(batch) if partition_and_offsets(batch) == (PARTITION, vec![OFFSET])),
+        "the refresh joins again instead of an error: {read:?}"
+    );
+    assert_eq!(cluster.count(JOIN_CONSUMER_GROUP_CODE), 2);
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_an_uncertain_group_mutation_when_the_refresh_joins_again_should_resolve_it_and_read()
+{
+    const OFFSET: u64 = 2;
+    for mutation in [CREATE_CONSUMER_GROUP_CODE, JOIN_CONSUMER_GROUP_CODE] {
+        let (client, cluster) = connect_to_three_replicas().await;
+        let creates = mutation == CREATE_CONSUMER_GROUP_CODE;
+        let metadata_primary = endpoint(METADATA_PRIMARY);
+        let group_at_rejoin = if creates {
+            Answer::Body(Vec::new())
+        } else {
+            existing_group()
+        };
+        cluster.script(
+            &metadata_primary,
+            GET_CONSUMER_GROUP_CODE,
+            [existing_group(), group_at_rejoin, existing_group()],
+        );
+        // The join of `build()` goes through.
+        let earlier_mutations = usize::from(!creates);
+        if !creates {
+            cluster.script(&metadata_primary, mutation, [Answer::Commit(COMMIT_OP)]);
+        }
+        cluster.script(
+            &metadata_primary,
+            mutation,
+            [Answer::Silence, Answer::Commit(COMMIT_OP)],
+        );
+        cluster.script(
+            &metadata_primary,
+            SYNC_CONSUMER_GROUP_CODE,
+            repeat_n(assignment(&[PARTITION]), GROUP_REFRESHES),
+        );
+        script_fetches(&cluster, [served_answer(PARTITION, OFFSET)]);
+        // Auto-creation creates a group by its name only.
+        let mut reader = builder_on(
+            client,
+            Consumer::group(Identifier::named(GROUP_NAME).unwrap()),
+        )
+        .retry_interval(RETRY_INTERVAL)
+        .create_group(true)
+        .build()
+        .await
+        .unwrap();
+        forget_membership(&reader);
+
+        let uncertain = timeout(TEST_BUDGET, reader.next()).await.unwrap();
+        let read = timeout(TEST_BUDGET, reader.next()).await.unwrap();
+
+        let requests = cluster.received(mutation);
+        let (lost, replayed) = (
+            &requests[earlier_mutations],
+            &requests[earlier_mutations + 1],
+        );
+        let op = lost.header.request;
+        assert_eq!(
+            uncertain.unwrap_err(),
+            ReadError::new(None, IggyError::OperationUncertain(op)),
+            "mutation {mutation}"
+        );
+        assert!(read.is_ok(), "mutation {mutation}: {read:?}");
+        assert_eq!(
+            (replayed.header.request, &replayed.body),
+            (op, &lost.body),
+            "the next join resolves the mutation by its replay, mutation {mutation}"
+        );
+        assert_eq!(
+            reader
+                .config
+                .client
+                .read()
+                .await
+                .resolve_operation(op, (Instant::now() + OPERATION_TIMEOUT).into_std())
+                .await,
+            Ok(()),
+            "mutation {mutation}"
+        );
+    }
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_a_live_session_when_a_replica_refuses_a_fetch_with_stale_client_should_fetch_again_without_a_new_sign_in()
+ {
+    const OFFSET: u64 = 2;
+    let (client, cluster) = connect_to_three_replicas().await;
+    script_fetches(
+        &cluster,
+        [Answer::Detached, served_answer(PARTITION, OFFSET)],
+    );
+    let mut reader = standalone(client, [PARTITION]).build().await.unwrap();
+
+    let refused = timeout(POLL_TIMEOUT, reader.next()).await.unwrap();
+
+    assert_eq!(
+        refused.unwrap_err(),
+        ReadError::new(None, IggyError::StaleClient)
+    );
+    next_batch(&mut reader).await;
+    assert!((2..=3).contains(&cluster.count(FETCH_MESSAGES_CODE)));
+    assert_eq!(cluster.count(LOGIN_REGISTER_CODE), 1);
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_a_lost_route_link_when_a_fetch_outlasts_it_should_fetch_again_once_the_link_is_back()
+{
+    const OFFSET: u64 = 2;
+    let (client, cluster) = connect_to_three_replicas().await;
+    let mut reader = standalone(client, [PARTITION]).build().await.unwrap();
+    let primary = endpoint(PRIMARY);
+    cluster.down.lock().unwrap().insert(primary.clone());
+    cluster.drop_connections(&primary);
+
+    let lost = timeout(POLL_TIMEOUT, reader.next()).await;
+    assert!(
+        lost.is_err(),
+        "the driver retries a fetch that no replica admitted: {lost:?}"
+    );
+    cluster.down.lock().unwrap().clear();
+    script_fetches(&cluster, [served_answer(PARTITION, OFFSET)]);
+
+    next_batch(&mut reader).await;
+    assert_eq!(cluster.count(LOGIN_REGISTER_CODE), 1);
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_an_evicted_fetch_when_the_client_signs_in_again_should_wait_for_signed_in_and_keep_reading_enabled()
+ {
+    const OFFSET: u64 = 2;
+    let (client, cluster) = connect_to_three_replicas().await;
+    let mut reader = standalone(client, [PARTITION]).build().await.unwrap();
+    *cluster.session.lock().unwrap() = None;
+    cluster
+        .held_registers
+        .lock()
+        .unwrap()
+        .insert(USERNAME.to_owned());
+    script_fetches(
+        &cluster,
+        [
+            Answer::Evict(EvictionReason::NoSession),
+            served_answer(PARTITION, OFFSET),
+        ],
+    );
+    let shared = Arc::clone(&reader.shared);
+
+    let evicted = timeout(POLL_TIMEOUT, reader.next()).await;
+    assert!(
+        matches!(&evicted, Ok(Err(error))
+            if *error == ReadError::new(Some(PARTITION), IggyError::TransientNotCommitted)),
+        "a noncommitting fetch that lost its session ends without a stored offset: {evicted:?}"
+    );
+    eventually("the client signs in again", || {
+        cluster.count(LOGIN_REGISTER_CODE) == 2
+    })
+    .await;
+    eventually("the reader sees the sign-out", || {
+        !shared.can_poll.load(ORDERING)
+    })
+    .await;
+    let parked = timeout(POLL_TIMEOUT, reader.next()).await;
+    assert!(parked.is_err(), "{parked:?}");
+    assert_eq!(cluster.count(FETCH_MESSAGES_CODE), 1);
+    cluster.held_registers.lock().unwrap().clear();
+    eventually("the reader sees the sign-in", || {
+        shared.can_poll.load(ORDERING)
+    })
+    .await;
+
+    next_batch(&mut reader).await;
+    assert!(
+        shared.can_poll.load(ORDERING),
+        "a late error of the evicted fetch keeps reading enabled"
+    );
+}
+
+/// A committing fetch whose reply is lost may have stored its offset. The next read from `Next`
+/// starts past the delivered messages, so the reader reads the skipped ones by offset without a
+/// commit and hands them over, without an error and without an abandon by the application.
+#[tokio::test(start_paused = true)]
+async fn given_a_lost_committing_fetch_reply_when_the_next_read_skips_offsets_should_deliver_them_by_offset_without_a_commit()
+ {
+    const DELIVERED: u64 = 4;
+    const SKIPPED: [u64; 3] = [5, 6, 7];
+    let (client, cluster) = connect_to_three_replicas().await;
+    script_fetches(
+        &cluster,
+        [
+            served_answer(PARTITION, DELIVERED),
+            Answer::Silence,
+            served_answer(PARTITION, SKIPPED[2]),
+        ],
+    );
+    cluster.script(
+        &endpoint(PRIMARY),
+        POLL_MESSAGES_DEFERRED_CODE,
+        [
+            polled_answer(PARTITION, &[DELIVERED]),
+            polled_answer(PARTITION, &SKIPPED),
+        ],
+    );
+    let mut reader = committing(standalone(client, [PARTITION]))
+        .build()
+        .await
+        .unwrap();
+    let delivered = reader.next().await.unwrap();
+    assert_eq!(
+        partition_and_offsets(&delivered),
+        (PARTITION, vec![DELIVERED])
+    );
+
+    let caught_up = timeout(request_timeout(&reader) * 3, reader.next())
+        .await
+        .expect("the reader reads on after a lost reply")
+        .expect("a lost reply is no error of the reader");
+
+    assert_eq!(
+        partition_and_offsets(&caught_up),
+        (PARTITION, SKIPPED.to_vec())
+    );
+    let fetches = cluster.received(FETCH_MESSAGES_CODE);
+    assert_eq!(
+        targets_of(&fetches[2])
+            .into_iter()
+            .map(|(partition, strategy)| (partition, strategy.kind))
+            .collect::<Vec<_>>(),
+        [(PARTITION, WirePollingStrategy::next().kind)],
+        "an OnRead reader reads from the stored offset, which the server fences"
+    );
+    let catch_up = DeferredPollMessagesRequest::decode_from(
+        &cluster.received(POLL_MESSAGES_DEFERRED_CODE)[1].body,
+    )
+    .unwrap();
+    assert_eq!(
+        (
+            catch_up.poll.strategy,
+            catch_up.poll.count,
+            catch_up.poll.auto_commit
+        ),
+        (WirePollingStrategy::offset(SKIPPED[0]), 3, false)
+    );
+    reader.close().await.unwrap();
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_the_first_committing_next_reply_is_lost_when_reading_should_recover_its_messages() {
+    const SKIPPED: [u64; 3] = [5, 6, 7];
+    let (client, cluster) = connect_to_three_replicas().await;
+    script_fetches(
+        &cluster,
+        [Answer::Silence, served_answer(PARTITION, SKIPPED[2])],
+    );
+    cluster.script(
+        &endpoint(PRIMARY),
+        POLL_MESSAGES_DEFERRED_CODE,
+        [
+            polled_answer(PARTITION, &SKIPPED[..1]),
+            polled_answer(PARTITION, &SKIPPED),
+        ],
+    );
+    let mut reader = committing(standalone(client, [PARTITION]))
+        .prefetch_messages(1)
+        .build()
+        .await
+        .unwrap();
+
+    let caught_up = timeout(request_timeout(&reader) * 3, reader.next())
+        .await
+        .unwrap()
+        .unwrap();
+
+    assert_eq!(
+        partition_and_offsets(&caught_up),
+        (PARTITION, SKIPPED.to_vec())
+    );
+    reader.close().await.unwrap();
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_the_first_committing_next_reply_is_lost_when_closing_should_rewind_to_the_resolved_start()
+ {
+    for first in [0, 5] {
+        let (client, cluster) = connect_to_three_replicas().await;
+        script_fetches(&cluster, [Answer::Silence]);
+        script_initial_next(&cluster, &[(PARTITION, first)]);
+        let mut reader = committing(standalone(client, [PARTITION]))
+            .build()
+            .await
+            .unwrap();
+        start_fetch(&mut reader, &cluster, 1).await;
+        reader.close().await.unwrap();
+        if first == 0 {
+            assert_eq!(
+                cluster.count(iggy_binary_protocol::codes::DELETE_CONSUMER_OFFSET_CODE),
+                1
+            );
+            assert!(stored_offsets(&cluster).is_empty());
+        } else {
+            assert_eq!(stored_offsets(&cluster), [first - 1]);
+        }
+    }
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_a_committing_fetch_in_flight_when_closing_should_rewind_over_its_batch() {
+    const FIRST: u64 = 5;
+    const REPLY_DELAY: Duration = Duration::from_millis(100);
+    let (client, cluster) = connect_to_three_replicas().await;
+    cluster.hold(FETCH_MESSAGES_CODE);
+    script_fetches(&cluster, [served_answer(PARTITION, FIRST)]);
+    script_initial_next(&cluster, &[(PARTITION, FIRST)]);
+    let mut reader = committing(standalone(client, [PARTITION]))
+        .build()
+        .await
+        .unwrap();
+    start_fetch(&mut reader, &cluster, 1).await;
+    let request_timeout = request_timeout(&reader);
+    let started = Instant::now();
+
+    let (closed, ()) = tokio::join!(reader.close(), async {
+        sleep(REPLY_DELAY).await;
+        cluster.release(FETCH_MESSAGES_CODE);
+    });
+
+    closed.unwrap();
+    assert!(
+        started.elapsed() < request_timeout,
+        "the close ends with the fetch, not at its deadline"
+    );
+    assert_eq!(
+        stored_offsets(&cluster),
+        [FIRST - 1],
+        "the server committed a batch that the application never got"
+    );
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_a_committing_fetch_without_a_reply_when_closing_should_wait_for_its_deadline() {
+    let (client, cluster) = connect_to_three_replicas().await;
+    script_fetches(&cluster, []);
+    script_initial_next(&cluster, &[(PARTITION, 0)]);
+    let mut reader = committing(standalone(client, [PARTITION]))
+        .build()
+        .await
+        .unwrap();
+    start_fetch(&mut reader, &cluster, 1).await;
+    let op = cluster.received(FETCH_MESSAGES_CODE)[0].header.request;
+    let request_timeout = request_timeout(&reader);
+    let reader_client = reader.config.client.clone();
+    let started = Instant::now();
+
+    let closed = reader.close().await;
+
+    assert!(
+        started.elapsed() >= request_timeout,
+        "the server can commit until the deadline of the fetch, so the close waits for it"
+    );
+    closed.unwrap();
+    assert!(
+        !reader_client.read().await.retains_operation(op).await,
+        "the close abandons the lost fetch, so the client reads the partition again"
+    );
+    assert!(stored_offsets(&cluster).is_empty(), "nothing was delivered");
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_a_committing_fetch_in_flight_when_the_reader_drops_should_return_at_once_and_block_its_partition()
+ {
+    let (client, cluster) = connect_to_three_replicas().await;
+    script_fetches(&cluster, []);
+    script_initial_next(&cluster, &[(PARTITION, 0)]);
+    let mut reader = committing(standalone(client, [PARTITION]))
+        .build()
+        .await
+        .unwrap();
+    start_fetch(&mut reader, &cluster, 1).await;
+    let op = cluster.received(FETCH_MESSAGES_CODE)[0].header.request;
+    let request_timeout = request_timeout(&reader);
+    let client = reader.config.client.clone();
+    let dropped = Instant::now();
+
+    drop(reader);
+
+    let builder = ReaderBuilder::new(
+        client,
+        identifier(STREAM_ID),
+        identifier(TOPIC_ID),
+        Consumer::new(identifier(CONSUMER_ID)),
+        None,
+    )
+    .partitions([PARTITION]);
+    let mut next_reader = committing(builder).build().await.unwrap();
+    let blocked = timeout(POLL_TIMEOUT, next_reader.next()).await.unwrap();
+    assert_eq!(
+        blocked.unwrap_err(),
+        ReadError::new(
+            Some(PARTITION),
+            IggyError::PartitionLaneBlocked(PARTITION, op)
+        ),
+        "the server can still commit the dropped fetch"
+    );
+    assert!(
+        dropped.elapsed() < request_timeout,
+        "the drop blocks the partition before the deadline of its fetch"
+    );
+    assert_eq!(cluster.count(FETCH_MESSAGES_CODE), 1);
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_a_committing_fetch_in_flight_when_the_epoch_changes_should_hand_over_its_batch() {
+    const FIRST: u64 = 5;
+    let (client, cluster) = connect_to_three_replicas().await;
+    cluster.hold(FETCH_MESSAGES_CODE);
+    script_fetches(&cluster, [served_answer(PARTITION, FIRST)]);
+    script_initial_next(&cluster, &[(PARTITION, FIRST)]);
+    let mut reader = committing(standalone(client, [PARTITION]))
+        .build()
+        .await
+        .unwrap();
+    start_fetch(&mut reader, &cluster, 1).await;
+
+    reader.shared.epoch.fetch_add(1, ORDERING);
+    cluster.release(FETCH_MESSAGES_CODE);
+    let read = timeout(TEST_BUDGET, reader.next()).await;
+
+    assert!(
+        matches!(&read, Ok(Ok(batch)) if partition_and_offsets(batch) == (PARTITION, vec![FIRST])),
+        "the server committed this batch, so a new epoch must not drop it: {read:?}"
+    );
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_a_signed_out_client_when_reads_fail_with_unauthenticated_should_back_off_between_attempts()
+ {
+    let (client, cluster) = connect_to_three_replicas().await;
+    client.logout_user().await.unwrap();
+    let mut reader = standalone(client, [PARTITION]).build().await.unwrap();
+    let unauthenticated = ReadError::new(None, IggyError::Unauthenticated);
+    assert_eq!(reader.next().await.unwrap_err(), unauthenticated);
+    let started = Instant::now();
+
+    let refused = reader.next().await;
+
+    assert_eq!(refused.unwrap_err(), unauthenticated);
+    assert!(started.elapsed() >= RETRY_INTERVAL);
+    assert_eq!(cluster.count(FETCH_MESSAGES_CODE), 0);
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_a_join_in_flight_when_the_reader_drops_should_let_it_finish_and_keep_metadata_calls_usable()
+ {
+    let (client, cluster) = connect_to_three_replicas().await;
+    script_group(&cluster, &[PARTITION]);
+    let reader = member(client).build().await.unwrap();
+    let client = reader.config.client.clone();
+    cluster.hold(JOIN_CONSUMER_GROUP_CODE);
+    forget_membership(&reader);
+    eventually("the refresh joins again", || {
+        cluster.count(JOIN_CONSUMER_GROUP_CODE) == 2
+    })
+    .await;
+
+    drop(reader);
+    cluster.release(JOIN_CONSUMER_GROUP_CODE);
+    let deleted = client
+        .read()
+        .await
+        .delete_topic(&identifier(STREAM_ID), &identifier(TOPIC_ID))
+        .await;
+
+    assert_eq!(
+        deleted,
+        Ok(()),
+        "the join that the server received finished"
+    );
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_a_join_in_flight_when_the_session_ends_should_join_again_on_the_next_refresh() {
+    let (client, cluster) = connect_to_three_replicas().await;
+    script_group(&cluster, &[PARTITION]);
+    // A fetch parks as on a server, so the refreshes run on time.
+    script_fetches(&cluster, []);
+    let reader = member(client).build().await.unwrap();
+    cluster.hold(JOIN_CONSUMER_GROUP_CODE);
+    forget_membership(&reader);
+    eventually("the refresh joins again", || {
+        cluster.count(JOIN_CONSUMER_GROUP_CODE) == 2
+    })
+    .await;
+
+    forget_membership(&reader);
+    cluster.release(JOIN_CONSUMER_GROUP_CODE);
+
+    eventually("the next refresh joins for the new session", || {
+        cluster.count(JOIN_CONSUMER_GROUP_CODE) == 3
+    })
+    .await;
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_a_partition_without_a_route_yet_when_refreshing_should_retry_without_an_error() {
+    const OFFSET: u64 = 2;
+    let (client, cluster) = connect_to_three_replicas().await;
+    let route = cluster.route.lock().unwrap().take();
+    script_fetches(&cluster, [served_answer(PARTITION, OFFSET)]);
+    let mut reader = standalone(client, [PARTITION]).build().await.unwrap();
+    eventually("the refresh asks for the route", || {
+        cluster.count(GET_PARTITION_ROUTE_CODE) > 0
+    })
+    .await;
+    *cluster.route.lock().unwrap() = route;
+
+    let read = timeout(POLL_TIMEOUT, reader.next()).await.unwrap();
+
+    assert!(
+        matches!(&read, Ok(batch) if partition_and_offsets(batch) == (PARTITION, vec![OFFSET])),
+        "a partition that is not routable yet is retried, not reported: {read:?}"
+    );
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_a_signed_in_client_when_a_late_disconnected_event_arrives_should_keep_reading() {
+    const OFFSET: u64 = 2;
+    let (client, cluster) = connect_to_three_replicas().await;
+    // The events can make the first batch stale, so a second one follows it.
+    script_fetches(
+        &cluster,
+        [
+            served_answer(PARTITION, OFFSET),
+            served_answer(PARTITION, OFFSET + 1),
+        ],
+    );
+    let mut reader = standalone(client, [PARTITION]).build().await.unwrap();
+
+    publish(
+        &reader,
+        &[DiagnosticEvent::SignedIn, DiagnosticEvent::Disconnected],
+    )
+    .await;
+    sleep(RETRY_INTERVAL).await;
+    let read = timeout(POLL_TIMEOUT, reader.next()).await;
+
+    assert!(matches!(read, Ok(Ok(_))), "{read:?}");
+    assert!(reader.shared.can_poll.load(ORDERING));
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_a_group_member_when_a_fetch_finds_its_session_ended_should_sign_in_and_join_again() {
+    const OFFSET: u64 = 2;
+    let (client, cluster) = connect_to_three_replicas().await;
+    script_group(&cluster, &[PARTITION]);
+    script_fetches(
+        &cluster,
+        [
+            Answer::Refuse(IggyError::StaleClient),
+            served_answer(PARTITION, OFFSET),
+        ],
+    );
+    let mut reader = member(client).build().await.unwrap();
+
+    let refused = timeout(POLL_TIMEOUT, reader.next()).await.unwrap();
+
+    assert_eq!(
+        refused.unwrap_err(),
+        ReadError::new(None, IggyError::StaleClient)
+    );
+    next_batch(&mut reader).await;
+    assert_eq!(cluster.count(LOGIN_REGISTER_CODE), 2);
+    assert!((2..=3).contains(&cluster.count(JOIN_CONSUMER_GROUP_CODE)));
+}
+
+/// The worker abandons a lost committing read itself, so a seek needs no abandon first.
+#[tokio::test(start_paused = true)]
+async fn given_a_lost_committing_reply_when_seeking_should_read_from_the_new_position() {
+    const SEEK_OFFSET: u64 = 9;
+    let (client, cluster) = connect_to_three_replicas().await;
+    script_fetches(
+        &cluster,
+        [Answer::Silence, served_answer(PARTITION, SEEK_OFFSET)],
+    );
+    script_initial_next(&cluster, &[(PARTITION, 0)]);
+    let mut reader = committing(standalone(client, [PARTITION]))
+        .build()
+        .await
+        .unwrap();
+    start_fetch(&mut reader, &cluster, 1).await;
+    let seek_start = PollingStrategy::offset(SEEK_OFFSET);
+
+    let sought = reader.seek(PARTITION, seek_start).await;
+    let resumed = timeout(request_timeout(&reader) * 3, reader.next())
+        .await
+        .unwrap()
+        .unwrap();
+
+    assert_eq!(sought, Ok(()));
+    assert_eq!(
+        partition_and_offsets(&resumed),
+        (PARTITION, vec![SEEK_OFFSET])
+    );
+    let fetches = cluster.received(FETCH_MESSAGES_CODE);
+    assert_eq!(fetches.len(), 2);
+    assert_eq!(
+        targets_of(&fetches[1]),
+        [(PARTITION, WirePollingStrategy::offset(SEEK_OFFSET))]
+    );
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_a_partition_refused_beside_a_served_one_when_a_fetch_completes_should_deliver_the_served_batch()
+ {
+    const OFFSET: u64 = 3;
+    let (client, cluster) = connect_to_three_replicas().await;
+    script_group(&cluster, &[OTHER_PARTITION, PARTITION]);
+    let not_owned = IggyError::ConsumerGroupPartitionNotOwned(u128::default(), PARTITION);
+    script_fetches(
+        &cluster,
+        [sections_answer(&[
+            served(OTHER_PARTITION, &[OFFSET], OFFSET + 1),
+            refused(PARTITION, &not_owned),
+        ])],
+    );
+    let mut reader = member(client).build().await.unwrap();
+
+    let batch = timeout(POLL_TIMEOUT, reader.next()).await.unwrap().unwrap();
+    let after = timeout(POLL_TIMEOUT, reader.next()).await;
+
+    assert_eq!(
+        partition_and_offsets(&batch),
+        (OTHER_PARTITION, vec![OFFSET]),
+        "a partition that the member no longer owns keeps the batch of another one"
+    );
+    assert!(
+        after.is_err(),
+        "the member resyncs a partition that it does not own without an error: {after:?}"
+    );
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_a_partition_without_history_when_read_should_stop_it_until_a_seek() {
+    const OFFSET: u64 = 3;
+    const SEEK_OFFSET: u64 = 11;
+    let (client, cluster) = connect_to_three_replicas().await;
+    // The first reply gives the partition a position in its history, which the second loses.
+    script_fetches(
+        &cluster,
+        [
+            sections_answer(&[served(PARTITION, &[OFFSET], OFFSET + 1)]),
+            sections_answer(&[
+                served(OTHER_PARTITION, &[OFFSET], OFFSET + 1),
+                refused(PARTITION, &IggyError::HistoryUnavailable),
+            ]),
+            Answer::Silence,
+            served_answer(PARTITION, SEEK_OFFSET),
+        ],
+    );
+    let mut reader = standalone(client, [OTHER_PARTITION, PARTITION])
+        .build()
+        .await
+        .unwrap();
+
+    timeout(POLL_TIMEOUT, reader.next()).await.unwrap().unwrap();
+    let batch = timeout(POLL_TIMEOUT, reader.next()).await.unwrap().unwrap();
+    let stopped = timeout(POLL_TIMEOUT, reader.next()).await.unwrap();
+    eventually("the next fetch starts", || {
+        cluster.count(FETCH_MESSAGES_CODE) == 3
+    })
+    .await;
+    reader
+        .seek(PARTITION, PollingStrategy::offset(SEEK_OFFSET))
+        .await
+        .unwrap();
+    let resumed = timeout(POLL_TIMEOUT, reader.next()).await.unwrap().unwrap();
+
+    assert_eq!(
+        partition_and_offsets(&batch),
+        (OTHER_PARTITION, vec![OFFSET]),
+        "a refused partition keeps the batch of another one"
+    );
+    assert_eq!(
+        stopped.unwrap_err(),
+        ReadError::new(Some(PARTITION), IggyError::HistoryUnavailable)
+    );
+    assert_eq!(
+        partition_and_offsets(&resumed),
+        (PARTITION, vec![SEEK_OFFSET])
+    );
+    let fetches = cluster.received(FETCH_MESSAGES_CODE);
+    assert_eq!(
+        partitions_of(&fetches[2]),
+        [OTHER_PARTITION],
+        "a stopped partition reads nothing"
+    );
+    assert_eq!(
+        targets_of(&fetches[3]),
+        [(PARTITION, WirePollingStrategy::offset(SEEK_OFFSET))]
+    );
+}
+
+#[tokio::test(start_paused = true)]
+async fn given_two_partitions_of_one_primary_when_one_moves_should_fetch_both_with_one_request_and_regroup()
+ {
+    let (client, cluster) = connect_to_three_replicas().await;
+    // The reply waits until the route moved, so the refresh that it starts sees the move.
+    cluster.hold(FETCH_MESSAGES_CODE);
+    script_fetches(
+        &cluster,
+        [sections_answer(&[refused(
+            PARTITION,
+            &IggyError::NotPrimary,
+        )])],
+    );
+    cluster.script(
+        &endpoint(MOVED_PRIMARY),
+        FETCH_MESSAGES_CODE,
+        repeat_n(Answer::Silence, SILENT_READS),
+    );
+    let _reader = standalone(client, [OTHER_PARTITION, PARTITION])
+        .build()
+        .await
+        .unwrap();
+    eventually("the first fetch arrives", || {
+        cluster.count(FETCH_MESSAGES_CODE) == 1
+    })
+    .await;
+    let first = cluster.received(FETCH_MESSAGES_CODE).remove(0);
+
+    *cluster.route.lock().unwrap() = Some(route_to(MOVED_PRIMARY, MOVED_VIEW));
+    cluster.release(FETCH_MESSAGES_CODE);
+
+    assert_eq!(
+        (first.endpoint.clone(), partitions_of(&first)),
+        (endpoint(PRIMARY), vec![OTHER_PARTITION, PARTITION]),
+        "one fetch reads the partitions of one primary"
+    );
+    eventually("the moved partition fetches from its new primary", || {
+        cluster.received(FETCH_MESSAGES_CODE).iter().any(|fetch| {
+            fetch.endpoint == endpoint(MOVED_PRIMARY) && partitions_of(fetch).contains(&PARTITION)
+        })
+    })
+    .await;
+}
+
+/// A signed-in client of three ready replicas. Replica 0 is the metadata primary, and
+/// `PRIMARY` leads every partition.
+async fn connect_to_three_replicas() -> (TcpClient, Arc<FakeCluster>) {
+    let cluster = FakeCluster::new(REPLICAS);
+    *cluster.route.lock().unwrap() = Some(route_to(PRIMARY, FIRST_VIEW));
+    let links = connected(&cluster, usize::from(REPLICAS)).await;
+    (TcpClient::from_links(links), cluster)
+}
+
+fn route_to(primary: u8, view: u32) -> PartitionRouteResponse {
+    PartitionRouteResponse {
+        incarnation: INCARNATION,
+        metadata_op: METADATA_OP,
+        view,
+        partitions_count: PARTITIONS_COUNT,
+        primary_replica: primary,
+    }
+}
+
+/// A standalone reader of `partitions` whose refused partitions retry after `RETRY_INTERVAL`.
+fn standalone(client: TcpClient, partitions: impl IntoIterator<Item = u32>) -> ReaderBuilder {
+    builder_on(client, Consumer::new(identifier(CONSUMER_ID)))
+        .partitions(partitions)
+        .retry_interval(RETRY_INTERVAL)
+}
+
+/// A group reader whose refused partitions retry after `RETRY_INTERVAL`.
+fn member(client: TcpClient) -> ReaderBuilder {
+    builder_on(client, group()).retry_interval(RETRY_INTERVAL)
+}
+
+/// The deadline of each read of `reader` after its start.
+fn request_timeout(reader: &PartitionReader) -> Duration {
+    reader.config.options.request_timeout().get_duration()
+}
+
+/// Makes the next refresh join again, as after the loss of the session that joined.
+fn forget_membership(reader: &PartitionReader) {
+    reader
+        .shared
+        .membership
+        .as_ref()
+        .expect("a group reader has a membership")
+        .forget();
+}
+
+/// Scripts the joins of a group of `GROUP_REFRESHES` refreshes that assign `partitions`.
+fn script_group(cluster: &FakeCluster, partitions: &[u32]) {
+    let metadata_primary = endpoint(METADATA_PRIMARY);
+    cluster.script(
+        &metadata_primary,
+        GET_CONSUMER_GROUP_CODE,
+        repeat_n(existing_group(), GROUP_REFRESHES),
+    );
+    cluster.script(
+        &metadata_primary,
+        SYNC_CONSUMER_GROUP_CODE,
+        repeat_n(assignment(partitions), GROUP_REFRESHES),
+    );
+}
+
+fn existing_group() -> Answer {
+    Answer::Body(
+        ConsumerGroupDetailsResponse {
+            group: ConsumerGroupResponse {
+                id: GROUP_ID,
+                partitions_count: PARTITIONS_COUNT,
+                members_count: 0,
+                name: WireName::new(GROUP_NAME).unwrap(),
+            },
+            members: Vec::new(),
+        }
+        .to_bytes()
+        .to_vec(),
+    )
+}
+
+fn assignment(partitions: &[u32]) -> Answer {
+    Answer::Body(
+        SyncConsumerGroupResponse {
+            generation: GROUP_GENERATION,
+            partitions: partitions.to_vec(),
+        }
+        .to_bytes()
+        .to_vec(),
+    )
+}
+
+/// Scripts `answers` for the next fetches on `PRIMARY`, then `SILENT_READS` fetches without a
+/// reply.
+fn script_fetches(cluster: &FakeCluster, answers: impl IntoIterator<Item = Answer>) {
+    cluster.script(
+        &endpoint(PRIMARY),
+        FETCH_MESSAGES_CODE,
+        answers
+            .into_iter()
+            .chain(repeat_n(Answer::Silence, SILENT_READS)),
+    );
+}
+
+fn script_initial_next(cluster: &FakeCluster, starts: &[(u32, u64)]) {
+    cluster.script(
+        &endpoint(PRIMARY),
+        POLL_MESSAGES_DEFERRED_CODE,
+        starts
+            .iter()
+            .map(|&(partition, offset)| polled_answer(partition, &[offset])),
+    );
+}
+
+fn sections_answer(sections: &[Section]) -> Answer {
+    Answer::Body(fetch_body(sections).to_vec())
+}
+
+/// A fetch reply that serves one message of `partition` at `offset`.
+fn served_answer(partition: u32, offset: u64) -> Answer {
+    sections_answer(&[served(partition, &[offset], offset + 1)])
+}
+
+/// A poll reply with one message of `partition` at each of `offsets`.
+fn polled_answer(partition: u32, offsets: &[u64]) -> Answer {
+    let next_offset = offsets.last().map_or(0, |last| last + 1);
+    let (section, records) = served(partition, offsets, next_offset);
+    let mut body = PollMessagesResponseHeader {
+        partition_id: partition,
+        current_offset: section.current_offset,
+        messages_count: section.messages_count,
+        context: section.context,
+    }
+    .to_bytes()
+    .to_vec();
+    body.extend(records);
+    Answer::Body(body)
+}
+
+fn fetch_request(fetch: &Received) -> FetchMessagesRequest {
+    FetchMessagesRequest::decode(&fetch.body).unwrap().0
+}
+
+fn targets_of(fetch: &Received) -> Vec<(u32, WirePollingStrategy)> {
+    fetch_request(fetch)
+        .targets
+        .into_iter()
+        .map(|target| (target.partition_id, target.strategy))
+        .collect()
+}
+
+fn partitions_of(fetch: &Received) -> Vec<u32> {
+    targets_of(fetch)
+        .into_iter()
+        .map(|(partition, _)| partition)
+        .collect()
+}
+
+/// Asks `reader` for a batch until its fetch number `fetches` reaches `cluster`, then leaves
+/// that fetch in flight.
+async fn start_fetch(reader: &mut PartitionReader, cluster: &FakeCluster, fetches: usize) {
+    let mut next = Box::pin(reader.next());
+    timeout(TEST_BUDGET, async {
+        while cluster.count(FETCH_MESSAGES_CODE) < fetches {
+            assert!(
+                next.as_mut()
+                    .poll(&mut Context::from_waker(Waker::noop()))
+                    .is_pending()
+            );
+            yield_now().await;
+        }
+    })
+    .await
+    .unwrap();
+}
+
+/// Takes errors until a batch arrives.
+async fn next_batch(reader: &mut PartitionReader) -> PolledMessages {
+    let mut last_error = None;
+    let read = timeout(TEST_BUDGET, async {
+        loop {
+            match reader.next().await {
+                Ok(batch) => return batch,
+                Err(error) => last_error = Some(error),
+            }
+        }
+    })
+    .await;
+    read.unwrap_or_else(|_| panic!("no batch arrived, last error: {last_error:?}"))
+}
+
+/// Publishes `events` in order on the client of `reader`.
+async fn publish(reader: &PartitionReader, events: &[DiagnosticEvent]) {
+    let client = reader.config.client.read().await;
+    let ClientWrapper::Tcp(client) = &*client else {
+        panic!("a test reader runs on a TCP client");
+    };
+    for event in events {
+        client.publish_event(*event).await;
+    }
+}
diff --git a/core/sdk/src/clients/reader/fixtures.rs b/core/sdk/src/clients/reader/fixtures.rs
new file mode 100644
index 0000000..025d8c8
--- /dev/null
+++ b/core/sdk/src/clients/reader/fixtures.rs
@@ -0,0 +1,210 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Fixtures that the tests of the reader handle and of its fetch loop share.
+
+use std::str::FromStr;
+use std::sync::Arc;
+
+use bytes::Bytes;
+use iggy_binary_protocol::STATUS_OK;
+use iggy_binary_protocol::WireDecode;
+use iggy_binary_protocol::batch::{BATCH_HEADER_SIZE, BATCH_MESSAGE_HEADER_SIZE, BatchHeader};
+use iggy_binary_protocol::codes::STORE_CONSUMER_OFFSET_CODE;
+use iggy_binary_protocol::primitives::partition_history::PartitionContext;
+use iggy_binary_protocol::requests::consumer_offsets::StoreConsumerOffsetRequest;
+use iggy_binary_protocol::responses::messages::{
+    FetchMessagesResponse, FetchMessagesSection, FetchMessagesSectionHeader, PartitionRouteResponse,
+};
+use iggy_common::locking::{IggyRwLock, IggyRwLockFn};
+use iggy_common::{
+    CommitPolicy, Consumer, FetchedSection, Identifier, IggyDuration, IggyError, IggyMessage,
+    PollOptions, PolledMessages,
+};
+
+use crate::client_wrappers::client_wrapper::ClientWrapper;
+use crate::clients::reader_builder::{DEFAULT_MAX_WAIT, ReaderBuilder};
+use crate::links::fake_cluster::{FakeCluster, connected};
+use crate::tcp::tcp_client::TcpClient;
+
+pub(super) const STREAM_ID: u32 = 1;
+pub(super) const TOPIC_ID: u32 = 1;
+pub(super) const CONSUMER_ID: u32 = 1;
+pub(super) const GROUP_ID: u32 = 1;
+/// Above every partition of the tests.
+const PARTITIONS_COUNT: u32 = 4;
+const REPLICAS: u8 = 1;
+pub(super) const PRIMARY: u8 = 0;
+const VIEW: u32 = 0;
+/// The history of a batch until a test changes it. Metadata revisions start at 1, so no real
+/// partition has the incarnation 0 of an uncaptured context.
+pub(super) const CONTEXT: PartitionContext = PartitionContext {
+    incarnation: 1,
+    owner_generation: 0,
+    metadata_op: 1,
+};
+
+/// A section as the server writes it: its table entry and its records.
+pub(crate) type Section = (FetchMessagesSectionHeader, Vec<u8>);
+
+/// A reader on a client that never connects.
+pub(super) fn builder_for(consumer: Consumer) -> ReaderBuilder {
+    builder_on(TcpClient::default(), consumer)
+}
+
+pub(super) fn builder_on(client: TcpClient, consumer: Consumer) -> ReaderBuilder {
+    ReaderBuilder::new(
+        IggyRwLock::new(ClientWrapper::Tcp(client)),
+        identifier(STREAM_ID),
+        identifier(TOPIC_ID),
+        consumer,
+        None,
+    )
+}
+
+/// A signed-in client of one replica, which leads every partition and accepts each offset
+/// store and delete that a test does not script.
+pub(super) async fn connect_to_one_replica() -> (TcpClient, Arc<FakeCluster>) {
+    let cluster = FakeCluster::new(REPLICAS);
+    *cluster.route.lock().unwrap() = Some(PartitionRouteResponse {
+        incarnation: CONTEXT.incarnation,
+        metadata_op: CONTEXT.metadata_op,
+        view: VIEW,
+        partitions_count: PARTITIONS_COUNT,
+        primary_replica: PRIMARY,
+    });
+    let links = connected(&cluster, usize::from(REPLICAS)).await;
+    (TcpClient::from_links(links), cluster)
+}
+
+pub(super) fn identifier(id: u32) -> Identifier {
+    Identifier::numeric(id).unwrap()
+}
+
+pub(super) fn group() -> Consumer {
+    Consumer::group(identifier(GROUP_ID))
+}
+
+/// `builder` with `CommitPolicy::OnRead` and the default wait.
+pub(super) fn committing(builder: ReaderBuilder) -> ReaderBuilder {
+    builder.options(PollOptions {
+        commit: CommitPolicy::OnRead,
+        ..PollOptions::wait(IggyDuration::from(DEFAULT_MAX_WAIT))
+    })
+}
+
+/// The offsets of the stores that `cluster` received, in order.
+pub(super) fn stored_offsets(cluster: &FakeCluster) -> Vec<u64> {
+    cluster
+        .received(STORE_CONSUMER_OFFSET_CODE)
+        .iter()
+        .map(|store| {
+            StoreConsumerOffsetRequest::decode_from(&store.body)
+                .unwrap()
+                .offset
+        })
+        .collect()
+}
+
+pub(super) fn partition_and_offsets(batch: &PolledMessages) -> (u32, Vec<u64>) {
+    let offsets = batch
+        .messages
+        .iter()
+        .map(|message| message.header.offset)
+        .collect();
+    (batch.partition_id, offsets)
+}
+
+pub(crate) fn message_at(offset: u64) -> IggyMessage {
+    let mut message = IggyMessage::from_str("payload").unwrap();
+    message.header.offset = offset;
+    message
+}
+
+/// An unstamped batch of `partition` with one message at each of `offsets`.
+pub(crate) fn batch(
+    partition: u32,
+    offsets: impl IntoIterator<Item = u64>,
+    current_offset: u64,
+) -> PolledMessages {
+    let messages: Vec<_> = offsets.into_iter().map(message_at).collect();
+    PolledMessages::new(
+        PartitionContext::default(),
+        partition,
+        current_offset,
+        u32::try_from(messages.len()).unwrap(),
+        messages,
+    )
+}
+
+/// A section of `partition` with one empty message at each of `offsets`, after which the
+/// partition continues at `next_offset`. A zeroed frame is such a message, and the client does
+/// not check its checksum.
+pub(crate) fn served(partition: u32, offsets: &[u64], next_offset: u64) -> Section {
+    let mut records = Vec::new();
+    for offset in offsets {
+        let mut batch = vec![0; BATCH_HEADER_SIZE + BATCH_MESSAGE_HEADER_SIZE];
+        let mut header = BatchHeader::new(u64::from(partition), 0, batch.len() as u64, 1);
+        header.base_offset = *offset;
+        header.encode_into(&mut batch);
+        records.extend(batch);
+    }
+    let header = FetchMessagesSectionHeader {
+        partition_id: partition,
+        status: STATUS_OK,
+        current_offset: next_offset.saturating_sub(1),
+        messages_count: u32::try_from(offsets.len()).unwrap(),
+        context: PartitionContext::default(),
+        next_offset,
+        records_len: u32::try_from(records.len()).unwrap(),
+    };
+    (header, records)
+}
+
+/// A section that refuses `partition` with `error`.
+pub(crate) fn refused(partition: u32, error: &IggyError) -> Section {
+    let header = FetchMessagesSectionHeader {
+        partition_id: partition,
+        status: error.as_code(),
+        current_offset: 0,
+        messages_count: 0,
+        context: PartitionContext::default(),
+        next_offset: 0,
+        records_len: 0,
+    };
+    (header, Vec::new())
+}
+
+/// The body of a fetch reply with `served`, as the server writes it.
+pub(crate) fn fetch_body(served: &[Section]) -> Bytes {
+    FetchMessagesResponse {
+        sections: served
+            .iter()
+            .map(|(header, records)| FetchMessagesSection {
+                header: *header,
+                records,
+            })
+            .collect(),
+    }
+    .to_bytes()
+    .unwrap()
+}
+
+/// The sections of a fetch reply with `served`, as the client returns them.
+pub(crate) fn sections(served: &[Section]) -> Vec<FetchedSection> {
+    FetchedSection::from_reply(&fetch_body(served)).unwrap()
+}
diff --git a/core/sdk/src/clients/reader/membership.rs b/core/sdk/src/clients/reader/membership.rs
new file mode 100644
index 0000000..900e448
--- /dev/null
+++ b/core/sdk/src/clients/reader/membership.rs
@@ -0,0 +1,216 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::sync::Arc;
+use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
+
+use iggy_common::locking::{IggyRwLock, IggyRwLockFn};
+use iggy_common::{Client, ConsumerGroupClient, Identifier, IggyError};
+use tokio::sync::Mutex;
+use tracing::{debug, error, info, trace};
+
+use crate::client_wrappers::client_wrapper::ClientWrapper;
+use crate::driver::operation_deadline;
+
+const ORDERING: Ordering = Ordering::SeqCst;
+
+/// The membership of a group reader. The reader joins in `build()`, joins again after the
+/// client loses its session, and leaves in `close()`.
+pub(crate) struct Membership {
+    client: IggyRwLock<ClientWrapper>,
+    stream: Arc<Identifier>,
+    topic: Arc<Identifier>,
+    group: Identifier,
+    /// Only `IggyConsumer` creates a missing group.
+    create: bool,
+    joined: AtomicBool,
+    /// Rises at each `forget`, so a join that a forget overtook does not count as joined.
+    forgets: AtomicU64,
+    /// A create or a join whose outcome is unknown, resolved before the next join.
+    uncertain: Mutex<Option<u64>>,
+}
+
+impl Membership {
+    pub(crate) fn new(
+        client: IggyRwLock<ClientWrapper>,
+        stream: Arc<Identifier>,
+        topic: Arc<Identifier>,
+        group: Identifier,
+        create: bool,
+    ) -> Self {
+        Self {
+            client,
+            stream,
+            topic,
+            group,
+            create,
+            joined: AtomicBool::new(false),
+            forgets: AtomicU64::new(0),
+            uncertain: Mutex::new(None),
+        }
+    }
+
+    /// Joins the group unless the member already joined in this session. A server treats a
+    /// second join from the same session as a no-op.
+    pub(crate) async fn join(&self) -> Result<(), IggyError> {
+        if self.joined.load(ORDERING) {
+            return Ok(());
+        }
+        let forgets = self.forgets.load(ORDERING);
+        let (stream_id, topic_id, group_id) = (&self.stream, &self.topic, &self.group);
+        let mut uncertain_operation = self.uncertain.lock().await;
+        let client = self.client.read().await;
+        if let Some(op) = *uncertain_operation {
+            let result = client
+                .resolve_operation(op, operation_deadline().into_std())
+                .await;
+            if matches!(result, Err(IggyError::OperationUncertain(_))) {
+                return result;
+            }
+            *uncertain_operation = None;
+            match result {
+                Ok(())
+                | Err(IggyError::OperationNotRetained(_))
+                | Err(IggyError::ConsumerGroupNameAlreadyExists(_, _)) => {}
+                Err(error) => return Err(error),
+            }
+        }
+        // A proved join can belong to an earlier session, so establish membership again.
+        trace!("Validating consumer group: {group_id} for topic: {topic_id}, stream: {stream_id}");
+        if client
+            .get_consumer_group(stream_id, topic_id, group_id)
+            .await?
+            .is_none()
+        {
+            if !self.create {
+                error!("Consumer group does not exist and auto-creation is disabled.");
+                return Err(IggyError::ConsumerGroupNameNotFound(
+                    group_id.to_string(),
+                    Identifier::from_identifier(topic_id),
+                ));
+            }
+            info!("Creating consumer group: {group_id} for topic: {topic_id}, stream: {stream_id}");
+            match client
+                .create_consumer_group(stream_id, topic_id, &group_id.get_string_value()?)
+                .await
+            {
+                Ok(_) | Err(IggyError::ConsumerGroupNameAlreadyExists(_, _)) => {}
+                Err(error) => {
+                    if let IggyError::OperationUncertain(op) = &error {
+                        *uncertain_operation = Some(*op);
+                    }
+                    error!(
+                        "Failed to create consumer group {group_id} for topic: {topic_id}, stream: {stream_id}: {error}"
+                    );
+                    return Err(error);
+                }
+            }
+        }
+        info!("Joining consumer group: {group_id} for topic: {topic_id}, stream: {stream_id}");
+        if let Err(error) = client
+            .join_consumer_group(stream_id, topic_id, group_id)
+            .await
+        {
+            self.joined.store(false, ORDERING);
+            if let IggyError::OperationUncertain(op) = &error {
+                *uncertain_operation = Some(*op);
+            }
+            error!(
+                "Failed to join consumer group: {group_id} for topic: {topic_id}, stream: {stream_id}: {error}"
+            );
+            return Err(error);
+        }
+        // The session that joined can end while the join is in flight.
+        if self.forgets.load(ORDERING) == forgets {
+            self.joined.store(true, ORDERING);
+        }
+        info!("Joined consumer group: {group_id} for topic: {topic_id}, stream: {stream_id}");
+        Ok(())
+    }
+
+    /// The session that joined is gone, so the next refresh joins again.
+    pub(crate) fn forget(&self) {
+        self.forgets.fetch_add(1, ORDERING);
+        self.joined.store(false, ORDERING);
+    }
+
+    /// Lets the server give the partitions to the other members at once, instead of after the
+    /// connection times out. It leaves also when a reconnect forgot the join, because the
+    /// session that joined can still hold the member.
+    pub(crate) async fn leave(&self) {
+        self.joined.store(false, ORDERING);
+        let (stream_id, topic_id, group_id) = (&self.stream, &self.topic, &self.group);
+        trace!("Leaving consumer group: {group_id} for stream: {stream_id}, topic: {topic_id}");
+        if let Err(error) = self
+            .client
+            .read()
+            .await
+            .leave_consumer_group(stream_id, topic_id, group_id)
+            .await
+        {
+            // Expected for a member that the server already dropped, and for a deleted group.
+            debug!(
+                "Failed to leave consumer group: {group_id} for stream: {stream_id}, topic: {topic_id}. {error}"
+            );
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use iggy_binary_protocol::codes::LEAVE_CONSUMER_GROUP_CODE;
+
+    use crate::clients::reader::fixtures::{
+        GROUP_ID, PRIMARY, STREAM_ID, TOPIC_ID, connect_to_one_replica, identifier,
+    };
+    use crate::links::fake_cluster::{Answer, endpoint};
+
+    /// A reconnect clears the joined flag, while the session that joined can still hold the
+    /// member on the server.
+    #[tokio::test]
+    async fn given_a_reconnect_after_the_join_when_leaving_should_send_the_leave_and_ignore_a_missing_member()
+     {
+        let (client, cluster) = connect_to_one_replica().await;
+        cluster.script(
+            &endpoint(PRIMARY),
+            LEAVE_CONSUMER_GROUP_CODE,
+            [Answer::Refuse(IggyError::ConsumerGroupMemberNotFound(
+                1,
+                identifier(TOPIC_ID),
+                identifier(STREAM_ID),
+            ))],
+        );
+        let membership = Membership::new(
+            IggyRwLock::new(ClientWrapper::Tcp(client)),
+            Arc::new(identifier(STREAM_ID)),
+            Arc::new(identifier(TOPIC_ID)),
+            identifier(GROUP_ID),
+            false,
+        );
+        membership.joined.store(true, ORDERING);
+        membership.forget();
+
+        membership.leave().await;
+
+        assert_eq!(
+            cluster.count(LEAVE_CONSUMER_GROUP_CODE),
+            1,
+            "the close leaves whatever the client knows of its session"
+        );
+    }
+}
diff --git a/core/sdk/src/clients/reader/progress.rs b/core/sdk/src/clients/reader/progress.rs
new file mode 100644
index 0000000..5ea4522
--- /dev/null
+++ b/core/sdk/src/clients/reader/progress.rs
@@ -0,0 +1,815 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::collections::{BTreeMap, BTreeSet};
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
+
+use dashmap::DashMap;
+use iggy_binary_protocol::primitives::partition_history::PartitionContext;
+use iggy_common::locking::{IggyRwLock, IggyRwLockFn};
+use iggy_common::{
+    AutoCommit, Checkpoint, Client, CommitEvery, CommitPolicy, Consumer, ConsumerOffsetClient,
+    Identifier, IggyError, PolledMessages, PollingKind, PollingStrategy, ReaderStamp,
+};
+use tokio::sync::Notify;
+use tracing::error;
+
+use super::ORDERING;
+use crate::client_wrappers::client_wrapper::ClientWrapper;
+use crate::driver::operation_deadline;
+
+/// Reader ids start at 1, so no reader shares an id within the process.
+static NEXT_READER: AtomicU64 = AtomicU64::new(1);
+
+#[derive(Clone, Copy)]
+enum OffsetMutation {
+    Store { partition_id: u32, offset: u64 },
+    StorePosition(Checkpoint),
+    Delete { partition_id: Option<u32> },
+    DeletePosition(Checkpoint),
+}
+
+/// What a reader delivered, counted and committed, its blocked partitions and the generations
+/// of its partitions. The fetch loop, the reader handle and `IggyConsumer` share it.
+pub(crate) struct Progress {
+    client: IggyRwLock<ClientWrapper>,
+    consumer: Arc<Consumer>,
+    stream: Arc<Identifier>,
+    topic: Arc<Identifier>,
+    reader: u64,
+    /// Set for `CommitPolicy::Auto`.
+    auto: Option<AutoCommit>,
+    /// The last message that the application took from each partition.
+    delivered: DashMap<u32, Checkpoint>,
+    /// With `CommitPolicy::Auto`: the latest counted message of each partition.
+    counted: DashMap<u32, Counted>,
+    /// Counted positions of partitions that a rebalance takes away, stored before the counted
+    /// ones of the next flush.
+    retiring: Mutex<Vec<Checkpoint>>,
+    /// Wakes the store task of the reader when a count makes a store due.
+    due: Notify,
+    /// The offset that this reader believes the server holds for each partition.
+    committed: DashMap<u32, Checkpoint>,
+    /// The operation of an unknown outcome that blocks the progress of a partition until the
+    /// caller abandons it.
+    blocked: DashMap<u32, u64>,
+    /// With `OnRead`: the first offset of a committed section that did not decode.
+    undecoded: DashMap<u32, Checkpoint>,
+    /// One lock, so a seek and its generation appear together.
+    generations: Mutex<Generations>,
+    /// Bumped under the lock before each change of a generation.
+    generation_changes: AtomicU64,
+}
+
+#[derive(Clone, Copy)]
+struct Counted {
+    checkpoint: Checkpoint,
+    /// The messages counted since the last store of the partition.
+    since_store: u32,
+}
+
+#[derive(Default)]
+struct Generations {
+    last: u64,
+    current: BTreeMap<u32, u64>,
+    seeks: BTreeMap<u32, PollingStrategy>,
+    /// The generation that the last seek of each partition started.
+    sought: BTreeMap<u32, u64>,
+}
+
+impl Progress {
+    pub(crate) fn new(
+        client: IggyRwLock<ClientWrapper>,
+        consumer: Arc<Consumer>,
+        stream: Arc<Identifier>,
+        topic: Arc<Identifier>,
+        commit: CommitPolicy,
+    ) -> Self {
+        Self {
+            client,
+            consumer,
+            stream,
+            topic,
+            reader: NEXT_READER.fetch_add(1, Ordering::Relaxed),
+            auto: match commit {
+                CommitPolicy::Auto(auto) => Some(auto),
+                CommitPolicy::Manual | CommitPolicy::OnRead => None,
+            },
+            delivered: DashMap::new(),
+            counted: DashMap::new(),
+            retiring: Mutex::new(Vec::new()),
+            due: Notify::new(),
+            committed: DashMap::new(),
+            blocked: DashMap::new(),
+            undecoded: DashMap::new(),
+            generations: Mutex::new(Generations::default()),
+            generation_changes: AtomicU64::new(0),
+        }
+    }
+
+    pub(crate) fn stamp(&self, generation: u64) -> ReaderStamp {
+        ReaderStamp {
+            reader: self.reader,
+            generation,
+        }
+    }
+
+    pub(crate) fn generation(&self, partition: u32) -> Option<u64> {
+        self.generations().current.get(&partition).copied()
+    }
+
+    /// Whether a read of `generation` still counts. With `OnRead`, the server stored the offset
+    /// of a read, so a read of a revoked or reassigned partition still counts. Only a seek drops
+    /// what was read before it.
+    pub(crate) fn keeps(&self, partition: u32, generation: u64, on_read: bool) -> bool {
+        let generations = self.generations();
+        if generations.current.get(&partition) == Some(&generation) {
+            return true;
+        }
+        on_read
+            && generations
+                .sought
+                .get(&partition)
+                .is_none_or(|sought| generation >= *sought)
+    }
+
+    /// Rises before any answer of [`Self::keeps`] changes, so a caller that sees the same count
+    /// can keep its earlier answer.
+    pub(crate) fn generation_changes(&self) -> u64 {
+        self.generation_changes.load(ORDERING)
+    }
+
+    /// Starts a new generation for each partition of `assigned` outside `staying`, and forgets
+    /// the other partitions with their pending seeks. Returns the generations of `assigned`.
+    /// Both lists are sorted.
+    pub(crate) fn assign(&self, assigned: &[u32], staying: &[u32]) -> BTreeMap<u32, u64> {
+        let mut guard = self.generations_mut();
+        let generations = &mut *guard;
+        generations
+            .current
+            .retain(|partition, _| assigned.binary_search(partition).is_ok());
+        generations
+            .seeks
+            .retain(|partition, _| staying.binary_search(partition).is_ok());
+        for partition in assigned {
+            if staying.binary_search(partition).is_err()
+                || !generations.current.contains_key(partition)
+            {
+                generations.last += 1;
+                generations.current.insert(*partition, generations.last);
+            }
+        }
+        generations.current.clone()
+    }
+
+    /// Starts a new generation of `partition` that reads from `start`, and returns it.
+    pub(crate) fn seek(&self, partition: u32, start: PollingStrategy) -> u64 {
+        let mut generations = self.generations_mut();
+        generations.last += 1;
+        let generation = generations.last;
+        generations.current.insert(partition, generation);
+        generations.seeks.insert(partition, start);
+        generations.sought.insert(partition, generation);
+        generation
+    }
+
+    /// With `OnRead`: the application stored the offset of `stored`, so the reader reads the
+    /// partition on after it in a new generation, and the close keeps that offset as the last
+    /// delivered message. A partition that the reader does not read only keeps the store.
+    pub(crate) fn read_after(&self, stored: Checkpoint) {
+        let partition = stored.partition_id();
+        let Some(next) = stored.offset().checked_add(1) else {
+            return;
+        };
+        if self.generation(partition).is_none() {
+            return;
+        }
+        let context = stored.context();
+        let start = if context == PartitionContext::default() {
+            PollingStrategy::offset(next)
+        } else {
+            PollingStrategy::continuation(partition, PollingKind::Offset, next, context)
+        };
+        self.resume_after(stored, start);
+    }
+
+    /// With `OnRead`: the server holds the offset of `stored`, so the reader reads its partition
+    /// from `start` in a new generation, and `stored` is the last delivered message of that
+    /// generation and its commit record, which the close keeps.
+    pub(crate) fn resume_after(&self, stored: Checkpoint, start: PollingStrategy) {
+        let partition = stored.partition_id();
+        let generation = self.seek(partition, start);
+        let stored = Checkpoint::stamped(
+            partition,
+            stored.offset(),
+            stored.context(),
+            Some(self.stamp(generation)),
+        );
+        self.committed.insert(partition, stored);
+        self.deliver(&stored);
+    }
+
+    /// The seeks that the fetch loop did not apply yet, each with its generation.
+    pub(crate) fn take_seeks(&self) -> Vec<(u32, PollingStrategy, u64)> {
+        let mut guard = self.generations();
+        let generations = &mut *guard;
+        std::mem::take(&mut generations.seeks)
+            .into_iter()
+            .filter_map(|(partition, start)| {
+                let generation = generations.current.get(&partition).copied()?;
+                Some((partition, start, generation))
+            })
+            .collect()
+    }
+
+    pub(crate) fn deliver(&self, checkpoint: &Checkpoint) {
+        self.delivered
+            .insert(checkpoint.partition_id(), *checkpoint);
+    }
+
+    pub(crate) fn last_delivered(&self, partition: u32) -> Option<Checkpoint> {
+        self.delivered.get(&partition).map(|entry| *entry.value())
+    }
+
+    /// Counts `messages` up to `checkpoint` under `CommitPolicy::Auto`, and wakes the store task
+    /// when its event makes a store due. A count only moves forward within a generation, and a
+    /// newer generation replaces it. `ends_batch` marks the last message of a batch.
+    pub(crate) fn count(&self, checkpoint: Checkpoint, messages: u32, ends_batch: bool) {
+        let Some(auto) = self.auto else {
+            return;
+        };
+        let generation = |checkpoint: &Checkpoint| checkpoint.stamp().map(|stamp| stamp.generation);
+        let since_store = {
+            let mut counted = self
+                .counted
+                .entry(checkpoint.partition_id())
+                .or_insert(Counted {
+                    checkpoint,
+                    since_store: 0,
+                });
+            let previous = counted.checkpoint;
+            if generation(&checkpoint) > generation(&previous) {
+                counted.checkpoint = checkpoint;
+                counted.since_store = 0;
+            } else if generation(&checkpoint) == generation(&previous)
+                && (!same_incarnation_and_owner(previous.context(), checkpoint.context())
+                    || checkpoint.offset() > previous.offset())
+            {
+                counted.checkpoint = checkpoint;
+            } else if generation(&checkpoint) < generation(&previous) {
+                return;
+            }
+            counted.since_store = counted.since_store.saturating_add(messages);
+            counted.since_store
+        };
+        let due = match auto.every {
+            None => false,
+            Some(CommitEvery::Message) => true,
+            Some(CommitEvery::Messages(every)) => since_store >= every.get(),
+            Some(CommitEvery::Batch) => ends_batch,
+        };
+        if due {
+            self.due.notify_one();
+        }
+    }
+
+    /// Counts a checkpoint that the application processed, under `CommitPolicy::Auto`.
+    pub(crate) fn mark(&self, checkpoint: Checkpoint) -> Result<(), IggyError> {
+        if self.auto.is_none()
+            || checkpoint
+                .stamp()
+                .is_none_or(|stamp| stamp.reader != self.reader)
+        {
+            return Err(IggyError::InvalidCommand);
+        }
+        self.count(checkpoint, 1, false);
+        Ok(())
+    }
+
+    /// The latest counted message of `partition`.
+    #[cfg(test)]
+    pub(crate) fn last_counted(&self, partition: u32) -> Option<Checkpoint> {
+        self.counted
+            .get(&partition)
+            .map(|counted| counted.checkpoint)
+    }
+
+    /// Resolves once a count made a store due since the last call.
+    pub(crate) async fn store_due(&self) {
+        self.due.notified().await;
+    }
+
+    /// Takes the counted positions of `partitions` out of the record, so the next flush stores
+    /// them before a rebalance starts their new generations.
+    pub(crate) fn retire_counted(&self, partitions: &[u32]) {
+        let retired: Vec<Checkpoint> = partitions
+            .iter()
+            .filter_map(|partition| self.counted.remove(partition))
+            .map(|(_, counted)| counted.checkpoint)
+            .filter(|checkpoint| self.is_current(checkpoint))
+            .collect();
+        if retired.is_empty() {
+            return;
+        }
+        self.retiring_guard().extend(retired);
+        self.due.notify_one();
+    }
+
+    /// Stores each counted position that is ahead of the last store of its generation, the
+    /// retired ones first. A position of an older generation is dropped. Every partition is
+    /// tried, and the first failure is the error.
+    pub(crate) async fn flush_counted(&self) -> Result<(), IggyError> {
+        let mut failure = None;
+        let retiring = std::mem::take(&mut *self.retiring_guard());
+        for checkpoint in retiring {
+            if self.commit_covers(&checkpoint) {
+                continue;
+            }
+            if let Err(error) = self.store(checkpoint).await {
+                error!(
+                    "Failed to store the counted offset of a partition that a rebalance took away: {checkpoint:?}. {error}"
+                );
+                failure.get_or_insert(error);
+            }
+        }
+        self.counted
+            .retain(|_, counted| self.is_current(&counted.checkpoint));
+        let counted: Vec<Counted> = self.counted.iter().map(|entry| *entry.value()).collect();
+        for Counted {
+            checkpoint,
+            since_store,
+        } in counted
+        {
+            if self.commit_covers(&checkpoint) {
+                continue;
+            }
+            match self.store(checkpoint).await {
+                Ok(()) => {
+                    if let Some(mut counted) = self.counted.get_mut(&checkpoint.partition_id()) {
+                        counted.since_store = counted.since_store.saturating_sub(since_store);
+                    }
+                }
+                Err(error) => {
+                    error!("Failed to store the counted offset: {checkpoint:?}. {error}");
+                    failure.get_or_insert(error);
+                }
+            }
+        }
+        failure.map_or(Ok(()), Err)
+    }
+
+    /// The last delivered message of `partition` that a replay filter skips up to. A seek reads
+    /// again on purpose, so a message delivered before the last seek of its partition is none.
+    pub(crate) fn replay_floor(&self, partition: u32) -> Option<Checkpoint> {
+        let delivered = self.last_delivered(partition)?;
+        let sought = self.generations().sought.get(&partition).copied();
+        sought
+            .is_none_or(|sought| {
+                delivered
+                    .stamp()
+                    .is_some_and(|stamp| stamp.generation >= sought)
+            })
+            .then_some(delivered)
+    }
+
+    pub(crate) fn last_committed(&self, partition: u32) -> Option<Checkpoint> {
+        self.committed.get(&partition).map(|entry| *entry.value())
+    }
+
+    /// With `OnRead`, the server stored the offset of a batch that the reader hands over.
+    pub(crate) fn record_commit(&self, checkpoint: Checkpoint) {
+        self.committed.insert(checkpoint.partition_id(), checkpoint);
+    }
+
+    /// Stores the offset of a checkpoint that this reader delivered. A checkpoint of an older
+    /// generation, or at or below the last commit of its generation, stores nothing.
+    pub(crate) async fn commit(&self, checkpoint: Checkpoint) -> Result<(), IggyError> {
+        let Some(stamp) = checkpoint
+            .stamp()
+            .filter(|stamp| stamp.reader == self.reader)
+        else {
+            return Err(IggyError::InvalidCommand);
+        };
+        if self.generation(checkpoint.partition_id()) != Some(stamp.generation)
+            || self.commit_covers(&checkpoint)
+        {
+            return Ok(());
+        }
+        self.store(checkpoint).await
+    }
+
+    pub(crate) fn block(&self, partition: u32, operation: u64) {
+        self.blocked.insert(partition, operation);
+    }
+
+    pub(crate) fn blocked(&self, partition: u32) -> Option<u64> {
+        self.blocked.get(&partition).map(|entry| *entry.value())
+    }
+
+    pub(crate) fn blocks(&self) -> Vec<(u32, u64)> {
+        // The fetch loop asks on every turn, and `DashMap::iter` allocates for each shard, also
+        // for an empty map.
+        if self.blocked.is_empty() {
+            return Vec::new();
+        }
+        self.blocked
+            .iter()
+            .map(|entry| (*entry.key(), *entry.value()))
+            .collect()
+    }
+
+    /// Lifts the block of `partition` only while `operation` still holds it.
+    pub(crate) fn unblock(&self, partition: u32, operation: u64) {
+        self.blocked
+            .remove_if(&partition, |_, blocking| *blocking == operation);
+    }
+
+    /// Lifts each block whose operation the client no longer retains, as after
+    /// `abandon_operation`, which wakes no reader.
+    pub(crate) async fn unblock_abandoned(&self) {
+        let blocks = self.blocks();
+        if blocks.is_empty() {
+            return;
+        }
+        let client = self.client.read().await;
+        for (partition, operation) in blocks {
+            if !client.retains_operation(operation).await {
+                self.unblock(partition, operation);
+            }
+        }
+    }
+
+    /// Keeps the lowest first offset of the undecoded sections of one history.
+    pub(crate) fn remember_undecoded(&self, start: Checkpoint) {
+        self.undecoded
+            .entry(start.partition_id())
+            .and_modify(|previous| {
+                if !same_incarnation_and_owner(previous.context(), start.context())
+                    || start.offset() < previous.offset()
+                {
+                    *previous = start;
+                }
+            })
+            .or_insert(start);
+    }
+
+    #[cfg(test)]
+    pub(crate) fn undecoded(&self, partition: u32) -> Option<Checkpoint> {
+        self.undecoded.get(&partition).map(|start| *start)
+    }
+
+    pub(crate) fn forget_undecoded(&self, partition: u32) {
+        self.undecoded.remove(&partition);
+    }
+
+    /// Forgets an undecoded start of `partition` in another history than `context`.
+    pub(crate) fn forget_undecoded_history(&self, partition: u32, context: PartitionContext) {
+        self.undecoded.remove_if(&partition, |_, start| {
+            !same_incarnation_and_owner(start.context(), context)
+        });
+    }
+
+    /// A delivered batch covers the undecoded start of its partition, or replaces its history.
+    pub(crate) fn forget_undecoded_covered(&self, batch: &PolledMessages) {
+        self.undecoded.remove_if(&batch.partition_id, |_, start| {
+            !same_incarnation_and_owner(start.context(), batch.context())
+                || batch
+                    .messages
+                    .first()
+                    .zip(batch.messages.last())
+                    .is_some_and(|(first, last)| {
+                        first.header.offset <= start.offset()
+                            && last.header.offset >= start.offset()
+                    })
+        });
+    }
+
+    pub(crate) fn retain_undecoded(&self, partitions: &[u32]) {
+        self.undecoded
+            .retain(|partition, _| partitions.binary_search(partition).is_ok());
+    }
+
+    /// The `OnRead` close: the server stored the offsets of `undelivered`, so the offset of
+    /// each partition moves back to its last delivered message. A partition that delivered
+    /// nothing in the history of a batch moves before that batch when the reader resumes at
+    /// `Next`. `owned` limits a group member to the partitions that it still owns. Every
+    /// partition is tried, and the first failure is the error.
+    pub(crate) async fn rewind(
+        &self,
+        undelivered: Vec<PolledMessages>,
+        owned: Option<&[u32]>,
+        resumes_next: bool,
+    ) -> Result<(), IggyError> {
+        let mut failure = None;
+        let mut rewound = BTreeSet::new();
+        for polled in undelivered {
+            let partition_id = polled.partition_id;
+            if self.blocked.contains_key(&partition_id)
+                || owned.is_some_and(|owned| !owned.contains(&partition_id))
+            {
+                continue;
+            }
+            let (Some(first), Some(last)) = (polled.checkpoint_at(0), polled.checkpoint()) else {
+                continue;
+            };
+            // The server refuses the final store of a message delivered in
+            // another history, so that store cannot rewind this batch.
+            if self.last_delivered(partition_id).is_some_and(|delivered| {
+                same_incarnation_and_owner(delivered.context(), polled.context())
+            }) {
+                self.committed.insert(partition_id, last);
+                continue;
+            }
+            // The batches of one history arrive in offset order, so the first
+            // one holds the oldest undelivered message of that history.
+            let history = (
+                partition_id,
+                polled.context().incarnation,
+                polled.context().owner_generation,
+            );
+            if !resumes_next || !rewound.insert(history) {
+                continue;
+            }
+            if let Err(error) = self.rewind_before(first).await {
+                error!(
+                    "Failed to rewind the offset before undelivered messages in partition: {partition_id}. {error}"
+                );
+                failure.get_or_insert(error);
+            }
+        }
+        if resumes_next {
+            let undecoded: Vec<_> = self.undecoded.iter().map(|entry| *entry.value()).collect();
+            for start in undecoded {
+                let partition_id = start.partition_id();
+                if self.blocked.contains_key(&partition_id)
+                    || owned.is_some_and(|owned| !owned.contains(&partition_id))
+                    || self.last_delivered(partition_id).is_some_and(|delivered| {
+                        same_incarnation_and_owner(delivered.context(), start.context())
+                    })
+                    || self.last_committed(partition_id).is_some_and(|committed| {
+                        same_incarnation_and_owner(committed.context(), start.context())
+                            && committed.offset() >= start.offset()
+                    })
+                {
+                    continue;
+                }
+                match self.rewind_before(start).await {
+                    Ok(()) => self.forget_undecoded(partition_id),
+                    Err(error) => {
+                        error!(?start, %error, "Failed to rewind an undecoded committed fetch section");
+                        failure.get_or_insert(error);
+                    }
+                }
+            }
+        }
+        for checkpoint in self.unstored_delivered(owned) {
+            let partition_id = checkpoint.partition_id();
+            // A rewind under a newer history replaced the commit record. The server refuses a
+            // position of the older history, which means nothing in the newer one.
+            if self.blocked.contains_key(&partition_id)
+                || self.last_committed(partition_id).is_some_and(|committed| {
+                    !same_incarnation_and_owner(committed.context(), checkpoint.context())
+                })
+            {
+                continue;
+            }
+            if let Err(error) = self.store(checkpoint).await {
+                error!("Failed to store delivered position: {checkpoint:?}. {error}");
+                failure.get_or_insert(error);
+            }
+        }
+        failure.map_or(Ok(()), Err)
+    }
+
+    /// Stores the offset of `checkpoint` without the generation rule, so it can move the
+    /// server offset back.
+    pub(crate) async fn store(&self, checkpoint: Checkpoint) -> Result<(), IggyError> {
+        self.mutate_offset(OffsetMutation::StorePosition(checkpoint))
+            .await?;
+        self.committed.insert(checkpoint.partition_id(), checkpoint);
+        self.undecoded
+            .remove_if(&checkpoint.partition_id(), |_, start| {
+                same_incarnation_and_owner(start.context(), checkpoint.context())
+                    && checkpoint.offset() >= start.offset()
+            });
+        Ok(())
+    }
+
+    /// Stores `offset` without a context. The record proves no coverage in any history, so a
+    /// later commit of the same position still goes out.
+    pub(crate) async fn store_offset(
+        &self,
+        partition_id: u32,
+        offset: u64,
+    ) -> Result<(), IggyError> {
+        self.mutate_offset(OffsetMutation::Store {
+            partition_id,
+            offset,
+        })
+        .await?;
+        self.committed.insert(
+            partition_id,
+            Checkpoint::new(partition_id, offset, PartitionContext::default()),
+        );
+        Ok(())
+    }
+
+    /// Deletes the stored offset of `partition_id`. For `None` the server resolves the
+    /// partition, so every commit record goes.
+    pub(crate) async fn delete_offset(&self, partition_id: Option<u32>) -> Result<(), IggyError> {
+        // Cancellation can hide a committed delete, so a later store must reach the server.
+        match partition_id {
+            Some(partition_id) => {
+                self.committed.remove(&partition_id);
+            }
+            None => self.committed.clear(),
+        }
+        self.mutate_offset(OffsetMutation::Delete { partition_id })
+            .await
+    }
+
+    fn is_current(&self, checkpoint: &Checkpoint) -> bool {
+        checkpoint.stamp().is_some_and(|stamp| {
+            self.generation(checkpoint.partition_id()) == Some(stamp.generation)
+        })
+    }
+
+    fn retiring_guard(&self) -> MutexGuard<'_, Vec<Checkpoint>> {
+        self.retiring.lock().unwrap_or_else(PoisonError::into_inner)
+    }
+
+    fn generations(&self) -> MutexGuard<'_, Generations> {
+        self.generations
+            .lock()
+            .unwrap_or_else(PoisonError::into_inner)
+    }
+
+    /// A caller that sees the new count then waits for this lock, so it never keeps an answer
+    /// that the change made stale.
+    fn generations_mut(&self) -> MutexGuard<'_, Generations> {
+        let generations = self.generations();
+        self.generation_changes.fetch_add(1, ORDERING);
+        generations
+    }
+
+    fn commit_covers(&self, checkpoint: &Checkpoint) -> bool {
+        self.last_committed(checkpoint.partition_id())
+            .is_some_and(|committed| {
+                committed.stamp() == checkpoint.stamp()
+                    && same_incarnation_and_owner(committed.context(), checkpoint.context())
+                    && checkpoint.offset() <= committed.offset()
+            })
+    }
+
+    async fn rewind_before(&self, start: Checkpoint) -> Result<(), IggyError> {
+        match start.offset().checked_sub(1) {
+            Some(offset) => {
+                let before = Checkpoint::new(start.partition_id(), offset, start.context());
+                self.store(before).await
+            }
+            None => {
+                self.committed.remove(&start.partition_id());
+                self.mutate_offset(OffsetMutation::DeletePosition(start))
+                    .await
+            }
+        }
+    }
+
+    /// The last delivered message of each partition where it differs from the commit record.
+    /// `owned` limits a group member to the partitions that it owns.
+    pub(crate) fn unstored_delivered(&self, owned: Option<&[u32]>) -> Vec<Checkpoint> {
+        self.delivered
+            .iter()
+            .map(|entry| *entry.value())
+            .filter(|delivered| {
+                owned.is_none_or(|owned| owned.contains(&delivered.partition_id()))
+                    && self
+                        .last_committed(delivered.partition_id())
+                        .is_none_or(|committed| committed != *delivered)
+            })
+            .collect()
+    }
+
+    async fn mutate_offset(&self, mutation: OffsetMutation) -> Result<(), IggyError> {
+        crate::clients::with_client!(self, client => {
+            let deadline = operation_deadline();
+            match self.send_offset_mutation(&client, mutation).await {
+                Err(IggyError::PartitionLaneBlocked(partition, operation)) => {
+                    self.block(partition, operation);
+                    Err(IggyError::PartitionLaneBlocked(partition, operation))
+                }
+                Err(IggyError::LaneBlocked(op)) => {
+                    if matches!(
+                        client.resolve_operation(op, deadline.into_std()).await,
+                        Err(IggyError::OperationUncertain(_))
+                    ) {
+                        return Err(IggyError::LaneBlocked(op));
+                    }
+                    // The blocker has its own outcome. Re-entering the lane either
+                    // admits this new mutation or reports that it remains blocked.
+                    self.send_offset_mutation(&client, mutation).await
+                }
+                result => result,
+            }
+        })
+    }
+
+    async fn send_offset_mutation(
+        &self,
+        client: &ClientWrapper,
+        mutation: OffsetMutation,
+    ) -> Result<(), IggyError> {
+        match mutation {
+            OffsetMutation::Store {
+                partition_id,
+                offset,
+            } => {
+                client
+                    .store_consumer_offset(
+                        &self.consumer,
+                        &self.stream,
+                        &self.topic,
+                        Some(partition_id),
+                        offset,
+                    )
+                    .await
+            }
+            OffsetMutation::Delete { partition_id } => {
+                client
+                    .delete_consumer_offset(&self.consumer, &self.stream, &self.topic, partition_id)
+                    .await
+            }
+            OffsetMutation::StorePosition(checkpoint) => {
+                client
+                    .store_consumer_position(&self.consumer, &self.stream, &self.topic, &checkpoint)
+                    .await
+            }
+            OffsetMutation::DeletePosition(checkpoint) => {
+                client
+                    .delete_consumer_position(
+                        &self.consumer,
+                        &self.stream,
+                        &self.topic,
+                        &checkpoint,
+                    )
+                    .await
+            }
+        }
+    }
+}
+
+/// The server can store the offsets of a committing read whose reply never reaches the
+/// application. Dropping this forgets the commit record of each partition that it holds, where
+/// the record equals the delivered message, so the `OnRead` close stores that message again,
+/// which moves the server offset back to it.
+pub(crate) struct PendingCommit {
+    partitions: Vec<u32>,
+    progress: Arc<Progress>,
+}
+
+impl PendingCommit {
+    pub(crate) fn new(progress: Arc<Progress>, partitions: Vec<u32>) -> Self {
+        Self {
+            partitions,
+            progress,
+        }
+    }
+
+    /// Every committed batch reaches the application, except the ones of `lost`.
+    pub(crate) fn settle(mut self, lost: Vec<u32>) {
+        self.partitions = lost;
+    }
+}
+
+impl Drop for PendingCommit {
+    fn drop(&mut self) {
+        for partition in &self.partitions {
+            let delivered = self.progress.last_delivered(*partition);
+            self.progress
+                .committed
+                .remove_if(partition, |_, committed| delivered == Some(*committed));
+        }
+    }
+}
+
+/// Offsets are comparable only within one partition incarnation and owner. The metadata frontier
+/// is left out: another group's ownership change can advance it between two polls of the same
+/// partition.
+pub(crate) fn same_incarnation_and_owner(left: PartitionContext, right: PartitionContext) -> bool {
+    left.incarnation == right.incarnation && left.owner_generation == right.owner_generation
+}
diff --git a/core/sdk/src/clients/reader/worker.rs b/core/sdk/src/clients/reader/worker.rs
new file mode 100644
index 0000000..e8d2c2b
--- /dev/null
+++ b/core/sdk/src/clients/reader/worker.rs
@@ -0,0 +1,6029 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::collections::{BTreeMap, BTreeSet};
+use std::sync::Arc;
+use std::time::Duration;
+
+use async_broadcast::Receiver;
+use futures_util::StreamExt;
+use iggy_binary_protocol::primitives::partition_history::PartitionContext;
+use iggy_binary_protocol::responses::messages::FetchMessagesResponse;
+use iggy_common::locking::IggyRwLockFn;
+use iggy_common::{
+    AssignmentVersion, Checkpoint, Client, CommitPolicy, DecodeError, DeferredPollOptions,
+    DiagnosticEvent, EncryptorKind, FetchedSection, IggyDuration, IggyError,
+    MIN_DEFERRED_POLL_MAX_BYTES, MessageClient, PollOptions, PolledMessages, PollingKind,
+    PollingStrategy, UndecodedRecord,
+};
+use tokio::sync::{Semaphore, mpsc, watch};
+use tokio::task::{AbortHandle, JoinSet};
+use tokio::time::{Instant, MissedTickBehavior, timeout_at};
+use tracing::{error, warn};
+
+use super::progress::{PendingCommit, same_incarnation_and_owner};
+use super::{Config, FetchCapacity, ORDERING, ReadError, Refusal, Reply, Shared, first_as_offset};
+use crate::driver::{
+    DEFERRED_CONSUMER_CAPACITY, MAX_DEFERRED_REQUESTS_PER_CONSUMER, with_deadline,
+};
+use crate::links::CANCEL_COMMITTING;
+
+const ASSIGNMENT_REFRESH_INTERVAL: Duration = Duration::from_secs(1);
+/// The message limit of a probe, and the size of a section whose failure names its message.
+const SINGLE_RECORD: u32 = 1;
+/// A partition refused this many times in a row for a reason that does not pass by itself stops
+/// until a seek.
+pub(super) const MAX_LASTING_REFUSALS: u32 = 6;
+/// The wait after a lasting refusal doubles at most this many times.
+const MAX_REFUSAL_BACKOFF_STEPS: u32 = 5;
+
+enum Completed {
+    Poll(CompletedPoll),
+    Fetch(CompletedFetch),
+}
+
+/// A poll of one partition.
+struct CompletedPoll {
+    /// The fetch state that started the poll.
+    epoch: u64,
+    kind: FetchKind,
+    partition: u32,
+    /// The position that the poll read from.
+    strategy: PollingStrategy,
+    /// The generation of the position.
+    generation: u64,
+    result: Result<PolledMessages, DecodeError>,
+    capacity: Arc<FetchCapacity>,
+    /// Set for a poll that commits its batch.
+    commit: Option<PendingCommit>,
+}
+
+/// A fetch of partitions of one group.
+struct CompletedFetch {
+    /// The fetch state that started the fetch.
+    epoch: u64,
+    /// See [`Active::fetch`].
+    fetch: u64,
+    /// The partitions that the fetch read, each with the position that it read from and the
+    /// generation of that position.
+    targets: Vec<(u32, PollingStrategy, u64)>,
+    /// The sections, whose records the worker decodes once the positions moved.
+    result: Result<Vec<FetchedSection>, IggyError>,
+    capacity: Arc<FetchCapacity>,
+    /// Set for a fetch that commits its batches.
+    commit: Option<PendingCommit>,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum FetchKind {
+    /// A poll of one partition.
+    Alone,
+    /// A poll of one partition that neither commits nor delivers. It resolves a
+    /// start other than `Offset` or `Next` before a fetch that commits reads from
+    /// it, or it probes a target of a fetch that the server refused for a reason
+    /// that names no target.
+    Check,
+    /// A fetch of the idle partitions of one group.
+    Main,
+    /// A fetch of the partitions of one group that became idle while its main
+    /// fetch parked. It commits nothing, and each new one restarts it.
+    Second,
+    /// A fetch of backlogged partitions of one group that commits its batches,
+    /// beside the main fetch. Its reads return at once, so several may run, and
+    /// it tells them from the one fetch of the group that may park, which never
+    /// restarts.
+    Backlog,
+}
+
+/// The poll or the fetch that reads a partition.
+struct Active {
+    task: AbortHandle,
+    kind: FetchKind,
+    /// A restart can cancel a fetch that already ended. Its reply is then stale,
+    /// and the number tells it apart from the fetch that replaced it.
+    fetch: u64,
+}
+
+/// The assignment that a refresh read.
+struct Refreshed {
+    /// The fetch state that started the refresh.
+    epoch: u64,
+    assignment: AssignmentVersion,
+    partitions: Vec<u32>,
+    /// The readiness groups. `None` on a transport without them, where each partition polls
+    /// alone.
+    groups: Option<Vec<Vec<u32>>>,
+}
+
+/// The fetch loop of a reader. Its reads run in `tasks`, and one refresh of the assignment and
+/// the readiness groups runs apart from them.
+struct RetainedBatch {
+    batch: PolledMessages,
+    capacity: Arc<FetchCapacity>,
+}
+
+pub(super) struct Worker {
+    config: Arc<Config>,
+    shared: Arc<Shared>,
+    sender: mpsc::UnboundedSender<Reply>,
+    events: Option<Receiver<DiagnosticEvent>>,
+    capacity: Arc<Semaphore>,
+    recovery_capacity: Arc<Semaphore>,
+    retained_batches: BTreeMap<u32, RetainedBatch>,
+    /// With `OnRead`: the operation of a committing read of each partition whose outcome is
+    /// unknown, 0 when its error names none. Only while one is held does a `Next` read that
+    /// starts past the expected offset read the skipped messages again. A later committing read
+    /// that returns messages from the expected offset ends it: its store fences out the lost
+    /// read on the server.
+    lost_operations: BTreeMap<u32, u64>,
+    request_capacity: Arc<Semaphore>,
+    /// Turns true in the close, so each committing read in flight cancels and takes its one
+    /// reply.
+    cancel_committing: watch::Sender<bool>,
+    tasks: JoinSet<Completed>,
+    /// At most one refresh, apart from the fetches, so a slow one never holds
+    /// their replies back.
+    refreshing: JoinSet<Result<Refreshed, IggyError>>,
+    /// The poll or the fetch that reads each partition.
+    active: BTreeMap<u32, Active>,
+    /// Numbers the polls and the fetches.
+    fetches: u64,
+    /// Partitions whose next read is a poll of one message: a fetch section refused for a first
+    /// record above `max_bytes`, which a poll always returns, or a message of a section that did
+    /// not decode, which the poll names.
+    read_alone: BTreeSet<u32>,
+    /// The end of a section that did not decode. Its partition reads alone until its position
+    /// passes it, so each message of the section is read once.
+    read_alone_until: BTreeMap<u32, u64>,
+    /// With `OnRead`: the last offset that a lost read committed past the position of a
+    /// partition. The partition reads up to it again by offset without a commit, then reads
+    /// `Next` again.
+    catch_up: BTreeMap<u32, u64>,
+    /// With `OnRead`: the operations of lost replies, which the worker abandons because the next
+    /// `Next` read finds what they committed.
+    abandoning: Vec<(u64, Vec<u32>)>,
+    /// Partitions that probe alone after the server refused a fetch of theirs.
+    probing: BTreeSet<u32>,
+    /// Partitions whose last section of their position ended before the head, so their next
+    /// read returns at once.
+    backlogged: BTreeSet<u32>,
+    /// Partitions that read nothing until a seek.
+    stopped: BTreeSet<u32>,
+    /// The lasting refusals in a row of each partition.
+    lasting_refusals: BTreeMap<u32, u32>,
+    /// The lasting refusals in a row of the assignment refresh.
+    refresh_refusals: u32,
+    positions: BTreeMap<u32, PollingStrategy>,
+    /// The generation of each position.
+    generations: BTreeMap<u32, u64>,
+    /// The position that the generation of a partition started at: that of a seek, or the
+    /// first message that a `Last` or `Timestamp` start resolved to.
+    seek_starts: BTreeMap<u32, PollingStrategy>,
+    retry_at: BTreeMap<u32, Instant>,
+    partitions: Vec<u32>,
+    assignment: Option<AssignmentVersion>,
+    /// The latest error of each partition, and of the whole reader, that waits for a slot.
+    pending_errors: BTreeMap<Option<u32>, Refusal>,
+    epoch: u64,
+    cursor: Option<u32>,
+    /// The group that the next group fetch starts the walk from, so every group gets a turn
+    /// when there are fewer slots than groups.
+    group_cursor: usize,
+    /// The assigned partitions, grouped by primary endpoint and split to fit one
+    /// fetch each. Empty while every partition polls alone.
+    groups: Vec<Vec<u32>>,
+    /// The fetch reply in which each partition last delivered messages. A fetch
+    /// reads its targets in order, so the ones served last go to its end.
+    served: BTreeMap<u32, u64>,
+    replies: u64,
+    /// Set when a primary moved or the membership ended, so the next turn refreshes.
+    refresh_now: bool,
+}
+
+impl Worker {
+    pub(super) fn new(
+        config: Arc<Config>,
+        shared: Arc<Shared>,
+        sender: mpsc::UnboundedSender<Reply>,
+        events: Option<Receiver<DiagnosticEvent>>,
+    ) -> Self {
+        // The fixed partitions of a standalone reader start their generations at once, so a
+        // seek before the first refresh survives it.
+        let generations = if config.is_group() {
+            BTreeMap::new()
+        } else {
+            shared.progress.assign(&config.partitions, &[])
+        };
+        Self {
+            capacity: Arc::new(Semaphore::new(config.slots)),
+            recovery_capacity: Arc::new(Semaphore::new(1)),
+            retained_batches: BTreeMap::new(),
+            lost_operations: BTreeMap::new(),
+            request_capacity: Arc::new(Semaphore::new(MAX_DEFERRED_REQUESTS_PER_CONSUMER)),
+            cancel_committing: watch::channel(false).0,
+            epoch: shared.epoch.load(ORDERING),
+            config,
+            shared,
+            sender,
+            events,
+            tasks: JoinSet::new(),
+            refreshing: JoinSet::new(),
+            active: BTreeMap::new(),
+            fetches: 0,
+            read_alone: BTreeSet::new(),
+            read_alone_until: BTreeMap::new(),
+            catch_up: BTreeMap::new(),
+            abandoning: Vec::new(),
+            probing: BTreeSet::new(),
+            backlogged: BTreeSet::new(),
+            stopped: BTreeSet::new(),
+            lasting_refusals: BTreeMap::new(),
+            refresh_refusals: 0,
+            positions: BTreeMap::new(),
+            generations,
+            seek_starts: BTreeMap::new(),
+            retry_at: BTreeMap::new(),
+            partitions: Vec::new(),
+            assignment: None,
+            pending_errors: BTreeMap::new(),
+            cursor: None,
+            group_cursor: 0,
+            groups: Vec::new(),
+            served: BTreeMap::new(),
+            replies: 0,
+            refresh_now: false,
+        }
+    }
+
+    pub(super) async fn run(mut self) {
+        if self.events.is_none() {
+            self.events = Some(
+                self.config
+                    .client
+                    .read()
+                    .await
+                    .subscribe_events()
+                    .await
+                    .new_receiver(),
+            );
+        }
+        let mut refresh = tokio::time::interval(ASSIGNMENT_REFRESH_INTERVAL);
+        refresh.set_missed_tick_behavior(MissedTickBehavior::Delay);
+        loop {
+            if self.sender.is_closed() {
+                return;
+            }
+            if self.shared.closing.load(ORDERING) {
+                self.finish_fetches().await;
+                // The close then stores the last delivered offset over what they committed.
+                self.abandon_lost().await;
+                return;
+            }
+            if self.epoch != self.shared.epoch.load(ORDERING) {
+                self.rewind();
+                self.epoch = self.shared.epoch.load(ORDERING);
+                refresh.reset_immediately();
+            }
+            self.apply_seeks();
+            if std::mem::take(&mut self.refresh_now) {
+                refresh.reset_immediately();
+            }
+            self.abandon_lost().await;
+            self.shared.progress.unblock_abandoned().await;
+            let enabled = self.shared.can_poll.load(ORDERING)
+                || !self.config.client.read().await.can_recover().await;
+            self.flush_errors();
+            if enabled {
+                self.schedule();
+            }
+            let retry_at = self
+                .retry_at
+                .values()
+                .min()
+                .copied()
+                .unwrap_or_else(|| Instant::now() + ASSIGNMENT_REFRESH_INTERVAL);
+            tokio::select! {
+                _ = self.sender.closed() => return,
+                _ = self.shared.wake.notified() => {},
+                _ = tokio::time::sleep_until(retry_at), if !self.retry_at.is_empty() => {
+                    self.retry_at.retain(|_, deadline| *deadline > Instant::now());
+                },
+                _ = refresh.tick() => {
+                    if enabled && self.refreshing.is_empty() {
+                        let deadline = Instant::now() + self.request_timeout();
+                        self.refreshing.spawn(with_deadline(deadline, self.read_assignment()));
+                    }
+                },
+                Some(refreshed) = self.refreshing.join_next(), if !self.refreshing.is_empty() => {
+                    match refreshed {
+                        Ok(Ok(refreshed)) => {
+                            self.refresh_refusals = 0;
+                            if self.apply(refreshed) {
+                                refresh.reset();
+                            } else {
+                                refresh.reset_immediately();
+                            }
+                        }
+                        Ok(Err(error)) => {
+                            let delay = if lasting_refusal(&error) {
+                                self.refresh_refusals = self.refresh_refusals.saturating_add(1);
+                                backoff(self.config.retry_interval, self.refresh_refusals)
+                            } else {
+                                self.config.retry_interval
+                            };
+                            self.refuse_refresh(error);
+                            refresh.reset_after(delay);
+                        }
+                        // The handle sees the closed channel and starts a new worker.
+                        Err(_) => return,
+                    }
+                },
+                Some(completed) = self.tasks.join_next(), if !self.tasks.is_empty() => {
+                    match completed {
+                        Ok(completed) => {
+                            self.complete(completed);
+                            if self.partitions.is_empty() {
+                                refresh.reset_after(self.config.retry_interval);
+                            }
+                        },
+                        // A restart cancelled the fetch.
+                        Err(error) if error.is_cancelled() => {}
+                        Err(_) => return,
+                    }
+                },
+                event = next_event(&mut self.events) => self.on_event(event).await,
+            }
+        }
+    }
+
+    /// The server can commit the batches of a committing fetch in flight, so the
+    /// worker ends once those fetches hand their batches over. Nothing new starts,
+    /// and every other fetch ends with the worker. Each committing read cancels,
+    /// which ends one that waits on the server at once, and its one reply still
+    /// tells what the server stored.
+    async fn finish_fetches(&mut self) {
+        if !self.config.on_read() {
+            return;
+        }
+        for active in self.active.values() {
+            if active.kind == FetchKind::Check {
+                active.task.abort();
+            }
+        }
+        self.cancel_committing.send_replace(true);
+        while let Some(completed) = self.tasks.join_next().await {
+            if let Ok(completed) = completed {
+                self.complete(completed);
+            }
+        }
+        drop(PendingCommit::new(
+            Arc::clone(&self.shared.progress),
+            self.catch_up.keys().copied().collect(),
+        ));
+    }
+
+    /// Keeps reading in step with the connection, and makes the next refresh join again after
+    /// the session that joined is gone.
+    async fn on_event(&mut self, event: Option<DiagnosticEvent>) {
+        let Some(event) = event else {
+            self.events = None;
+            return;
+        };
+        match event {
+            DiagnosticEvent::Shutdown => {
+                warn!("Reader client has been shut down");
+                self.forget_membership();
+                self.shared.can_poll.store(false, ORDERING);
+                self.shared.epoch.fetch_add(1, ORDERING);
+                self.events = None;
+                return;
+            }
+            DiagnosticEvent::SignedIn => {}
+            DiagnosticEvent::Disconnected => {
+                warn!("Reader client disconnected from the server");
+                self.forget_membership();
+            }
+            DiagnosticEvent::Connected | DiagnosticEvent::SignedOut => self.forget_membership(),
+        }
+        // Links publish from several tasks, so an event can arrive after one that reports a
+        // later state. The state of the client decides instead.
+        let authenticated = self.config.client.read().await.is_authenticated().await;
+        self.shared.can_poll.store(authenticated, ORDERING);
+        self.shared.epoch.fetch_add(1, ORDERING);
+    }
+
+    /// A new epoch discards every batch that the application did not take, so each partition
+    /// continues after its last delivered message of the generation, or from the start of the
+    /// generation. The server already committed the batches of a committing fetch, so that
+    /// fetch finishes, delivers them and moves its positions. Nothing is fetched until the next
+    /// refresh confirms the assignment.
+    fn rewind(&mut self) {
+        if !self.config.on_read() {
+            self.tasks = JoinSet::new();
+            self.active.clear();
+            let partitions: Vec<u32> = self.positions.keys().copied().collect();
+            for partition in partitions {
+                let generation = self.generations.get(&partition).copied();
+                let delivered =
+                    self.shared
+                        .progress
+                        .last_delivered(partition)
+                        .filter(|delivered| {
+                            delivered.stamp().map(|stamp| stamp.generation) == generation
+                        });
+                let resumed = delivered.and_then(|delivered| {
+                    let same_history = self
+                        .positions
+                        .get(&partition)
+                        .and_then(|position| position.context())
+                        .is_some_and(|context| {
+                            same_incarnation_and_owner(context, delivered.context())
+                        });
+                    let next = delivered.offset().checked_add(1).filter(|_| same_history)?;
+                    Some(continuation_at(partition, next, delivered.context()))
+                });
+                match resumed.or_else(|| self.seek_starts.get(&partition).copied()) {
+                    Some(position) => {
+                        self.positions.insert(partition, position);
+                    }
+                    None => {
+                        self.positions.remove(&partition);
+                    }
+                }
+            }
+        }
+        self.groups.clear();
+        self.read_alone.clear();
+        self.read_alone_until.clear();
+        self.lasting_refusals.clear();
+        self.probing.clear();
+        self.backlogged.clear();
+        self.retry_at.clear();
+        self.partitions.clear();
+        self.pending_errors
+            .retain(|partition, _| partition.is_some());
+    }
+
+    fn invalidate(&mut self) {
+        self.epoch = self.shared.epoch.fetch_add(1, ORDERING) + 1;
+        self.rewind();
+    }
+
+    /// A seek takes effect here, between two turns. It aborts nothing: a new read of its
+    /// partition starts when the read in flight ends, whose reply the new generation ignores.
+    fn apply_seeks(&mut self) {
+        for (partition, start, generation) in self.shared.progress.take_seeks() {
+            let start = first_as_offset(start);
+            self.positions.insert(partition, start);
+            self.seek_starts.insert(partition, start);
+            self.generations.insert(partition, generation);
+            self.retry_at.remove(&partition);
+            self.read_alone.remove(&partition);
+            self.read_alone_until.remove(&partition);
+            self.catch_up.remove(&partition);
+            self.retained_batches.remove(&partition);
+            self.lost_operations.remove(&partition);
+            self.lasting_refusals.remove(&partition);
+            self.probing.remove(&partition);
+            self.backlogged.remove(&partition);
+            self.stopped.remove(&partition);
+            self.pending_errors.remove(&Some(partition));
+            self.shared.progress.forget_undecoded(partition);
+        }
+    }
+
+    fn read_assignment(
+        &self,
+    ) -> impl Future<Output = Result<Refreshed, IggyError>> + Send + 'static {
+        let config = Arc::clone(&self.config);
+        let shared = Arc::clone(&self.shared);
+        let epoch = self.epoch;
+        async move {
+            if let Some(membership) = &shared.membership {
+                let membership = Arc::clone(membership);
+                // A dropped reader aborts this task. A join that the server received must still
+                // finish, or its retained operation blocks every later metadata call.
+                tokio::spawn(async move { membership.join().await })
+                    .await
+                    .unwrap_or(Err(IggyError::ClientShutdown))?;
+            }
+            let (assignment, mut partitions) = if config.is_group() {
+                config
+                    .client
+                    .read()
+                    .await
+                    .deferred_poll_partitions(&config.stream, &config.topic, &config.consumer.id)
+                    .await?
+            } else {
+                (AssignmentVersion::default(), config.partitions.clone())
+            };
+            partitions.sort_unstable();
+            let groups = if partitions.is_empty() {
+                None
+            } else {
+                match config
+                    .client
+                    .read()
+                    .await
+                    .readiness_groups(&config.stream, &config.topic, &partitions)
+                    .await
+                {
+                    Ok(groups) => Some(groups),
+                    // HTTP has no fetch, so each partition polls alone.
+                    Err(IggyError::FeatureUnavailable) => None,
+                    Err(error) => return Err(error),
+                }
+            };
+            Ok(Refreshed {
+                epoch,
+                assignment,
+                partitions,
+                groups,
+            })
+        }
+    }
+
+    /// Returns false for a refresh that started before the current fetch
+    /// state, which only a new refresh replaces.
+    fn apply(&mut self, refreshed: Refreshed) -> bool {
+        if refreshed.epoch != self.epoch || self.epoch != self.shared.epoch.load(ORDERING) {
+            return false;
+        }
+        if self.assignment != Some(refreshed.assignment) {
+            // The server keeps the owner of a partition that stays with its
+            // member, so that partition continues from its position. Every
+            // partition has a new owner in a new session. A partition that
+            // moved away and back between two refreshes has one too: the
+            // server refuses a read from its old position, and the worker
+            // then drops that position.
+            let same_session = self
+                .assignment
+                .is_some_and(|assignment| assignment.session == refreshed.assignment.session);
+            let staying: Vec<u32> = if !self.config.is_group() {
+                refreshed.partitions.clone()
+            } else if same_session {
+                refreshed
+                    .partitions
+                    .iter()
+                    .copied()
+                    .filter(|partition| self.generations.contains_key(partition))
+                    .collect()
+            } else {
+                Vec::new()
+            };
+            if self.config.is_group() {
+                // The counted positions of the partitions that leave go out before their
+                // generations end.
+                let leaving: Vec<u32> = self
+                    .generations
+                    .keys()
+                    .copied()
+                    .filter(|partition| staying.binary_search(partition).is_err())
+                    .collect();
+                self.shared.progress.retire_counted(&leaving);
+                for partition in leaving {
+                    self.interrupt_catch_up(partition);
+                }
+            }
+            self.invalidate();
+            if self.config.is_group() {
+                self.generations = self.shared.progress.assign(&refreshed.partitions, &staying);
+            }
+            let stays = |partition: &u32| staying.binary_search(partition).is_ok();
+            self.positions.retain(|partition, _| stays(partition));
+            self.seek_starts.retain(|partition, _| stays(partition));
+            self.stopped.retain(stays);
+            self.pending_errors.retain(|partition, refusal| {
+                partition.as_ref().is_none_or(stays)
+                    || matches!(refusal.error.error, IggyError::FetchProgressUncertain(_))
+            });
+            self.assignment = Some(refreshed.assignment);
+        }
+        self.partitions = refreshed.partitions;
+        self.shared.set_partitions(&self.partitions);
+        let groups = refreshed.groups.unwrap_or_default();
+        if groups != self.groups {
+            // A fetch takes no partitions after it starts, so the fetches that
+            // commit nothing start over in the new groups. A committing fetch
+            // ends on its own, and its partitions wait for it.
+            if !self.config.on_read() {
+                self.tasks = JoinSet::new();
+                self.active.clear();
+            }
+            self.groups = groups;
+        }
+        self.served
+            .retain(|partition, _| self.partitions.binary_search(partition).is_ok());
+        self.backlogged
+            .retain(|partition| self.partitions.binary_search(partition).is_ok());
+        self.shared.progress.retain_undecoded(&self.partitions);
+        true
+    }
+
+    fn refuse_refresh(&mut self, error: IggyError) {
+        match error {
+            // The next refresh joins again.
+            IggyError::ConsumerGroupMemberNotFound(..) if self.shared.membership.is_some() => {
+                self.forget_membership();
+            }
+            IggyError::Disconnected | IggyError::Unauthenticated | IggyError::StaleClient => {
+                self.forget_membership();
+                self.report(ReadError::new(None, error));
+            }
+            // A partition that is not routable yet, or a primary that is moving, settles by
+            // itself, and the next refresh runs after the retry interval.
+            IggyError::TransientNotAccepted
+            | IggyError::TransientNotCommitted
+            | IggyError::NotPrimary
+            | IggyError::DeferredPollCapacityExceeded => {
+                warn!("Reader assignment refresh will be retried after a transient error: {error}");
+            }
+            error => self.report(ReadError::new(None, error)),
+        }
+    }
+
+    fn reserve(&self) -> Option<Arc<FetchCapacity>> {
+        self.reserve_from(&self.capacity)
+    }
+
+    fn reserve_recovery(&self, partition: u32) -> Option<Arc<FetchCapacity>> {
+        if self.retained_batches.contains_key(&partition) {
+            self.reserve_from(&self.recovery_capacity)
+        } else {
+            self.reserve()
+        }
+    }
+
+    fn reserve_from(&self, capacity: &Arc<Semaphore>) -> Option<Arc<FetchCapacity>> {
+        Arc::clone(capacity).try_acquire_owned().ok().map(|permit| {
+            Arc::new(FetchCapacity {
+                permit: Some(permit),
+                wake: Arc::clone(&self.shared.wake),
+            })
+        })
+    }
+
+    /// The error waits for a slot. It goes out at the next turn, after the batches of the same
+    /// reply, so a `seek` after it does not drop them.
+    fn report(&mut self, error: ReadError) {
+        let generation = error
+            .partition_id
+            .map(|partition| self.generation(partition));
+        self.pending_errors
+            .insert(error.partition_id, Refusal { error, generation });
+    }
+
+    /// An error also needs a slot.
+    fn flush_errors(&mut self) {
+        while !self.pending_errors.is_empty() {
+            let Some(capacity) = self.reserve() else {
+                return;
+            };
+            let Some((_, refusal)) = self.pending_errors.pop_first() else {
+                return;
+            };
+            self.deliver(Reply {
+                epoch: self.epoch,
+                assignment: self.assignment,
+                result: Err(refusal),
+                capacity,
+            });
+        }
+    }
+
+    /// The application takes one reply per demand and asks again for the next. Each reply
+    /// holds a slot, and the channel has one place per slot, so the send never fails.
+    fn deliver(&self, reply: Reply) {
+        self.shared.demand.store(false, ORDERING);
+        let _ = self.sender.send(reply);
+    }
+
+    fn schedule(&mut self) {
+        if self.partitions.is_empty() {
+            return;
+        }
+        if self.groups.is_empty() {
+            if self.config.on_read() {
+                self.schedule_on_demand();
+            } else {
+                self.schedule_alone();
+            }
+            return;
+        }
+        // Every fetch of this mode commits its batches, so the reader fetches only when the
+        // application waits for one, and then fills every free slot.
+        if !self.config.on_read() || self.shared.demand.load(ORDERING) {
+            self.schedule_groups();
+        }
+        self.schedule_alone();
+    }
+
+    /// Without readiness groups, each committing poll stores the offset of its batch. The
+    /// reader polls on demand, so it holds at most one committed batch per partition that the
+    /// application did not ask for.
+    fn schedule_on_demand(&mut self) {
+        if !self.shared.demand.load(ORDERING) {
+            return;
+        }
+        for index in 0..self.partitions.len() {
+            let partition = self.partitions[index];
+            if !self.is_idle(partition) {
+                continue;
+            }
+            let Some(capacity) = self.reserve_recovery(partition) else {
+                return;
+            };
+            let kind = if self.needs_start(partition) {
+                FetchKind::Check
+            } else {
+                FetchKind::Alone
+            };
+            self.spawn_poll(partition, kind, capacity);
+        }
+    }
+
+    /// A fetch takes no partitions after it starts, so the main fetch of a group
+    /// runs until it ends. The quiet partitions that become idle meanwhile share a
+    /// second fetch, and each new one restarts only that fetch. This keeps a busy
+    /// partition among quiet ones from waiting up to `max_wait` for the next main
+    /// fetch. The next main fetch takes over the partitions of the second one, so
+    /// the second one stays small. Every group with idle partitions gets a slot,
+    /// those without a main fetch first, before any group gets a second one. The
+    /// slots left then spread the backlogged partitions of each group over more
+    /// fetches. Their reads return at once, so each slot keeps a full read in
+    /// flight, as a poll of each partition would, and no more reads park.
+    ///
+    /// A committing fetch never restarts, so with `OnRead` the quiet partitions
+    /// that turn idle wait for the one main fetch of their group to end, and only
+    /// the backlogged ones read beside it, in fetches of their own.
+    fn schedule_groups(&mut self) {
+        let count = self.groups.len();
+        let start = self.group_cursor % count;
+        let mut plans = Vec::new();
+        for kind in [FetchKind::Main, FetchKind::Second] {
+            for index in 0..count {
+                let group = (start + index) % count;
+                let main = self.main_in_flight(group);
+                if main != (kind == FetchKind::Second) {
+                    continue;
+                }
+                let targets = self.targets(group);
+                if targets.is_empty() {
+                    continue;
+                }
+                let (mut backlogged, mut quiet): (Vec<u32>, Vec<u32>) = targets
+                    .into_iter()
+                    .partition(|partition| self.backlogged.contains(partition));
+                backlogged.sort_by_key(|partition| {
+                    self.served.get(partition).copied().unwrap_or_default()
+                });
+                if main && self.config.on_read() {
+                    quiet.clear();
+                    if backlogged.is_empty() {
+                        continue;
+                    }
+                }
+                plans.push(GroupPlan {
+                    group,
+                    main,
+                    quiet,
+                    backlogged,
+                    extra: 0,
+                });
+            }
+        }
+        let free = self.capacity.available_permits();
+        plans.truncate(free);
+        if plans.iter().any(|plan| plan.room() > 0) {
+            let mut extra = free
+                .saturating_sub(plans.len())
+                .saturating_sub(self.alone_reserve());
+            while extra > 0 {
+                let before = extra;
+                for plan in plans.iter_mut().filter(|plan| plan.extra < plan.room()) {
+                    if extra == 0 {
+                        break;
+                    }
+                    plan.extra += 1;
+                    extra -= 1;
+                }
+                if extra == before {
+                    break;
+                }
+            }
+        }
+        for plan in plans {
+            if !self.spawn_group(plan, count) {
+                return;
+            }
+        }
+    }
+
+    /// Starts the fetches of one group: the quiet partitions in one fetch, which takes over the
+    /// second fetch in flight, and the backlogged ones dealt in turn over the extra slots.
+    /// Without an extra slot they join the quiet ones. Returns false when no slot is left.
+    fn spawn_group(&mut self, plan: GroupPlan, count: usize) -> bool {
+        let GroupPlan {
+            group,
+            main,
+            quiet,
+            backlogged,
+            extra,
+        } = plan;
+        let on_read = self.config.on_read();
+        let chunks = extra + usize::from(quiet.is_empty());
+        let mut waiting = quiet;
+        let mut spread = vec![Vec::new(); chunks];
+        for (index, partition) in backlogged.into_iter().enumerate() {
+            match index
+                .checked_rem(chunks)
+                .and_then(|chunk| spread.get_mut(chunk))
+            {
+                Some(targets) => targets.push(partition),
+                None => waiting.push(partition),
+            }
+        }
+        if !waiting.is_empty() {
+            let Some(capacity) = self.reserve() else {
+                return false;
+            };
+            if let Some((second, task)) = self.group_fetch(group, FetchKind::Second) {
+                task.abort();
+                let restarted: Vec<u32> = self
+                    .active
+                    .iter()
+                    .filter(|(_, active)| active.fetch == second)
+                    .map(|(partition, _)| *partition)
+                    .collect();
+                for partition in &restarted {
+                    self.active.remove(partition);
+                }
+                waiting.extend(restarted);
+            }
+            let kind = if main {
+                FetchKind::Second
+            } else {
+                FetchKind::Main
+            };
+            self.group_cursor = (group + 1) % count;
+            self.spawn_fetch(waiting, kind, capacity);
+        }
+        let kind = if on_read {
+            FetchKind::Backlog
+        } else {
+            FetchKind::Main
+        };
+        for targets in spread {
+            let Some(capacity) = self.reserve() else {
+                return false;
+            };
+            self.group_cursor = (group + 1) % count;
+            self.spawn_fetch(targets, kind, capacity);
+        }
+        true
+    }
+
+    /// Whether the fetch of `group` that may park is in flight. With `OnRead` a check that
+    /// resolves a `Next` start counts too, so the partition joins the main fetch after it.
+    fn main_in_flight(&self, group: usize) -> bool {
+        self.group_fetch(group, FetchKind::Main).is_some()
+            || (self.config.on_read()
+                && self.groups[group].iter().any(|partition| {
+                    self.active
+                        .get(partition)
+                        .is_some_and(|active| active.kind == FetchKind::Check)
+                        && self.position(*partition).kind == PollingKind::Next
+                        && self.needs_start(*partition)
+                }))
+    }
+
+    /// The slots that the polls alone after the group fetches can take: one for each idle
+    /// partition that no group fetch reads, within the limit of [`Self::schedule_alone`].
+    fn alone_reserve(&self) -> usize {
+        let limit = self.config.slots.saturating_sub(self.groups.len()).max(1);
+        let running = self
+            .active
+            .values()
+            .filter(|active| matches!(active.kind, FetchKind::Alone | FetchKind::Check))
+            .count();
+        let waiting = self
+            .partitions
+            .iter()
+            .filter(|partition| self.is_idle(**partition) && !self.fetched_by_group(**partition))
+            .count();
+        waiting.min(limit.saturating_sub(running))
+    }
+
+    /// The fetch of `kind` in flight for `group`.
+    fn group_fetch(&self, group: usize, kind: FetchKind) -> Option<(u64, AbortHandle)> {
+        self.groups[group].iter().find_map(|partition| {
+            self.active
+                .get(partition)
+                .filter(|active| active.kind == kind)
+                .map(|active| (active.fetch, active.task.clone()))
+        })
+    }
+
+    /// Polls partitions alone, from the one after the last polled one. With
+    /// readiness groups, it polls only the partitions that the group fetches
+    /// cannot read, and keeps one fetch slot per group free of them, so polls that
+    /// wait for a first message never hold every slot. With `OnRead`, a poll that
+    /// commits or delivers waits for the application to ask, as the fetches do.
+    fn schedule_alone(&mut self) {
+        let by_group = !self.groups.is_empty();
+        // Every partition is in a group fetch, so the walk below would find nothing.
+        if by_group
+            && self.probing.is_empty()
+            && self.read_alone.is_empty()
+            && self.catch_up.is_empty()
+            && !(self.config.on_read() && self.config.start.kind == PollingKind::Next)
+            && fetched_by_group_kind(self.config.start.kind)
+            && self
+                .positions
+                .values()
+                .all(|position| fetched_by_group_kind(position.kind))
+        {
+            return;
+        }
+        let limit = if by_group {
+            self.config.slots.saturating_sub(self.groups.len()).max(1)
+        } else {
+            MAX_DEFERRED_REQUESTS_PER_CONSUMER
+        };
+        let mut running = self
+            .active
+            .values()
+            .filter(|active| matches!(active.kind, FetchKind::Alone | FetchKind::Check))
+            .count();
+        let start = self.cursor.map_or(0, |cursor| {
+            self.partitions.partition_point(|id| *id <= cursor)
+        });
+        for index in 0..self.partitions.len() {
+            if running >= limit {
+                break;
+            }
+            let partition = self.partitions[(start + index) % self.partitions.len()];
+            if !self.is_idle(partition) {
+                continue;
+            }
+            let on_read = self.config.on_read();
+            let demand = self.shared.demand.load(ORDERING);
+            if self.needs_start(partition)
+                && self.position(partition).kind == PollingKind::Next
+                && !demand
+            {
+                continue;
+            }
+            let kind = if !by_group {
+                FetchKind::Alone
+            } else if self.fetched_by_group(partition) {
+                continue;
+            } else if on_read
+                && (self.read_alone.contains(&partition) || self.catching_up(partition).is_some())
+            {
+                if !demand {
+                    continue;
+                }
+                FetchKind::Alone
+            } else if on_read || self.probing.contains(&partition) {
+                FetchKind::Check
+            } else {
+                FetchKind::Alone
+            };
+            let capacity = match kind {
+                FetchKind::Alone if on_read => self.reserve_recovery(partition),
+                FetchKind::Check if on_read => self
+                    .reserve()
+                    .or_else(|| self.reserve_from(&self.recovery_capacity)),
+                _ => self.reserve(),
+            };
+            let Some(capacity) = capacity else {
+                break;
+            };
+            self.spawn_poll(partition, kind, capacity);
+            running += 1;
+        }
+    }
+
+    /// The partitions of a group that its next fetch reads.
+    fn targets(&self, group: usize) -> Vec<u32> {
+        self.groups[group]
+            .iter()
+            .copied()
+            .filter(|partition| self.is_idle(*partition) && self.fetched_by_group(*partition))
+            .collect()
+    }
+
+    /// A fetch of a group reads `Offset` and `Next` positions only.
+    fn fetched_by_group(&self, partition: u32) -> bool {
+        !self.probing.contains(&partition)
+            && !self.needs_start(partition)
+            && !self.read_alone.contains(&partition)
+            && self.catching_up(partition).is_none()
+            && fetched_by_group_kind(self.position(partition).kind)
+    }
+
+    fn needs_start(&self, partition: u32) -> bool {
+        let position = self.position(partition);
+        self.config.on_read()
+            && matches!(
+                position.kind,
+                PollingKind::Next | PollingKind::Last | PollingKind::Timestamp
+            )
+            && position.context().is_none()
+    }
+
+    fn is_idle(&self, partition: u32) -> bool {
+        !self.active.contains_key(&partition)
+            && !self.retry_at.contains_key(&partition)
+            && !self.stopped.contains(&partition)
+            && self.shared.progress.blocked(partition).is_none()
+    }
+
+    fn position(&self, partition: u32) -> PollingStrategy {
+        self.positions
+            .get(&partition)
+            .copied()
+            .unwrap_or(self.config.start)
+    }
+
+    /// A partition without a generation reads nothing that counts.
+    fn generation(&self, partition: u32) -> u64 {
+        self.generations
+            .get(&partition)
+            .copied()
+            .unwrap_or_default()
+    }
+
+    fn keeps(&self, partition: u32, generation: u64) -> bool {
+        self.shared
+            .progress
+            .keeps(partition, generation, self.config.on_read())
+    }
+
+    fn request_timeout(&self) -> Duration {
+        self.config.options.request_timeout().get_duration()
+    }
+
+    fn spawn_fetch(&mut self, partitions: Vec<u32>, kind: FetchKind, capacity: Arc<FetchCapacity>) {
+        let targets = self.fetch_targets(partitions);
+        let wire_targets: Vec<(u32, PollingStrategy)> = targets
+            .iter()
+            .map(|(partition, position, _)| (*partition, *position))
+            .collect();
+        let partitions: Vec<u32> = targets.iter().map(|(partition, ..)| *partition).collect();
+        let config = Arc::clone(&self.config);
+        let on_read = config.on_read();
+        let count = config.options.count;
+        let options = DeferredPollOptions {
+            max_bytes: fetch_max_bytes(config.options.max_bytes, targets.len()),
+            ..DeferredPollOptions::from(config.options)
+        };
+        let commit = on_read
+            .then(|| PendingCommit::new(Arc::clone(&self.shared.progress), partitions.clone()));
+        let epoch = self.epoch;
+        self.fetches += 1;
+        let fetch = self.fetches;
+        let task = self.tasks.spawn(DEFERRED_CONSUMER_CAPACITY.scope(
+            Arc::clone(&self.request_capacity),
+            CANCEL_COMMITTING.scope(self.cancel_committing.subscribe(), async move {
+                let started = Instant::now();
+                let deadline = started + options.request_timeout.get_duration();
+                // Only the lock wait has a limit here: the driver ends the fetch by the same
+                // deadline, and a committing fetch must never be dropped once sent.
+                let result = async {
+                    let client = timeout_at(deadline, config.client.read())
+                        .await
+                        .map_err(|_| IggyError::TransientNotAccepted)?;
+                    let sections = client
+                        .fetch_messages(
+                            &config.stream,
+                            &config.topic,
+                            &wire_targets,
+                            &config.consumer,
+                            count,
+                            on_read,
+                            options.remaining(started.elapsed())?,
+                        )
+                        .await?;
+                    let messages: u64 = sections
+                        .iter()
+                        .map(|section| u64::from(section.messages_count))
+                        .sum();
+                    if messages > u64::from(count) {
+                        return Err(IggyError::InvalidMessagesCount);
+                    }
+                    Ok(sections)
+                }
+                .await;
+                Completed::Fetch(CompletedFetch {
+                    epoch,
+                    fetch,
+                    targets,
+                    result,
+                    capacity,
+                    commit,
+                })
+            }),
+        ));
+        for partition in partitions {
+            self.active.insert(
+                partition,
+                Active {
+                    task: task.clone(),
+                    kind,
+                    fetch,
+                },
+            );
+        }
+    }
+
+    /// A fetch serves its targets in order, so the partitions that delivered
+    /// messages last go to its end, behind the ones that never did.
+    fn fetch_targets(&self, mut partitions: Vec<u32>) -> Vec<(u32, PollingStrategy, u64)> {
+        partitions.sort_by_key(|partition| self.served.get(partition).copied().unwrap_or_default());
+        partitions
+            .into_iter()
+            .map(|partition| {
+                (
+                    partition,
+                    self.position(partition),
+                    self.generation(partition),
+                )
+            })
+            .collect()
+    }
+
+    fn spawn_poll(&mut self, partition: u32, kind: FetchKind, capacity: Arc<FetchCapacity>) {
+        let config = Arc::clone(&self.config);
+        let strategy = self.position(partition);
+        let generation = self.generation(partition);
+        let catching_up = self.catching_up(partition);
+        let options = if self.probing.contains(&partition) {
+            // Only a refusal answers a probe, so it does not wait.
+            PollOptions {
+                count: SINGLE_RECORD,
+                min_count: SINGLE_RECORD,
+                max_wait: IggyDuration::from(Duration::ZERO),
+                ..config.options
+            }
+        } else if self.read_alone.contains(&partition) || self.needs_start(partition) {
+            PollOptions {
+                count: SINGLE_RECORD,
+                min_count: SINGLE_RECORD,
+                ..config.options
+            }
+        } else if let Some(until) = catching_up {
+            // The server committed these messages already, so the read takes them and no more.
+            let range = until.saturating_sub(strategy.value).saturating_add(1);
+            PollOptions {
+                count: u32::try_from(range)
+                    .unwrap_or(u32::MAX)
+                    .min(config.options.count),
+                min_count: SINGLE_RECORD,
+                max_wait: IggyDuration::from(Duration::ZERO),
+                ..config.options
+            }
+        } else {
+            config.options
+        };
+        // A check delivers nothing, and a catch-up reads what is committed, so neither commits.
+        let on_read = config.on_read() && kind == FetchKind::Alone && catching_up.is_none();
+        let commit =
+            on_read.then(|| PendingCommit::new(Arc::clone(&self.shared.progress), vec![partition]));
+        let policy = if on_read {
+            CommitPolicy::OnRead
+        } else {
+            CommitPolicy::Manual
+        };
+        let epoch = self.epoch;
+        self.fetches += 1;
+        let fetch = self.fetches;
+        let task = self.tasks.spawn(DEFERRED_CONSUMER_CAPACITY.scope(
+            Arc::clone(&self.request_capacity),
+            CANCEL_COMMITTING.scope(self.cancel_committing.subscribe(), async move {
+                let started = Instant::now();
+                let deadline = started + options.request_timeout().get_duration();
+                // As in `spawn_fetch`, only the lock wait has a limit here.
+                let result = async {
+                    let client =
+                        timeout_at(deadline, config.client.read())
+                            .await
+                            .map_err(|_| {
+                                DecodeError::without_record(IggyError::TransientNotAccepted)
+                            })?;
+                    let remaining = DeferredPollOptions::from(options)
+                        .remaining(started.elapsed())
+                        .map_err(DecodeError::without_record)?;
+                    let polled = client
+                        .poll_messages_raw(
+                            &config.stream,
+                            &config.topic,
+                            partition,
+                            &config.consumer,
+                            &strategy,
+                            PollOptions {
+                                max_wait: remaining.max_wait,
+                                commit: policy,
+                                ..options
+                            },
+                        )
+                        .await?;
+                    if polled.count > options.count
+                        || polled.messages.len() > options.count as usize
+                    {
+                        return Err(DecodeError::without_record(IggyError::InvalidMessagesCount));
+                    }
+                    Ok(polled)
+                }
+                .await;
+                Completed::Poll(CompletedPoll {
+                    epoch,
+                    kind,
+                    partition,
+                    strategy,
+                    generation,
+                    result,
+                    capacity,
+                    commit,
+                })
+            }),
+        ));
+        self.active.insert(partition, Active { task, kind, fetch });
+        self.cursor = Some(partition);
+    }
+
+    fn complete(&mut self, completed: Completed) {
+        match completed {
+            Completed::Fetch(completed) => self.complete_fetch(completed),
+            Completed::Poll(completed) if completed.kind == FetchKind::Check => {
+                self.complete_check(completed);
+            }
+            Completed::Poll(completed) => {
+                let partition = completed.partition;
+                self.complete_poll(completed);
+                self.read_alone_to_section_end(partition);
+            }
+        }
+    }
+
+    /// Keeps a partition reading alone while its position lies inside a section that did not
+    /// decode.
+    fn read_alone_to_section_end(&mut self, partition: u32) {
+        let Some(&until) = self.read_alone_until.get(&partition) else {
+            return;
+        };
+        let position = self.position(partition);
+        if position.kind == PollingKind::Offset && position.value < until {
+            self.read_alone.insert(partition);
+        } else {
+            self.read_alone_until.remove(&partition);
+        }
+    }
+
+    /// The section table moves the positions, so the next fetch of a group that
+    /// commits nothing starts before the worker decodes the records. With an
+    /// encryptor, a batch that does not decrypt keeps its position, so the
+    /// worker decrypts first.
+    fn complete_fetch(&mut self, completed: CompletedFetch) {
+        let CompletedFetch {
+            epoch,
+            fetch,
+            mut targets,
+            result,
+            capacity,
+            commit,
+        } = completed;
+        // Sorted, so each section of a reply finds its target and its hold by binary search.
+        targets.sort_unstable_by_key(|(partition, ..)| *partition);
+        let held: Vec<u32> = targets
+            .iter()
+            .map(|(partition, ..)| *partition)
+            .filter(|partition| {
+                self.active
+                    .get(partition)
+                    .is_some_and(|active| active.fetch == fetch)
+            })
+            .collect();
+        let is_held = |partition: &u32| held.binary_search(partition).is_ok();
+        for partition in &held {
+            self.active.remove(partition);
+        }
+        let committing = commit.is_some();
+        let sections = match result {
+            Ok(sections) => sections,
+            Err(error) => {
+                if let Some(commit) = commit {
+                    if may_hide_commit(&error) {
+                        for (partition, _, generation) in &targets {
+                            if self.keeps(*partition, *generation) {
+                                self.lose(*partition, &error);
+                            }
+                        }
+                    } else {
+                        commit.settle(Vec::new());
+                    }
+                }
+                if self.is_current(epoch) {
+                    self.refuse_fetch(&held, error);
+                } else if committing {
+                    let partitions: Vec<u32> =
+                        targets.iter().map(|(partition, ..)| *partition).collect();
+                    self.abandon_stale(&partitions, &error);
+                }
+                return;
+            }
+        };
+        let mut served = Vec::with_capacity(sections.len());
+        let mut answered = Vec::with_capacity(sections.len());
+        for section in sections {
+            let partition = section.partition_id;
+            let Ok(target) = targets.binary_search_by_key(&partition, |(target, ..)| *target)
+            else {
+                continue;
+            };
+            if section.status.is_err() || section.messages_count > 0 {
+                answered.push(partition);
+            }
+            let (_, sent, generation) = targets[target];
+            match &section.status {
+                Err(error) => {
+                    if self.is_current(epoch)
+                        && is_held(&partition)
+                        && self.keeps(partition, generation)
+                    {
+                        self.refuse_partition(partition, error.clone());
+                    } else if committing {
+                        self.abandon_stale(&[partition], error);
+                    }
+                }
+                Ok(()) => served.push((sent, generation, section)),
+            }
+        }
+        let current = self.is_current(epoch);
+        if current {
+            answered.sort_unstable();
+            let idle: Vec<u32> = held
+                .iter()
+                .copied()
+                .filter(|partition| answered.binary_search(partition).is_err())
+                .collect();
+            self.pause_idle(&idle);
+        }
+        // The server committed the batches of a committing fetch, so they reach the
+        // application even after the fetch state was cleared, unless a seek replaced the
+        // generation of their partition. The close then moves the offset back over them.
+        let replaced: Vec<u32> = served
+            .iter()
+            .filter(|(_, generation, section)| {
+                committing
+                    && section.messages_count > 0
+                    && !self.keeps(section.partition_id, *generation)
+            })
+            .map(|(_, _, section)| section.partition_id)
+            .collect();
+        served.retain(|(sent, generation, section)| {
+            let partition = section.partition_id;
+            self.keeps(partition, *generation)
+                && (committing
+                    || (current && is_held(&partition) && *sent == self.position(partition)))
+        });
+        self.replies += 1;
+        let mut batches = Vec::with_capacity(served.len());
+        let mut lost = replaced;
+        let mut undecoded = Vec::with_capacity(served.len());
+        let mut kept = Vec::with_capacity(served.len());
+        for (sent, generation, section) in served {
+            let partition = section.partition_id;
+            let current = committing && sent == self.position(partition);
+            if current
+                && self.skipped_by_lost_read(
+                    partition,
+                    &sent,
+                    section_start(&section),
+                    section.context,
+                )
+            {
+                let until = section.next_offset.saturating_sub(1);
+                self.start_catch_up(partition, sent.value, until, section.context);
+                if section.messages_count > 0 {
+                    match section.decode_records() {
+                        Ok(mut batch) => {
+                            batch.set_stamp(self.shared.progress.stamp(generation));
+                            self.retained_batches.insert(
+                                partition,
+                                RetainedBatch {
+                                    batch,
+                                    capacity: Arc::clone(&capacity),
+                                },
+                            );
+                        }
+                        Err(error) => self.refuse_section(&section, false, true, &mut lost, error),
+                    }
+                }
+                lost.push(partition);
+                continue;
+            }
+            if current && section.messages_count > 0 {
+                self.lost_operations.remove(&partition);
+            }
+            kept.push((sent, generation, section));
+        }
+        for (sent, generation, section) in kept {
+            let Some(encryptor) = self.config.encryptor.clone() else {
+                let moved = self.advance(sent, &section);
+                undecoded.push((moved, generation, section));
+                continue;
+            };
+            let partition = section.partition_id;
+            let mut polled = match section.decode_records() {
+                Ok(polled) => polled,
+                Err(error) => {
+                    let rewind = sent == self.position(partition);
+                    self.refuse_section(&section, rewind, committing, &mut lost, error);
+                    continue;
+                }
+            };
+            match decrypt_messages(&encryptor, &mut polled) {
+                Ok(()) => {
+                    self.advance(sent, &section);
+                }
+                Err((index, failed, error)) => {
+                    let rewind = sent == self.position(partition);
+                    let record = polled.undecoded_record(index).map(Box::new);
+                    self.refuse_undecoded(
+                        rewind,
+                        partition,
+                        failed,
+                        section.context,
+                        error,
+                        record,
+                    );
+                }
+            }
+            if !polled.messages.is_empty() {
+                polled.set_stamp(self.shared.progress.stamp(generation));
+                batches.push(polled);
+            }
+        }
+        if !committing && self.shared.can_poll.load(ORDERING) {
+            self.schedule();
+        }
+        for (moved, generation, section) in undecoded {
+            if section.messages_count == 0 {
+                continue;
+            }
+            match section.decode_records() {
+                Ok(mut polled) => {
+                    polled.set_stamp(self.shared.progress.stamp(generation));
+                    batches.push(polled);
+                }
+                // A committing fetch already moved the server offset past the
+                // records, so the worker reads them again from their start. A
+                // fetch sent from the moved position then reads stale.
+                Err(error) => self.refuse_section(&section, moved, committing, &mut lost, error),
+            }
+        }
+        if let Some(commit) = commit {
+            commit.settle(lost);
+        }
+        for batch in &batches {
+            self.shared.progress.forget_undecoded_covered(batch);
+        }
+        if !batches.is_empty() {
+            self.deliver(Reply {
+                epoch: self.epoch,
+                assignment: self.assignment,
+                result: Ok(batches),
+                capacity,
+            });
+        }
+    }
+
+    /// A section that does not decode has no message boundary, so it fails at its start. Unless
+    /// it holds one message, its partition reads from there one message at a time: each good
+    /// message reaches the application, and the read of the bad one names it.
+    fn refuse_section(
+        &mut self,
+        section: &FetchedSection,
+        rewind: bool,
+        committing: bool,
+        lost: &mut Vec<u32>,
+        error: DecodeError,
+    ) {
+        let partition = section.partition_id;
+        let start = section_start(section);
+        if committing {
+            lost.push(partition);
+            self.shared.progress.remember_undecoded(Checkpoint::new(
+                partition,
+                start,
+                section.context,
+            ));
+        }
+        if rewind && section.messages_count != SINGLE_RECORD {
+            warn!(
+                "Failed to decode {count} messages from offset: {start}, partition ID: {partition}. They are read again one at a time. {}",
+                error.error,
+                count = section.messages_count
+            );
+            self.positions.insert(
+                partition,
+                continuation_at(partition, start, section.context),
+            );
+            self.read_alone.insert(partition);
+            self.read_alone_until.insert(partition, section.next_offset);
+            return;
+        }
+        self.refuse_undecoded(
+            rewind,
+            partition,
+            start,
+            section.context,
+            error.error,
+            error.record,
+        );
+    }
+
+    /// The message at `start` does not decode. Its partition reads it alone again until a seek
+    /// to the skip of the error.
+    fn refuse_message(&mut self, partition: u32, start: &PollingStrategy, error: DecodeError) {
+        self.read_alone.insert(partition);
+        let context = start.context().unwrap_or_default();
+        self.refuse_undecoded(
+            false,
+            partition,
+            start.value,
+            context,
+            error.error,
+            error.record,
+        );
+    }
+
+    /// The reader reads the message at `failed` again until a seek to the continuation after
+    /// it. With `rewind`, the position moves back to it.
+    fn refuse_undecoded(
+        &mut self,
+        rewind: bool,
+        partition: u32,
+        failed: u64,
+        context: PartitionContext,
+        error: IggyError,
+        record: Option<Box<UndecodedRecord>>,
+    ) {
+        if rewind {
+            self.positions
+                .insert(partition, continuation_at(partition, failed, context));
+        }
+        self.retry(&[partition]);
+        self.report(ReadError {
+            partition_id: Some(partition),
+            error,
+            skip: failed
+                .checked_add(1)
+                .map(|next| continuation_at(partition, next, context)),
+            offset: record.as_ref().and_then(|record| record.offset),
+            record: record.map(|record| record.bytes),
+        });
+    }
+
+    /// Moves the position of the partition of `section` after it, unless a new
+    /// assignment or a seek replaced the position that the fetch read from. A
+    /// `Next` target continues from an offset too, also without a read.
+    fn advance(&mut self, sent: PollingStrategy, section: &FetchedSection) -> bool {
+        let partition = section.partition_id;
+        self.lasting_refusals.remove(&partition);
+        self.shared
+            .progress
+            .forget_undecoded_history(partition, section.context);
+        if sent != self.position(partition) {
+            return false;
+        }
+        let position = self.continue_at(partition, section.next_offset, section.context);
+        self.positions.insert(partition, position);
+        if ends_before_head(section) {
+            self.backlogged.insert(partition);
+        } else {
+            self.backlogged.remove(&partition);
+        }
+        if section.messages_count > 0 {
+            self.served.insert(partition, self.replies);
+        }
+        true
+    }
+
+    /// A check delivers nothing. Its batch resolves the start of a fetch that
+    /// commits, and its refusal names the partition of a refused fetch.
+    fn complete_check(&mut self, completed: CompletedPoll) {
+        let partition = completed.partition;
+        self.active.remove(&partition);
+        let probe = self.probing.remove(&partition);
+        if !self.is_current(completed.epoch) || !self.keeps(partition, completed.generation) {
+            return;
+        }
+        match completed.result {
+            // A probe that passes hands its partition back to the group fetch.
+            Ok(polled) if polled.messages.is_empty() && !probe => self.pause_idle(&[partition]),
+            Ok(polled) => {
+                if (self.needs_start(partition)
+                    || !matches!(
+                        completed.strategy.kind,
+                        PollingKind::Offset | PollingKind::Next
+                    ))
+                    && completed.strategy == self.position(partition)
+                    && let Some(first) = polled.messages.first()
+                {
+                    let position = if completed.strategy.kind == PollingKind::Next {
+                        self.continue_at(partition, first.header.offset, polled.context())
+                    } else {
+                        continuation_at(partition, first.header.offset, polled.context())
+                    };
+                    self.positions.insert(partition, position);
+                }
+            }
+            // A poll returns a record above `max_bytes` alone, so its refusal is final.
+            Err(DecodeError {
+                error: IggyError::LaneBlocked(operation),
+                ..
+            }) if self.config.on_read() => {
+                self.refuse(
+                    &[partition],
+                    IggyError::PartitionLaneBlocked(partition, operation),
+                );
+            }
+            Err(error) => self.refuse(&[partition], error.error),
+        }
+    }
+
+    fn complete_poll(&mut self, mut completed: CompletedPoll) {
+        let partition = completed.partition;
+        if self.config.on_read()
+            && completed.commit.is_none()
+            && (!self.is_current(completed.epoch)
+                || !self.keeps(partition, completed.generation)
+                || completed.strategy != self.position(partition))
+        {
+            return;
+        }
+        self.active.remove(&partition);
+        let commits = completed.commit.is_some();
+        if let Err(error) = &completed.result
+            && commits
+            && may_hide_commit(&error.error)
+            && self.keeps(partition, completed.generation)
+        {
+            self.lose(partition, &error.error);
+        }
+        // Dropped unsettled, it forgets the commit of its partition, so the close rewinds.
+        let commit = completed.commit.take().filter(|_| {
+            !completed
+                .result
+                .as_ref()
+                .is_err_and(|error| may_hide_commit(&error.error))
+        });
+        // A mark holds for one read. Another failure to decode marks the partition again.
+        let alone = self.read_alone.remove(&partition);
+        if self.config.on_read() {
+            let lost = self.complete_on_demand(completed, alone, commits);
+            if let Some(commit) = commit {
+                commit.settle(lost);
+            }
+            return;
+        }
+        if !self.is_current(completed.epoch) || !self.keeps(partition, completed.generation) {
+            return;
+        }
+        let mut polled = match completed.result {
+            Ok(polled) => {
+                self.lasting_refusals.remove(&partition);
+                polled
+            }
+            Err(error) if names_message(alone, &completed.strategy, &error.error) => {
+                self.refuse_message(partition, &completed.strategy, error);
+                return;
+            }
+            Err(error) => {
+                self.refuse(&[partition], error.error);
+                return;
+            }
+        };
+        let context = polled.context();
+        let polled_nothing = polled.messages.is_empty();
+        let failure = self
+            .config
+            .encryptor
+            .as_deref()
+            .and_then(|encryptor| decrypt_messages(encryptor, &mut polled).err())
+            .map(|(index, failed, error)| {
+                (failed, error, polled.undecoded_record(index).map(Box::new))
+            });
+        if let Some(last) = polled.messages.last().map(|message| message.header.offset) {
+            let Some(next) = last.checked_add(1) else {
+                self.refuse(&[partition], IggyError::InvalidOffset(last));
+                return;
+            };
+            // A new read of such a start resolves against a later end, and would skip the
+            // messages in between if the epoch drops this batch.
+            if !matches!(
+                completed.strategy.kind,
+                PollingKind::Offset | PollingKind::Next
+            ) && let Some(first) = polled.messages.first()
+            {
+                self.seek_starts.insert(
+                    partition,
+                    continuation_at(partition, first.header.offset, context),
+                );
+            }
+            self.positions
+                .insert(partition, continuation_at(partition, next, context));
+            polled.set_stamp(self.shared.progress.stamp(completed.generation));
+            self.deliver(Reply {
+                epoch: self.epoch,
+                assignment: self.assignment,
+                result: Ok(vec![polled]),
+                capacity: completed.capacity,
+            });
+        }
+        match failure {
+            Some((failed, error, record)) => {
+                self.refuse_undecoded(true, partition, failed, context, error, record);
+            }
+            None if polled_nothing => self.pause_idle(&[partition]),
+            None => {}
+        }
+    }
+
+    /// The server committed a batch that holds messages, so the batch reaches the application
+    /// even after the fetch state was cleared, unless a seek replaced the generation of its
+    /// partition. Returns the partition when the server committed messages that the batch does
+    /// not hand over. `commits` is set for a read that stored its offset.
+    fn complete_on_demand(
+        &mut self,
+        completed: CompletedPoll,
+        alone: bool,
+        commits: bool,
+    ) -> Vec<u32> {
+        let partition = completed.partition;
+        let mut polled = match completed.result {
+            Ok(polled) => {
+                self.lasting_refusals.remove(&partition);
+                polled
+            }
+            Err(DecodeError { error, record }) => {
+                if !self.keeps(partition, completed.generation) {
+                    if commits {
+                        self.abandon_stale(&[partition], &error);
+                    }
+                    return Vec::new();
+                }
+                let strategy = completed.strategy;
+                if strategy.kind == PollingKind::Next && undeliverable(&error) {
+                    match strategy.context() {
+                        // The server stored the offset of a reply that the worker cannot hand
+                        // over. A continuation knows where that reply started, so the partition
+                        // reads it again one message at a time and the close rewinds before it.
+                        Some(context) if self.is_current(completed.epoch) => {
+                            let start = Checkpoint::new(partition, strategy.value, context);
+                            self.shared.progress.remember_undecoded(start);
+                            self.positions.insert(
+                                partition,
+                                continuation_at(partition, strategy.value, context),
+                            );
+                            self.read_alone.insert(partition);
+                            self.retry(&[partition]);
+                        }
+                        Some(_) => {}
+                        // A read from the stored offset has no known start.
+                        None => {
+                            self.stopped.insert(partition);
+                            self.report(ReadError::new(Some(partition), error));
+                        }
+                    }
+                } else if self.is_current(completed.epoch) {
+                    if names_message(alone, &strategy, &error) {
+                        // The server stored the offset of the message, so the close rewinds
+                        // before it.
+                        self.shared.progress.remember_undecoded(Checkpoint::new(
+                            partition,
+                            strategy.value,
+                            strategy.context().unwrap_or_default(),
+                        ));
+                        self.refuse_message(partition, &strategy, DecodeError { error, record });
+                    } else if self.catching_up(partition).is_some() && undeliverable(&error) {
+                        // A reply without message boundaries names no message, so the range is
+                        // read one message at a time, and the read of a bad one names it.
+                        self.read_alone.insert(partition);
+                    } else {
+                        self.refuse(&[partition], error);
+                    }
+                } else if commits {
+                    self.abandon_stale(&[partition], &error);
+                }
+                return Vec::new();
+            }
+        };
+        if !self.keeps(partition, completed.generation) {
+            // The close moves the offset back over a committed batch that a seek dropped.
+            return if commits && !polled.messages.is_empty() {
+                vec![partition]
+            } else {
+                Vec::new()
+            };
+        }
+        let Some(last) = polled.messages.last().map(|message| message.header.offset) else {
+            if completed.strategy == self.position(partition)
+                && self.skipped_by_lost_read(
+                    partition,
+                    &completed.strategy,
+                    polled.current_offset.saturating_add(1),
+                    polled.context(),
+                )
+            {
+                self.start_catch_up(
+                    partition,
+                    completed.strategy.value,
+                    polled.current_offset,
+                    polled.context(),
+                );
+                return vec![partition];
+            }
+            if completed.strategy == self.position(partition)
+                && let Some(until) = self.catching_up(partition)
+            {
+                self.end_catch_up(partition, until, polled.context());
+                return Vec::new();
+            }
+            self.pause_idle(&[partition]);
+            return Vec::new();
+        };
+        let first = polled
+            .messages
+            .first()
+            .map_or(last, |message| message.header.offset);
+        let current = completed.strategy == self.position(partition);
+        if current
+            && self.skipped_by_lost_read(partition, &completed.strategy, first, polled.context())
+        {
+            self.start_catch_up(partition, completed.strategy.value, last, polled.context());
+            polled.set_stamp(self.shared.progress.stamp(completed.generation));
+            self.retained_batches.insert(
+                partition,
+                RetainedBatch {
+                    batch: polled,
+                    capacity: completed.capacity,
+                },
+            );
+            return vec![partition];
+        }
+        if current && commits {
+            self.lost_operations.remove(&partition);
+        }
+        let mut overflow = None;
+        // A new assignment replaces the position that the poll started from.
+        if current {
+            match last.checked_add(1) {
+                Some(next) => {
+                    let position = self.after_delivery(partition, next, polled.context());
+                    self.positions.insert(partition, position);
+                }
+                None => overflow = Some(IggyError::InvalidOffset(last)),
+            }
+        }
+        polled.set_stamp(self.shared.progress.stamp(completed.generation));
+        self.deliver(Reply {
+            epoch: self.epoch,
+            assignment: self.assignment,
+            result: Ok(vec![polled]),
+            capacity: completed.capacity,
+        });
+        if let Some(error) = overflow {
+            self.refuse(&[partition], error);
+        }
+        Vec::new()
+    }
+
+    /// The position after a delivery that ends before `next`. With `OnRead` it reads `Next`, so
+    /// the server refuses its commit once the stored offset moved, and it keeps `next` for the
+    /// gap check.
+    fn continue_at(&self, partition: u32, next: u64, context: PartitionContext) -> PollingStrategy {
+        if self.config.on_read() {
+            PollingStrategy::continuation(partition, PollingKind::Next, next, context)
+        } else {
+            continuation_at(partition, next, context)
+        }
+    }
+
+    /// [`Self::continue_at`], except that a partition reads by offset until it passes the end of
+    /// its catch-up, or of a section that did not decode.
+    fn after_delivery(
+        &mut self,
+        partition: u32,
+        next: u64,
+        context: PartitionContext,
+    ) -> PollingStrategy {
+        if self
+            .read_alone_until
+            .get(&partition)
+            .is_some_and(|until| next < *until)
+        {
+            return continuation_at(partition, next, context);
+        }
+        match self.catch_up.get(&partition) {
+            Some(&until) if next <= until => continuation_at(partition, next, context),
+            Some(_) => {
+                self.catch_up.remove(&partition);
+                self.retained_batches.remove(&partition);
+                self.lost_operations.remove(&partition);
+                self.continue_at(partition, next, context)
+            }
+            None => self.continue_at(partition, next, context),
+        }
+    }
+
+    /// The end of the catch-up of `partition`, while its position lies inside it.
+    fn catching_up(&self, partition: u32) -> Option<u64> {
+        let until = *self.catch_up.get(&partition)?;
+        let position = self.position(partition);
+        (position.kind == PollingKind::Offset && position.value <= until).then_some(until)
+    }
+
+    /// A lost read committed `from..=until` without handing it over, so the partition reads that
+    /// range again by offset without a commit before it reads `Next` again.
+    fn start_catch_up(&mut self, partition: u32, from: u64, until: u64, context: PartitionContext) {
+        warn!(
+            "Partition: {partition} skipped offsets from: {from} to: {until}, which a lost read committed. They are read again without a commit."
+        );
+        self.positions
+            .insert(partition, continuation_at(partition, from, context));
+        self.catch_up.insert(partition, until);
+        self.shared
+            .progress
+            .remember_undecoded(Checkpoint::new(partition, from, context));
+    }
+
+    /// A committing read from the `Next` continuation `sent` whose reply starts at `start` in the
+    /// history `context` skipped messages that a lost read of the partition committed. Without
+    /// a lost read, the application or another session stored that offset, so the read goes on
+    /// from there.
+    fn skipped_by_lost_read(
+        &self,
+        partition: u32,
+        sent: &PollingStrategy,
+        start: u64,
+        context: PartitionContext,
+    ) -> bool {
+        self.lost_operations.contains_key(&partition) && skipped(sent, start, context)
+    }
+
+    /// A committing read whose outcome is unknown blocks its partitions on the client until its
+    /// operation is abandoned, also after a seek or a new fetch state dropped its reply.
+    fn abandon_stale(&mut self, partitions: &[u32], error: &IggyError) {
+        if let IggyError::FetchProgressUncertain(operation) = error
+            && self.config.on_read()
+        {
+            self.abandoning.push((*operation, partitions.to_vec()));
+        }
+    }
+
+    /// Marks a committing read of `partition` that failed with `error` as lost, unless an
+    /// earlier lost read is still held.
+    fn lose(&mut self, partition: u32, error: &IggyError) {
+        let operation = match error {
+            IggyError::FetchProgressUncertain(operation) => *operation,
+            _ => 0,
+        };
+        self.lost_operations.entry(partition).or_insert(operation);
+    }
+
+    /// A catch-up read that found nothing: the history no longer holds the rest of the range,
+    /// so the reader reports it lost once and reads on after the retained batch.
+    fn end_catch_up(&mut self, partition: u32, until: u64, context: PartitionContext) {
+        let resume = self
+            .retained_batches
+            .get(&partition)
+            .and_then(|retained| retained.batch.messages.last())
+            .map_or(until, |last| last.header.offset)
+            .saturating_add(1);
+        self.interrupt_catch_up(partition);
+        self.shared.progress.forget_undecoded(partition);
+        let position = self.continue_at(partition, resume, context);
+        self.positions.insert(partition, position);
+    }
+
+    fn interrupt_catch_up(&mut self, partition: u32) -> bool {
+        let Some(until) = self.catch_up.remove(&partition) else {
+            return false;
+        };
+        let position = self.position(partition);
+        if let Some(active) = self.active.remove(&partition) {
+            active.task.abort();
+        }
+        let retained = self.retained_batches.remove(&partition);
+        let lost_end = retained
+            .as_ref()
+            .and_then(|retained| retained.batch.messages.first())
+            .map_or(until, |first| first.header.offset.saturating_sub(1));
+        let operation = self.lost_operations.remove(&partition).unwrap_or_default();
+        if position.value <= lost_end {
+            error!(
+                "Partition: {partition} lost offsets from: {} to: {lost_end} during read recovery",
+                position.value
+            );
+            let error = ReadError {
+                partition_id: Some(partition),
+                error: IggyError::FetchProgressUncertain(operation),
+                skip: lost_end.checked_add(1).map(|next| {
+                    continuation_at(partition, next, position.context().unwrap_or_default())
+                }),
+                offset: None,
+                record: None,
+            };
+            if let Some(retained) = &retained {
+                self.deliver(Reply {
+                    epoch: self.epoch,
+                    assignment: self.assignment,
+                    result: Err(Refusal {
+                        error,
+                        generation: retained.batch.stamp().map(|stamp| stamp.generation),
+                    }),
+                    capacity: Arc::clone(&retained.capacity),
+                });
+            } else {
+                self.report(error);
+            }
+        }
+        if let Some(mut retained) = retained {
+            retained
+                .batch
+                .messages
+                .retain(|message| message.header.offset >= position.value);
+            retained.batch.count = u32::try_from(retained.batch.messages.len()).unwrap_or(u32::MAX);
+            if !retained.batch.messages.is_empty() {
+                self.deliver(Reply {
+                    epoch: self.epoch,
+                    assignment: self.assignment,
+                    result: Ok(vec![retained.batch]),
+                    capacity: retained.capacity,
+                });
+            }
+        }
+        true
+    }
+
+    /// Abandons the operations of lost committing reads, so their progress blocks go. The next
+    /// `Next` read of each partition finds what they committed.
+    async fn abandon_lost(&mut self) {
+        if self.abandoning.is_empty() {
+            return;
+        }
+        let mut operations = std::mem::take(&mut self.abandoning);
+        operations.sort_unstable_by_key(|(operation, _)| *operation);
+        let client = self.config.client.clone();
+        let client = client.read().await;
+        if client.operation_lineage().await.is_none() {
+            for (operation, partitions) in operations {
+                for partition in partitions {
+                    self.block(partition, operation);
+                    self.report(ReadError::new(
+                        Some(partition),
+                        IggyError::FetchProgressUncertain(operation),
+                    ));
+                }
+            }
+            return;
+        }
+        operations.dedup_by_key(|(operation, _)| *operation);
+        for (operation, _) in operations {
+            // An operation that the client no longer retains is already free.
+            let _ = client.abandon_operation(operation).await;
+        }
+    }
+
+    /// A fetch that the server refused before its first read names no target,
+    /// also when the history of one changed or the member no longer owns one.
+    /// Each target then probes alone, and its own refusal tells.
+    fn refuse_fetch(&mut self, partitions: &[u32], error: IggyError) {
+        if partitions.len() > 1
+            && matches!(
+                error,
+                IggyError::HistoryUnavailable | IggyError::ConsumerGroupPartitionNotOwned(..)
+            )
+        {
+            self.probing.extend(partitions);
+            return;
+        }
+        self.refuse(partitions, error);
+    }
+
+    /// The reads of one fetch share one turn for a first record above `max_bytes`, so a section
+    /// that lost it is read again by a poll of its partition, which always returns that record.
+    /// Only a failure of that poll is reported.
+    fn refuse_partition(&mut self, partition: u32, error: IggyError) {
+        if error == IggyError::InvalidSizeBytes {
+            self.read_alone.insert(partition);
+            return;
+        }
+        self.refuse(&[partition], error);
+    }
+
+    /// The one classifier of read refusals. A refusal of one partition affects only that
+    /// partition, so the healthy sections of the same reply survive.
+    fn refuse(&mut self, partitions: &[u32], error: IggyError) {
+        for partition in partitions {
+            self.backlogged.remove(partition);
+        }
+        match error {
+            IggyError::FetchProgressUncertain(operation) if self.config.on_read() => {
+                warn!(
+                    "A read of partitions: {partitions:?} lost its reply. Operation: {operation} needs recovery."
+                );
+                for &partition in partitions {
+                    self.lost_operations.insert(partition, operation);
+                    let position = self.position(partition);
+                    if let Some(context) = position.context() {
+                        self.shared.progress.remember_undecoded(Checkpoint::new(
+                            partition,
+                            position.value,
+                            context,
+                        ));
+                    }
+                }
+                self.abandoning.push((operation, partitions.to_vec()));
+                self.retry(partitions);
+            }
+            // The server refused the commit of a committing read, which stored nothing.
+            IggyError::TransientNotAccepted if self.config.on_read() => self.retry(partitions),
+            IggyError::FetchProgressUncertain(operation) => {
+                for &partition in partitions {
+                    self.block(partition, operation);
+                    self.report(ReadError::new(Some(partition), error.clone()));
+                }
+            }
+            IggyError::PartitionLaneBlocked(partition, operation) => {
+                self.block(partition, operation);
+                self.report(ReadError::new(Some(partition), error));
+            }
+            IggyError::NotPrimary => {
+                self.retry(partitions);
+                self.refresh_now = true;
+            }
+            IggyError::ConsumerGroupPartitionNotOwned(..) => {
+                for &partition in partitions {
+                    self.interrupt_catch_up(partition);
+                    self.positions.remove(&partition);
+                    self.shared.progress.forget_undecoded(partition);
+                }
+                self.refresh_now = true;
+                self.count_lasting(partitions, &error);
+            }
+            IggyError::ConsumerGroupMemberNotFound(..) => {
+                self.forget_membership();
+                self.retry(partitions);
+                self.refresh_now = true;
+            }
+            IggyError::Disconnected | IggyError::Unauthenticated | IggyError::StaleClient => {
+                self.forget_membership();
+                self.invalidate();
+                self.report(ReadError::new(None, error));
+            }
+            IggyError::HistoryUnavailable => {
+                for &partition in partitions {
+                    let interrupted = self.interrupt_catch_up(partition);
+                    self.shared.progress.forget_undecoded(partition);
+                    // A position without a history reads the history of the cached route, which
+                    // the driver drops after this refusal, so only a lost history stops.
+                    if self.position(partition).context().is_none() {
+                        warn!(
+                            "Partition: {partition} has a new history, which the next read takes from a new route."
+                        );
+                        self.retry(&[partition]);
+                        continue;
+                    }
+                    self.positions.remove(&partition);
+                    self.retry_at.remove(&partition);
+                    self.stopped.insert(partition);
+                    if !interrupted {
+                        self.report(ReadError::new(Some(partition), error.clone()));
+                    }
+                }
+            }
+            error if lasting_refusal(&error) => self.count_lasting(partitions, &error),
+            error => {
+                self.retry(partitions);
+                let partition = match partitions {
+                    [partition] => Some(*partition),
+                    _ => None,
+                };
+                self.report(ReadError::new(partition, error));
+            }
+        }
+    }
+
+    /// A lasting refusal reads its partition again after a wait that doubles each time, and the
+    /// [`MAX_LASTING_REFUSALS`]-th in a row stops it until a seek with one error.
+    fn count_lasting(&mut self, partitions: &[u32], error: &IggyError) {
+        for &partition in partitions {
+            let refusals = self.lasting_refusals.entry(partition).or_default();
+            *refusals += 1;
+            let refusals = *refusals;
+            if refusals < MAX_LASTING_REFUSALS {
+                let delay = backoff(self.config.retry_interval, refusals);
+                warn!(
+                    "Partition: {partition} was refused: {error}. It is read again in {delay:?}, refusal {refusals} of {MAX_LASTING_REFUSALS}."
+                );
+                self.retry_at.insert(partition, Instant::now() + delay);
+                continue;
+            }
+            self.lasting_refusals.remove(&partition);
+            self.retry_at.remove(&partition);
+            self.stopped.insert(partition);
+            self.report(ReadError::new(Some(partition), error.clone()));
+        }
+    }
+
+    /// The driver blocks the partition until `abandon_operation`. With `OnRead`, the partition
+    /// also stops until a seek.
+    fn block(&mut self, partition: u32, operation: u64) {
+        self.shared.progress.block(partition, operation);
+        self.retry_at.remove(&partition);
+        if self.config.on_read() {
+            self.stopped.insert(partition);
+        }
+    }
+
+    fn forget_membership(&self) {
+        if let Some(membership) = &self.shared.membership {
+            membership.forget();
+        }
+    }
+
+    fn retry(&mut self, partitions: &[u32]) {
+        let retry_at = Instant::now() + self.config.retry_interval;
+        for partition in partitions {
+            self.retry_at.insert(*partition, retry_at);
+        }
+    }
+
+    /// A read that waits for nothing answers an idle partition at once, so the partition reads
+    /// again after the retry interval instead of at the round trip rate.
+    fn pause_idle(&mut self, partitions: &[u32]) {
+        if self.config.options.max_wait.as_micros() == 0 {
+            self.retry(partitions);
+        }
+    }
+
+    fn is_current(&self, epoch: u64) -> bool {
+        epoch == self.epoch && self.epoch == self.shared.epoch.load(ORDERING)
+    }
+}
+
+/// The fetches that one group starts in a turn of a reader that commits nothing.
+struct GroupPlan {
+    group: usize,
+    /// A main fetch of the group is in flight.
+    main: bool,
+    quiet: Vec<u32>,
+    /// The least recently served first.
+    backlogged: Vec<u32>,
+    /// The slots beyond the first one.
+    extra: usize,
+}
+
+impl GroupPlan {
+    /// The extra slots that the group can use, so that every fetch reads a backlogged partition.
+    fn room(&self) -> usize {
+        self.backlogged
+            .len()
+            .saturating_sub(usize::from(self.quiet.is_empty()))
+    }
+}
+
+async fn next_event(events: &mut Option<Receiver<DiagnosticEvent>>) -> Option<DiagnosticEvent> {
+    match events {
+        Some(events) => events.next().await,
+        None => std::future::pending().await,
+    }
+}
+
+/// A fetch of a group reads `Offset` and `Next` positions only.
+fn fetched_by_group_kind(kind: PollingKind) -> bool {
+    matches!(kind, PollingKind::Offset | PollingKind::Next)
+}
+
+fn continuation_at(partition: u32, offset: u64, context: PartitionContext) -> PollingStrategy {
+    PollingStrategy::continuation(partition, PollingKind::Offset, offset, context)
+}
+
+/// Keeps the messages before the first one that does not decrypt, and returns the index and
+/// offset of that message with its error.
+fn decrypt_messages(
+    encryptor: &EncryptorKind,
+    polled: &mut PolledMessages,
+) -> Result<(), (usize, u64, IggyError)> {
+    let partition = polled.partition_id;
+    let failed = polled
+        .messages
+        .iter_mut()
+        .enumerate()
+        .find_map(|(index, message)| {
+            let offset = message.header.offset;
+            encryptor
+                .decrypt_message(message)
+                .err()
+                .map(|error| (index, offset, error))
+        });
+    let Some((index, offset, error)) = failed else {
+        return Ok(());
+    };
+    error!(
+        "Failed to decrypt the message at offset: {offset}, partition ID: {partition}. A seek to the skip of its read error passes it. {error}"
+    );
+    polled.messages.truncate(index);
+    polled.count = u32::try_from(index).unwrap_or(polled.count);
+    Err((index, offset, error))
+}
+
+/// The records limit of a fetch whose whole reply, with its section table,
+/// fits the reservation of one reply.
+fn fetch_max_bytes(max_bytes: u32, targets: usize) -> u32 {
+    let records = (max_bytes as usize)
+        .saturating_sub(FetchMessagesResponse::head_size(targets))
+        .max(MIN_DEFERRED_POLL_MAX_BYTES);
+    u32::try_from(records).unwrap_or(max_bytes)
+}
+
+/// A section that ended before the head of its partition, so the next read of the partition
+/// returns at once. An empty partition also reports the head 0.
+fn ends_before_head(section: &FetchedSection) -> bool {
+    section.next_offset <= section.current_offset
+        && (section.messages_count > 0 || section.current_offset > 0)
+}
+
+/// The offset of the first record of `section`, or one before it. Offsets are
+/// dense, so a fetch from there reads the section again.
+fn section_start(section: &FetchedSection) -> u64 {
+    section
+        .next_offset
+        .saturating_sub(u64::from(section.messages_count))
+}
+
+/// A read from the `Next` continuation `sent` whose reply starts at `start` in the history
+/// `context` passed messages after the expected offset.
+fn skipped(sent: &PollingStrategy, start: u64, context: PartitionContext) -> bool {
+    sent.kind == PollingKind::Next
+        && start > sent.value
+        && sent
+            .context()
+            .is_some_and(|sent| same_incarnation_and_owner(sent, context))
+}
+
+/// A refusal that a retry soon after does not change: a missing permission, partition, topic
+/// or stream, a request the server cannot serve, or a partition that the server keeps refusing
+/// to this member after each refresh. Its partition reads again later each time, and stops
+/// after [`MAX_LASTING_REFUSALS`] in a row.
+pub(crate) fn lasting_refusal(error: &IggyError) -> bool {
+    matches!(
+        error,
+        IggyError::Unauthorized
+            | IggyError::ConsumerGroupPartitionNotOwned(..)
+            | IggyError::FeatureUnavailable
+            | IggyError::InvalidCommand
+            | IggyError::PartitionNotFound(..)
+            | IggyError::StreamIdNotFound(..)
+            | IggyError::StreamNameNotFound(..)
+            | IggyError::TopicIdNotFound(..)
+            | IggyError::TopicNameNotFound(..)
+    )
+}
+
+/// The wait after the `refusals`-th lasting refusal in a row.
+fn backoff(retry_interval: Duration, refusals: u32) -> Duration {
+    retry_interval.saturating_mul(1 << refusals.saturating_sub(1).min(MAX_REFUSAL_BACKOFF_STEPS))
+}
+
+/// A reply that never arrived can commit batches that never reach the
+/// application. So can an undeliverable reply, and a fetch that loses its
+/// session or its permission after its first read.
+fn may_hide_commit(error: &IggyError) -> bool {
+    undeliverable(error)
+        || matches!(
+            error,
+            IggyError::TransientNotCommitted
+                | IggyError::FetchProgressUncertain(_)
+                | IggyError::Unauthenticated
+                | IggyError::StaleClient
+                | IggyError::Unauthorized
+        )
+}
+
+/// A read alone from an offset holds one message, so a reply of it that does
+/// not decode names that message.
+fn names_message(alone: bool, start: &PollingStrategy, error: &IggyError) -> bool {
+    alone && start.kind == PollingKind::Offset && undeliverable(error)
+}
+
+/// A reply that arrived but does not decode, or that the worker drops for
+/// holding more messages than it asked for.
+fn undeliverable(error: &IggyError) -> bool {
+    matches!(
+        error,
+        IggyError::InvalidNumberEncoding
+            | IggyError::InvalidMessagePayloadLength
+            | IggyError::InvalidMessagesCount
+    )
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use iggy_binary_protocol::batch::BATCH_MESSAGE_HEADER_SIZE;
+
+    use crate::client_wrappers::client_wrapper::ClientWrapper;
+    use crate::http::http_client::HttpClient;
+    use bytes::Bytes;
+    use iggy_binary_protocol::WireDecode;
+    use iggy_binary_protocol::WirePollingStrategy;
+    use iggy_binary_protocol::codes::POLL_MESSAGES_DEFERRED_CODE;
+    use iggy_binary_protocol::requests::messages::DeferredPollMessagesRequest;
+    use iggy_common::locking::IggyRwLock;
+    use iggy_common::{Aes256GcmEncryptor, AutoCommit, Consumer, Identifier, IggyTimestamp};
+    use tokio::task::yield_now;
+    use tokio::time::{advance, timeout};
+
+    use crate::clients::reader::fixtures::{
+        Section, batch, builder_for, builder_on, connect_to_one_replica, message_at, refused,
+        sections, served, stored_offsets,
+    };
+    use crate::clients::reader_builder::{DEFAULT_MAX_WAIT, ReaderBuilder};
+    use crate::links::fake_cluster::{Answer, endpoint};
+
+    type Replies = mpsc::UnboundedReceiver<Reply>;
+
+    const GROUP_ID: u32 = 1;
+    const SESSION: u64 = 1;
+    const VERSION: u64 = 1;
+    const OPERATION: u64 = 42;
+    const KEY: [u8; 32] = [1; 32];
+    const RELEASE_TIMEOUT: Duration = Duration::from_secs(1);
+    /// The messages that a partition holds after a section of [`behind_head`].
+    const BACKLOG: u64 = 100;
+
+    #[tokio::test(start_paused = true)]
+    async fn byte_and_message_reservations_cover_inflight_and_queued_replies() {
+        const BATCH_COUNT: u32 = 10;
+        const REPLY_BYTES: u32 = 1024;
+        const SLOTS: usize = 2;
+        for (bytes, messages) in [
+            (REPLY_BYTES * 2, BATCH_COUNT * 10),
+            (REPLY_BYTES * 10, BATCH_COUNT * 2),
+        ] {
+            let (mut worker, mut receiver) = worker_of(
+                standalone([0, 1, 2, 3])
+                    .options(PollOptions {
+                        count: BATCH_COUNT,
+                        max_bytes: REPLY_BYTES,
+                        ..PollOptions::default()
+                    })
+                    .prefetch_bytes(bytes)
+                    .prefetch_messages(messages),
+            );
+            assert_eq!(worker.config.slots, SLOTS);
+            let client = worker.config.client.clone();
+            let _blocked_connection = client.write().await;
+            assert!(worker.apply(assigned(&worker, SESSION, VERSION, [0, 1, 2, 3])));
+            worker.schedule();
+            assert_eq!(worker.tasks.len(), SLOTS, "reserve before sending");
+            assert_eq!(worker.capacity.available_permits(), 0);
+            fail_reads(&mut worker).await;
+            worker.flush_errors();
+            assert_eq!(
+                receiver.len(),
+                SLOTS,
+                "completed failures retain their bounded queue reservations"
+            );
+            worker.schedule();
+            assert!(
+                worker.tasks.is_empty(),
+                "a stalled application stops new fetches"
+            );
+            drop(receiver.recv().await.unwrap());
+            worker.retry_at.clear();
+            worker.schedule();
+            assert_eq!(
+                worker.tasks.len(),
+                1,
+                "draining releases exactly one full-batch reservation"
+            );
+            fail_reads(&mut worker).await;
+            worker.invalidate();
+            worker.report(ReadError::new(None, IggyError::Disconnected));
+            worker.flush_errors();
+            assert_eq!(
+                worker.pending_errors.len(),
+                1,
+                "an error waits for a slot under backpressure"
+            );
+            let stale = receiver.recv().await.unwrap();
+            assert_ne!(stale.epoch, worker.epoch);
+            let reported = receiver.recv().await.unwrap();
+            assert_eq!(reported.epoch, worker.epoch);
+            assert!(matches!(
+                reported.result,
+                Err(Refusal {
+                    error: ReadError {
+                        partition_id: None,
+                        error: IggyError::Disconnected,
+                        ..
+                    },
+                    ..
+                })
+            ));
+            drop((stale, reported));
+            worker.flush_errors();
+            let error = next_error(&mut receiver);
+            assert!(
+                error.partition_id.is_some() && error.error == IggyError::CannotReadMessage,
+                "the error of a partition outlives the epoch"
+            );
+        }
+    }
+
+    #[tokio::test]
+    async fn refused_polls_reach_the_application_and_refused_refreshes_retry_quietly() {
+        let (mut worker, mut receiver) = worker_of(standalone([0]));
+        for error in [
+            IggyError::TransientNotAccepted,
+            IggyError::DeferredPollCapacityExceeded,
+        ] {
+            worker.complete(polled(
+                &worker,
+                FetchKind::Alone,
+                worker.position(0),
+                0,
+                Err(error.clone()),
+            ));
+            assert!(worker.retry_at.contains_key(&0), "the partition retries");
+            worker.flush_errors();
+            assert_eq!(
+                next_error(&mut receiver),
+                ReadError::new(Some(0), error.clone()),
+                "a refused poll reaches the application"
+            );
+            worker.refuse_refresh(error);
+            worker.flush_errors();
+            assert!(
+                receiver.try_recv().is_err(),
+                "the next refresh retries a transient refusal without an error"
+            );
+        }
+    }
+
+    #[tokio::test]
+    async fn given_an_empty_next_probe_when_seeking_after_resolution_should_resolve_the_new_start_again()
+     {
+        let (mut worker, receiver) = worker_of(committing(standalone([0])));
+        assert!(worker.apply(assigned(&worker, SESSION, VERSION, [0])));
+        worker.complete(polled(
+            &worker,
+            FetchKind::Check,
+            PollingStrategy::next(),
+            0,
+            Ok(batch(0, [], 20)),
+        ));
+        assert!(worker.needs_start(0));
+        assert_eq!(worker.position(0), PollingStrategy::next());
+        worker.complete(polled(
+            &worker,
+            FetchKind::Check,
+            PollingStrategy::next(),
+            0,
+            Ok(batch(0, [5], 20)),
+        ));
+        assert_eq!(worker.position(0), next_at(0, 5));
+        assert!(!worker.needs_start(0));
+        assert!(receiver.is_empty());
+        assert!(worker.shared.progress.last_committed(0).is_none());
+        worker.shared.progress.seek(0, PollingStrategy::next());
+        worker.apply_seeks();
+        assert!(worker.needs_start(0));
+        worker.complete(polled(
+            &worker,
+            FetchKind::Check,
+            PollingStrategy::next(),
+            0,
+            Ok(batch(0, [12], 20)),
+        ));
+        assert_eq!(worker.position(0), next_at(0, 12));
+        assert!(receiver.is_empty());
+    }
+
+    #[tokio::test]
+    async fn given_a_dynamic_on_read_start_when_polling_alone_should_resolve_it_before_committing()
+    {
+        for start in [
+            PollingStrategy::last(),
+            PollingStrategy::timestamp(IggyTimestamp::from(1000)),
+        ] {
+            let (mut worker, _receiver) = worker_of(committing(standalone([0]).start(start)));
+            assert!(worker.apply(assigned(&worker, SESSION, VERSION, [0])));
+            worker.shared.demand.store(true, ORDERING);
+            worker.schedule();
+            assert_eq!(worker.active[&0].kind, FetchKind::Check, "{start:?}");
+        }
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn committing_poll_waits_for_demand_and_survives_invalidation() {
+        let (mut worker, mut receiver) = worker_of(committing(standalone([0, 1])));
+        let client = worker.config.client.clone();
+        let _blocked_connection = client.write().await;
+        assert!(worker.apply(assigned(&worker, SESSION, VERSION, [0, 1])));
+        if worker.config.on_read() {
+            for (partition, offset) in [(0, 3), (1, 0)] {
+                worker
+                    .positions
+                    .insert(partition, next_at(partition, offset));
+            }
+        }
+        worker.schedule();
+        assert!(
+            worker.tasks.is_empty(),
+            "no poll before the application asks"
+        );
+        worker.shared.demand.store(true, ORDERING);
+        worker.schedule();
+        worker.schedule();
+        assert_eq!(
+            worker.tasks.len(),
+            2,
+            "one committing poll per idle partition"
+        );
+        let started = worker.epoch;
+        worker.invalidate();
+        while let Some(completed) = worker.tasks.join_next().await {
+            let Completed::Poll(completed) = completed.unwrap() else {
+                panic!("a reader without readiness groups polls one partition");
+            };
+            assert_eq!(completed.epoch, started, "the poll survives invalidation");
+            assert!(completed.commit.is_some(), "the poll commits its batch");
+            worker.complete(Completed::Poll(completed));
+        }
+        worker.flush_errors();
+        assert!(receiver.is_empty(), "an invalidated poll drops its error");
+        assert!(worker.retry_at.is_empty());
+
+        let mut late = polled(
+            &worker,
+            FetchKind::Alone,
+            next_at(0, 3),
+            0,
+            Ok(batch(0, [3, 4], 9)),
+        );
+        if let Completed::Poll(poll) = &mut late {
+            poll.epoch = started;
+        }
+        worker.complete(late);
+        assert_eq!(
+            offsets(&next_batches(&mut receiver)[0]),
+            [3, 4],
+            "the server committed the batch, so the application gets it"
+        );
+        assert!(
+            !worker.shared.demand.load(ORDERING),
+            "the batch answers the demand"
+        );
+        assert_eq!(worker.position(0), next_at(0, 5));
+        worker.complete(polled(
+            &worker,
+            FetchKind::Alone,
+            next_at(0, 5),
+            0,
+            Ok(batch(0, [5], 9)),
+        ));
+        assert_eq!(offsets(&next_batches(&mut receiver)[0]), [5]);
+        assert_eq!(worker.position(0), next_at(0, 6));
+    }
+
+    /// A reply from `Next` that starts past the expected offset shows that a lost read committed
+    /// the messages in between. The partition reads them and the batch again by offset without a
+    /// commit, then reads `Next` again.
+    #[tokio::test]
+    async fn given_a_received_batch_behind_a_lost_range_when_ownership_changes_should_report_the_gap_and_keep_the_batch()
+     {
+        for fetch in [false, true] {
+            for reassigned in [false, true] {
+                let (mut worker, mut receiver) = worker_of(committing(builder_for(group())));
+                assert!(worker.apply(grouped(&worker, SESSION, VERSION, vec![vec![0]])));
+                worker.positions.insert(0, next_at(0, 5));
+                worker.refuse(&[0], IggyError::FetchProgressUncertain(OPERATION));
+                let completed = if fetch {
+                    reply(
+                        &worker,
+                        1,
+                        vec![(0, next_at(0, 5), worker.generation(0))],
+                        Ok(sections(&[served(0, &[7, 8], 9)])),
+                    )
+                } else {
+                    polled(
+                        &worker,
+                        FetchKind::Alone,
+                        next_at(0, 5),
+                        0,
+                        Ok(batch(0, [7, 8], 8)),
+                    )
+                };
+                worker.complete(completed);
+                assert!(receiver.is_empty());
+                if reassigned {
+                    assert!(worker.apply(grouped(&worker, SESSION, VERSION + 1, Vec::new())));
+                } else {
+                    worker.refuse(&[0], IggyError::ConsumerGroupPartitionNotOwned(1, 0));
+                }
+                worker.flush_errors();
+                let error = next_error(&mut receiver);
+                assert_eq!(error.error, IggyError::FetchProgressUncertain(OPERATION));
+                assert_eq!(error.partition_id, Some(0));
+                assert_eq!(error.skip, Some(at(0, 7)));
+                assert_eq!(offsets(&next_batches(&mut receiver)[0]), [7, 8]);
+                assert!(worker.catch_up.is_empty());
+            }
+        }
+    }
+
+    #[tokio::test]
+    async fn given_a_partial_catch_up_when_closing_should_rewind_over_the_retained_batch() {
+        let (client, cluster) = connect_to_one_replica().await;
+        let (mut worker, mut receiver) = worker_of(
+            committing(builder_on(client, Consumer::default()).partitions([0]))
+                .prefetch_messages(1),
+        );
+        assert!(worker.apply(assigned(&worker, SESSION, VERSION, [0])));
+        worker.positions.insert(0, next_at(0, 5));
+        worker.refuse(&[0], IggyError::FetchProgressUncertain(OPERATION));
+        worker.complete(polled(
+            &worker,
+            FetchKind::Alone,
+            next_at(0, 5),
+            0,
+            Ok(batch(0, [7, 8], 8)),
+        ));
+        worker.complete(polled(
+            &worker,
+            FetchKind::Alone,
+            at(0, 5),
+            0,
+            Ok(batch(0, [5], 8)),
+        ));
+        let delivered = next_batches(&mut receiver).remove(0).checkpoint().unwrap();
+        worker.shared.progress.deliver(&delivered);
+        worker.shared.progress.record_commit(delivered);
+        worker.finish_fetches().await;
+        worker
+            .shared
+            .progress
+            .rewind(Vec::new(), None, true)
+            .await
+            .unwrap();
+        assert_eq!(stored_offsets(&cluster), [5]);
+    }
+
+    #[tokio::test]
+    async fn given_a_partly_recovered_batch_when_ownership_changes_should_deliver_only_its_unread_messages()
+     {
+        let (mut worker, mut receiver) =
+            worker_of(committing(standalone([0])).prefetch_messages(1));
+        assert!(worker.apply(assigned(&worker, SESSION, VERSION, [0])));
+        worker.positions.insert(0, next_at(0, 5));
+        worker.refuse(&[0], IggyError::FetchProgressUncertain(OPERATION));
+        worker.complete(polled(
+            &worker,
+            FetchKind::Alone,
+            next_at(0, 5),
+            0,
+            Ok(batch(0, [7, 8], 8)),
+        ));
+        assert_eq!(worker.capacity.available_permits(), 0);
+        worker.complete(polled(
+            &worker,
+            FetchKind::Alone,
+            at(0, 5),
+            0,
+            Ok(batch(0, [5, 6, 7], 8)),
+        ));
+        assert_eq!(offsets(&next_batches(&mut receiver)[0]), [5, 6, 7]);
+        worker.refuse(&[0], IggyError::ConsumerGroupPartitionNotOwned(1, 0));
+        assert_eq!(offsets(&next_batches(&mut receiver)[0]), [8]);
+        worker.flush_errors();
+        assert!(receiver.is_empty());
+        assert_eq!(worker.capacity.available_permits(), 1);
+        assert_eq!(worker.recovery_capacity.available_permits(), 1);
+    }
+
+    /// The lost read still blocks its partition on the client, so the reader abandons it also
+    /// when a seek replaced its generation, or a new epoch its fetch state.
+    #[tokio::test]
+    async fn given_a_lost_committing_read_of_a_replaced_generation_when_completed_should_still_abandon_it()
+     {
+        for (case, fetch) in [
+            ("poll after a seek", false),
+            ("fetch section after a seek", true),
+        ] {
+            let (mut worker, _receiver) = worker_of(committing(builder_for(group())));
+            assert!(worker.apply(grouped(&worker, SESSION, VERSION, vec![vec![0]])));
+            worker.positions.insert(0, next_at(0, 5));
+            let completed = if fetch {
+                let mut lost = sections(&[served(0, &[], 5)]);
+                lost[0].status = Err(IggyError::FetchProgressUncertain(OPERATION));
+                reply(
+                    &worker,
+                    1,
+                    vec![(0, next_at(0, 5), worker.generation(0))],
+                    Ok(lost),
+                )
+            } else {
+                let Completed::Poll(mut completed) = polled(
+                    &worker,
+                    FetchKind::Alone,
+                    next_at(0, 5),
+                    0,
+                    Ok(batch(0, [], 5)),
+                ) else {
+                    panic!("a poll");
+                };
+                completed.result = Err(DecodeError::without_record(
+                    IggyError::FetchProgressUncertain(OPERATION),
+                ));
+                Completed::Poll(completed)
+            };
+            worker.shared.progress.seek(0, PollingStrategy::offset(9));
+            worker.apply_seeks();
+
+            worker.complete(completed);
+
+            assert_eq!(
+                worker.abandoning,
+                [(OPERATION, vec![0])],
+                "{case}: the lost operation is abandoned"
+            );
+        }
+        let (mut worker, _receiver) = worker_of(committing(builder_for(group())));
+        assert!(worker.apply(grouped(&worker, SESSION, VERSION, vec![vec![0]])));
+        worker.positions.insert(0, next_at(0, 5));
+        let completed = reply(
+            &worker,
+            1,
+            vec![(0, next_at(0, 5), worker.generation(0))],
+            Err(IggyError::FetchProgressUncertain(OPERATION)),
+        );
+        worker.invalidate();
+
+        worker.complete(completed);
+
+        assert_eq!(
+            worker.abandoning,
+            [(OPERATION, vec![0])],
+            "a lost fetch of an older epoch is abandoned"
+        );
+    }
+
+    /// The server stored the offset of a committing read whose batch a seek dropped, so the
+    /// close moves the offset back to the last delivered message.
+    #[tokio::test]
+    async fn given_a_committing_read_whose_batch_a_seek_dropped_when_completed_should_leave_its_store_to_the_close()
+     {
+        for fetch in [false, true] {
+            let (mut worker, receiver) = worker_of(committing(builder_for(group())));
+            assert!(worker.apply(grouped(&worker, SESSION, VERSION, vec![vec![0]])));
+            let delivered = Checkpoint::new(0, 4, PartitionContext::default());
+            worker.shared.progress.deliver(&delivered);
+            worker.shared.progress.record_commit(delivered);
+            worker.positions.insert(0, next_at(0, 5));
+            let completed = if fetch {
+                reply(
+                    &worker,
+                    1,
+                    vec![(0, next_at(0, 5), worker.generation(0))],
+                    Ok(sections(&[served(0, &[5, 6], 7)])),
+                )
+            } else {
+                polled(
+                    &worker,
+                    FetchKind::Alone,
+                    next_at(0, 5),
+                    0,
+                    Ok(batch(0, [5, 6], 6)),
+                )
+            };
+            worker.shared.progress.seek(0, PollingStrategy::offset(20));
+            worker.apply_seeks();
+
+            worker.complete(completed);
+
+            assert!(
+                receiver.is_empty(),
+                "fetch: {fetch}: the seek drops the batch"
+            );
+            assert_eq!(
+                worker.shared.progress.last_committed(0),
+                None,
+                "fetch: {fetch}: the close stores the delivered offset again"
+            );
+        }
+    }
+
+    /// A catch-up reply that does not decode has no message boundary, so the reader reads the
+    /// range one message at a time, and the read of the bad message names it with a skip.
+    #[tokio::test]
+    async fn given_a_catch_up_reply_that_does_not_decode_when_completed_should_read_alone_and_name_the_bad_message()
+     {
+        let (mut worker, _receiver) = worker_of(committing(standalone([0])).prefetch_messages(2));
+        assert!(worker.apply(assigned(&worker, SESSION, VERSION, [0])));
+        worker.positions.insert(0, next_at(0, 5));
+        worker.refuse(&[0], IggyError::FetchProgressUncertain(OPERATION));
+        worker.complete(polled(
+            &worker,
+            FetchKind::Alone,
+            next_at(0, 5),
+            0,
+            Ok(batch(0, [7, 8], 8)),
+        ));
+        worker.retry_at.clear();
+
+        worker.complete(polled(
+            &worker,
+            FetchKind::Alone,
+            at(0, 5),
+            0,
+            Err(IggyError::InvalidNumberEncoding),
+        ));
+
+        assert!(
+            worker.read_alone.contains(&0) && worker.pending_errors.is_empty(),
+            "the range is read one message at a time before any error"
+        );
+        worker.complete(polled(
+            &worker,
+            FetchKind::Alone,
+            at(0, 5),
+            0,
+            Err(IggyError::InvalidNumberEncoding),
+        ));
+        let error = &worker.pending_errors[&Some(0)].error;
+        assert_eq!(
+            (&error.error, error.skip),
+            (&IggyError::InvalidNumberEncoding, Some(at(0, 6)))
+        );
+    }
+
+    /// The history no longer holds the range, so the reader reports it once and reads on after
+    /// the retained batch, instead of reading the same empty range again at once.
+    #[tokio::test]
+    async fn given_a_catch_up_read_that_finds_nothing_when_completed_should_report_the_range_and_read_on_after_it()
+     {
+        let (mut worker, mut receiver) =
+            worker_of(committing(standalone([0])).prefetch_messages(1));
+        assert!(worker.apply(assigned(&worker, SESSION, VERSION, [0])));
+        worker.positions.insert(0, next_at(0, 5));
+        worker.refuse(&[0], IggyError::FetchProgressUncertain(OPERATION));
+        worker.complete(polled(
+            &worker,
+            FetchKind::Alone,
+            next_at(0, 5),
+            0,
+            Ok(batch(0, [7, 8], 8)),
+        ));
+        assert_eq!(worker.catching_up(0), Some(8));
+
+        worker.complete(polled(
+            &worker,
+            FetchKind::Alone,
+            at(0, 5),
+            0,
+            Ok(batch(0, [], 8)),
+        ));
+
+        assert_eq!(worker.catching_up(0), None, "the catch-up ends");
+        let error = next_error(&mut receiver);
+        assert_eq!(
+            (error.partition_id, error.error, error.skip),
+            (
+                Some(0),
+                IggyError::FetchProgressUncertain(OPERATION),
+                Some(at(0, 7))
+            )
+        );
+        assert_eq!(offsets(&next_batches(&mut receiver)[0]), [7, 8]);
+        assert_eq!(worker.position(0), next_at(0, 9));
+    }
+
+    #[tokio::test]
+    async fn given_a_retained_batch_when_seeking_should_discard_it_and_its_lost_range() {
+        let (mut worker, mut receiver) =
+            worker_of(committing(standalone([0])).prefetch_messages(1));
+        assert!(worker.apply(assigned(&worker, SESSION, VERSION, [0])));
+        worker.positions.insert(0, next_at(0, 5));
+        worker.refuse(&[0], IggyError::FetchProgressUncertain(OPERATION));
+        worker.complete(polled(
+            &worker,
+            FetchKind::Alone,
+            next_at(0, 5),
+            0,
+            Ok(batch(0, [7, 8], 8)),
+        ));
+        worker.shared.progress.seek(0, PollingStrategy::offset(20));
+        worker.apply_seeks();
+        worker.refuse(&[0], IggyError::ConsumerGroupPartitionNotOwned(1, 0));
+        worker.flush_errors();
+        assert!(receiver.try_recv().is_err());
+        assert!(worker.retained_batches.is_empty());
+        assert!(worker.catch_up.is_empty());
+        assert_eq!(worker.capacity.available_permits(), 1);
+    }
+
+    #[tokio::test]
+    async fn given_an_interrupted_catch_up_when_its_late_reply_arrives_should_not_deliver_it_after_the_retained_batch()
+     {
+        let (mut worker, mut receiver) =
+            worker_of(committing(standalone([0])).prefetch_messages(1));
+        assert!(worker.apply(assigned(&worker, SESSION, VERSION, [0])));
+        worker.positions.insert(0, next_at(0, 5));
+        worker.refuse(&[0], IggyError::FetchProgressUncertain(OPERATION));
+        worker.complete(polled(
+            &worker,
+            FetchKind::Alone,
+            next_at(0, 5),
+            0,
+            Ok(batch(0, [7, 8], 8)),
+        ));
+        let mut late = polled(
+            &worker,
+            FetchKind::Alone,
+            at(0, 5),
+            0,
+            Ok(batch(0, [5, 6], 8)),
+        );
+        if let Completed::Poll(poll) = &mut late {
+            poll.commit = None;
+        }
+        worker.refuse(&[0], IggyError::ConsumerGroupPartitionNotOwned(1, 0));
+        worker.complete(late);
+        assert!(matches!(
+            next_error(&mut receiver).error,
+            IggyError::FetchProgressUncertain(_)
+        ));
+        assert_eq!(offsets(&next_batches(&mut receiver)[0]), [7, 8]);
+        assert!(receiver.is_empty());
+    }
+
+    #[tokio::test]
+    async fn given_a_lost_on_read_tail_when_the_next_poll_is_empty_should_recover_the_skipped_messages()
+     {
+        let (mut worker, receiver) = worker_of(committing(standalone([0])));
+        assert!(worker.apply(assigned(&worker, SESSION, VERSION, [0])));
+        worker.positions.insert(0, next_at(0, 5));
+        worker.refuse(&[0], IggyError::FetchProgressUncertain(OPERATION));
+        worker.complete(polled(
+            &worker,
+            FetchKind::Alone,
+            next_at(0, 5),
+            0,
+            Ok(batch(0, [], 7)),
+        ));
+        assert_eq!(worker.position(0), at(0, 5));
+        assert_eq!(worker.catching_up(0), Some(7));
+        assert!(receiver.is_empty());
+    }
+
+    #[tokio::test]
+    async fn given_an_on_read_poll_that_skips_offsets_when_completed_should_read_them_again_without_a_commit()
+     {
+        const EXPECTED: u64 = 6;
+        const COMMITTED: u64 = 9;
+        let (client, cluster) = connect_to_one_replica().await;
+        let (mut worker, mut receiver) = worker_of(committing(
+            builder_on(client, Consumer::default()).partitions([0]),
+        ));
+        assert!(worker.apply(assigned(&worker, SESSION, VERSION, [0])));
+        let delivered = Checkpoint::new(0, EXPECTED - 1, PartitionContext::default());
+        worker.shared.progress.deliver(&delivered);
+        worker.shared.progress.record_commit(delivered);
+        worker.positions.insert(0, next_at(0, EXPECTED));
+        worker.refuse(&[0], IggyError::FetchProgressUncertain(OPERATION));
+        worker.retry_at.clear();
+
+        worker.complete(polled(
+            &worker,
+            FetchKind::Alone,
+            next_at(0, EXPECTED),
+            0,
+            Ok(batch(0, [COMMITTED - 1, COMMITTED], COMMITTED + 1)),
+        ));
+
+        assert!(
+            receiver.is_empty(),
+            "the batch waits for the skipped messages"
+        );
+        assert_eq!(worker.position(0), at(0, EXPECTED));
+        assert_eq!(
+            worker.shared.progress.last_committed(0),
+            None,
+            "the server committed messages that never arrived, so the close rewinds"
+        );
+        worker.shared.demand.store(true, ORDERING);
+        worker.schedule();
+        worker.tasks.join_next().await.unwrap().unwrap();
+        let catch_up = DeferredPollMessagesRequest::decode_from(
+            &cluster.received(POLL_MESSAGES_DEFERRED_CODE)[0].body,
+        )
+        .unwrap();
+        assert_eq!(
+            (
+                catch_up.poll.strategy,
+                catch_up.poll.count,
+                catch_up.poll.auto_commit
+            ),
+            (
+                WirePollingStrategy::offset(EXPECTED),
+                u32::try_from(COMMITTED - EXPECTED + 1).unwrap(),
+                false
+            ),
+            "the skipped messages and the batch, by offset, without a commit"
+        );
+        worker.active.clear();
+        worker.complete(polled(
+            &worker,
+            FetchKind::Alone,
+            at(0, EXPECTED),
+            0,
+            Ok(batch(0, EXPECTED..=COMMITTED, COMMITTED + 1)),
+        ));
+        assert_eq!(
+            offsets(&next_batches(&mut receiver)[0]),
+            (EXPECTED..=COMMITTED).collect::<Vec<_>>()
+        );
+        assert_eq!(worker.position(0), next_at(0, COMMITTED + 1));
+        assert!(worker.catch_up.is_empty());
+    }
+
+    /// The application or another session stored an offset ahead of the reader.
+    #[tokio::test]
+    async fn given_no_lost_read_when_a_committing_read_starts_past_the_expected_offset_should_deliver_from_there()
+     {
+        for fetch in [false, true] {
+            let (mut worker, mut receiver) = worker_of(committing(builder_for(group())));
+            assert!(worker.apply(grouped(&worker, SESSION, VERSION, vec![vec![0]])));
+            worker.positions.insert(0, next_at(0, 5));
+            let completed = if fetch {
+                reply(
+                    &worker,
+                    1,
+                    vec![(0, next_at(0, 5), worker.generation(0))],
+                    Ok(sections(&[served(0, &[9, 10], 11)])),
+                )
+            } else {
+                polled(
+                    &worker,
+                    FetchKind::Alone,
+                    next_at(0, 5),
+                    0,
+                    Ok(batch(0, [9, 10], 10)),
+                )
+            };
+
+            worker.complete(completed);
+
+            assert_eq!(
+                offsets(&next_batches(&mut receiver)[0]),
+                [9, 10],
+                "fetch: {fetch}: the read goes on from the stored offset"
+            );
+            assert_eq!(worker.catching_up(0), None);
+            assert_eq!(worker.position(0), next_at(0, 11));
+        }
+    }
+
+    #[tokio::test]
+    async fn given_a_lost_read_when_a_later_committing_read_returns_the_expected_offset_should_accept_the_next_jump()
+     {
+        let (mut worker, mut receiver) = worker_of(committing(standalone([0])));
+        assert!(worker.apply(assigned(&worker, SESSION, VERSION, [0])));
+        worker.positions.insert(0, next_at(0, 5));
+        worker.refuse(&[0], IggyError::FetchProgressUncertain(OPERATION));
+        worker.complete(polled(
+            &worker,
+            FetchKind::Alone,
+            next_at(0, 5),
+            0,
+            Ok(batch(0, [5, 6], 6)),
+        ));
+        assert_eq!(offsets(&next_batches(&mut receiver)[0]), [5, 6]);
+
+        worker.complete(polled(
+            &worker,
+            FetchKind::Alone,
+            next_at(0, 7),
+            0,
+            Ok(batch(0, [12], 12)),
+        ));
+
+        assert_eq!(
+            offsets(&next_batches(&mut receiver)[0]),
+            [12],
+            "the store of the later read fenced out the lost one"
+        );
+        assert_eq!(worker.catching_up(0), None);
+    }
+
+    #[tokio::test]
+    async fn given_a_committing_fetch_that_lost_its_session_when_the_next_read_skips_offsets_should_read_them_again()
+     {
+        let (mut worker, receiver) = worker_of(committing(builder_for(group())));
+        assert!(worker.apply(grouped(&worker, SESSION, VERSION, vec![vec![0]])));
+        worker.positions.insert(0, next_at(0, 5));
+        worker.complete(reply(
+            &worker,
+            1,
+            vec![(0, next_at(0, 5), worker.generation(0))],
+            Err(IggyError::Unauthenticated),
+        ));
+
+        worker.complete(polled(
+            &worker,
+            FetchKind::Alone,
+            next_at(0, 5),
+            0,
+            Ok(batch(0, [8, 9], 9)),
+        ));
+
+        assert!(
+            receiver.is_empty(),
+            "the batch waits for the skipped messages"
+        );
+        assert_eq!(worker.position(0), at(0, 5));
+        assert_eq!(worker.catching_up(0), Some(9));
+    }
+
+    #[tokio::test]
+    async fn given_a_standalone_reader_without_readiness_groups_when_scheduled_should_keep_one_poll_per_partition_within_the_budget()
+     {
+        const PARTITIONS: [u32; 3] = [0, 1, 2];
+        const BUDGET_SLOTS: usize = 2;
+        let budget = PollOptions::default().count * u32::try_from(BUDGET_SLOTS).unwrap();
+        for (builder, polled_partitions) in [
+            (standalone(PARTITIONS), &PARTITIONS[..]),
+            (
+                standalone(PARTITIONS).prefetch_messages(budget),
+                &PARTITIONS[..BUDGET_SLOTS],
+            ),
+        ] {
+            let (mut worker, _receiver) = worker_of(builder);
+            let client = worker.config.client.clone();
+            let _blocked_connection = client.write().await;
+            assert_eq!(
+                worker.config.slots.min(PARTITIONS.len()),
+                polled_partitions.len()
+            );
+            assert!(worker.apply(assigned(&worker, SESSION, VERSION, PARTITIONS)));
+            assert!(worker.groups.is_empty());
+            worker.schedule();
+            worker.schedule();
+            assert_eq!(
+                worker.tasks.len(),
+                polled_partitions.len(),
+                "one waiting poll per partition, within the budget"
+            );
+            assert_eq!(
+                worker.active.keys().copied().collect::<Vec<_>>(),
+                polled_partitions
+            );
+            assert!(
+                worker
+                    .active
+                    .values()
+                    .all(|active| active.kind == FetchKind::Alone)
+            );
+        }
+    }
+
+    /// The server returns a record above `max_bytes` alone in the reply of a poll, so a poll
+    /// refused for its byte limit is final.
+    #[tokio::test]
+    async fn given_a_poll_refused_for_its_byte_limit_when_completed_should_report_it_at_once() {
+        for (builder, kind) in [
+            (standalone([0]), FetchKind::Alone),
+            (committing(standalone([0])), FetchKind::Alone),
+            (standalone([0]), FetchKind::Check),
+        ] {
+            let (mut worker, mut receiver) = worker_of(builder);
+            worker.complete(polled(
+                &worker,
+                kind,
+                worker.position(0),
+                0,
+                Err(IggyError::InvalidSizeBytes),
+            ));
+            worker.flush_errors();
+            assert!(!worker.read_alone.contains(&0), "{kind:?}");
+            assert!(worker.retry_at.contains_key(&0), "{kind:?}");
+            assert_eq!(
+                next_error(&mut receiver),
+                ReadError::new(Some(0), IggyError::InvalidSizeBytes),
+                "{kind:?}"
+            );
+        }
+    }
+
+    /// The reads of one fetch share one turn for a first record above `max_bytes`, so a section
+    /// that lost the turn is read again by a poll of its partition, which always has one.
+    #[tokio::test]
+    async fn given_a_fetch_section_refused_for_its_byte_limit_when_scheduled_should_poll_its_partition_alone()
+     {
+        for builder in [builder_for(group()), committing(builder_for(group()))] {
+            let (mut worker, mut receiver) = worker_of(builder);
+            let on_read = worker.config.on_read();
+            let client = worker.config.client.clone();
+            let _blocked_connection = client.write().await;
+            assert!(worker.apply(grouped(&worker, SESSION, VERSION, vec![vec![0, 1]])));
+            worker.shared.demand.store(true, ORDERING);
+            if worker.config.on_read() {
+                for (partition, offset) in [(0, 0), (1, 0)] {
+                    worker
+                        .positions
+                        .insert(partition, next_at(partition, offset));
+                }
+            }
+            worker.schedule();
+            worker.complete(reply_of(
+                &worker,
+                0,
+                &[refused(0, &IggyError::InvalidSizeBytes)],
+            ));
+            worker.flush_errors();
+            assert!(worker.read_alone.contains(&0));
+            assert!(
+                receiver.try_recv().is_err() && worker.retry_at.is_empty(),
+                "only the poll of the partition reports an error"
+            );
+
+            worker.shared.demand.store(true, ORDERING);
+            worker.schedule();
+            assert_eq!(worker.active[&0].kind, FetchKind::Alone);
+            assert_eq!(
+                worker.active[&1].kind,
+                FetchKind::Main,
+                "on_read {on_read}: the other partitions of the group read beside the record"
+            );
+        }
+    }
+
+    #[tokio::test]
+    async fn new_generation_resumes_after_the_last_delivered_message() {
+        let (mut worker, mut receiver) = worker_of(standalone([0]).start(PollingStrategy::first()));
+        assert!(worker.apply(assigned(&worker, SESSION, VERSION, [0])));
+        for (delivered, resumed) in [(false, PollingStrategy::offset(0)), (true, at(0, 1))] {
+            worker.complete(polled(
+                &worker,
+                FetchKind::Alone,
+                worker.position(0),
+                0,
+                Ok(batch(0, [0, 1, 2], 9)),
+            ));
+            let batches = next_batches(&mut receiver);
+            if delivered {
+                worker
+                    .shared
+                    .progress
+                    .deliver(&batches[0].checkpoint_at(0).unwrap());
+            }
+            let stale = assigned(&worker, SESSION, VERSION, [0]);
+            worker.invalidate();
+            assert!(
+                !worker.apply(stale),
+                "a refresh from before the new epoch is read again"
+            );
+            assert!(worker.partitions.is_empty());
+            assert!(worker.apply(assigned(&worker, SESSION, VERSION, [0])));
+            assert_eq!(worker.partitions, [0]);
+            assert_eq!(
+                worker.position(0),
+                resumed,
+                "the batch that the application did not take is fetched again"
+            );
+        }
+    }
+
+    #[tokio::test]
+    async fn rebalance_keeps_the_position_of_a_partition_that_stays_with_the_member() {
+        const NEW_SESSION: u64 = SESSION + 1;
+        let (mut worker, mut receiver) =
+            worker_of(builder_for(group()).start(PollingStrategy::first()));
+        let progress = Arc::clone(&worker.shared.progress);
+        let generation = |partition| progress.generation(partition);
+        assert!(worker.apply(assigned(&worker, SESSION, VERSION, [0, 1, 2])));
+        for partition in [0, 2] {
+            worker.complete(polled(
+                &worker,
+                FetchKind::Alone,
+                worker.position(partition),
+                partition,
+                Ok(batch(partition, [0, 1, 2], 9)),
+            ));
+            let delivered = next_batches(&mut receiver)[0].checkpoint_at(0).unwrap();
+            progress.deliver(&delivered);
+        }
+        let before = [0, 1, 2].map(generation);
+
+        assert!(worker.apply(assigned(&worker, SESSION, VERSION + 1, [0, 1, 3])));
+        assert_eq!(
+            worker.position(0),
+            at(0, 1),
+            "the partition continues after its last delivered message"
+        );
+        assert_eq!(
+            [generation(0), generation(1)],
+            [before[0], before[1]],
+            "a partition that stays keeps its generation"
+        );
+        assert_eq!(
+            generation(2),
+            None,
+            "a revoked partition loses its generation"
+        );
+        assert!(
+            generation(3).is_some() && !before.contains(&generation(3)),
+            "a newly assigned partition starts a new generation"
+        );
+        assert!(
+            !worker.positions.contains_key(&2),
+            "a partition that left starts over when it comes back"
+        );
+
+        assert!(worker.apply(assigned(&worker, SESSION, VERSION + 2, [0, 1, 2, 3])));
+        assert!(
+            generation(2).is_some() && generation(2) != before[2],
+            "a partition that comes back starts a new generation"
+        );
+
+        assert!(worker.apply(assigned(&worker, NEW_SESSION, VERSION + 2, [0, 1, 2, 3])));
+        assert_eq!(
+            worker.position(0),
+            PollingStrategy::offset(0),
+            "a new session owns every partition anew"
+        );
+        assert_ne!(generation(0), before[0], "in a new generation");
+    }
+
+    #[tokio::test]
+    async fn committing_member_retries_a_lost_fetch_until_the_partition_changes_owner() {
+        let (mut worker, mut receiver) = worker_of(committing(builder_for(group())));
+        let client = worker.config.client.clone();
+        let _blocked_connection = client.write().await;
+        assert!(worker.apply(grouped(&worker, SESSION, VERSION, vec![vec![0], vec![1]])));
+        worker.shared.demand.store(true, ORDERING);
+        worker.schedule();
+        worker.complete(reply_of(&worker, 0, &[served(0, &[3, 4], 5)]));
+        assert_eq!(offsets(&next_batches(&mut receiver)[0]), [3, 4]);
+        let retried = worker.position(0);
+
+        worker.complete(failure_of(
+            &worker,
+            1,
+            IggyError::FetchProgressUncertain(OPERATION),
+        ));
+        assert!(
+            worker.retry_at.contains_key(&1)
+                && !worker.stopped.contains(&1)
+                && worker.shared.progress.blocked(1).is_none(),
+            "the next read of a lost reply finds what it committed"
+        );
+        worker.flush_errors();
+        assert!(receiver.is_empty());
+
+        worker.shared.demand.store(true, ORDERING);
+        worker.schedule();
+        worker.complete(failure_of(&worker, 0, IggyError::TransientNotCommitted));
+        assert!(
+            worker.retry_at.contains_key(&0)
+                && !worker.stopped.contains(&0)
+                && worker.shared.progress.blocked(0).is_none(),
+            "a lost reply is a transient, so the partition retries"
+        );
+        worker.flush_errors();
+        assert_eq!(
+            next_error(&mut receiver),
+            ReadError::new(Some(0), IggyError::TransientNotCommitted)
+        );
+
+        assert!(worker.apply(grouped(&worker, SESSION, VERSION + 1, vec![vec![0]])));
+        assert_eq!(
+            worker.position(0),
+            retried,
+            "`Next` would start after the batches that the server may have committed"
+        );
+
+        worker.shared.demand.store(true, ORDERING);
+        worker.schedule();
+        worker.complete(reply_of(
+            &worker,
+            0,
+            &[refused(0, &IggyError::ConsumerGroupPartitionNotOwned(1, 0))],
+        ));
+        assert_eq!(
+            worker.position(0),
+            PollingStrategy::next(),
+            "a partition that moved away and back resolves its position again"
+        );
+        assert!(worker.refresh_now);
+        worker.flush_errors();
+        assert!(receiver.is_empty(), "a moved partition reports nothing");
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_a_message_that_does_not_decrypt_when_polled_should_deliver_the_messages_before_it_and_skip_it_after_a_seek()
+     {
+        const FAILED: u64 = 4;
+        let encryptor = encryptor();
+        let (mut worker, mut receiver) = encrypted_worker(standalone([0]), &encryptor);
+        let client = worker.config.client.clone();
+        let _blocked_connection = client.write().await;
+        assert!(worker.apply(assigned(&worker, SESSION, VERSION, [0])));
+        worker.complete(polled(
+            &worker,
+            FetchKind::Alone,
+            worker.position(0),
+            0,
+            Ok(sealed_batch(&encryptor, &[3, FAILED, 5], Some(FAILED))),
+        ));
+        assert_eq!(
+            offsets(&next_batches(&mut receiver)[0]),
+            [3],
+            "the messages before the failed one arrive first"
+        );
+        worker.flush_errors();
+        let skip = at(0, FAILED + 1);
+        assert_eq!(
+            next_error(&mut receiver),
+            ReadError {
+                partition_id: Some(0),
+                error: IggyError::CannotDecryptData,
+                skip: Some(skip),
+                offset: None,
+                record: None
+            }
+        );
+        assert_eq!(
+            worker.position(0),
+            at(0, FAILED),
+            "the partition reads the failed message again"
+        );
+        assert_eq!(
+            worker.retry_at.get(&0).copied(),
+            Some(Instant::now() + worker.config.retry_interval)
+        );
+
+        advance(worker.config.retry_interval).await;
+        worker
+            .retry_at
+            .retain(|_, deadline| *deadline > Instant::now());
+        worker.schedule();
+        assert!(
+            worker.active.contains_key(&0),
+            "the partition retries after the retry interval"
+        );
+        worker.complete(polled(
+            &worker,
+            FetchKind::Alone,
+            at(0, FAILED),
+            0,
+            Ok(sealed_batch(&encryptor, &[FAILED, 5], Some(FAILED))),
+        ));
+        worker.flush_errors();
+        assert_eq!(
+            next_error(&mut receiver).skip,
+            Some(skip),
+            "the message fails again until a seek"
+        );
+        assert!(receiver.is_empty());
+
+        worker.shared.progress.seek(0, skip);
+        worker.apply_seeks();
+        assert!(!worker.retry_at.contains_key(&0), "a seek ends the retry");
+        worker.complete(polled(
+            &worker,
+            FetchKind::Alone,
+            skip,
+            0,
+            Ok(sealed_batch(&encryptor, &[5], None)),
+        ));
+        assert_eq!(
+            offsets(&next_batches(&mut receiver)[0]),
+            [5],
+            "the seek reads on after the failed message"
+        );
+        assert_eq!(worker.position(0), at(0, 6));
+    }
+
+    #[tokio::test]
+    async fn committing_member_sends_one_fetch_per_group_on_demand() {
+        let (mut worker, mut receiver) = worker_of(committing(builder_for(group())));
+        let client = worker.config.client.clone();
+        let _blocked_connection = client.write().await;
+        assert!(worker.apply(grouped(
+            &worker,
+            SESSION,
+            VERSION,
+            vec![vec![0, 2], vec![1]]
+        )));
+        if worker.config.on_read() {
+            for (partition, offset) in [(0, 0), (1, 0), (2, 0)] {
+                worker
+                    .positions
+                    .insert(partition, next_at(partition, offset));
+            }
+        }
+        worker.schedule();
+        assert!(
+            worker.tasks.is_empty(),
+            "no fetch before the application asks"
+        );
+        worker.shared.demand.store(true, ORDERING);
+        worker.schedule();
+        worker.schedule();
+        assert_eq!(worker.tasks.len(), 2, "one fetch per primary group");
+        assert_eq!(fetch_of(&worker, 0), fetch_of(&worker, 2));
+
+        worker.complete(failure_of(&worker, 1, IggyError::TransientNotAccepted));
+        assert!(worker.retry_at.contains_key(&1), "the partition retries");
+        worker.flush_errors();
+        assert!(
+            receiver.is_empty(),
+            "a refused committing fetch stored nothing, so it retries without an error"
+        );
+        worker.shared.demand.store(true, ORDERING);
+        worker.schedule();
+        assert!(
+            !worker.active.contains_key(&1),
+            "a refused partition waits for its retry"
+        );
+
+        let epoch = worker.epoch;
+        worker.complete(reply_of(
+            &worker,
+            0,
+            &[
+                served(0, &[0], 1),
+                refused(2, &IggyError::ConsumerGroupPartitionNotOwned(1, 2)),
+            ],
+        ));
+        assert_eq!(
+            worker.epoch, epoch,
+            "a moved partition keeps the other sections of its reply"
+        );
+        assert!(
+            worker.refresh_now && !worker.positions.contains_key(&2),
+            "a moved partition refreshes the assignment at once"
+        );
+        assert_eq!(
+            next_batches(&mut receiver)[0].partition_id,
+            0,
+            "the server committed the batch, so the application gets it"
+        );
+        worker.flush_errors();
+        assert!(receiver.is_empty(), "a moved partition reports nothing");
+    }
+
+    #[tokio::test]
+    async fn group_fetch_starts_the_next_fetch_from_the_section_table() {
+        let (mut worker, mut receiver) = fetching_worker();
+        let client = worker.config.client.clone();
+        let _blocked_connection = client.write().await;
+        let partitions = worker.partitions.clone();
+        worker.schedule();
+        assert_eq!(
+            worker.tasks.len(),
+            1,
+            "one fetch reads every partition of the group"
+        );
+        let first = fetch_of(&worker, 0);
+        assert!(
+            partitions
+                .iter()
+                .all(|partition| fetch_of(&worker, *partition) == first)
+        );
+
+        worker.complete(reply_of(&worker, 3, &[served(3, &[5, 6], 7)]));
+        let next = fetch_of(&worker, 3);
+        assert!(
+            next != first
+                && partitions
+                    .iter()
+                    .all(|partition| fetch_of(&worker, *partition) == next),
+            "the next fetch reads every partition again"
+        );
+        assert_eq!(worker.position(3), at(3, 7));
+        let batches = next_batches(&mut receiver);
+        assert_eq!(
+            (batches[0].partition_id, offsets(&batches[0])),
+            (3, vec![5, 6])
+        );
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn partitions_that_turn_idle_share_a_second_fetch_until_the_main_fetch_ends() {
+        let (mut worker, _receiver) = fetching_worker();
+        let client = worker.config.client.clone();
+        let _blocked_connection = client.write().await;
+        worker.retry(&[0, 1]);
+        worker.schedule();
+        let main = worker.active[&2].task.id();
+        assert!(!worker.active.contains_key(&0));
+
+        worker.retry_at.remove(&0);
+        worker.schedule();
+        let first = worker.active[&0].task.id();
+        assert!(
+            first != main && worker.active[&0].kind == FetchKind::Second,
+            "a partition that turns idle while the main fetch parks goes into a second fetch"
+        );
+        assert!(
+            timeout(Duration::from_millis(1), worker.tasks.join_next())
+                .await
+                .is_err(),
+            "a second fetch cancels nothing"
+        );
+
+        worker.retry_at.remove(&1);
+        worker.schedule();
+        let second = worker.active[&1].task.id();
+        assert_eq!(
+            worker.active[&0].task.id(),
+            second,
+            "partitions that turn idle share the second fetch"
+        );
+        assert_eq!(
+            worker.active[&2].task.id(),
+            main,
+            "the main fetch keeps its partitions"
+        );
+        let replaced = worker.tasks.join_next_with_id().await.unwrap();
+        assert!(
+            replaced.is_err_and(|error| error.is_cancelled() && error.id() == first),
+            "a newly idle partition restarts only the second fetch"
+        );
+
+        worker.complete(reply_of(&worker, 2, &[]));
+        let next = fetch_of(&worker, 0);
+        assert!(
+            worker.active[&0].kind == FetchKind::Main
+                && worker
+                    .partitions
+                    .iter()
+                    .all(|partition| fetch_of(&worker, *partition) == next),
+            "the next main fetch takes every idle partition"
+        );
+        let replaced = worker.tasks.join_next_with_id().await.unwrap();
+        assert!(
+            replaced.is_err_and(|error| error.is_cancelled() && error.id() == second),
+            "the next main fetch cancels the second"
+        );
+    }
+
+    #[tokio::test]
+    async fn changed_groups_restart_only_the_fetches_that_commit_nothing() {
+        for builder in [builder_for(group()), committing(builder_for(group()))] {
+            let (mut worker, _receiver) = worker_of(builder);
+            let on_read = worker.config.on_read();
+            let client = worker.config.client.clone();
+            let _blocked_connection = client.write().await;
+            worker.shared.demand.store(true, ORDERING);
+            assert!(worker.apply(grouped(&worker, SESSION, VERSION, vec![vec![0, 1]])));
+            if worker.config.on_read() {
+                for (partition, offset) in [(0, 0), (1, 0)] {
+                    worker
+                        .positions
+                        .insert(partition, next_at(partition, offset));
+                }
+            }
+            worker.schedule();
+            let fetch = worker.active[&0].task.id();
+            assert!(worker.apply(grouped(&worker, SESSION, VERSION, vec![vec![0, 1]])));
+            worker.schedule();
+            assert_eq!(
+                worker.active[&0].task.id(),
+                fetch,
+                "a refresh that keeps the groups keeps the fetches"
+            );
+
+            assert!(worker.apply(grouped(&worker, SESSION, VERSION, vec![vec![0], vec![1]])));
+            worker.schedule();
+            if on_read {
+                assert!(
+                    worker.active[&0].task.id() == fetch && worker.tasks.len() == 1,
+                    "a committing fetch runs on, and its partitions wait for its reply"
+                );
+            } else {
+                assert!(
+                    worker.active[&0].task.id() != fetch
+                        && fetch_of(&worker, 0) != fetch_of(&worker, 1),
+                    "the fetches start over in the new groups"
+                );
+            }
+
+            worker.invalidate();
+            assert_eq!(
+                worker.tasks.len(),
+                usize::from(on_read),
+                "a rewind cancels only the fetches that commit nothing"
+            );
+        }
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn committing_shutdown_waits_for_its_fetch_and_cancels_its_checks() {
+        let (mut worker, receiver) = worker_of(committing(builder_for(group())));
+        let client = worker.config.client.clone();
+        let _blocked_connection = client.write().await;
+        assert!(worker.apply(grouped(&worker, SESSION, VERSION, vec![vec![0, 1]])));
+        worker.positions.insert(1, PollingStrategy::last());
+        let delivered = Checkpoint::new(0, 4, PartitionContext::default());
+        worker.shared.progress.deliver(&delivered);
+        worker.shared.progress.record_commit(delivered);
+        worker.shared.demand.store(true, ORDERING);
+        worker.positions.insert(0, next_at(0, 5));
+        worker.schedule();
+        assert_eq!(worker.active[&0].kind, FetchKind::Main);
+        assert_eq!(
+            worker.active[&1].kind,
+            FetchKind::Check,
+            "a last start resolves with a poll that commits nothing"
+        );
+
+        let started = Instant::now();
+        worker.finish_fetches().await;
+        assert!(worker.tasks.is_empty());
+        assert_eq!(
+            started.elapsed(),
+            worker.request_timeout(),
+            "close waits for the committing fetch until its deadline and no longer"
+        );
+        assert_eq!(
+            worker.shared.progress.last_committed(0),
+            Some(delivered),
+            "a fetch that never left the client hides no commit"
+        );
+        worker.flush_errors();
+        assert!(
+            receiver.is_empty(),
+            "a committing fetch that stored nothing retries without an error"
+        );
+        assert!(
+            !worker.retry_at.contains_key(&1),
+            "close cancels the check, which commits nothing"
+        );
+    }
+
+    #[tokio::test]
+    async fn served_targets_go_last_and_next_targets_continue_from_an_offset() {
+        let (mut worker, _receiver) = fetching_worker();
+        let client = worker.config.client.clone();
+        let _blocked_connection = client.write().await;
+        let partitions = worker.partitions.clone();
+        let order = |worker: &Worker| -> Vec<u32> {
+            worker
+                .fetch_targets(partitions.clone())
+                .into_iter()
+                .map(|(partition, ..)| partition)
+                .collect()
+        };
+        worker.schedule();
+        worker.complete(reply_of(
+            &worker,
+            0,
+            &[served(0, &[3, 4], 5), served(1, &[], 9)],
+        ));
+        assert_eq!(worker.position(0), at(0, 5));
+        assert_eq!(
+            worker.position(1),
+            at(1, 9),
+            "a `Next` target continues from its section, also without a read"
+        );
+        assert_eq!(
+            worker.position(2),
+            PollingStrategy::next(),
+            "a target without a section keeps its position"
+        );
+        let first = order(&worker);
+        assert!(
+            first.last() == Some(&0) && first[..2] == [1, 2],
+            "a served target goes to the end of the next fetch"
+        );
+
+        worker.complete(reply_of(&worker, 1, &[served(1, &[9], 10)]));
+        assert_eq!(
+            order(&worker)[partitions.len() - 2..],
+            [0, 1],
+            "the target served last goes last"
+        );
+    }
+
+    #[tokio::test]
+    async fn refused_sections_resync_only_their_partitions() {
+        let (mut worker, mut receiver) = fetching_worker();
+        let client = worker.config.client.clone();
+        let _blocked_connection = client.write().await;
+        for (partition, offset) in [(1, 11), (2, 21), (3, 31), (4, 41)] {
+            worker.positions.insert(partition, at(partition, offset));
+        }
+        worker.schedule();
+        let epoch = worker.epoch;
+        worker.complete(reply_of(
+            &worker,
+            0,
+            &[
+                served(0, &[0], 1),
+                refused(1, &IggyError::HistoryUnavailable),
+                refused(2, &IggyError::NotPrimary),
+                refused(4, &IggyError::ConsumerGroupPartitionNotOwned(1, 4)),
+            ],
+        ));
+        assert_eq!(
+            next_batches(&mut receiver)[0].partition_id,
+            0,
+            "the healthy section reaches the application"
+        );
+        assert_eq!(
+            worker.epoch, epoch,
+            "a moved partition keeps the other sections of its reply"
+        );
+        worker.flush_errors();
+        assert_eq!(
+            next_error(&mut receiver),
+            ReadError::new(Some(1), IggyError::HistoryUnavailable),
+            "a lost history reaches the application"
+        );
+        assert!(
+            receiver.is_empty(),
+            "a moved primary and a moved partition report nothing"
+        );
+        assert!(
+            !worker.positions.contains_key(&1)
+                && worker.stopped.contains(&1)
+                && !worker.retry_at.contains_key(&1),
+            "a lost history stops its partition until a seek"
+        );
+        assert!(
+            !worker.positions.contains_key(&4),
+            "a moved partition resolves its start again"
+        );
+        assert!(
+            worker.refresh_now,
+            "a moved primary groups the partitions again"
+        );
+        assert!(worker.retry_at.contains_key(&2) && worker.retry_at.contains_key(&4));
+        assert_eq!(
+            worker.position(3),
+            at(3, 31),
+            "a target without a section keeps its position"
+        );
+        assert!(
+            worker.active.contains_key(&3) && !worker.active.contains_key(&1),
+            "the next fetch leaves out only the refused partitions"
+        );
+    }
+
+    #[tokio::test]
+    async fn refused_fetch_probes_each_target_alone() {
+        let (mut worker, mut receiver) = fetching_worker();
+        let client = worker.config.client.clone();
+        let _blocked_connection = client.write().await;
+        let partitions = worker.partitions.clone();
+        worker.positions.insert(4, at(4, 21));
+        worker.positions.insert(6, at(6, 31));
+        worker.schedule();
+        worker.complete(failure_of(&worker, 0, IggyError::HistoryUnavailable));
+        assert_eq!(
+            worker.probing,
+            partitions.iter().copied().collect(),
+            "a refusal before the first read names no target, so each one probes alone"
+        );
+        worker.flush_errors();
+        assert!(receiver.is_empty(), "only a probe reports the lost history");
+        assert_eq!(worker.positions.get(&4), Some(&at(4, 21)));
+
+        worker.complete(polled(
+            &worker,
+            FetchKind::Check,
+            at(4, 21),
+            4,
+            Err(IggyError::HistoryUnavailable),
+        ));
+        assert!(
+            !worker.positions.contains_key(&4) && worker.stopped.contains(&4),
+            "a lost history stops its partition until a seek"
+        );
+        worker.flush_errors();
+        assert_eq!(
+            next_error(&mut receiver),
+            ReadError::new(Some(4), IggyError::HistoryUnavailable)
+        );
+
+        let epoch = worker.epoch;
+        worker.complete(polled(
+            &worker,
+            FetchKind::Check,
+            at(6, 31),
+            6,
+            Err(IggyError::ConsumerGroupPartitionNotOwned(1, 6)),
+        ));
+        assert!(
+            !worker.positions.contains_key(&6)
+                && worker.retry_at.contains_key(&6)
+                && worker.refresh_now
+                && worker.epoch == epoch,
+            "a moved partition resolves its start after the refresh"
+        );
+        worker.complete(polled(
+            &worker,
+            FetchKind::Check,
+            PollingStrategy::next(),
+            5,
+            Ok(batch(5, [], 0)),
+        ));
+        worker.flush_errors();
+        assert!(
+            receiver.is_empty(),
+            "neither a moved partition nor a probe delivers"
+        );
+
+        worker.schedule();
+        assert_eq!(
+            worker.active[&5].kind,
+            FetchKind::Main,
+            "a partition that passed its probe joins the group fetch again"
+        );
+        assert!(
+            worker
+                .active
+                .iter()
+                .all(|(partition, active)| *partition == 5 || active.kind == FetchKind::Check),
+            "a probing partition is left out of the group fetch"
+        );
+    }
+
+    #[tokio::test]
+    async fn fetched_batch_that_does_not_decrypt_keeps_its_position() {
+        let (mut worker, mut receiver) =
+            in_one_group(encrypted_worker(builder_for(group()), &encryptor()));
+        let client = worker.config.client.clone();
+        let _blocked_connection = client.write().await;
+        worker.positions.insert(0, PollingStrategy::offset(3));
+        worker.schedule();
+        worker.complete(reply_of(
+            &worker,
+            0,
+            &[served(0, &[3, 4], 5), served(1, &[], 9)],
+        ));
+        assert_eq!(
+            worker.position(0),
+            at(0, 3),
+            "a batch that does not decrypt keeps its position"
+        );
+        assert!(
+            worker.retry_at.contains_key(&0) && !worker.active.contains_key(&0),
+            "the next fetch leaves the partition out until its retry"
+        );
+        assert_eq!(worker.position(1), at(1, 9));
+        worker.flush_errors();
+        assert_eq!(
+            next_error(&mut receiver),
+            ReadError {
+                partition_id: Some(0),
+                error: IggyError::CannotDecryptData,
+                skip: Some(at(0, 4)),
+                offset: Some(3),
+                record: Some(Bytes::from(vec![0; BATCH_MESSAGE_HEADER_SIZE])),
+            },
+            "the error names the message and carries its record as served"
+        );
+        assert!(receiver.is_empty());
+    }
+
+    #[tokio::test]
+    async fn given_undecodable_healthy_data_beside_unknown_progress_should_keep_its_start_until_retry_succeeds()
+     {
+        let (mut worker, mut receiver) = worker_of(committing(builder_for(group())));
+        let client = worker.config.client.clone();
+        let _blocked_connection = client.write().await;
+        assert!(worker.apply(grouped(&worker, SESSION, VERSION, vec![vec![0, 1]])));
+        let context = PartitionContext {
+            incarnation: 3,
+            owner_generation: 4,
+            metadata_op: 5,
+        };
+        let delivered = Checkpoint::new(0, 6, context);
+        worker.shared.progress.deliver(&delivered);
+        worker.shared.progress.record_commit(delivered);
+        worker.shared.demand.store(true, ORDERING);
+        if worker.config.on_read() {
+            for (partition, offset) in [(0, 7), (1, 0)] {
+                worker
+                    .positions
+                    .insert(partition, next_at(partition, offset));
+            }
+        }
+        worker.schedule();
+        let fetch = fetch_of(&worker, 0);
+        let (mut header, _) = served(0, &[7], 8);
+        header.context = context;
+        header.records_len = 1;
+        let mut response = sections(&[
+            (header, vec![0]),
+            refused(1, &IggyError::TransientNotCommitted),
+        ]);
+        response[1].status = Err(IggyError::FetchProgressUncertain(OPERATION));
+        worker.complete(reply(
+            &worker,
+            fetch,
+            targets_of(&worker, fetch),
+            Ok(response),
+        ));
+        let start = worker.shared.progress.undecoded(0).unwrap();
+        assert_eq!((start.offset(), start.context()), (7, context));
+        assert_eq!(
+            worker.shared.progress.undecoded(1),
+            Some(Checkpoint::new(1, 0, PartitionContext::default()))
+        );
+        assert_eq!(
+            worker.position(0),
+            continuation_at(0, 7, context),
+            "the committed records are read again from their start"
+        );
+        assert_eq!(
+            worker.shared.progress.last_committed(0),
+            None,
+            "the server committed records that never arrived, so the close stores the delivered offset again"
+        );
+        assert_eq!(worker.shared.progress.blocked(1), None);
+        assert_eq!(worker.abandoning, [(OPERATION, vec![1])]);
+        worker.flush_errors();
+        let undecoded = next_error(&mut receiver);
+        assert_eq!(
+            (undecoded.partition_id, undecoded.skip),
+            (Some(0), Some(continuation_at(0, 8, context)))
+        );
+        assert!(
+            receiver.is_empty(),
+            "a lost section is no error of the reader"
+        );
+
+        worker.retry_at.clear();
+        worker.shared.demand.store(true, ORDERING);
+        worker.schedule();
+        let (mut retry, records) = served(0, &[7], 8);
+        retry.context = context;
+        worker.complete(reply_of(&worker, 0, &[(retry, records)]));
+        assert_eq!(
+            offsets(&next_batches(&mut receiver)[0]),
+            [7],
+            "the retry from the start delivers the records"
+        );
+        assert_eq!(
+            worker.position(0),
+            PollingStrategy::continuation(0, PollingKind::Next, 8, context)
+        );
+        assert_eq!(
+            worker.shared.progress.undecoded(0),
+            None,
+            "a successful retry removes the saved start"
+        );
+
+        worker.shared.progress.remember_undecoded(start);
+        worker.shared.demand.store(true, ORDERING);
+        worker.schedule();
+        let (mut recreated, records) = served(0, &[0], 1);
+        recreated.context = PartitionContext {
+            incarnation: context.incarnation + 1,
+            ..context
+        };
+        worker.complete(reply_of(&worker, 0, &[(recreated, records)]));
+        assert_eq!(
+            worker.shared.progress.undecoded(0),
+            None,
+            "a new incarnation must not reuse the old rewind context"
+        );
+    }
+
+    #[tokio::test]
+    async fn section_that_does_not_decode_is_read_again_from_its_start() {
+        for stale in [served(0, &[5], 6), refused(0, &IggyError::InvalidSizeBytes)] {
+            let (mut worker, mut receiver) = fetching_worker();
+            let client = worker.config.client.clone();
+            let _blocked_connection = client.write().await;
+            worker.positions.insert(0, PollingStrategy::offset(3));
+            worker.schedule();
+            let (mut header, records) = served(0, &[3], 5);
+            header.messages_count = 2;
+            worker.complete(reply_of(&worker, 0, &[(header, records)]));
+            let start = at(0, 3);
+            assert_eq!(
+                worker.position(0),
+                start,
+                "the records are read again from their start"
+            );
+            assert!(worker.read_alone.contains(&0));
+            worker.flush_errors();
+            assert!(
+                receiver.is_empty(),
+                "a failure of two messages names neither of them"
+            );
+
+            let fetch = fetch_of(&worker, 0);
+            worker.complete(reply(
+                &worker,
+                fetch,
+                vec![(0, at(0, 5), worker.generation(0))],
+                Ok(sections(&[stale])),
+            ));
+            assert_eq!(
+                worker.position(0),
+                start,
+                "a fetch sent before the failure reads stale"
+            );
+            worker.flush_errors();
+            assert!(receiver.is_empty());
+            assert_eq!(worker.active[&0].kind, FetchKind::Alone);
+
+            worker.complete(polled(
+                &worker,
+                FetchKind::Alone,
+                start,
+                0,
+                Err(IggyError::InvalidMessagesCount),
+            ));
+            assert_eq!(worker.position(0), start);
+            assert!(
+                worker.read_alone.contains(&0) && worker.retry_at.contains_key(&0),
+                "the retry reads the message alone again"
+            );
+            worker.flush_errors();
+            assert_eq!(
+                next_error(&mut receiver),
+                ReadError {
+                    partition_id: Some(0),
+                    error: IggyError::InvalidMessagesCount,
+                    skip: Some(at(0, 4)),
+                    offset: None,
+                    record: None,
+                }
+            );
+        }
+    }
+
+    #[tokio::test]
+    async fn given_a_section_that_does_not_decode_when_its_messages_read_alone_should_read_alone_to_its_end()
+     {
+        const START: u64 = 3;
+        const END: u64 = START + 4;
+        let (mut worker, _receiver) = fetching_worker();
+        let client = worker.config.client.clone();
+        let _blocked_connection = client.write().await;
+        worker.positions.insert(0, at(0, START));
+        worker.schedule();
+        let (mut header, records) = served(0, &[START], END);
+        header.messages_count = 4;
+        worker.complete(reply_of(&worker, 0, &[(header, records)]));
+        assert!(worker.read_alone.contains(&0));
+
+        for offset in START..END {
+            assert!(
+                worker.read_alone.contains(&0),
+                "offset {offset}: a message of the section is read alone"
+            );
+            worker.complete(polled(
+                &worker,
+                FetchKind::Alone,
+                at(0, offset),
+                0,
+                Ok(batch(0, [offset], offset + 1)),
+            ));
+        }
+
+        assert_eq!(worker.position(0), at(0, END));
+        assert!(
+            !worker.read_alone.contains(&0),
+            "past the section the group fetch reads the partition again"
+        );
+    }
+
+    #[tokio::test]
+    async fn given_a_last_start_when_the_epoch_changes_before_a_delivery_should_read_again_from_the_resolved_start()
+     {
+        let (mut worker, _receiver) = worker_of(standalone([0]).start(PollingStrategy::last()));
+        assert!(worker.apply(assigned(&worker, SESSION, VERSION, [0])));
+        worker.complete(polled(
+            &worker,
+            FetchKind::Alone,
+            PollingStrategy::last(),
+            0,
+            Ok(batch(0, [7, 8], 9)),
+        ));
+        assert_eq!(worker.position(0), at(0, 9));
+
+        worker.invalidate();
+
+        assert_eq!(
+            worker.position(0),
+            at(0, 7),
+            "a new `Last` read would skip the messages that arrived since the first one"
+        );
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_auto_commit_when_a_rebalance_takes_a_partition_away_should_store_its_counted_position()
+     {
+        const COUNTED: u64 = 7;
+        let (client, cluster) = connect_to_one_replica().await;
+        let (mut worker, _receiver) = worker_of(builder_on(client, group()).options(PollOptions {
+            commit: CommitPolicy::Auto(AutoCommit::default()),
+            ..PollOptions::default()
+        }));
+        assert!(worker.apply(assigned(&worker, SESSION, VERSION, [0, 1])));
+        let progress = Arc::clone(&worker.shared.progress);
+        let stamp = progress.stamp(worker.generation(1));
+        progress.count(
+            Checkpoint::stamped(1, COUNTED, PartitionContext::default(), Some(stamp)),
+            1,
+            false,
+        );
+
+        assert!(worker.apply(assigned(&worker, SESSION, VERSION + 1, [0])));
+        progress.flush_counted().await.unwrap();
+
+        assert_eq!(
+            stored_offsets(&cluster),
+            [COUNTED],
+            "the count of the partition that left is stored, not dropped with its generation"
+        );
+    }
+
+    #[tokio::test]
+    async fn given_a_committing_read_alone_when_its_message_does_not_decode_should_remember_it_for_the_close()
+     {
+        const BAD: u64 = 4;
+        let (mut worker, _receiver) = worker_of(committing(standalone([0])));
+        assert!(worker.apply(assigned(&worker, SESSION, VERSION, [0])));
+        worker.positions.insert(0, at(0, BAD));
+        worker.read_alone.insert(0);
+
+        worker.complete(polled(
+            &worker,
+            FetchKind::Alone,
+            at(0, BAD),
+            0,
+            Err(IggyError::InvalidMessagePayloadLength),
+        ));
+
+        assert_eq!(
+            worker.shared.progress.undecoded(0),
+            Some(Checkpoint::new(0, BAD, PartitionContext::default())),
+            "the server stored the offset of the message, so the close moves it back"
+        );
+    }
+
+    #[tokio::test]
+    async fn given_a_committed_section_that_does_not_decode_when_read_alone_should_deliver_the_good_message_and_skip_only_the_bad_one()
+     {
+        const GOOD: u64 = 3;
+        const BAD: u64 = GOOD + 1;
+        let (mut worker, mut receiver) = in_one_group(worker_of(committing(builder_for(group()))));
+        let client = worker.config.client.clone();
+        let _blocked_connection = client.write().await;
+        worker.positions.insert(0, at(0, GOOD));
+        worker.shared.demand.store(true, ORDERING);
+        if worker.config.on_read() {
+            for partition in worker.partitions.clone() {
+                worker
+                    .positions
+                    .entry(partition)
+                    .or_insert(next_at(partition, 0));
+            }
+        }
+        worker.schedule();
+        let (mut header, records) = served(0, &[GOOD], BAD + 1);
+        header.messages_count = 2;
+        worker.complete(reply_of(&worker, 0, &[(header, records)]));
+        assert_eq!(worker.position(0), at(0, GOOD));
+        worker.flush_errors();
+        assert!(
+            receiver.is_empty(),
+            "a failure of two messages names neither of them"
+        );
+
+        worker.shared.demand.store(true, ORDERING);
+        worker.schedule();
+        assert_eq!(worker.active[&0].kind, FetchKind::Alone);
+        worker.complete(polled(
+            &worker,
+            FetchKind::Alone,
+            at(0, GOOD),
+            0,
+            Ok(batch(0, [GOOD], BAD)),
+        ));
+        assert_eq!(offsets(&next_batches(&mut receiver)[0]), [GOOD]);
+        assert_eq!(worker.position(0), at(0, BAD));
+        worker.shared.demand.store(true, ORDERING);
+        worker.schedule();
+        assert_eq!(
+            worker.active[&0].kind,
+            FetchKind::Alone,
+            "the next message of the section reads alone, not in a group fetch"
+        );
+
+        worker.complete(polled(
+            &worker,
+            FetchKind::Alone,
+            at(0, BAD),
+            0,
+            Err(IggyError::InvalidMessagePayloadLength),
+        ));
+        assert_eq!(worker.position(0), at(0, BAD));
+        assert!(
+            worker.read_alone.contains(&0) && worker.retry_at.contains_key(&0),
+            "the retry reads the message alone again"
+        );
+        worker.flush_errors();
+        assert_eq!(
+            next_error(&mut receiver),
+            ReadError {
+                partition_id: Some(0),
+                error: IggyError::InvalidMessagePayloadLength,
+                skip: Some(at(0, BAD + 1)),
+                offset: None,
+                record: None,
+            }
+        );
+    }
+
+    #[tokio::test]
+    async fn given_partial_unknown_progress_when_fetch_completes_should_deliver_healthy_data_and_read_the_lost_target_again()
+     {
+        let (mut worker, mut receiver) = worker_of(committing(builder_for(group())));
+        let client = worker.config.client.clone();
+        let _blocked_connection = client.write().await;
+        assert!(worker.apply(grouped(&worker, SESSION, VERSION, vec![vec![0, 1]])));
+        worker.shared.demand.store(true, ORDERING);
+        if worker.config.on_read() {
+            for (partition, offset) in [(0, 7), (1, 0)] {
+                worker
+                    .positions
+                    .insert(partition, next_at(partition, offset));
+            }
+        }
+        worker.schedule();
+        let fetch = fetch_of(&worker, 0);
+        let mut response = sections(&[
+            served(0, &[7], 8),
+            refused(1, &IggyError::TransientNotCommitted),
+        ]);
+        response[1].status = Err(IggyError::FetchProgressUncertain(OPERATION));
+        worker.complete(reply(
+            &worker,
+            fetch,
+            targets_of(&worker, fetch),
+            Ok(response),
+        ));
+        worker.flush_errors();
+        let batches = next_batches(&mut receiver);
+        assert_eq!(
+            (batches.len(), batches[0].partition_id, offsets(&batches[0])),
+            (1, 0, vec![7]),
+            "the committed data reaches the application"
+        );
+        assert!(
+            receiver.is_empty(),
+            "a lost section is no error of the reader"
+        );
+        assert_eq!(worker.shared.progress.blocked(1), None);
+        assert_eq!(worker.abandoning, [(OPERATION, vec![1])]);
+
+        worker.retry_at.clear();
+        worker.shared.demand.store(true, ORDERING);
+        worker.schedule();
+        assert!(
+            worker.active.contains_key(&1),
+            "the lost target reads again, and its read finds what the lost one committed"
+        );
+    }
+
+    #[tokio::test]
+    async fn blocked_lane_of_a_target_holds_only_its_partition() {
+        let (mut worker, mut receiver) = worker_of(committing(builder_for(group())));
+        let client = worker.config.client.clone();
+        let _blocked_connection = client.write().await;
+        assert!(worker.apply(grouped(&worker, SESSION, VERSION, vec![vec![0, 1]])));
+        let delivered = Checkpoint::new(1, 7, PartitionContext::default());
+        worker.shared.progress.deliver(&delivered);
+        worker.shared.progress.record_commit(delivered);
+        worker.shared.demand.store(true, ORDERING);
+        if worker.config.on_read() {
+            for (partition, offset) in [(0, 0), (1, 8)] {
+                worker
+                    .positions
+                    .insert(partition, next_at(partition, offset));
+            }
+        }
+        worker.schedule();
+        worker.complete(failure_of(
+            &worker,
+            0,
+            IggyError::PartitionLaneBlocked(1, OPERATION),
+        ));
+        assert_eq!(
+            worker.shared.progress.last_committed(1),
+            Some(delivered),
+            "a fetch refused before its read commits nothing, so no close store moves the offset back"
+        );
+        assert_eq!(
+            (
+                worker.shared.progress.blocked(1),
+                worker.shared.progress.blocked(0)
+            ),
+            (Some(OPERATION), None)
+        );
+        worker.flush_errors();
+        assert_eq!(
+            next_error(&mut receiver),
+            ReadError::new(Some(1), IggyError::PartitionLaneBlocked(1, OPERATION))
+        );
+
+        worker.shared.demand.store(true, ORDERING);
+        worker.schedule();
+        assert!(
+            worker.active.contains_key(&0) && !worker.active.contains_key(&1),
+            "the other target is fetched again"
+        );
+    }
+
+    #[tokio::test]
+    async fn committing_member_resolves_a_timestamp_start_with_a_check_that_commits_nothing() {
+        const TIMESTAMP_US: u64 = 1_000;
+        let (mut worker, mut receiver) = worker_of(committing(
+            builder_for(group()).start(PollingStrategy::first()),
+        ));
+        let client = worker.config.client.clone();
+        let _blocked_connection = client.write().await;
+        assert!(worker.apply(grouped(&worker, SESSION, VERSION, vec![vec![0, 1]])));
+        let timestamp = PollingStrategy::timestamp(IggyTimestamp::from(TIMESTAMP_US));
+        worker.positions.insert(1, timestamp);
+        worker.schedule();
+        assert_eq!(
+            worker.active[&1].kind,
+            FetchKind::Check,
+            "a check runs without demand, because it commits nothing"
+        );
+        assert!(
+            !worker.active.contains_key(&0),
+            "a `First` start needs no check"
+        );
+
+        worker.shared.demand.store(true, ORDERING);
+        worker.schedule();
+        let fetch = fetch_of(&worker, 0);
+        assert_eq!(
+            targets_of(&worker, fetch),
+            [(0, PollingStrategy::offset(0), worker.generation(0))],
+            "`First` goes into the fetch as offset 0, which leaves out the partition of the check"
+        );
+        worker.complete(polled(
+            &worker,
+            FetchKind::Check,
+            timestamp,
+            1,
+            Ok(batch(1, [5, 6], 9)),
+        ));
+        assert_eq!(
+            worker.position(1),
+            at(1, 5),
+            "the group fetch reads the checked messages again"
+        );
+        assert!(receiver.is_empty(), "a check delivers nothing");
+
+        worker.complete(reply_of(&worker, 0, &[served(0, &[0], 1)]));
+        assert_eq!(offsets(&next_batches(&mut receiver)[0]), [0]);
+        worker.shared.demand.store(true, ORDERING);
+        worker.schedule();
+        assert_eq!(
+            fetch_of(&worker, 0),
+            fetch_of(&worker, 1),
+            "the resolved start joins the group fetch"
+        );
+    }
+
+    #[tokio::test]
+    async fn group_member_should_drop_only_the_continuation_of_the_refused_partition() {
+        for error in [
+            IggyError::HistoryUnavailable,
+            IggyError::ConsumerGroupPartitionNotOwned(1, 3),
+        ] {
+            let stops = error == IggyError::HistoryUnavailable;
+            let (mut worker, mut receiver) = worker_of(builder_for(group()));
+            assert!(worker.apply(assigned(&worker, SESSION, VERSION, [3, 4])));
+            worker.positions.insert(3, at(3, 11));
+            worker.positions.insert(4, at(4, 21));
+            let epoch = worker.epoch;
+            worker.complete(polled(
+                &worker,
+                FetchKind::Alone,
+                at(3, 11),
+                3,
+                Err(error.clone()),
+            ));
+            assert!(!worker.positions.contains_key(&3));
+            assert_eq!(worker.positions.get(&4), Some(&at(4, 21)));
+            assert_eq!(
+                worker.epoch, epoch,
+                "a refused partition keeps the reads of the others"
+            );
+            assert_eq!(
+                worker.stopped.contains(&3),
+                stops,
+                "a lost history stops its partition until a seek"
+            );
+            assert_eq!(
+                worker.retry_at.contains_key(&3),
+                !stops,
+                "a moved partition retries after the refresh"
+            );
+            worker.flush_errors();
+            if stops {
+                assert_eq!(next_error(&mut receiver), ReadError::new(Some(3), error));
+                worker.shared.progress.seek(3, PollingStrategy::offset(5));
+                worker.apply_seeks();
+                assert!(
+                    worker.is_idle(3) && worker.position(3) == PollingStrategy::offset(5),
+                    "a seek resumes the partition from its start"
+                );
+            } else {
+                assert!(receiver.is_empty(), "a moved partition reports nothing");
+                assert!(worker.refresh_now);
+            }
+        }
+    }
+
+    #[tokio::test]
+    async fn given_a_zero_wait_when_a_poll_returns_nothing_should_poll_again_after_the_retry_interval()
+     {
+        let on_read_without_wait = PollOptions {
+            commit: CommitPolicy::OnRead,
+            ..PollOptions::default()
+        };
+        for (builder, waits) in [
+            (standalone([0]).options(PollOptions::default()), false),
+            (standalone([0]).options(on_read_without_wait), false),
+            (standalone([0]), true),
+            (committing(standalone([0])), true),
+        ] {
+            let (mut worker, _receiver) = worker_of(builder);
+            assert!(worker.apply(assigned(&worker, SESSION, VERSION, [0])));
+
+            worker.complete(polled(
+                &worker,
+                FetchKind::Alone,
+                PollingStrategy::next(),
+                0,
+                Ok(batch(0, Vec::new(), 0)),
+            ));
+
+            assert_eq!(
+                worker.retry_at.contains_key(&0),
+                !waits,
+                "on_read: {}, max_wait: {}: a read that waited for nothing pauses an idle partition",
+                worker.config.on_read(),
+                worker.config.options.max_wait
+            );
+        }
+    }
+
+    #[tokio::test]
+    async fn given_a_zero_wait_when_a_fetch_returns_nothing_should_fetch_again_after_the_retry_interval()
+     {
+        let (mut worker, _receiver) =
+            worker_of(builder_for(group()).options(PollOptions::default()));
+        let client = worker.config.client.clone();
+        let _blocked_connection = client.write().await;
+        assert!(worker.apply(grouped(&worker, SESSION, VERSION, vec![vec![0, 1, 2]])));
+        worker.schedule();
+
+        worker.complete(reply_of(
+            &worker,
+            0,
+            &[served(1, &[], 0), served(2, &[7], 8)],
+        ));
+
+        assert!(
+            worker.retry_at.contains_key(&0)
+                && worker.retry_at.contains_key(&1)
+                && !worker.retry_at.contains_key(&2),
+            "only the partitions that returned nothing pause"
+        );
+        assert_eq!(
+            worker.active.keys().copied().collect::<Vec<_>>(),
+            [2],
+            "the partition that returned messages reads on"
+        );
+    }
+
+    #[tokio::test]
+    async fn given_a_position_without_history_when_its_read_finds_no_history_should_retry_instead_of_stopping()
+     {
+        let (mut worker, mut receiver) = worker_of(standalone([3]));
+        assert!(worker.apply(assigned(&worker, SESSION, VERSION, [3])));
+
+        worker.complete(polled(
+            &worker,
+            FetchKind::Alone,
+            PollingStrategy::next(),
+            3,
+            Err(IggyError::HistoryUnavailable),
+        ));
+
+        worker.flush_errors();
+        assert!(
+            receiver.try_recv().is_err(),
+            "only a stop reports a lost history"
+        );
+        assert!(
+            !worker.stopped.contains(&3) && worker.retry_at.contains_key(&3),
+            "the next read takes the history of a new route"
+        );
+        assert_eq!(worker.position(3), PollingStrategy::next());
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_a_lasting_refusal_when_it_repeats_should_retry_later_each_time_and_then_stop_with_one_error()
+     {
+        let (mut worker, mut receiver) = worker_of(standalone([0]));
+        assert!(worker.apply(assigned(&worker, SESSION, VERSION, [0])));
+        let mut delays = Vec::new();
+
+        for _ in 0..MAX_LASTING_REFUSALS {
+            worker.flush_errors();
+            assert!(
+                receiver.try_recv().is_err(),
+                "a refusal before the last one is only logged"
+            );
+            worker.complete(polled(
+                &worker,
+                FetchKind::Alone,
+                PollingStrategy::next(),
+                0,
+                Err(IggyError::Unauthorized),
+            ));
+            if let Some(retry_at) = worker.retry_at.remove(&0) {
+                delays.push(retry_at - Instant::now());
+            }
+        }
+
+        let interval = worker.config.retry_interval;
+        assert_eq!(
+            delays,
+            [1, 2, 4, 8, 16].map(|factor| interval * factor),
+            "the wait doubles with each refusal in a row"
+        );
+        assert!(
+            worker.stopped.contains(&0),
+            "the partition stops until a seek"
+        );
+        worker.flush_errors();
+        assert_eq!(
+            next_error(&mut receiver),
+            ReadError::new(Some(0), IggyError::Unauthorized)
+        );
+    }
+
+    /// A member that the server keeps refusing after each refresh loops without a sign, so the
+    /// refusal counts as lasting, while each one still drops the position and refreshes.
+    #[tokio::test(start_paused = true)]
+    async fn given_a_partition_not_owned_when_it_repeats_after_each_refresh_should_report_it_once_after_the_limit()
+     {
+        let not_owned = IggyError::ConsumerGroupPartitionNotOwned(1, 0);
+        let (mut worker, mut receiver) = worker_of(builder_for(group()));
+        assert!(worker.apply(grouped(&worker, SESSION, VERSION, vec![vec![0]])));
+
+        for refusal in 1..=MAX_LASTING_REFUSALS {
+            worker.flush_errors();
+            assert!(
+                receiver.try_recv().is_err(),
+                "refusal {refusal}: a refusal before the last one is only logged"
+            );
+            worker.positions.insert(0, at(0, 5));
+            worker.refuse(&[0], not_owned.clone());
+            assert!(
+                !worker.positions.contains_key(&0) && std::mem::take(&mut worker.refresh_now),
+                "refusal {refusal}: the position goes and the assignment refreshes"
+            );
+            worker.retry_at.remove(&0);
+        }
+
+        assert!(worker.stopped.contains(&0));
+        worker.flush_errors();
+        assert_eq!(
+            next_error(&mut receiver),
+            ReadError::new(Some(0), not_owned)
+        );
+        assert!(receiver.try_recv().is_err(), "the refusal is reported once");
+    }
+
+    #[tokio::test]
+    async fn given_a_partition_not_owned_when_a_read_then_succeeds_should_count_again_from_zero() {
+        let not_owned = IggyError::ConsumerGroupPartitionNotOwned(1, 0);
+        let (mut worker, _receiver) = worker_of(builder_for(group()));
+        assert!(worker.apply(grouped(&worker, SESSION, VERSION, vec![vec![0]])));
+        for _ in 1..MAX_LASTING_REFUSALS {
+            worker.refuse(&[0], not_owned.clone());
+        }
+        assert_eq!(
+            worker.lasting_refusals.get(&0),
+            Some(&(MAX_LASTING_REFUSALS - 1))
+        );
+        worker.retry_at.clear();
+        worker.schedule();
+
+        worker.complete(reply_of(&worker, 0, &[served(0, &[5], 6)]));
+        worker.refuse(&[0], not_owned);
+
+        assert_eq!(
+            worker.lasting_refusals.get(&0),
+            Some(&1),
+            "a good read starts the count again"
+        );
+        assert!(!worker.stopped.contains(&0) && worker.pending_errors.is_empty());
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_a_transient_refusal_when_it_repeats_should_retry_after_the_interval_and_never_stop()
+     {
+        let (mut worker, mut receiver) = worker_of(standalone([0]));
+        assert!(worker.apply(assigned(&worker, SESSION, VERSION, [0])));
+
+        for _ in 0..MAX_LASTING_REFUSALS * 2 {
+            worker.complete(polled(
+                &worker,
+                FetchKind::Alone,
+                PollingStrategy::next(),
+                0,
+                Err(IggyError::TransientNotAccepted),
+            ));
+            assert_eq!(
+                worker
+                    .retry_at
+                    .remove(&0)
+                    .map(|retry_at| retry_at - Instant::now()),
+                Some(worker.config.retry_interval)
+            );
+            worker.flush_errors();
+            assert_eq!(
+                next_error(&mut receiver),
+                ReadError::new(Some(0), IggyError::TransientNotAccepted)
+            );
+        }
+
+        assert!(!worker.stopped.contains(&0));
+    }
+
+    #[tokio::test]
+    async fn committing_worker_should_drop_only_the_continuation_of_a_partition_without_history() {
+        let (mut worker, mut receiver) = worker_of(committing(
+            standalone([3, 4]).start(PollingStrategy::first()),
+        ));
+        let client = worker.config.client.clone();
+        let _blocked_connection = client.write().await;
+        assert!(worker.apply(assigned(&worker, SESSION, VERSION, [3, 4])));
+        worker.positions.insert(3, at(3, 11));
+        worker.positions.insert(4, at(4, 21));
+        worker.complete(polled(
+            &worker,
+            FetchKind::Alone,
+            at(3, 11),
+            3,
+            Err(IggyError::HistoryUnavailable),
+        ));
+        worker.flush_errors();
+        assert_eq!(
+            next_error(&mut receiver),
+            ReadError::new(Some(3), IggyError::HistoryUnavailable)
+        );
+        assert!(!worker.positions.contains_key(&3));
+        assert!(
+            worker.stopped.contains(&3) && !worker.retry_at.contains_key(&3),
+            "a lost history stops its partition instead of a retry"
+        );
+        assert_eq!(worker.positions.get(&4), Some(&at(4, 21)));
+
+        worker.shared.demand.store(true, ORDERING);
+        worker.schedule();
+        assert!(
+            !worker.active.contains_key(&3) && worker.active.contains_key(&4),
+            "a stopped partition reads nothing until a seek"
+        );
+        worker.shared.progress.seek(3, PollingStrategy::first());
+        worker.apply_seeks();
+        assert_eq!(
+            worker.position(3),
+            PollingStrategy::offset(0),
+            "a seek to `First` reads from offset 0"
+        );
+        worker.shared.demand.store(true, ORDERING);
+        worker.schedule();
+        assert!(
+            worker.active.contains_key(&3),
+            "a seek resumes the stopped partition"
+        );
+    }
+
+    #[tokio::test]
+    async fn given_a_standalone_reader_with_readiness_groups_when_a_primary_moves_should_regroup_its_fetches()
+     {
+        let (mut worker, receiver) = worker_of(standalone([0, 1, 2]));
+        let client = worker.config.client.clone();
+        let _blocked_connection = client.write().await;
+        assert!(worker.apply(grouped(&worker, SESSION, VERSION, vec![vec![0, 1, 2]])));
+        worker.schedule();
+        let fetch = fetch_of(&worker, 0);
+        assert!(
+            worker.tasks.len() == 1
+                && worker.active[&0].kind == FetchKind::Main
+                && targets_of(&worker, fetch).len() == 3,
+            "a standalone reader fetches its partitions by group"
+        );
+
+        worker.complete(failure_of(&worker, 0, IggyError::NotPrimary));
+        assert!(
+            [0, 1, 2]
+                .iter()
+                .all(|partition| worker.retry_at.contains_key(partition)),
+            "the partitions retry"
+        );
+        assert!(worker.refresh_now, "the next turn refreshes the groups");
+        worker.flush_errors();
+        assert!(
+            receiver.is_empty(),
+            "a moved primary reaches no application"
+        );
+
+        assert!(worker.apply(grouped(
+            &worker,
+            SESSION,
+            VERSION,
+            vec![vec![0], vec![1, 2]]
+        )));
+        worker.retry_at.clear();
+        worker.schedule();
+        assert!(
+            fetch_of(&worker, 0) != fetch_of(&worker, 1)
+                && fetch_of(&worker, 1) == fetch_of(&worker, 2),
+            "the next fetches follow the new groups"
+        );
+    }
+
+    #[tokio::test]
+    async fn given_a_next_start_when_a_poll_returns_nothing_should_stay_next() {
+        for builder in [standalone([0]), committing(standalone([0]))] {
+            let (mut worker, receiver) = worker_of(builder);
+            worker.complete(polled(
+                &worker,
+                FetchKind::Alone,
+                PollingStrategy::next(),
+                0,
+                Ok(batch(0, [], 9)),
+            ));
+            assert_eq!(
+                worker.position(0),
+                PollingStrategy::next(),
+                "only a fetch section resolves `Next` without a message"
+            );
+            assert!(receiver.is_empty());
+        }
+    }
+
+    #[tokio::test]
+    async fn given_a_last_start_when_reads_return_nothing_should_keep_it_until_one_reads() {
+        for builder in [builder_for(group()), committing(builder_for(group()))] {
+            let (mut worker, mut receiver) = worker_of(builder.start(PollingStrategy::last()));
+            let on_read = worker.config.on_read();
+            let kind = if on_read {
+                FetchKind::Check
+            } else {
+                FetchKind::Alone
+            };
+            let client = worker.config.client.clone();
+            let _blocked_connection = client.write().await;
+            assert!(worker.apply(grouped(&worker, SESSION, VERSION, vec![vec![0]])));
+            worker.schedule();
+            assert_eq!(
+                worker.active[&0].kind, kind,
+                "a `Last` start reads alone before the group fetch"
+            );
+            worker.complete(polled(
+                &worker,
+                kind,
+                PollingStrategy::last(),
+                0,
+                Ok(batch(0, [], 9)),
+            ));
+            assert_eq!(
+                worker.position(0),
+                PollingStrategy::last(),
+                "an empty read keeps the start"
+            );
+            worker.schedule();
+            assert_eq!(worker.active[&0].kind, kind, "the start takes another read");
+            worker.complete(polled(
+                &worker,
+                kind,
+                PollingStrategy::last(),
+                0,
+                Ok(batch(0, [7, 8], 9)),
+            ));
+            if on_read {
+                assert_eq!(
+                    worker.position(0),
+                    at(0, 7),
+                    "a check moves the position to its first message, which the committing fetch reads again"
+                );
+                assert!(receiver.is_empty(), "a check delivers nothing");
+            } else {
+                assert_eq!(worker.position(0), at(0, 9));
+                assert_eq!(
+                    offsets(&next_batches(&mut receiver)[0]),
+                    [7, 8],
+                    "a poll alone delivers its batch"
+                );
+            }
+            worker.shared.demand.store(true, ORDERING);
+            worker.schedule();
+            assert_eq!(
+                worker.active[&0].kind,
+                FetchKind::Main,
+                "the resolved start joins the group fetch"
+            );
+        }
+    }
+
+    #[tokio::test]
+    async fn given_a_seek_when_a_read_of_the_older_generation_completes_should_drop_it_without_advance()
+     {
+        const SEEK: u64 = 10;
+        for builder in [builder_for(group()), committing(builder_for(group()))] {
+            let (mut worker, mut receiver) = worker_of(builder);
+            let client = worker.config.client.clone();
+            let _blocked_connection = client.write().await;
+            assert!(worker.apply(grouped(&worker, SESSION, VERSION, vec![vec![0, 1]])));
+            worker.shared.demand.store(true, ORDERING);
+            if worker.config.on_read() {
+                for (partition, offset) in [(0, 3), (1, 7)] {
+                    worker
+                        .positions
+                        .insert(partition, next_at(partition, offset));
+                }
+            }
+            worker.schedule();
+            let fetch = reply_of(&worker, 0, &[served(0, &[3, 4], 5), served(1, &[7], 8)]);
+            let poll = polled(
+                &worker,
+                FetchKind::Alone,
+                worker.position(0),
+                0,
+                Ok(batch(0, [3, 4], 5)),
+            );
+            worker
+                .shared
+                .progress
+                .seek(0, PollingStrategy::offset(SEEK));
+            worker.apply_seeks();
+
+            worker.complete(fetch);
+            assert_eq!(
+                worker.position(0),
+                PollingStrategy::offset(SEEK),
+                "a section of the older generation moves nothing"
+            );
+            let delivered: Vec<u32> = next_batches(&mut receiver)
+                .iter()
+                .map(|batch| batch.partition_id)
+                .collect();
+            assert_eq!(delivered, [1], "only the other partition delivers");
+            worker.complete(poll);
+            assert_eq!(
+                worker.position(0),
+                PollingStrategy::offset(SEEK),
+                "a poll of the older generation moves nothing"
+            );
+            assert!(receiver.is_empty(), "nor does it deliver");
+        }
+    }
+
+    /// The next `Next` read finds what a lost committing read stored, so the worker abandons
+    /// the operation itself and reads again after the retry interval, without an error.
+    #[tokio::test]
+    async fn given_an_empty_or_uncertain_fetch_section_when_closing_should_rewind_only_uncertain_progress()
+     {
+        for uncertain in [false, true] {
+            let (client, cluster) = connect_to_one_replica().await;
+            let (mut worker, _receiver) = worker_of(committing(
+                builder_on(client, Consumer::default()).partitions([0]),
+            ));
+            assert!(worker.apply(grouped(&worker, SESSION, VERSION, vec![vec![0]])));
+            worker.positions.insert(0, next_at(0, 5));
+            let client = worker.config.client.clone();
+            let blocked = client.write().await;
+            worker.shared.demand.store(true, ORDERING);
+            worker.schedule();
+            let fetch = fetch_of(&worker, 0);
+            let mut response = sections(&[served(0, &[], 5)]);
+            if uncertain {
+                response[0].status = Err(IggyError::FetchProgressUncertain(OPERATION));
+            }
+            worker.complete(reply(
+                &worker,
+                fetch,
+                targets_of(&worker, fetch),
+                Ok(response),
+            ));
+            worker.tasks.abort_all();
+            drop(blocked);
+            worker.abandon_lost().await;
+            worker
+                .shared
+                .progress
+                .rewind(Vec::new(), None, true)
+                .await
+                .unwrap();
+            assert_eq!(
+                stored_offsets(&cluster),
+                if uncertain { vec![4] } else { Vec::new() }
+            );
+        }
+    }
+
+    #[tokio::test]
+    async fn given_http_when_a_committing_reply_is_lost_should_keep_its_partition_blocked() {
+        let builder = ReaderBuilder::new(
+            IggyRwLock::new(ClientWrapper::Http(HttpClient::default())),
+            Identifier::numeric(1).unwrap(),
+            Identifier::numeric(1).unwrap(),
+            Consumer::default(),
+            None,
+        )
+        .partitions([0]);
+        let (mut worker, mut receiver) = worker_of(committing(builder));
+        assert!(worker.apply(assigned(&worker, SESSION, VERSION, [0])));
+        worker.positions.insert(0, next_at(0, 5));
+        worker.refuse(&[0], IggyError::FetchProgressUncertain(OPERATION));
+        worker.abandon_lost().await;
+        worker.flush_errors();
+        assert_eq!(worker.shared.progress.blocked(0), Some(OPERATION));
+        assert_eq!(
+            next_error(&mut receiver),
+            ReadError::new(Some(0), IggyError::FetchProgressUncertain(OPERATION))
+        );
+        assert!(worker.abandoning.is_empty());
+    }
+
+    #[tokio::test]
+    async fn given_a_lost_committing_reply_when_completed_should_abandon_it_and_read_on() {
+        let (mut worker, receiver) = worker_of(committing(builder_for(group())));
+        let client = worker.config.client.clone();
+        let blocked_connection = client.write().await;
+        assert!(worker.apply(grouped(&worker, SESSION, VERSION, vec![vec![0]])));
+        worker.shared.demand.store(true, ORDERING);
+        worker.positions.insert(0, next_at(0, 0));
+        worker.schedule();
+
+        worker.complete(failure_of(
+            &worker,
+            0,
+            IggyError::FetchProgressUncertain(OPERATION),
+        ));
+
+        worker.flush_errors();
+        assert!(
+            receiver.is_empty(),
+            "a lost reply is no error of the reader"
+        );
+        assert_eq!(worker.shared.progress.blocked(0), None);
+        assert!(!worker.stopped.contains(&0));
+        assert!(worker.retry_at.contains_key(&0));
+        assert_eq!(worker.abandoning, [(OPERATION, vec![0])]);
+        drop(blocked_connection);
+        worker.abandon_lost().await;
+        assert!(worker.abandoning.is_empty());
+        worker.retry_at.clear();
+        worker.shared.demand.store(true, ORDERING);
+        worker.schedule();
+        assert_eq!(worker.active[&0].kind, FetchKind::Main);
+    }
+
+    #[tokio::test]
+    async fn given_a_committing_poll_that_does_not_decode_when_it_read_from_next_should_stop_its_partition_until_a_seek()
+     {
+        const DELIVERED: u64 = 4;
+        let undecodable = IggyError::InvalidMessagePayloadLength;
+        for (start, stops) in [
+            (PollingStrategy::next(), true),
+            (at(0, DELIVERED + 1), false),
+        ] {
+            let (mut worker, mut receiver) = worker_of(committing(standalone([0])));
+            let client = worker.config.client.clone();
+            let _blocked_connection = client.write().await;
+            assert!(worker.apply(assigned(&worker, SESSION, VERSION, [0])));
+            let progress = Arc::clone(&worker.shared.progress);
+            let delivered = Checkpoint::new(0, DELIVERED, PartitionContext::default());
+            progress.deliver(&delivered);
+            progress.record_commit(delivered);
+
+            worker.complete(polled(
+                &worker,
+                FetchKind::Alone,
+                start,
+                0,
+                Err(undecodable.clone()),
+            ));
+            worker.flush_errors();
+
+            assert_eq!(
+                next_error(&mut receiver),
+                ReadError::new(Some(0), undecodable.clone()),
+                "{start:?}"
+            );
+            assert_eq!(
+                progress.last_committed(0),
+                None,
+                "{start:?}: the server stored the offset of the batch, so the close stores the delivered offset again"
+            );
+            assert_eq!(
+                (
+                    worker.stopped.contains(&0),
+                    worker.retry_at.contains_key(&0)
+                ),
+                (stops, !stops),
+                "{start:?}: an offset reads the batch again, but `Next` starts after it"
+            );
+            progress.seek(0, PollingStrategy::offset(DELIVERED + 1));
+            worker.apply_seeks();
+            assert!(
+                !worker.stopped.contains(&0),
+                "{start:?}: a seek resumes the partition"
+            );
+        }
+    }
+
+    #[tokio::test]
+    async fn given_many_regroups_when_fetches_restart_should_never_hold_more_than_the_slots() {
+        const SLOTS: u32 = 2;
+        const REGROUPS: usize = 8;
+        let options = PollOptions::default();
+        let (mut worker, receiver) = worker_of(
+            builder_for(group())
+                .options(options)
+                .prefetch_bytes(options.max_bytes * SLOTS),
+        );
+        let slots = worker.config.slots;
+        assert_eq!(slots, SLOTS as usize);
+        let client = worker.config.client.clone();
+        let _blocked_connection = client.write().await;
+        worker.report(ReadError::new(None, IggyError::CannotReadMessage));
+        worker.flush_errors();
+        let layouts = [vec![vec![0, 1], vec![2, 3]], vec![vec![0, 2], vec![1, 3]]];
+        assert!(worker.apply(grouped(&worker, SESSION, VERSION, layouts[0].clone())));
+        worker.schedule();
+        assert_eq!(
+            worker.tasks.len() + receiver.len(),
+            slots,
+            "a queued reply holds a slot, so one group waits"
+        );
+
+        for layout in layouts.iter().cycle().skip(1).take(REGROUPS) {
+            assert!(worker.apply(grouped(&worker, SESSION, VERSION, layout.clone())));
+            worker.schedule();
+            assert!(
+                worker.tasks.is_empty(),
+                "a cancelled fetch holds its slot until it ends"
+            );
+            timeout(RELEASE_TIMEOUT, async {
+                while worker.capacity.available_permits() + receiver.len() < slots {
+                    yield_now().await;
+                }
+            })
+            .await
+            .expect("a cancelled fetch returns its slot");
+            worker.schedule();
+            assert_eq!(
+                worker.tasks.len() + receiver.len(),
+                slots,
+                "every slot holds a read in flight or a queued reply"
+            );
+        }
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_more_groups_than_slots_when_each_fetch_ends_should_give_every_group_a_turn() {
+        const GROUPS: u32 = 3;
+        for builder in [builder_for(group()), committing(builder_for(group()))] {
+            let max_bytes = PollOptions::default().max_bytes;
+            let (mut worker, _receiver) = worker_of(builder.prefetch_bytes(max_bytes));
+            assert_eq!(worker.config.slots, 1);
+            let client = worker.config.client.clone();
+            let _blocked_connection = client.write().await;
+            let groups = (0..GROUPS).map(|partition| vec![partition]).collect();
+            assert!(worker.apply(grouped(&worker, SESSION, VERSION, groups)));
+
+            let mut fetched = Vec::new();
+            for _ in 0..GROUPS * 2 {
+                worker.shared.demand.store(true, ORDERING);
+                if worker.config.on_read() {
+                    for (partition, offset) in [(0, 0), (1, 0), (2, 0)] {
+                        worker
+                            .positions
+                            .insert(partition, next_at(partition, offset));
+                    }
+                }
+                worker.schedule();
+                let partitions: Vec<u32> = worker.active.keys().copied().collect();
+                fetched.extend(partitions);
+                let Completed::Fetch(mut completed) =
+                    worker.tasks.join_next().await.unwrap().unwrap()
+                else {
+                    panic!("a group reads with a fetch");
+                };
+                completed.result = Ok(Vec::new());
+                worker.complete(Completed::Fetch(completed));
+            }
+
+            assert_eq!(
+                fetched,
+                [0, 1, 2, 0, 1, 2],
+                "on_read: {}: each group fetches in turn",
+                worker.config.on_read()
+            );
+        }
+    }
+
+    #[tokio::test]
+    async fn given_one_fetch_slot_when_another_partition_needs_a_start_should_check_it() {
+        let options = PollOptions {
+            count: 1,
+            commit: CommitPolicy::OnRead,
+            ..PollOptions::default()
+        };
+        let (mut worker, _receiver) = worker_of(
+            builder_for(group())
+                .options(options)
+                .prefetch_messages(options.count),
+        );
+        assert_eq!(worker.config.slots, 1);
+        let client = worker.config.client.clone();
+        let _blocked_connection = client.write().await;
+        assert!(worker.apply(grouped(&worker, SESSION, VERSION, vec![vec![0, 1]])));
+        worker.positions.insert(0, next_at(0, 0));
+        worker.shared.demand.store(true, ORDERING);
+
+        worker.schedule();
+
+        assert_eq!(worker.active[&0].kind, FetchKind::Main);
+        assert_eq!(worker.active[&1].kind, FetchKind::Check);
+    }
+
+    #[tokio::test]
+    async fn given_a_group_without_a_fetch_when_a_slot_is_free_should_start_it_before_a_second_fetch()
+     {
+        const SLOTS: u32 = 2;
+        let options = PollOptions::default();
+        let (mut worker, _receiver) = worker_of(
+            builder_for(group())
+                .options(options)
+                .prefetch_bytes(options.max_bytes * SLOTS),
+        );
+        assert_eq!(worker.config.slots, SLOTS as usize);
+        let client = worker.config.client.clone();
+        let _blocked_connection = client.write().await;
+        assert!(worker.apply(grouped(
+            &worker,
+            SESSION,
+            VERSION,
+            vec![vec![0, 1], vec![2]]
+        )));
+        worker.retry(&[1, 2]);
+        worker.schedule();
+        assert_eq!(worker.active[&0].kind, FetchKind::Main);
+
+        worker.retry_at.clear();
+        worker.schedule();
+
+        assert!(
+            worker
+                .active
+                .get(&2)
+                .is_some_and(|active| active.kind == FetchKind::Main)
+                && !worker.active.contains_key(&1),
+            "the idle group takes the last slot before a second fetch of a busy group"
+        );
+    }
+
+    #[tokio::test]
+    async fn given_backlogged_partitions_of_one_group_when_slots_are_free_should_read_each_in_its_own_fetch()
+     {
+        const BACKLOGGED: [u32; 4] = [0, 1, 2, 3];
+        let (mut worker, _receiver) = fetching_worker();
+        let client = worker.config.client.clone();
+        let _blocked_connection = client.write().await;
+        worker.schedule();
+        assert_eq!(worker.tasks.len(), 1);
+
+        let backlog: Vec<Section> = BACKLOGGED
+            .iter()
+            .map(|&partition| behind_head(partition, &[5], 6))
+            .collect();
+        let completed = reply_of(&worker, 0, &backlog);
+        worker.tasks.shutdown().await;
+        worker.complete(completed);
+
+        let fetches: BTreeSet<u64> = BACKLOGGED
+            .iter()
+            .map(|partition| fetch_of(&worker, *partition))
+            .collect();
+        assert_eq!(
+            fetches.len(),
+            BACKLOGGED.len(),
+            "each backlogged partition reads in a fetch of its own"
+        );
+        let quiet = fetch_of(&worker, 4);
+        assert!(
+            !fetches.contains(&quiet)
+                && worker
+                    .partitions
+                    .iter()
+                    .filter(|partition| !BACKLOGGED.contains(partition))
+                    .all(|partition| fetch_of(&worker, *partition) == quiet),
+            "the quiet partitions share one fetch"
+        );
+        assert!(
+            worker
+                .active
+                .values()
+                .all(|active| active.kind == FetchKind::Main)
+        );
+
+        worker.complete(reply_of(&worker, 0, &[behind_head(0, &[6], 7)]));
+        assert!(
+            fetch_of(&worker, 0) != quiet
+                && worker.active[&0].kind == FetchKind::Main
+                && worker.active[&4].fetch == quiet,
+            "a backlogged partition that turns idle reads again beside the parked fetch"
+        );
+        assert!(
+            timeout(Duration::from_millis(1), worker.tasks.join_next())
+                .await
+                .is_err(),
+            "a backlogged partition cancels no fetch"
+        );
+    }
+
+    #[tokio::test]
+    async fn given_more_backlogged_partitions_than_free_slots_when_scheduled_should_deal_them_over_the_slots()
+     {
+        const SLOTS: u32 = 3;
+        let options = PollOptions::default();
+        let (mut worker, _receiver) = worker_of(
+            builder_for(group())
+                .options(options)
+                .prefetch_bytes(options.max_bytes * SLOTS),
+        );
+        assert_eq!(worker.config.slots, SLOTS as usize);
+        let client = worker.config.client.clone();
+        let _blocked_connection = client.write().await;
+        assert!(worker.apply(grouped(
+            &worker,
+            SESSION,
+            VERSION,
+            vec![vec![0, 1, 2, 3, 4]]
+        )));
+        worker.schedule();
+
+        let backlog: Vec<Section> = (0..5)
+            .map(|partition| behind_head(partition, &[5], 6))
+            .collect();
+        let completed = reply_of(&worker, 0, &backlog);
+        worker.tasks.shutdown().await;
+        worker.complete(completed);
+
+        assert_eq!(
+            worker.tasks.len(),
+            usize::try_from(SLOTS).unwrap() - 1,
+            "the queued reply holds one slot, and a fetch takes each other one"
+        );
+        assert_eq!(
+            fetches_in_flight(&worker),
+            [vec![0, 2, 4], vec![1, 3]],
+            "the backlogged partitions are dealt in turn over the fetches"
+        );
+    }
+
+    #[tokio::test]
+    async fn given_backlogged_partitions_of_a_committing_group_when_the_application_asks_should_deal_them_over_the_free_slots()
+     {
+        const SLOTS: u32 = 3;
+        const PARTITIONS: [u32; 5] = [0, 1, 2, 3, 4];
+        let options = PollOptions {
+            commit: CommitPolicy::OnRead,
+            ..PollOptions::wait(IggyDuration::from(DEFAULT_MAX_WAIT))
+        };
+        let (mut worker, mut receiver) = worker_of(
+            builder_for(group())
+                .options(options)
+                .prefetch_bytes(options.max_bytes * SLOTS),
+        );
+        assert_eq!(worker.config.slots, SLOTS as usize);
+        let client = worker.config.client.clone();
+        let _blocked_connection = client.write().await;
+        assert!(worker.apply(grouped(
+            &worker,
+            SESSION,
+            VERSION,
+            vec![PARTITIONS.to_vec()]
+        )));
+        for partition in PARTITIONS {
+            worker.positions.insert(partition, next_at(partition, 0));
+        }
+        worker.shared.demand.store(true, ORDERING);
+        worker.schedule();
+        assert_eq!(worker.tasks.len(), 1, "one fetch per group before a reply");
+
+        let backlog: Vec<Section> = PARTITIONS
+            .iter()
+            .map(|&partition| behind_head(partition, &[0], 1))
+            .collect();
+        answer(&mut worker, 0, &backlog).await;
+        worker.schedule();
+        assert!(
+            worker.tasks.is_empty(),
+            "nothing starts before the application asks again"
+        );
+
+        worker.shared.demand.store(true, ORDERING);
+        worker.schedule();
+        assert_eq!(
+            worker.tasks.len(),
+            usize::try_from(SLOTS).unwrap() - 1,
+            "the queued reply holds one slot, and a committing fetch takes each other one"
+        );
+        assert_eq!(
+            fetches_in_flight(&worker),
+            [vec![0, 2, 4], vec![1, 3]],
+            "the backlogged partitions are dealt in turn over disjoint committing fetches"
+        );
+        assert!(
+            worker
+                .active
+                .values()
+                .all(|active| active.kind == FetchKind::Backlog)
+        );
+
+        next_batches(&mut receiver);
+        answer(&mut worker, 1, &[served(1, &[1], 2), served(3, &[1], 2)]).await;
+        worker.shared.demand.store(true, ORDERING);
+        worker.schedule();
+        let quiet = fetch_of(&worker, 1);
+        assert!(
+            fetch_of(&worker, 3) == quiet && worker.active[&1].kind == FetchKind::Main,
+            "partitions that caught up share one fetch that may park"
+        );
+
+        next_batches(&mut receiver);
+        answer(
+            &mut worker,
+            0,
+            &[
+                behind_head(0, &[1], 2),
+                behind_head(2, &[1], 2),
+                behind_head(4, &[1], 2),
+            ],
+        )
+        .await;
+        worker.shared.demand.store(true, ORDERING);
+        worker.schedule();
+        assert!(
+            fetch_of(&worker, 1) == quiet
+                && worker.active[&0].kind == FetchKind::Backlog
+                && fetches_in_flight(&worker) == [vec![0, 2, 4], vec![1, 3]],
+            "backlogged partitions read again beside the parked fetch"
+        );
+        assert!(
+            timeout(Duration::from_millis(1), worker.tasks.join_next())
+                .await
+                .is_err(),
+            "a backlog fetch cancels nothing"
+        );
+    }
+
+    /// A poll alone reads one message within `max_bytes`: the server returns a larger first
+    /// message alone anyway, and a reply of one message that does not decode names it.
+    #[tokio::test]
+    async fn given_a_partition_that_reads_alone_when_polled_should_ask_for_one_message_within_max_bytes()
+     {
+        let (client, cluster) = connect_to_one_replica().await;
+        cluster.script(
+            &endpoint(0),
+            POLL_MESSAGES_DEFERRED_CODE,
+            [Answer::Refuse(IggyError::InvalidSizeBytes)],
+        );
+        let (mut worker, mut receiver) =
+            worker_of(builder_on(client, Consumer::default()).partitions([0]));
+        assert!(worker.apply(assigned(&worker, SESSION, VERSION, [0])));
+        worker.read_alone.insert(0);
+        worker.schedule();
+        let completed = worker.tasks.join_next().await.unwrap().unwrap();
+        let request = DeferredPollMessagesRequest::decode_from(
+            &cluster.received(POLL_MESSAGES_DEFERRED_CODE)[0].body,
+        )
+        .unwrap();
+        assert_eq!(
+            (request.poll.count, request.min_count, request.max_bytes),
+            (
+                SINGLE_RECORD,
+                SINGLE_RECORD,
+                worker.config.options.max_bytes
+            ),
+            "one message within the byte limit of the reader"
+        );
+        worker.complete(completed);
+        worker.flush_errors();
+        assert_eq!(
+            next_error(&mut receiver),
+            ReadError::new(Some(0), IggyError::InvalidSizeBytes)
+        );
+        assert!(!worker.read_alone.contains(&0));
+    }
+
+    /// The client never reads a refused poll again, whatever its start and commit policy.
+    #[tokio::test]
+    async fn given_a_poll_refused_for_its_byte_limit_when_read_should_send_it_once_and_report_it() {
+        const COUNT: u32 = 2;
+        for commit in [CommitPolicy::Manual, CommitPolicy::OnRead] {
+            for start in [PollingStrategy::offset(0), PollingStrategy::last()] {
+                let case = format!("{commit:?} {start}");
+                let (client, cluster) = connect_to_one_replica().await;
+                cluster.script(
+                    &endpoint(0),
+                    POLL_MESSAGES_DEFERRED_CODE,
+                    [Answer::Refuse(IggyError::InvalidSizeBytes)],
+                );
+                let (mut worker, mut receiver) = worker_of(
+                    builder_on(client, Consumer::default())
+                        .partitions([0])
+                        .start(start)
+                        .options(PollOptions {
+                            count: COUNT,
+                            commit,
+                            ..PollOptions::default()
+                        }),
+                );
+                assert!(worker.apply(assigned(&worker, SESSION, VERSION, [0])));
+                worker.shared.demand.store(true, ORDERING);
+                worker.schedule();
+                let completed = worker.tasks.join_next().await.unwrap().unwrap();
+                let polls = cluster.received(POLL_MESSAGES_DEFERRED_CODE);
+                assert_eq!(polls.len(), 1, "{case}");
+                let request = DeferredPollMessagesRequest::decode_from(&polls[0].body).unwrap();
+                assert_eq!(
+                    request.poll.count,
+                    if worker.needs_start(0) {
+                        SINGLE_RECORD
+                    } else {
+                        COUNT
+                    },
+                    "{case}"
+                );
+                worker.complete(completed);
+                worker.flush_errors();
+                assert_eq!(
+                    next_error(&mut receiver),
+                    ReadError::new(Some(0), IggyError::InvalidSizeBytes),
+                    "{case}"
+                );
+                assert_eq!(worker.position(0), start, "{case}");
+                assert!(!worker.read_alone.contains(&0), "{case}");
+            }
+        }
+    }
+
+    /// The client sends no heartbeat inside a read, so the default wait stays well below the
+    /// 30 s operation deadline.
+    #[tokio::test]
+    async fn given_no_options_when_building_a_reader_should_wait_up_to_five_seconds() {
+        let (worker, _receiver) = worker_of(standalone([0]));
+
+        assert_eq!(
+            worker.config.options,
+            PollOptions::wait(IggyDuration::from(Duration::from_secs(5)))
+        );
+    }
+
+    fn group() -> Consumer {
+        Consumer::group(Identifier::numeric(GROUP_ID).unwrap())
+    }
+
+    fn standalone(partitions: impl IntoIterator<Item = u32>) -> ReaderBuilder {
+        builder_for(Consumer::default()).partitions(partitions)
+    }
+
+    /// The server stores the offset of each read, which waits as long as a reader does by
+    /// default.
+    fn committing(builder: ReaderBuilder) -> ReaderBuilder {
+        builder.options(PollOptions {
+            commit: CommitPolicy::OnRead,
+            ..PollOptions::wait(IggyDuration::from(DEFAULT_MAX_WAIT))
+        })
+    }
+
+    fn worker_of(builder: ReaderBuilder) -> (Worker, Replies) {
+        let (config, shared) = builder.into_parts().unwrap();
+        worker_on(config, shared)
+    }
+
+    fn worker_on(config: Config, shared: Shared) -> (Worker, Replies) {
+        let (sender, receiver) = mpsc::unbounded_channel();
+        let worker = Worker::new(Arc::new(config), Arc::new(shared), sender, None);
+        (worker, receiver)
+    }
+
+    fn encryptor() -> Arc<EncryptorKind> {
+        Arc::new(EncryptorKind::Aes256Gcm(
+            Aes256GcmEncryptor::new(&KEY).unwrap(),
+        ))
+    }
+
+    fn encrypted_worker(
+        builder: ReaderBuilder,
+        encryptor: &Arc<EncryptorKind>,
+    ) -> (Worker, Replies) {
+        let (mut config, shared) = builder.into_parts().unwrap();
+        config.encryptor = Some(Arc::clone(encryptor));
+        worker_on(config, shared)
+    }
+
+    /// A group member that commits nothing, with one partition more than its fetch slots, all
+    /// in one group.
+    fn fetching_worker() -> (Worker, Replies) {
+        in_one_group(worker_of(builder_for(group())))
+    }
+
+    /// Assigns one partition more than the fetch slots of the worker, all in one group.
+    fn in_one_group((mut worker, receiver): (Worker, Replies)) -> (Worker, Replies) {
+        let partitions: Vec<u32> = (0..=u32::try_from(worker.config.slots).unwrap()).collect();
+        assert!(worker.apply(grouped(&worker, SESSION, VERSION, vec![partitions])));
+        (worker, receiver)
+    }
+
+    /// A refresh in the current fetch state that read `partitions`, on a transport without
+    /// readiness groups.
+    fn assigned(
+        worker: &Worker,
+        session: u64,
+        version: u64,
+        partitions: impl Into<Vec<u32>>,
+    ) -> Refreshed {
+        Refreshed {
+            epoch: worker.epoch,
+            assignment: AssignmentVersion {
+                session,
+                generation: version,
+            },
+            partitions: partitions.into(),
+            groups: None,
+        }
+    }
+
+    /// A refresh in the current fetch state that read the partitions of `groups`.
+    fn grouped(worker: &Worker, session: u64, version: u64, groups: Vec<Vec<u32>>) -> Refreshed {
+        let mut partitions: Vec<u32> = groups.iter().flatten().copied().collect();
+        partitions.sort_unstable();
+        Refreshed {
+            groups: Some(groups),
+            ..assigned(worker, session, version, partitions)
+        }
+    }
+
+    /// A read of `partition` from `strategy` in the current fetch state and generation, which
+    /// commits as the read that the worker starts.
+    fn polled(
+        worker: &Worker,
+        kind: FetchKind,
+        strategy: PollingStrategy,
+        partition: u32,
+        result: Result<PolledMessages, IggyError>,
+    ) -> Completed {
+        let commits = worker.config.on_read()
+            && kind == FetchKind::Alone
+            && worker.catching_up(partition).is_none();
+        Completed::Poll(CompletedPoll {
+            epoch: worker.epoch,
+            kind,
+            partition,
+            strategy,
+            generation: worker.generation(partition),
+            result: result.map_err(DecodeError::without_record),
+            capacity: worker.reserve_recovery(partition).unwrap(),
+            commit: commits
+                .then(|| PendingCommit::new(Arc::clone(&worker.shared.progress), vec![partition])),
+        })
+    }
+
+    fn fetch_of(worker: &Worker, partition: u32) -> u64 {
+        worker.active[&partition].fetch
+    }
+
+    /// The partitions of each fetch in flight, sorted.
+    fn fetches_in_flight(worker: &Worker) -> Vec<Vec<u32>> {
+        let mut targets: Vec<Vec<u32>> = Vec::new();
+        for (partition, active) in &worker.active {
+            match targets
+                .iter_mut()
+                .find(|targets| fetch_of(worker, targets[0]) == active.fetch)
+            {
+                Some(targets) => targets.push(*partition),
+                None => targets.push(vec![*partition]),
+            }
+        }
+        targets.sort();
+        targets
+    }
+
+    /// A section like [`served`] whose partition holds more messages after it.
+    fn behind_head(partition: u32, offsets: &[u64], next_offset: u64) -> Section {
+        let (mut header, records) = served(partition, offsets, next_offset);
+        header.current_offset = next_offset + BACKLOG;
+        (header, records)
+    }
+
+    /// The reply with `served` to the fetch in flight that reads `partition`. The fetch read
+    /// each target from its current position.
+    fn reply_of(worker: &Worker, partition: u32, served: &[Section]) -> Completed {
+        let fetch = fetch_of(worker, partition);
+        reply(
+            worker,
+            fetch,
+            targets_of(worker, fetch),
+            Ok(sections(served)),
+        )
+    }
+
+    /// Ends the fetch in flight that reads `partition` with the reply `served`: its task gives
+    /// its slot back, and the reply takes one, as a real fetch does.
+    async fn answer(worker: &mut Worker, partition: u32, served: &[Section]) {
+        let completed = reply_of(worker, partition, served);
+        worker.active[&partition].task.abort();
+        worker.tasks.join_next().await;
+        worker.complete(completed);
+    }
+
+    fn failure_of(worker: &Worker, partition: u32, error: IggyError) -> Completed {
+        let fetch = fetch_of(worker, partition);
+        reply(worker, fetch, targets_of(worker, fetch), Err(error))
+    }
+
+    fn targets_of(worker: &Worker, fetch: u64) -> Vec<(u32, PollingStrategy, u64)> {
+        worker
+            .active
+            .iter()
+            .filter(|(_, active)| active.fetch == fetch)
+            .map(|(partition, _)| {
+                (
+                    *partition,
+                    worker.position(*partition),
+                    worker.generation(*partition),
+                )
+            })
+            .collect()
+    }
+
+    /// The reply to `fetch`, which read `targets`, in the current fetch state.
+    fn reply(
+        worker: &Worker,
+        fetch: u64,
+        targets: Vec<(u32, PollingStrategy, u64)>,
+        result: Result<Vec<FetchedSection>, IggyError>,
+    ) -> Completed {
+        let partitions = targets.iter().map(|(partition, ..)| *partition).collect();
+        Completed::Fetch(CompletedFetch {
+            epoch: worker.epoch,
+            fetch,
+            commit: worker
+                .config
+                .on_read()
+                .then(|| PendingCommit::new(Arc::clone(&worker.shared.progress), partitions)),
+            targets,
+            result,
+            capacity: worker.reserve().unwrap(),
+        })
+    }
+
+    /// Fails the reads in flight, which time out on the blocked connection.
+    async fn fail_reads(worker: &mut Worker) {
+        while let Some(completed) = worker.tasks.join_next().await {
+            let Completed::Poll(mut completed) = completed.unwrap() else {
+                panic!("a reader without readiness groups polls one partition");
+            };
+            assert!(
+                matches!(
+                    completed.result,
+                    Err(DecodeError {
+                        error: IggyError::TransientNotAccepted,
+                        ..
+                    })
+                ),
+                "a read that waits past its deadline for the client was never sent"
+            );
+            completed.result = Err(DecodeError::without_record(IggyError::CannotReadMessage));
+            worker.complete(Completed::Poll(completed));
+        }
+    }
+
+    fn next_batches(receiver: &mut Replies) -> Vec<PolledMessages> {
+        match receiver.try_recv().unwrap().result {
+            Ok(batches) => batches,
+            Err(refusal) => panic!("expected batches, got {}", refusal.error),
+        }
+    }
+
+    fn next_error(receiver: &mut Replies) -> ReadError {
+        match receiver.try_recv().unwrap().result {
+            Ok(batches) => panic!("expected an error, got {} batches", batches.len()),
+            Err(refusal) => refusal.error,
+        }
+    }
+
+    fn offsets(batch: &PolledMessages) -> Vec<u64> {
+        batch
+            .messages
+            .iter()
+            .map(|message| message.header.offset)
+            .collect()
+    }
+
+    /// The continuation of `partition` at `offset` in the default history.
+    fn at(partition: u32, offset: u64) -> PollingStrategy {
+        continuation_at(partition, offset, PartitionContext::default())
+    }
+
+    /// The `Next` continuation of `partition` that expects `offset`, in the default history: the
+    /// position of an `OnRead` partition after a delivery.
+    fn next_at(partition: u32, offset: u64) -> PollingStrategy {
+        PollingStrategy::continuation(
+            partition,
+            PollingKind::Next,
+            offset,
+            PartitionContext::default(),
+        )
+    }
+
+    /// A batch of partition 0 with a message at each of `offsets`, whose payloads `encryptor`
+    /// sealed, except the one at `unsealed`.
+    fn sealed_batch(
+        encryptor: &EncryptorKind,
+        offsets: &[u64],
+        unsealed: Option<u64>,
+    ) -> PolledMessages {
+        let messages: Vec<_> = offsets
+            .iter()
+            .map(|&offset| {
+                let mut message = message_at(offset);
+                if Some(offset) != unsealed {
+                    message.payload = Bytes::from(encryptor.encrypt(&message.payload).unwrap());
+                }
+                message
+            })
+            .collect();
+        PolledMessages::new(
+            PartitionContext::default(),
+            0,
+            offsets.last().copied().unwrap_or_default(),
+            u32::try_from(messages.len()).unwrap(),
+            messages,
+        )
+    }
+}
diff --git a/core/sdk/src/clients/reader_builder.rs b/core/sdk/src/clients/reader_builder.rs
new file mode 100644
index 0000000..26e022c
--- /dev/null
+++ b/core/sdk/src/clients/reader_builder.rs
@@ -0,0 +1,341 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::fmt::{self, Debug, Formatter};
+use std::sync::atomic::{AtomicBool, AtomicU64};
+use std::sync::{Arc, Mutex};
+use std::time::Duration;
+
+use iggy_common::locking::{IggyRwLock, IggyRwLockFn};
+use iggy_common::{
+    Client, CommitPolicy, Consumer, ConsumerKind, EncryptorKind, Identifier, IggyDuration,
+    IggyError, PollOptions, PollingStrategy, group_cache_key,
+};
+use tokio::sync::Notify;
+use tracing::error;
+
+use super::reader::membership::Membership;
+use super::reader::progress::Progress;
+use super::reader::{Config, PartitionReader, Shared, first_as_offset};
+use crate::client_wrappers::client_wrapper::ClientWrapper;
+use crate::driver::MAX_DEFERRED_REQUESTS_PER_CONSUMER;
+
+pub(crate) const DEFAULT_PREFETCH_BYTES: u32 = 16 * 1024 * 1024;
+pub(crate) const DEFAULT_PREFETCH_MESSAGES: u32 = 16_000;
+pub(crate) const DEFAULT_MAX_WAIT: Duration = Duration::from_secs(5);
+const DEFAULT_RETRY_INTERVAL: Duration = Duration::from_secs(1);
+
+/// Builds a [`PartitionReader`]. Get one from [`IggyClient::reader`](crate::prelude::IggyClient::reader).
+pub struct ReaderBuilder {
+    client: IggyRwLock<ClientWrapper>,
+    stream: Identifier,
+    topic: Identifier,
+    consumer: Consumer,
+    partitions: Option<Vec<u32>>,
+    start: PollingStrategy,
+    options: PollOptions,
+    prefetch_bytes: u32,
+    prefetch_messages: Option<u32>,
+    encryptor: Option<Arc<EncryptorKind>>,
+    retry_interval: Duration,
+    /// Set by `IggyConsumer`, which reports its deliveries to the same record.
+    progress: Option<Arc<Progress>>,
+    /// Set by `IggyConsumer`, which keeps an uncertain create or join for its next `init()`.
+    membership: Option<Arc<Membership>>,
+}
+
+impl Debug for ReaderBuilder {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+        f.debug_struct("ReaderBuilder")
+            .field("stream", &self.stream)
+            .field("topic", &self.topic)
+            .field("consumer", &self.consumer)
+            .field("partitions", &self.partitions)
+            .field("start", &self.start)
+            .field("options", &self.options)
+            .field("prefetch_bytes", &self.prefetch_bytes)
+            .field("prefetch_messages", &self.prefetch_messages)
+            .finish_non_exhaustive()
+    }
+}
+
+impl ReaderBuilder {
+    pub(crate) fn new(
+        client: IggyRwLock<ClientWrapper>,
+        stream: Identifier,
+        topic: Identifier,
+        consumer: Consumer,
+        encryptor: Option<Arc<EncryptorKind>>,
+    ) -> Self {
+        Self {
+            client,
+            stream,
+            topic,
+            consumer,
+            partitions: None,
+            start: PollingStrategy::next(),
+            options: PollOptions::wait(IggyDuration::from(DEFAULT_MAX_WAIT)),
+            prefetch_bytes: DEFAULT_PREFETCH_BYTES,
+            prefetch_messages: None,
+            encryptor,
+            retry_interval: DEFAULT_RETRY_INTERVAL,
+            progress: None,
+            membership: None,
+        }
+    }
+
+    /// The partitions of a standalone consumer, which requires them. A consumer group reads the
+    /// assignment of its member, so `build()` refuses them for a group.
+    pub fn partitions(self, partitions: impl IntoIterator<Item = u32>) -> Self {
+        Self {
+            partitions: Some(partitions.into_iter().collect()),
+            ..self
+        }
+    }
+
+    /// The start of a partition without a position. Default `PollingStrategy::next()`.
+    /// `build()` refuses a continuation, because it belongs to one partition.
+    pub fn start(self, start: PollingStrategy) -> Self {
+        Self { start, ..self }
+    }
+
+    /// The options of each request and the [`CommitPolicy`] of the reader. Default
+    /// `PollOptions::wait(5 s)`, which commits nothing.
+    pub fn options(self, options: PollOptions) -> Self {
+        Self { options, ..self }
+    }
+
+    /// The encoded bytes of the requests in flight and the queued batches. Default 16 MiB.
+    pub fn prefetch_bytes(self, bytes: u32) -> Self {
+        Self {
+            prefetch_bytes: bytes,
+            ..self
+        }
+    }
+
+    /// The messages of the requests in flight and the queued batches. Default: the larger of
+    /// 16,000 and `count`.
+    pub fn prefetch_messages(self, messages: u32) -> Self {
+        Self {
+            prefetch_messages: Some(messages),
+            ..self
+        }
+    }
+
+    /// The record of deliveries and commits, which `IggyConsumer` shares with its reader.
+    pub(crate) fn progress(self, progress: Arc<Progress>) -> Self {
+        Self {
+            progress: Some(progress),
+            ..self
+        }
+    }
+
+    /// The membership of a group reader, which `IggyConsumer` keeps across builds.
+    pub(crate) fn membership(self, membership: Arc<Membership>) -> Self {
+        Self {
+            membership: Some(membership),
+            ..self
+        }
+    }
+
+    /// A membership that creates a missing group, as the one of `IggyConsumer` does.
+    #[cfg(test)]
+    pub(crate) fn create_group(self, create_group: bool) -> Self {
+        let membership = Membership::new(
+            self.client.clone(),
+            Arc::new(self.stream.clone()),
+            Arc::new(self.topic.clone()),
+            self.consumer.id.clone(),
+            create_group,
+        );
+        self.membership(Arc::new(membership))
+    }
+
+    /// The wait before a refused partition reads again.
+    pub(crate) fn retry_interval(self, retry_interval: Duration) -> Self {
+        Self {
+            retry_interval,
+            ..self
+        }
+    }
+
+    /// Checks the configuration, joins the group of a group reader and starts reading.
+    ///
+    /// # Errors
+    ///
+    /// - [`IggyError::FeatureUnavailable`] for an external group, and for a consumer group on
+    ///   HTTP, which cannot join a group.
+    /// - [`IggyError::InvalidCommand`] for partitions of a consumer group, a standalone consumer
+    ///   without partitions, or a continuation as the start.
+    /// - [`IggyError::InvalidConfiguration`] for `CommitPolicy::OnRead` with an encryptor:
+    ///   the server would store the offset of a batch before it decrypts.
+    /// - The errors of [`PollOptions::validate`], and the error of the first join.
+    /// - [`IggyError::OperationUncertain`] for a create or a join of the group with an unknown
+    ///   outcome. The client keeps that operation, so a new `build()` gets
+    ///   [`IggyError::LaneBlocked`] until [`Client::resolve_operation`] or
+    ///   [`Client::abandon_operation`] settles it.
+    pub async fn build(self) -> Result<PartitionReader, IggyError> {
+        let (config, mut shared) = self.into_parts()?;
+        let events = {
+            let client = config.client.read().await;
+            // A transport without group state must fail before the join, or the member
+            // would hold partitions that it never reads.
+            if config.is_group() {
+                let state = client.deferred_group_state().await?;
+                let key = group_cache_key(&config.stream, &config.topic, &config.consumer.id);
+                shared.assignment_state = Some((state, key));
+            }
+            // A clone of the client's receiver holds every event published since the client
+            // started, and the worker would act on that history as if it were live.
+            client.subscribe_events().await.new_receiver()
+        };
+        if let Some(membership) = &shared.membership {
+            membership.join().await?;
+        }
+        Ok(PartitionReader::start(
+            Arc::new(config),
+            Arc::new(shared),
+            Some(events),
+        ))
+    }
+
+    /// Checks the configuration and builds the parts of a reader, without I/O.
+    pub(crate) fn into_parts(self) -> Result<(Config, Shared), IggyError> {
+        let is_group = match self.consumer.kind {
+            ConsumerKind::Consumer => false,
+            ConsumerKind::ConsumerGroup => true,
+            ConsumerKind::ExternalGroup => {
+                error!(
+                    "Reader of consumer: {} cannot read: an external group only holds offsets.",
+                    self.consumer
+                );
+                return Err(IggyError::FeatureUnavailable);
+            }
+        };
+        let partitions = match (is_group, self.partitions) {
+            (false, Some(mut partitions)) if !partitions.is_empty() => {
+                partitions.sort_unstable();
+                partitions.dedup();
+                partitions
+            }
+            (true, None) => Vec::new(),
+            (false, partitions) => {
+                error!(
+                    "Reader of consumer: {} has the partitions: {partitions:?}. A standalone consumer needs at least one partition.",
+                    self.consumer
+                );
+                return Err(IggyError::InvalidCommand);
+            }
+            (true, partitions) => {
+                error!(
+                    "Reader of consumer: {} has the partitions: {partitions:?}. A consumer group reads the partitions of its assignment, so it takes none.",
+                    self.consumer
+                );
+                return Err(IggyError::InvalidCommand);
+            }
+        };
+        if let Err(refusal) = self.options.validate() {
+            error!(
+                "Reader of consumer: {} has invalid poll options: {:?}: {refusal}.",
+                self.consumer, self.options
+            );
+            return Err(refusal);
+        }
+        if let Some(partition_id) = self.start.partition_id() {
+            error!(
+                "Reader of consumer: {} has the start: {}, a continuation of partition: {partition_id}. A continuation belongs to one partition, so it cannot start every partition.",
+                self.consumer, self.start
+            );
+            return Err(IggyError::InvalidCommand);
+        }
+        if self.options.commit == CommitPolicy::OnRead && self.encryptor.is_some() {
+            error!(
+                "Reader of consumer: {} has an encryptor and CommitPolicy::OnRead. The server would store the offset of a batch before it decrypts, so a batch that fails to decrypt would be lost.",
+                self.consumer
+            );
+            return Err(IggyError::InvalidConfiguration);
+        }
+        let prefetch_messages = self
+            .prefetch_messages
+            .unwrap_or(DEFAULT_PREFETCH_MESSAGES.max(self.options.count));
+        let slots = budget_slots(prefetch_messages, self.prefetch_bytes, self.options);
+        let start = first_as_offset(self.start);
+        let stream = Arc::new(self.stream);
+        let topic = Arc::new(self.topic);
+        let consumer = Arc::new(self.consumer);
+        let progress = self.progress.unwrap_or_else(|| {
+            Arc::new(Progress::new(
+                self.client.clone(),
+                Arc::clone(&consumer),
+                Arc::clone(&stream),
+                Arc::clone(&topic),
+                self.options.commit,
+            ))
+        });
+        // Only `IggyConsumer` creates a missing group, through the membership that it passes.
+        let membership = is_group.then(|| {
+            self.membership.unwrap_or_else(|| {
+                Arc::new(Membership::new(
+                    self.client.clone(),
+                    Arc::clone(&stream),
+                    Arc::clone(&topic),
+                    consumer.id.clone(),
+                    false,
+                ))
+            })
+        });
+        let shared = Shared {
+            progress,
+            epoch: AtomicU64::new(0),
+            wake: Arc::new(Notify::new()),
+            demand: AtomicBool::new(false),
+            closing: AtomicBool::new(false),
+            can_poll: AtomicBool::new(true),
+            partitions: Mutex::new(partitions.clone()),
+            membership,
+            assignment_state: None,
+        };
+        let config = Config {
+            client: self.client,
+            stream,
+            topic,
+            consumer,
+            partitions,
+            start,
+            options: self.options,
+            slots,
+            encryptor: self.encryptor,
+            retry_interval: self.retry_interval,
+        };
+        Ok((config, shared))
+    }
+}
+
+/// The requests in flight and the queued replies, each of which reserves `count` messages and
+/// `max_bytes` bytes. One fits even when the prefetch limits are lower, and the driver admits at
+/// most 16 waiting requests of one consumer.
+fn budget_slots(prefetch_messages: u32, prefetch_bytes: u32, options: PollOptions) -> usize {
+    let messages = prefetch_messages
+        .checked_div(options.count)
+        .unwrap_or_default();
+    let bytes = prefetch_bytes
+        .checked_div(options.max_bytes)
+        .unwrap_or_default();
+    usize::try_from(messages.min(bytes))
+        .unwrap_or(usize::MAX)
+        .clamp(1, MAX_DEFERRED_REQUESTS_PER_CONSUMER)
+}
diff --git a/core/sdk/src/consumer_ext/consumer_message_ext.rs b/core/sdk/src/consumer_ext/consumer_message_ext.rs
index 39dbea8..ee7415e 100644
--- a/core/sdk/src/consumer_ext/consumer_message_ext.rs
+++ b/core/sdk/src/consumer_ext/consumer_message_ext.rs
@@ -15,11 +15,12 @@
 // specific language governing permissions and limitations
 // under the License.
 
-use crate::clients::consumer::{AutoCommit, AutoCommitAfter, IggyConsumer};
+use crate::clients::consumer::IggyConsumer;
+use crate::clients::reader::lasting_refusal;
 use crate::consumer_ext::{IggyConsumerMessageExt, MessageConsumer};
-use crate::prelude::IggyError;
+use crate::prelude::{CommitAfter, IggyError, ReadError};
 use async_trait::async_trait;
-use futures_util::StreamExt;
+use std::future::poll_fn;
 use tokio::sync::oneshot;
 use tracing::{error, info, trace, warn};
 
@@ -27,6 +28,11 @@
 impl<'a> IggyConsumerMessageExt<'a> for IggyConsumer {
     /// Consume messages from the stream and process them with the given message consumer.
     ///
+    /// Under `CommitPolicy::Auto`, a message counts before the handler with
+    /// `CommitAfter::Delivery`, and once the handler succeeds with `CommitAfter::Processing`.
+    /// With `Processing`, a handler that fails ends the consumption with its error, and its
+    /// message stays uncounted, so the next run reads it again.
+    ///
     /// # Arguments
     ///
     /// * `message_consumer`: The message consumer to use. This must be a reference to a static
@@ -36,11 +42,32 @@
     ///
     /// # Errors
     ///
+    /// A [`ReadError`] with the partition of the error, if it has one, and one of these errors:
+    ///
     /// * `IggyError::InvalidServerAddress`: The server address is invalid.
     /// * `IggyError::InvalidClientAddress`: The client address is invalid.
+    /// * `IggyError::InvalidConfiguration`: The consumer was not initialized.
+    /// * The error of the handler, with the partition of its message, under
+    ///   `CommitAfter::Processing`.
     /// * `IggyError::ClientShutdown`: The client has been shut down.
     /// * `IggyError::LaneBlocked`: A retained operation that the consumer does not resolve blocks
     ///   its polls and requires caller recovery.
+    /// * `IggyError::PartitionLaneBlocked`: With `CommitPolicy::OnRead`, another call on the
+    ///   client left a committing read of the partition with an unknown outcome, and the
+    ///   partition stays stopped until the caller abandons the operation and seeks. A lost reply
+    ///   of the consumer itself ends nothing: the reader reads on and hands over what it stored.
+    /// * `IggyError::HistoryUnavailable`: The partition has a new history, and it stays stopped
+    ///   until the caller seeks.
+    /// * `IggyError::Unauthorized`, `IggyError::PartitionNotFound` and the other refusals that a
+    ///   retry does not change, with a partition: the reader retried the partition with a longer
+    ///   wait each time, and it stays stopped until the caller seeks.
+    /// * Any error with a [`ReadError::skip`]: A message of the partition does not decode, and
+    ///   the reader reads it again until the caller seeks to the skip.
+    /// * `IggyError::InvalidNumberEncoding`, `IggyError::InvalidMessagePayloadLength` or
+    ///   `IggyError::InvalidMessagesCount` without a skip: A reply of the partition does not
+    ///   decode, and the reader cannot name the message to skip. With
+    ///   `CommitPolicy::OnRead`, a read from `Next` stops the partition until the caller
+    ///   seeks.
     /// * Any other poll error, once the client cannot come back without the caller: it has no
     ///   link and no replica link that tries to reconnect, and the heartbeat does not restart it,
     ///   because reconnection is disabled or the client holds no session to resume, as after
@@ -55,42 +82,23 @@
         &mut self,
         message_consumer: &'a P,
         mut shutdown_rx: oneshot::Receiver<()>,
-    ) -> Result<(), IggyError>
+    ) -> Result<(), ReadError>
     where
         P: MessageConsumer + Sync,
     {
-        // Unlike a caller of `next()`, a message consumer gets no handle to store offsets with.
+        // Unlike a caller of `next()`, a message consumer gets no handle to commit with.
         if self.relies_on_manual_commits() {
             warn!(
-                "Consumer: {name} on topic: {topic} and stream: {stream} never stores offsets, because it runs with AutoCommit::Disabled. With PollingStrategy::next(), each restart reads the same messages again. Set auto_commit to store them.",
+                "Consumer: {name} on topic: {topic} and stream: {stream} never stores offsets, because it runs with CommitPolicy::Manual. With PollingStrategy::next(), each restart reads the same messages again. Set CommitPolicy::Auto in the poll options to store them.",
                 name = self.name(),
                 topic = self.topic(),
                 stream = self.stream()
             );
         }
-        let auto_commit = self.auto_commit();
-        let store_offset_after_each_message = matches!(
-            auto_commit,
-            AutoCommit::After(AutoCommitAfter::ConsumingEachMessage)
-                | AutoCommit::IntervalOrAfter(_, AutoCommitAfter::ConsumingEachMessage)
-        );
-
-        let store_offset_after_all_messages = matches!(
-            auto_commit,
-            AutoCommit::After(AutoCommitAfter::ConsumingAllMessages)
-                | AutoCommit::IntervalOrAfter(_, AutoCommitAfter::ConsumingAllMessages)
-        );
-
-        let store_after_every_nth_message = match auto_commit {
-            AutoCommit::After(AutoCommitAfter::ConsumingEveryNthMessage(n))
-            | AutoCommit::IntervalOrAfter(_, AutoCommitAfter::ConsumingEveryNthMessage(n)) => {
-                n as u64
-            }
-            _ => 0,
-        };
 
         loop {
             tokio::select! {
+                biased;
                 // Check first if we have received a shutdown signal
                 _ = &mut shutdown_rx => {
                     info!("Received shutdown signal, stopping message consumption from consumer {name} on topic: {topic} and stream: {stream}",
@@ -98,44 +106,52 @@
                     break;
                 }
 
-                message = self.next() => {
+                message = poll_fn(|cx| self.poll_message(cx)) => {
                     match message {
-                        Some(Ok(received_message)) => {
-                            let position = received_message.position();
+                        Some(Ok((received_message, ends_batch))) => {
+                            let checkpoint = received_message.checkpoint();
                             let partition_id = received_message.partition_id;
                             let current_offset = received_message.current_offset;
                             let message_offset = received_message.message.header.offset;
-                            if let Err(err) = message_consumer.consume(received_message).await {
-                                error!("Error while handling message at offset: {message_offset}/{current_offset}, partition: {partition_id} for consumer: {name} on topic: {topic} and stream: {stream} due to error: {err}",
-                                    name = self.name(), topic = self.topic(), stream = self.stream());
-                            } else {
-                                trace!("Message at offset: {message_offset}/{current_offset}, partition: {partition_id} has been handled by consumer: {name} on topic: {topic} and stream: {stream}",
-                                    name = self.name(), topic = self.topic(), stream = self.stream());
+                            let counts_after = self.counts_after();
+                            if counts_after == Some(CommitAfter::Delivery) {
+                                self.count(checkpoint, ends_batch);
                             }
-
-                            if store_offset_after_each_message {
-                                trace!("Storing offset: {message_offset}/{current_offset}, partition: {partition_id}, after each message for consumer: {name} on topic: {topic} and stream: {stream}",
-                                    name = self.name(), topic = self.topic(), stream = self.stream());
-                                self.send_store_offset(position);
-                            } else if store_after_every_nth_message > 0  && message_offset % store_after_every_nth_message == 0 {
-                                trace!("Storing offset: {message_offset}/{current_offset}, partition: {partition_id}, after every {store_after_every_nth_message} message for consumer: {name} on topic: {topic} and stream: {stream}",
-                                    store_after_every_nth_message = store_after_every_nth_message, name = self.name(), topic = self.topic(), stream = self.stream());
-                                self.send_store_offset(position);
-                            } else if store_offset_after_all_messages && message_offset == current_offset {
-                                trace!("Storing offset: {message_offset}/{current_offset}, partition: {partition_id}, after all messages for consumer: {name} on topic: {topic} and stream: {stream}",
-                                    name = self.name(), topic = self.topic(), stream = self.stream());
-                                self.send_store_offset(position);
+                            match message_consumer.consume(received_message).await {
+                                Ok(()) => {
+                                    trace!("Message at offset: {message_offset}/{current_offset}, partition: {partition_id} has been handled by consumer: {name} on topic: {topic} and stream: {stream}",
+                                        name = self.name(), topic = self.topic(), stream = self.stream());
+                                    if counts_after == Some(CommitAfter::Processing) {
+                                        self.count(checkpoint, ends_batch);
+                                    }
+                                }
+                                Err(err) => {
+                                    error!("Error while handling message at offset: {message_offset}/{current_offset}, partition: {partition_id} for consumer: {name} on topic: {topic} and stream: {stream} due to error: {err}",
+                                        name = self.name(), topic = self.topic(), stream = self.stream());
+                                    if counts_after == Some(CommitAfter::Processing) {
+                                        return Err(ReadError::new(Some(partition_id), err));
+                                    }
+                                }
                             }
                         }
                         Some(Err(err)) => {
-                            let ends = matches!(
-                                err,
-                                IggyError::InvalidServerAddress
-                                    | IggyError::InvalidClientAddress
-                                    | IggyError::InvalidConfiguration
-                                    | IggyError::LaneBlocked(_)
-                                    | IggyError::ClientShutdown
-                            ) || !self.client_can_recover().await;
+                            let ends = err.skip.is_some()
+                                || matches!(
+                                    err.error,
+                                    IggyError::InvalidServerAddress
+                                        | IggyError::InvalidClientAddress
+                                        | IggyError::InvalidConfiguration
+                                        | IggyError::LaneBlocked(_)
+                                        | IggyError::PartitionLaneBlocked(..)
+                                        | IggyError::FetchProgressUncertain(_)
+                                        | IggyError::HistoryUnavailable
+                                        | IggyError::InvalidNumberEncoding
+                                        | IggyError::InvalidMessagePayloadLength
+                                        | IggyError::InvalidMessagesCount
+                                        | IggyError::ClientShutdown
+                                )
+                                || (err.partition_id.is_some() && lasting_refusal(&err.error))
+                                || !self.client_can_recover().await;
                             if ends {
                                 error!("Client error: {err} for consumer: {name} on topic: {topic} and stream: {stream}",
                                     name = self.name(), topic = self.topic(), stream = self.stream());
diff --git a/core/sdk/src/consumer_ext/consumer_message_trait.rs b/core/sdk/src/consumer_ext/consumer_message_trait.rs
index 3202728..a4f58af 100644
--- a/core/sdk/src/consumer_ext/consumer_message_trait.rs
+++ b/core/sdk/src/consumer_ext/consumer_message_trait.rs
@@ -16,7 +16,7 @@
 // under the License.
 
 use crate::consumer_ext::MessageConsumer;
-use crate::prelude::IggyError;
+use crate::prelude::ReadError;
 use async_trait::async_trait;
 use tokio::sync::oneshot;
 
@@ -25,7 +25,12 @@
     /// This function starts an event loop that consumes messages from the stream and
     /// applies the provided consumer. The loop will exit when the shutdown receiver is triggered.
     ///
-    /// This can be combined with `AutoCommitAfter` to automatically commit offsets after consuming.
+    /// Under `CommitPolicy::Auto`, a message counts before the consumer runs with
+    /// `CommitAfter::Delivery`, and once it returns with success with `CommitAfter::Processing`.
+    /// With `Processing`, a consumer that fails ends the loop and leaves its message uncounted.
+    ///
+    /// An error that ends the loop names its partition, if it has one, so the caller can
+    /// recover that partition and consume again.
     ///
     /// # Arguments
     ///
@@ -36,7 +41,7 @@
         &mut self,
         message_consumer: &'a P,
         shutdown_rx: oneshot::Receiver<()>,
-    ) -> Result<(), IggyError>
+    ) -> Result<(), ReadError>
     where
         P: MessageConsumer + Sync;
 }
diff --git a/core/sdk/src/driver.rs b/core/sdk/src/driver.rs
index 45b3802..20cf77b 100644
--- a/core/sdk/src/driver.rs
+++ b/core/sdk/src/driver.rs
@@ -20,38 +20,44 @@
 //! A frame that can have taken effect is never frozen again, so a retry never
 //! applies a request twice.
 
-use crate::links::{Frame, Link, ReplicaLinks, Target, lost_link, pause_before_retry, read_reply};
+use crate::links::{
+    Frame, LateReply, Link, ReplicaLinks, Reply, Target, committing_cancel_requested, lost_link,
+    pause_before_retry, read_reply,
+};
 use crate::session_owner::{LaneKey, LaneTicket, RetainedOperation, SessionEpoch, SessionOwner};
 use crate::vsr::decode_reply_for;
 use bytes::Bytes;
-use futures::stream::{self, FuturesUnordered, StreamExt};
+use futures::stream::{self, StreamExt, TryStreamExt};
 use iggy_binary_protocol::codes::{
     BIND_SESSION_CODE, CANCEL_DEFERRED_REQUEST_CODE, CHANGE_PASSWORD_CODE,
-    DELETE_CONSUMER_OFFSET_CODE, GET_CONSUMER_OFFSET_CODE, GET_PARTITION_ROUTE_CODE,
-    LOGIN_REGISTER_CODE, LOGIN_REGISTER_WITH_PAT_CODE, POLL_MESSAGES_CODE,
-    POLL_MESSAGES_DEFERRED_CODE, SEND_MESSAGES_CODE, STORE_CONSUMER_OFFSET_CODE,
-    UPDATE_RESOURCE_CODE, WAIT_FOR_MESSAGES_CODE,
+    DELETE_CONSUMER_OFFSET_CODE, FETCH_MESSAGES_CODE, GET_CONSUMER_OFFSET_CODE,
+    GET_PARTITION_ROUTE_CODE, LOGIN_REGISTER_CODE, LOGIN_REGISTER_WITH_PAT_CODE,
+    POLL_MESSAGES_CODE, POLL_MESSAGES_DEFERRED_CODE, SEND_MESSAGES_CODE,
+    STORE_CONSUMER_OFFSET_CODE, UPDATE_RESOURCE_CODE,
 };
-use iggy_binary_protocol::consensus::{Operation, operation_for_code};
+use iggy_binary_protocol::consensus::{Operation, RequestHeader, operation_for_code};
 use iggy_binary_protocol::primitives::partition_history::PartitionContext;
 use iggy_binary_protocol::requests::consumer_offsets::{
     DeleteConsumerOffsetRequest, GetConsumerOffsetRequest, StoreConsumerOffsetRequest,
 };
 use iggy_binary_protocol::requests::messages::{
-    DeferredPollMessagesRequest, GetPartitionRouteRequest, MIN_DEFERRED_POLL_REPLY_MARGIN_US,
-    PollMessagesRequest, SendMessagesHeader, WaitForMessagesRequest,
+    DeferredPollMessagesRequest, FetchMessagesRequest, GetPartitionRouteRequest,
+    MIN_DEFERRED_POLL_MAX_BYTES, MIN_DEFERRED_POLL_REPLY_MARGIN_US, PollMessagesRequest,
+    SendMessagesHeader,
 };
 use iggy_binary_protocol::requests::system::{ResourceTarget, UpdateResourceRequest};
 use iggy_binary_protocol::requests::users::ChangePasswordRequest;
-use iggy_binary_protocol::responses::messages::{PartitionRouteResponse, WaitForMessagesResponse};
+use iggy_binary_protocol::responses::messages::{
+    FetchMessagesResponse, FetchMessagesSectionIterator, PartitionRouteResponse,
+};
 use iggy_binary_protocol::responses::users::LoginRegisterResponse;
 use iggy_binary_protocol::{
-    MAX_PARTITIONS_PER_REQUEST, WireDecode, WireEncode, WireOptions, WirePartitioning,
+    MAX_PARTITIONS_PER_REQUEST, STATUS_OK, WireDecode, WireEncode, WireOptions, WirePartitioning,
     WirePollingStrategy,
 };
 use iggy_common::{
-    BinaryOperationLineage, ClientState, Credentials, DeferredPollOptions, DiagnosticEvent,
-    HeaderKind, Identifier, IggyError, OptionKey,
+    BinaryOperationLineage, ClientState, CommitErrorScope, Credentials, DeferredPollOptions,
+    DiagnosticEvent, FetchedSection, HeaderKind, Identifier, IggyError, OptionKey,
 };
 use route_cache::RouteCache;
 use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
@@ -215,6 +221,9 @@
         self.poll_with_options(request, context, None).await
     }
 
+    /// An `auto_commit` poll whose outcome is unknown after it left the client
+    /// ends with `FetchProgressUncertain` and blocks its partition until an
+    /// abandon, because the server can still store its offset.
     pub(crate) async fn poll_with_options(
         &self,
         request: &PollMessagesRequest,
@@ -282,6 +291,7 @@
                 started,
                 progress_poll: request.auto_commit
                     || request.strategy.kind == WirePollingStrategy::next().kind,
+                committing_poll: request.auto_commit,
                 deadline: operation_deadline(),
                 ..call
             })
@@ -300,26 +310,19 @@
         partitions: &[u32],
     ) -> Result<Vec<Vec<u32>>, IggyError> {
         let lineage = self.session.operation_lineage().lineage();
-        let deadline = operation_deadline();
-        let mut routes = stream::iter(partitions.iter().copied())
-            .map(|partition_id| {
-                let key = PartitionKey {
-                    stream_id: stream_id.clone(),
-                    topic_id: topic_id.clone(),
-                    partition_id,
-                };
-                async move {
-                    self.route(&key, lineage, deadline)
-                        .await
-                        .map(|route| (route.primary_replica, partition_id))
-                }
-            })
-            .buffered(MAX_ROUTE_LOOKUPS_IN_FLIGHT);
+        let routes = self
+            .routes_of(
+                stream_id,
+                topic_id,
+                partitions,
+                lineage,
+                operation_deadline(),
+            )
+            .await?;
         let mut endpoints = std::collections::BTreeMap::<u8, Vec<u32>>::new();
-        while let Some(route) = routes.next().await {
-            let (primary_replica, partition_id) = route?;
+        for (&partition_id, route) in partitions.iter().zip(routes) {
             endpoints
-                .entry(primary_replica)
+                .entry(route.primary_replica)
                 .or_default()
                 .push(partition_id);
         }
@@ -334,114 +337,74 @@
         Ok(groups)
     }
 
-    pub(crate) async fn wait_for_messages(
+    /// One fetch on the primary of its targets. The targets of a readiness
+    /// group share it, so the call routes by the first one. The header
+    /// carries context zero, and each target its own fence.
+    pub(crate) async fn fetch_messages(
         &self,
-        request: &WaitForMessagesRequest,
+        request: &FetchMessagesRequest,
     ) -> Result<Bytes, IggyError> {
+        self.fetch_response(request).await.map(|reply| reply.body)
+    }
+
+    pub(crate) async fn fetch_sections(
+        &self,
+        request: &FetchMessagesRequest,
+    ) -> Result<Vec<FetchedSection>, IggyError> {
+        let reply = self.fetch_response(request).await?;
+        let mut sections = FetchedSection::from_reply(&reply.body)?;
+        if let Some(uncertain) = reply.uncertain_fetch {
+            for section in &mut sections {
+                if uncertain.partitions.contains(&section.partition_id) {
+                    section.status = Err(IggyError::FetchProgressUncertain(uncertain.operation_id));
+                }
+            }
+        }
+        Ok(sections)
+    }
+
+    async fn fetch_response(&self, request: &FetchMessagesRequest) -> Result<CallReply, IggyError> {
+        DeferredPollOptions::from(request).validate_fetch(request.count)?;
         let started = Instant::now();
         let deadline = started
             .checked_add(std::time::Duration::from_micros(request.request_timeout_us))
             .ok_or(IggyError::InvalidCommand)?;
         let stream_id = Identifier::try_from(&request.stream_id)?;
         let topic_id = Identifier::try_from(&request.topic_id)?;
-        let partitions: Vec<u32> = request
-            .partitions
-            .iter()
-            .map(|target| target.partition_id)
-            .collect();
-        let wait = move |group: Vec<u32>| {
-            let mut request = request.clone();
-            request
-                .partitions
-                .retain(|target| group.contains(&target.partition_id));
-            async move { (self.wait_on_primary(request, started).await, group) }
-        };
-        with_deadline(deadline, async {
-            let mut waits: FuturesUnordered<_> = self
-                .wait_groups(&stream_id, &topic_id, &partitions)
-                .await?
-                .into_iter()
-                .map(wait)
-                .collect();
-            let mut moves = 0;
-            while let Some((reply, group)) = waits.next().await {
-                let body = match reply {
-                    Err(IggyError::NotPrimary) => {
-                        self.routes().may_have_moved(&stream_id, &topic_id, &group);
-                        if !pause_before_retry(moves, operation_deadline(), rand::random()).await {
-                            return Err(IggyError::TransientNotAccepted);
-                        }
-                        moves = moves.saturating_add(1);
-                        let groups = self.wait_groups(&stream_id, &topic_id, &group).await?;
-                        waits.extend(groups.into_iter().map(wait));
-                        continue;
-                    }
-                    reply => reply?,
-                };
-                let response = WaitForMessagesResponse::decode_from(&body)
-                    .map_err(|_| IggyError::InvalidCommand)?;
-                if !response.partitions.is_empty() {
-                    return Ok(body);
-                }
-            }
-            Ok(WaitForMessagesResponse {
-                partitions: Vec::new(),
-            }
-            .to_bytes())
-        })
-        .await
-    }
-
-    /// [`Self::readiness_groups`], retried until a lookup answers or the
-    /// deadline passes.
-    async fn wait_groups(
-        &self,
-        stream_id: &Identifier,
-        topic_id: &Identifier,
-        partitions: &[u32],
-    ) -> Result<Vec<Vec<u32>>, IggyError> {
-        let mut retries = 0;
-        loop {
-            match self.readiness_groups(stream_id, topic_id, partitions).await {
-                Err(error) if retries_lookup(&error) => {
-                    if !pause_before_retry(retries, operation_deadline(), rand::random()).await {
-                        return Err(IggyError::TransientNotAccepted);
-                    }
-                    retries = retries.saturating_add(1);
-                }
-                groups => return groups,
-            }
-        }
-    }
-
-    /// `started` is when the wait began, before routing. The caller's deadline
-    /// ends the wait.
-    async fn wait_on_primary(
-        &self,
-        request: WaitForMessagesRequest,
-        started: Instant,
-    ) -> Result<Bytes, IggyError> {
         let partition_id = request
-            .partitions
+            .targets
             .first()
             .ok_or(IggyError::InvalidCommand)?
             .partition_id;
-        let stream_id = Identifier::try_from(&request.stream_id)?;
-        let topic_id = Identifier::try_from(&request.topic_id)?;
-        let _lease = self.deferred_lease().await?;
-        self.drive(Call {
-            started,
-            ..Call::new(
-                WAIT_FOR_MESSAGES_CODE,
+        let mut request = request.clone();
+        if let Some(limit) = self.links.reply_body_limit() {
+            fit_fetch_reply(&mut request, limit)?;
+        }
+        let progress_poll = request.auto_commit
+            || request
+                .targets
+                .iter()
+                .any(|target| target.strategy.kind == WirePollingStrategy::next().kind);
+        with_deadline(deadline, async {
+            let _lease = self.deferred_lease().await?;
+            let call = Call::new(
+                FETCH_MESSAGES_CODE,
                 request.to_bytes(),
                 RequestTarget::Partition(PartitionTarget {
                     stream_id: &stream_id,
                     topic_id: &topic_id,
                     partition_id,
-                    context: None,
+                    context: Some(PartitionContext::default()),
                 }),
                 operation_deadline(),
-            )
+            );
+            self.drive_reply(Call {
+                started,
+                progress_poll,
+                fetch: Some(request),
+                ..call
+            })
+            .await
         })
         .await
     }
@@ -484,10 +447,11 @@
                     .poll_with_options(&request.poll, context, Some((&request).into()))
                     .await;
             }
-            WAIT_FOR_MESSAGES_CODE => {
-                let request = WaitForMessagesRequest::decode_from(&payload)
+            // Each target carries its own fence, and a zero one takes the route.
+            FETCH_MESSAGES_CODE => {
+                let request = FetchMessagesRequest::decode_from(&payload)
                     .map_err(|_| IggyError::InvalidCommand)?;
-                return self.wait_for_messages(&request).await;
+                return self.fetch_messages(&request).await;
             }
             // A cancel names a pending request by its id, and the server answers no
             // cancel, so a new raw request could only wait for its deadline.
@@ -538,9 +502,9 @@
 
     /// Sends a non-replicated request to replica `replica_id` and returns its
     /// reply. Writes and partition reads go through the routing of their own
-    /// kind: a poll, a readiness wait or a consumer-offset read needs the
-    /// partition's primary and its incarnation, which a request to a chosen
-    /// replica does not carry. The server answers no cancel, so a raw one
+    /// kind: a poll, a fetch or a consumer-offset read needs
+    /// the partition's primary and its incarnation, which a request to a
+    /// chosen replica does not carry. The server answers no cancel, so a raw one
     /// could only wait for its deadline.
     pub(crate) async fn send_to_replica(
         &self,
@@ -553,7 +517,7 @@
                 code,
                 POLL_MESSAGES_CODE
                     | POLL_MESSAGES_DEFERRED_CODE
-                    | WAIT_FOR_MESSAGES_CODE
+                    | FETCH_MESSAGES_CODE
                     | CANCEL_DEFERRED_REQUEST_CODE
                     | GET_CONSUMER_OFFSET_CODE
             )
@@ -596,7 +560,11 @@
         self.session.abandon(op)
     }
 
-    async fn drive(&self, mut call: Call) -> Result<Bytes, IggyError> {
+    async fn drive(&self, call: Call) -> Result<Bytes, IggyError> {
+        self.drive_reply(call).await.map(|reply| reply.body)
+    }
+
+    async fn drive_reply(&self, mut call: Call) -> Result<CallReply, IggyError> {
         let lineage = self.session.operation_lineage().lineage();
         let started = SessionEpoch {
             lineage,
@@ -605,9 +573,16 @@
         loop {
             let next = match self.attempt(&mut call, started).await {
                 Ok(body) => {
+                    let uncertain_fetch = match call.accept_fetch_reply(&body) {
+                        Ok(uncertain) => uncertain,
+                        Err(error) => return Err(call.end(error)),
+                    };
                     self.refresh_sign_in(&call);
                     call.complete(Ok(()));
-                    return Ok(body);
+                    return Ok(CallReply {
+                        body,
+                        uncertain_fetch,
+                    });
                 }
                 Err(next) => next,
             };
@@ -615,6 +590,10 @@
             match next {
                 Next::Again if Instant::now() < call.deadline => {}
                 Next::Again => return Err(call.expire()),
+                // The reader of the call closes and cancelled it, so its refusal is the answer.
+                Next::Pause if call.committing() && committing_cancel_requested() => {
+                    return Err(call.expire());
+                }
                 Next::Pause => {
                     if !call.pause().await {
                         return Err(call.expire());
@@ -686,10 +665,32 @@
             self.wait_for_session(call, started).await?;
         }
         let route = self.route_for(call, started.lineage).await?;
+        self.fence_targets(call, started.lineage).await?;
         let context = self.context(call, route.as_ref());
+        let mut lane_context = context;
+        if lane_context.incarnation == 0
+            && (call.progress_poll
+                || matches!(
+                    call.code,
+                    GET_CONSUMER_OFFSET_CODE
+                        | STORE_CONSUMER_OFFSET_CODE
+                        | DELETE_CONSUMER_OFFSET_CODE
+                ))
+            && let Some(route) = &route
+        {
+            // Keep an explicit zero wire fence invalid while sharing local
+            // progress admission with callers that captured the route.
+            lane_context.incarnation = route.incarnation;
+        }
         // The lane comes first, so a blocked lane ends the call at once, also
         // while no link is ready.
-        let mut ticket = self.ticket(call, &context).await?;
+        let mut ticket = self.ticket(call, &lane_context).await?;
+        if call.committing()
+            && let Err(error) = ticket.reserve_fetch(call.deadline).await
+        {
+            call.ticket = Some(ticket);
+            return Err(Next::Fail(error));
+        }
         let body_len = call
             .frame
             .as_ref()
@@ -706,9 +707,25 @@
             }
         };
         let deadline = call.deadline;
+        let fetch = call.fetch.is_some();
+        let committing = call.committing();
         let (frame, ticket) = call.frame(ticket, context, started.lineage)?;
         let request = frame.header;
-        let result = hop.link.send(frame, deadline, ticket.sent_flag()).await;
+        // A committing request whose caller leaves gets one reply to its cancel, which settles it.
+        let late = ticket
+            .settlement()
+            .filter(|_| committing)
+            .map(|settlement| -> LateReply {
+                Box::new(move |reply| {
+                    if let Some(uncertain) = late_uncertain(&request, fetch, reply) {
+                        settlement.settle(uncertain);
+                    }
+                })
+            });
+        let result = hop
+            .link
+            .send_settled(frame, deadline, ticket.sent_flag(), late)
+            .await;
         let sent = *ticket.sent_flag();
         let result = match result {
             Ok(reply) => match decode_reply_for(&request, &reply.header, reply.body) {
@@ -716,12 +733,22 @@
                     if request.operation == Operation::NonReplicated
                         && self.session.epoch().lineage != started.lineage
                     {
-                        // Progress polls can move a cursor without a replayable receipt.
-                        call.uncertain |= call.progress_poll
-                            && matches!(
-                                &outcome.result,
-                                Ok(_) | Err(IggyError::TransientNotCommitted)
-                            );
+                        if call.fetch.is_some()
+                            && let Ok(body) = &outcome.result
+                        {
+                            // Caller isolation still rejects the call, but a received
+                            // table proves which progress lanes can reopen.
+                            call.accept_fetch_reply(body).map_err(Next::Fail)?;
+                        } else {
+                            // Progress polls can move a cursor without a replayable receipt.
+                            call.uncertain |= call.committing()
+                                && outcome.result.as_ref().is_err_and(fetch_error_is_uncertain);
+                            call.uncertain |= call.progress_poll
+                                && matches!(
+                                    &outcome.result,
+                                    Ok(_) | Err(IggyError::TransientNotCommitted)
+                                );
+                        }
                         return Err(Next::Fail(IggyError::Unauthenticated));
                     }
                     self.session.observe_metadata_reply(&reply.header);
@@ -735,6 +762,7 @@
                     }
                     if bound
                         && request.operation == Operation::NonReplicated
+                        && !call.committing_fetch()
                         && !is_sign_in(call.code)
                         && outcome.result == Err(IggyError::Unauthenticated)
                     {
@@ -840,14 +868,53 @@
         };
         match self.route(&partition.key, lineage, call.deadline).await {
             Ok(route) => Ok(Some(route)),
-            Err(error) if retries_lookup(&error) => {
-                call.refusal = IggyError::TransientNotAccepted;
-                Err(Next::Pause)
-            }
-            Err(error) => Err(Next::Fail(error)),
+            Err(error) => Err(call.lookup_failed(error)),
         }
     }
 
+    /// Gives each target of a fetch without a fence the incarnation of its
+    /// route, before the call freezes its first frame. Every later attempt
+    /// keeps these fences.
+    async fn fence_targets(&self, call: &mut Call, lineage: u64) -> Result<(), Next> {
+        let (Some(partition), Some(request), None) =
+            (&call.partition, &mut call.fetch, &call.frame)
+        else {
+            return Ok(());
+        };
+        let unfenced: Vec<u32> = request
+            .targets
+            .iter()
+            .filter(|target| target.incarnation == 0)
+            .map(|target| target.partition_id)
+            .collect();
+        if unfenced.is_empty() {
+            return Ok(());
+        }
+        let routes = self
+            .routes_of(
+                &partition.key.stream_id,
+                &partition.key.topic_id,
+                &unfenced,
+                lineage,
+                call.deadline,
+            )
+            .await;
+        let routes = match routes {
+            Ok(routes) => routes,
+            Err(error) => return Err(call.lookup_failed(error)),
+        };
+        for (target, route) in request
+            .targets
+            .iter_mut()
+            .filter(|target| target.incarnation == 0)
+            .zip(routes)
+        {
+            target.incarnation = route.incarnation;
+        }
+        call.payload = request.to_bytes();
+        Ok(())
+    }
+
     /// The context that a new frame of `call` carries. Every attempt and
     /// every replay keeps the context of the first frame.
     fn context(&self, call: &Call, route: Option<&PartitionRouteResponse>) -> PartitionContext {
@@ -876,8 +943,43 @@
     ) -> Result<LaneTicket, Next> {
         match call.ticket.take() {
             Some(ticket) => Ok(ticket),
+            None if call.progress_poll && (call.fetch.is_some() || call.committing_poll) => {
+                // A ticket blocks only the progress lanes that it holds besides
+                // its own, so a committing poll enters its lane as a fetch
+                // target does. A fetch that commits nothing guards only the
+                // stored offsets that its `Next` targets read.
+                let partitions: Vec<_> = match &call.fetch {
+                    Some(request) => request
+                        .targets
+                        .iter()
+                        .filter(|target| {
+                            request.auto_commit
+                                || target.strategy.kind == WirePollingStrategy::next().kind
+                        })
+                        .map(|target| (target.partition_id, target.incarnation))
+                        .collect(),
+                    None => vec![(call.partition_id(), context.incarnation)],
+                };
+                self.session
+                    .enter_progress_lanes(&partitions, call.deadline)
+                    .await
+                    .map_err(Next::Fail)
+            }
             None => {
                 let key = LaneKey::of(call.code, context, call.partition_id());
+                if call.code == GET_CONSUMER_OFFSET_CODE {
+                    return self
+                        .session
+                        .enter_progress_inspection(
+                            LaneKey::Progress {
+                                incarnation: context.incarnation,
+                                partition_id: call.partition_id(),
+                            },
+                            call.deadline,
+                        )
+                        .await
+                        .map_err(Next::Fail);
+                }
                 if call.progress_poll
                     || matches!(
                         call.code,
@@ -969,9 +1071,16 @@
         sent: bool,
     ) -> Result<Bytes, Next> {
         let error = match result {
-            Ok(body) => return self.signed_in(call, hop, body),
+            Ok(body) => {
+                self.refused_sections(call, &body);
+                return self.signed_in(call, hop, body);
+            }
             Err(error) => error,
         };
+        if call.committing() && sent && fetch_error_is_uncertain(&error) {
+            call.uncertain = true;
+            return Err(Next::Fail(error));
+        }
         if call.progress_poll
             && (error == IggyError::TransientNotCommitted || (sent && lost_link(&error)))
         {
@@ -1000,9 +1109,11 @@
                 call.settle_sent();
                 Err(Next::Pause)
             }
-            // A wait names several partitions and its refusal does not name
-            // the one that moved, so the caller looks up every route again.
-            IggyError::NotPrimary if call.code == WAIT_FOR_MESSAGES_CODE => Err(Next::Fail(error)),
+            // The refusal does not name the moved target, so the caller regroups.
+            IggyError::NotPrimary if call.fetch.is_some() => {
+                self.mark_targets(call, RouteCache::may_have_moved);
+                Err(Next::Fail(error))
+            }
             IggyError::NotPrimary => {
                 // Neither was admitted, so the deadline reports both as 58.
                 call.refusal = IggyError::TransientNotAccepted;
@@ -1102,11 +1213,93 @@
     }
 
     fn invalidate(&self, call: &Call, hop: &Hop) {
-        if let (Some(partition), Some(route)) = (&call.partition, &hop.route) {
+        if call.fetch.is_some() {
+            // A fetch refusal does not name its target.
+            self.mark_targets(call, RouteCache::invalidate_partitions);
+        } else if let (Some(partition), Some(route)) = (&call.partition, &hop.route) {
             self.routes().invalidate(route, partition.key.partition_id);
         }
     }
 
+    /// Applies `mark` to the routes of every target of a fetch `call`.
+    fn mark_targets(
+        &self,
+        call: &Call,
+        mark: fn(&mut RouteCache, &Identifier, &Identifier, &[u32]),
+    ) {
+        let (Some(partition), Some(request)) = (&call.partition, &call.fetch) else {
+            return;
+        };
+        let partitions: Vec<u32> = request
+            .targets
+            .iter()
+            .map(|target| target.partition_id)
+            .collect();
+        mark(
+            &mut self.routes(),
+            &partition.key.stream_id,
+            &partition.key.topic_id,
+            &partitions,
+        );
+    }
+
+    /// Marks the routes of refused fetch sections as [`Self::settle`] marks
+    /// the route of a refused call. `NotPrimary` sections make the next
+    /// readiness groups look their routes up again.
+    fn refused_sections(&self, call: &Call, body: &[u8]) {
+        let (Some(partition), Some(_)) = (&call.partition, &call.fetch) else {
+            return;
+        };
+        let Ok(sections) = FetchMessagesSectionIterator::new(body) else {
+            return;
+        };
+        let mut moved = Vec::new();
+        let mut invalid = Vec::new();
+        for section in sections.filter(|section| section.header.status != STATUS_OK) {
+            let partition_id = section.header.partition_id;
+            match IggyError::from_code(section.header.status) {
+                IggyError::NotPrimary => moved.push(partition_id),
+                IggyError::HistoryUnavailable | IggyError::ConsumerGroupPartitionNotOwned(..) => {
+                    invalid.push(partition_id);
+                }
+                error if is_namespace_miss(&error) => invalid.push(partition_id),
+                _ => {}
+            }
+        }
+        if moved.is_empty() && invalid.is_empty() {
+            return;
+        }
+        let (stream_id, topic_id) = (&partition.key.stream_id, &partition.key.topic_id);
+        let mut routes = self.routes();
+        routes.may_have_moved(stream_id, topic_id, &moved);
+        routes.invalidate_partitions(stream_id, topic_id, &invalid);
+    }
+
+    /// The routes of `partitions` in their order, with at most
+    /// [`MAX_ROUTE_LOOKUPS_IN_FLIGHT`] lookups at once. The first failed
+    /// lookup in that order is the error.
+    async fn routes_of(
+        &self,
+        stream_id: &Identifier,
+        topic_id: &Identifier,
+        partitions: &[u32],
+        lineage: u64,
+        deadline: Instant,
+    ) -> Result<Vec<PartitionRouteResponse>, IggyError> {
+        stream::iter(partitions.iter().copied())
+            .map(|partition_id| {
+                let key = PartitionKey {
+                    stream_id: stream_id.clone(),
+                    topic_id: topic_id.clone(),
+                    partition_id,
+                };
+                async move { self.route(&key, lineage, deadline).await }
+            })
+            .buffered(MAX_ROUTE_LOOKUPS_IN_FLIGHT)
+            .try_collect()
+            .await
+    }
+
     /// The route of `key`: the cached one if it is usable, else a lookup.
     async fn route(
         &self,
@@ -1131,7 +1324,8 @@
     /// retries until its deadline. A client with no ready replica, no session
     /// and no credentials cannot get a route, because the server refuses it
     /// every read and it never signs in by itself, so its lookup ends with
-    /// `Unauthenticated` at once.
+    /// `Unauthenticated` at once. A shut down or disconnected client without
+    /// a ready replica ends with the error of its state.
     async fn lookup(
         &self,
         key: &PartitionKey,
@@ -1144,11 +1338,13 @@
         let metadata_primary = self.links.metadata_primary();
         let first = hinted.or(metadata_primary);
         let mut replicas = self.links.ready_replicas();
-        if replicas.is_empty()
-            && !self.session.epoch().bound
-            && self.session.sign_in_credentials().is_none()
-        {
-            return Err(IggyError::Unauthenticated);
+        if replicas.is_empty() {
+            if let Some(error) = self.stopped() {
+                return Err(error);
+            }
+            if !self.session.epoch().bound && self.session.sign_in_credentials().is_none() {
+                return Err(IggyError::Unauthenticated);
+            }
         }
         let start = first.map_or(0, |first| replicas.partition_point(|id| *id < first));
         replicas.rotate_left(start);
@@ -1271,6 +1467,8 @@
     frame: Option<Frame>,
     /// Replaying a progress poll can advance its offset again without delivering the first result.
     progress_poll: bool,
+    /// A poll that has the server store the offset of its last message.
+    committing_poll: bool,
     /// An attempt of the frame can have taken effect, so the call never
     /// freezes again.
     uncertain: bool,
@@ -1282,6 +1480,60 @@
     not_primary: bool,
     /// A send that got `HistoryUnavailable` started over once.
     restarted: bool,
+    /// The request of a fetch. Its targets name the progress lanes and the
+    /// routes of the call, and a refusal of the fetch concerns all of them.
+    fetch: Option<FetchMessagesRequest>,
+}
+
+struct CallReply {
+    body: Bytes,
+    uncertain_fetch: Option<UncertainFetch>,
+}
+
+/// Whether a committing fetch that failed with `error` can have taken effect. The HTTP client
+/// applies the same list to the error id of a deferred poll reply.
+pub(crate) fn fetch_error_is_uncertain(error: &IggyError) -> bool {
+    !CommitErrorScope::Request.proves_nothing_stored(error)
+}
+
+/// The targets whose outcome the reply to the cancel of a committing request leaves unknown, or
+/// `None` when the reply proves nothing.
+fn late_uncertain(
+    request: &RequestHeader,
+    fetch: bool,
+    reply: Result<Reply, IggyError>,
+) -> Option<Vec<u32>> {
+    let reply = reply.ok()?;
+    match decode_reply_for(request, &reply.header, reply.body)
+        .ok()?
+        .result
+    {
+        Err(error) if fetch_error_is_uncertain(&error) => None,
+        Err(_) => Some(Vec::new()),
+        Ok(body) if fetch => FetchMessagesSectionIterator::new(&body)
+            .ok()
+            .map(|sections| {
+                sections
+                    .filter(|section| {
+                        section.header.status != STATUS_OK
+                            && fetch_section_is_uncertain(&IggyError::from_code(
+                                section.header.status,
+                            ))
+                    })
+                    .map(|section| section.header.partition_id)
+                    .collect()
+            }),
+        Ok(_) => Some(Vec::new()),
+    }
+}
+
+fn fetch_section_is_uncertain(error: &IggyError) -> bool {
+    !CommitErrorScope::Section.proves_nothing_stored(error)
+}
+
+struct UncertainFetch {
+    operation_id: u64,
+    partitions: Vec<u32>,
 }
 
 struct PartitionCall {
@@ -1341,6 +1593,69 @@
 }
 
 impl Call {
+    fn committing_fetch(&self) -> bool {
+        self.fetch
+            .as_ref()
+            .is_some_and(|request| request.auto_commit)
+    }
+
+    /// The server can store a consumer offset after the client loses the
+    /// reply, so an unknown outcome blocks the progress lanes of the call
+    /// until an abandon.
+    fn committing(&self) -> bool {
+        self.committing_poll || self.committing_fetch()
+    }
+
+    /// Validate the table while the progress gates still belong to the call.
+    /// Records remain opaque so a decode failure keeps the normal rewind path.
+    fn accept_fetch_reply(&mut self, body: &Bytes) -> Result<Option<UncertainFetch>, IggyError> {
+        let Some(request) = &self.fetch else {
+            return Ok(None);
+        };
+        let sections = FetchMessagesSectionIterator::new(body).map_err(|_| {
+            self.uncertain |= request.auto_commit;
+            IggyError::InvalidFormat
+        })?;
+        let mut targets: Vec<u32> = request
+            .targets
+            .iter()
+            .map(|target| target.partition_id)
+            .collect();
+        targets.sort_unstable();
+        let mut seen = vec![false; targets.len()];
+        let mut uncertain = Vec::new();
+        let mut messages = 0_u64;
+        for section in sections {
+            let partition_id = section.header.partition_id;
+            messages += u64::from(section.header.messages_count);
+            let first_of_target = targets
+                .binary_search(&partition_id)
+                .is_ok_and(|target| !std::mem::replace(&mut seen[target], true));
+            if !first_of_target || messages > u64::from(request.count) {
+                self.uncertain |= request.auto_commit;
+                return Err(IggyError::InvalidFormat);
+            }
+            if request.auto_commit
+                && section.header.status != STATUS_OK
+                && fetch_section_is_uncertain(&IggyError::from_code(section.header.status))
+            {
+                uncertain.push(partition_id);
+            }
+        }
+        if uncertain.is_empty() {
+            return Ok(None);
+        }
+        let mut ticket = self.ticket.take().ok_or(IggyError::InvalidConfiguration)?;
+        let operation_id = ticket
+            .retain_fetch_targets(uncertain.clone())
+            .ok_or(IggyError::InvalidConfiguration)?;
+        drop(ticket);
+        Ok(Some(UncertainFetch {
+            operation_id,
+            partitions: uncertain,
+        }))
+    }
+
     fn new(code: u32, payload: Bytes, target: RequestTarget<'_>, deadline: Instant) -> Self {
         let (partition, replica) = match target {
             RequestTarget::Metadata => (None, None),
@@ -1364,11 +1679,13 @@
             ticket: None,
             frame: None,
             progress_poll: false,
+            committing_poll: false,
             uncertain: false,
             refusal: IggyError::TransientNotAccepted,
             retries: 0,
             not_primary: false,
             restarted: false,
+            fetch: None,
         }
     }
 
@@ -1470,11 +1787,14 @@
                     remaining(request.wait_us, request.request_timeout_us)?;
                 Ok(Some(request.to_bytes()))
             }
-            WAIT_FOR_MESSAGES_CODE => {
-                let mut request = WaitForMessagesRequest::decode_from(&self.payload)
-                    .map_err(|_| IggyError::InvalidCommand)?;
+            FETCH_MESSAGES_CODE => {
+                let mut request = self.fetch.clone().ok_or(IggyError::InvalidCommand)?;
                 (request.wait_us, request.request_timeout_us) =
                     remaining(request.wait_us, request.request_timeout_us)?;
+                // A fetch needs the reply margin also without a wait.
+                if request.request_timeout_us < MIN_DEFERRED_POLL_REPLY_MARGIN_US {
+                    return Err(IggyError::TransientNotAccepted);
+                }
                 Ok(Some(request.to_bytes()))
             }
             _ => Ok(None),
@@ -1514,6 +1834,16 @@
         }
     }
 
+    /// The next step after a route lookup of the call failed with `error`.
+    fn lookup_failed(&mut self, error: IggyError) -> Next {
+        if retries_lookup(&error) {
+            self.refusal = IggyError::TransientNotAccepted;
+            Next::Pause
+        } else {
+            Next::Fail(error)
+        }
+    }
+
     /// Sleeps before the next attempt. Returns false instead if the attempt
     /// cannot start before the deadline.
     async fn pause(&mut self) -> bool {
@@ -1575,6 +1905,23 @@
     ) || lost_link(error)
 }
 
+/// Lowers the byte limits of `request` until its whole reply fits in
+/// `limit`, the largest reply body that a link reads. A link fails on a
+/// larger reply, so the server must not fill more.
+fn fit_fetch_reply(request: &mut FetchMessagesRequest, limit: usize) -> Result<(), IggyError> {
+    let records = limit
+        .checked_sub(FetchMessagesResponse::head_size(request.targets.len()))
+        .filter(|records| *records >= MIN_DEFERRED_POLL_MAX_BYTES)
+        .ok_or(IggyError::InvalidSizeBytes)?;
+    request.max_bytes = request
+        .max_bytes
+        .min(u32::try_from(records).unwrap_or(u32::MAX));
+    for target in &mut request.targets {
+        target.max_bytes = target.max_bytes.min(request.max_bytes);
+    }
+    Ok(())
+}
+
 /// Whether `error` tells that the metadata of the replica has no such
 /// stream, topic or partition.
 fn is_namespace_miss(error: &IggyError) -> bool {
@@ -1609,25 +1956,32 @@
     };
     use crate::tcp::tcp_client::TcpClient;
     use bytes::BytesMut;
+    use futures::stream::FuturesUnordered;
     use iggy_binary_protocol::codes::{
         CREATE_STREAM_CODE, GET_CLUSTER_METADATA_CODE, GET_STREAMS_CODE, GET_TOPIC_CODE,
         STORE_CONSUMER_OFFSET_CODE,
     };
     use iggy_binary_protocol::consensus::EvictionReason;
     use iggy_binary_protocol::requests::messages::{
-        RawMessage, SendMessagesEncoder, WaitForMessagesTarget,
+        FetchMessagesTarget, RawMessage, SendMessagesEncoder,
     };
-    use iggy_binary_protocol::{WireConsumer, WireIdentifier};
+    use iggy_binary_protocol::responses::messages::poll_messages::POLL_RESPONSE_HEADER_SIZE;
+    use iggy_binary_protocol::responses::messages::{
+        FetchMessagesSection, FetchMessagesSectionHeader, PollMessagesResponseHeader,
+    };
+    use iggy_binary_protocol::{MAX_RECORD_SIZE, WireConsumer, WireIdentifier};
     use iggy_common::{
-        AutoLogin, Client, Credentials, DiagnosticEvent, IggyMessage, MessageClient, Partitioning,
-        PendingOperationsConfig, UserClient,
+        AutoLogin, Client, CommitPolicy, Consumer, Credentials, DiagnosticEvent, IggyDuration,
+        IggyMessage, MessageClient, Partitioning, PendingOperationsConfig, PollOptions,
+        PollingStrategy, UserClient,
     };
     use secrecy::SecretString;
     use std::iter::repeat_n;
     use std::num::NonZeroUsize;
     use std::str::FromStr;
+    use std::sync::atomic::Ordering;
     use std::time::Duration;
-    use tokio::time::{sleep, timeout};
+    use tokio::time::{sleep, sleep_until, timeout};
 
     const REPLICAS: u8 = 3;
     const PRIMARY: u8 = 1;
@@ -1766,6 +2120,15 @@
                         .poll(&self.poll(true, WirePollingStrategy::first()), None)
                         .await
                 }
+                POLL_MESSAGES_DEFERRED_CODE => {
+                    self.driver
+                        .poll_with_options(
+                            &self.poll(true, WirePollingStrategy::first()),
+                            None,
+                            Some(DeferredPollOptions::default()),
+                        )
+                        .await
+                }
                 _ => self.call(code, RequestTarget::Metadata).await,
             }
         }
@@ -2040,6 +2403,59 @@
         Answer::Body(route.to_bytes().to_vec())
     }
 
+    /// A fetch of consumer 1 from `partitions` by the default options. No
+    /// target carries a fence.
+    fn fetch(
+        partitions: impl IntoIterator<Item = u32>,
+        auto_commit: bool,
+        strategy: WirePollingStrategy,
+    ) -> FetchMessagesRequest {
+        let options = DeferredPollOptions::default();
+        FetchMessagesRequest {
+            consumer: WireConsumer::consumer(WireIdentifier::numeric(1)),
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(2),
+            wait_us: options.max_wait.as_micros(),
+            request_timeout_us: options.request_timeout.as_micros(),
+            min_count: options.min_count,
+            count: options.min_count,
+            max_bytes: options.max_bytes,
+            auto_commit,
+            targets: partitions
+                .into_iter()
+                .map(|partition_id| FetchMessagesTarget {
+                    partition_id,
+                    strategy: strategy.clone(),
+                    max_bytes: options.max_bytes,
+                    incarnation: 0,
+                    owner_generation: 0,
+                })
+                .collect(),
+        }
+    }
+
+    /// A fetch reply with an empty section of each `(partition_id, status)`.
+    fn fetched_body(sections: &[(u32, u32)]) -> Answer {
+        let response = FetchMessagesResponse {
+            sections: sections
+                .iter()
+                .map(|&(partition_id, status)| FetchMessagesSection {
+                    header: FetchMessagesSectionHeader {
+                        partition_id,
+                        status,
+                        current_offset: 0,
+                        messages_count: 0,
+                        context: PartitionContext::default(),
+                        next_offset: 0,
+                        records_len: 0,
+                    },
+                    records: &[],
+                })
+                .collect(),
+        };
+        Answer::Body(response.to_bytes().unwrap().to_vec())
+    }
+
     fn endpoints(received: &[Received]) -> Vec<String> {
         received
             .iter()
@@ -2457,7 +2873,6 @@
             STORE_CONSUMER_OFFSET_CODE,
             POLL_MESSAGES_CODE,
             POLL_MESSAGES_DEFERRED_CODE,
-            WAIT_FOR_MESSAGES_CODE,
             CANCEL_DEFERRED_REQUEST_CODE,
             GET_CONSUMER_OFFSET_CODE,
         ] {
@@ -2750,24 +3165,38 @@
                         code,
                         [failure, Answer::Body(Vec::new())],
                     );
-                    let result = f
-                        .driver
-                        .poll_with_options(
-                            &f.poll(auto_commit, strategy.clone()),
-                            None,
-                            (code == POLL_MESSAGES_DEFERRED_CODE)
-                                .then(DeferredPollOptions::default),
-                        )
-                        .await;
+                    let request = f.poll(auto_commit, strategy.clone());
+                    let options =
+                        (code == POLL_MESSAGES_DEFERRED_CODE).then(DeferredPollOptions::default);
+                    let result = f.driver.poll_with_options(&request, None, options).await;
                     let polls = f.cluster.received(code);
                     if progress && uncertain {
-                        assert_eq!(result, Err(IggyError::TransientNotCommitted));
                         assert_eq!(polls.len(), 1, "uncertain progress must not be replayed");
                         let operation_id = polls[0].header.request;
-                        assert_eq!(
-                            f.driver.abandon(operation_id),
-                            Err(IggyError::OperationNotRetained(operation_id))
-                        );
+                        if auto_commit {
+                            assert_eq!(
+                                result,
+                                Err(IggyError::FetchProgressUncertain(operation_id))
+                            );
+                            let blocked =
+                                Err(IggyError::PartitionLaneBlocked(PARTITION_ID, operation_id));
+                            assert_eq!(
+                                f.call(STORE_CONSUMER_OFFSET_CODE, f.partition(None)).await,
+                                blocked
+                            );
+                            assert_eq!(
+                                f.driver.poll_with_options(&request, None, options).await,
+                                blocked
+                            );
+                            assert_eq!(f.cluster.count(code), 1);
+                            f.driver.abandon(operation_id).unwrap();
+                        } else {
+                            assert_eq!(result, Err(IggyError::TransientNotCommitted));
+                            assert_eq!(
+                                f.driver.abandon(operation_id),
+                                Err(IggyError::OperationNotRetained(operation_id))
+                            );
+                        }
                         f.call(STORE_CONSUMER_OFFSET_CODE, f.partition(None))
                             .await
                             .unwrap();
@@ -2787,61 +3216,168 @@
             STORE_CONSUMER_OFFSET_CODE,
             DELETE_CONSUMER_OFFSET_CODE,
         ] {
-            for (auto_commit, strategy) in [
-                (true, WirePollingStrategy::first()),
-                (false, WirePollingStrategy::next()),
+            let f = Fixture::new().await;
+            f.cluster.script(
+                &endpoint(PRIMARY),
+                POLL_MESSAGES_DEFERRED_CODE,
+                [Answer::Silence],
+            );
+            let request = f.poll(false, WirePollingStrategy::next());
+            let poll =
+                f.driver
+                    .poll_with_options(&request, None, Some(DeferredPollOptions::default()));
+            let offset = f.call(code, f.partition(None));
+            tokio::pin!(offset);
+            let expired = timeout(CALLER_TIMEOUT, async {
+                tokio::pin!(poll);
+                tokio::select! {
+                    _ = &mut poll => panic!("the unanswered poll returned"),
+                    () = eventually("the progress poll arrived", || f.cluster.count(POLL_MESSAGES_DEFERRED_CODE) == 1) => {}
+                }
+                assert!(futures::poll!(&mut offset).is_pending());
+                tokio::select! {
+                    _ = &mut poll => panic!("the unanswered poll returned"),
+                    _ = &mut offset => panic!("the offset request bypassed the progress barrier"),
+                    sent = f.send_messages() => { sent.unwrap(); }
+                }
+                tokio::select! {
+                    _ = &mut poll => panic!("the unanswered poll returned"),
+                    _ = &mut offset => panic!("the offset request bypassed the progress barrier"),
+                }
+            })
+            .await;
+            assert!(expired.is_err());
+            assert_eq!(
+                f.cluster.count(SEND_MESSAGES_CODE),
+                1,
+                "an offset request waiting behind a poll must leave sends unblocked"
+            );
+            assert_eq!(f.cluster.count(code), 0);
+            let poll_id = f.cluster.received(POLL_MESSAGES_DEFERRED_CODE)[0]
+                .header
+                .request;
+            assert_eq!(
+                f.driver.abandon(poll_id),
+                Err(IggyError::OperationNotRetained(poll_id))
+            );
+            offset.await.unwrap();
+            assert_eq!(f.cluster.count(code), 1);
+        }
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_a_committing_poll_when_dropped_after_the_send_should_block_its_partition_until_abandoned()
+     {
+        for poll_code in [POLL_MESSAGES_CODE, POLL_MESSAGES_DEFERRED_CODE] {
+            for code in [
+                GET_CONSUMER_OFFSET_CODE,
+                STORE_CONSUMER_OFFSET_CODE,
+                DELETE_CONSUMER_OFFSET_CODE,
             ] {
                 let f = Fixture::new().await;
-                f.cluster.script(
-                    &endpoint(PRIMARY),
-                    POLL_MESSAGES_DEFERRED_CODE,
-                    [Answer::Silence],
-                );
-                let request = f.poll(auto_commit, strategy);
-                let poll = f.driver.poll_with_options(
-                    &request,
-                    None,
-                    Some(DeferredPollOptions::default()),
-                );
-                let offset = f.call(code, f.partition(None));
-                tokio::pin!(offset);
-                let expired = timeout(CALLER_TIMEOUT, async {
-                    tokio::pin!(poll);
-                    tokio::select! {
-                        _ = &mut poll => panic!("the unanswered poll returned"),
-                        () = eventually("the progress poll arrived", || f.cluster.count(POLL_MESSAGES_DEFERRED_CODE) == 1) => {}
-                    }
-                    assert!(futures::poll!(&mut offset).is_pending());
-                    tokio::select! {
-                        _ = &mut poll => panic!("the unanswered poll returned"),
-                        _ = &mut offset => panic!("the offset request bypassed the progress barrier"),
-                        sent = f.send_messages() => { sent.unwrap(); }
-                    }
-                    tokio::select! {
-                        _ = &mut poll => panic!("the unanswered poll returned"),
-                        _ = &mut offset => panic!("the offset request bypassed the progress barrier"),
-                    }
-                }).await;
-                assert!(expired.is_err());
+                let case = format!("{poll_code} then {code}");
+                f.cluster
+                    .script(&endpoint(PRIMARY), poll_code, [Answer::Silence]);
+                let mut poll = Box::pin(f.call_to(poll_code));
+                let mut offset = Box::pin(f.call(code, f.partition(None)));
+                tokio::select! {
+                    polled = &mut poll => panic!("the unanswered poll returned: {polled:?}"),
+                    () = eventually("the committing poll arrived", || f.cluster.count(poll_code) == 1) => {}
+                }
+                assert!(futures::poll!(&mut offset).is_pending(), "{case}");
+                tokio::select! {
+                    polled = &mut poll => panic!("the unanswered poll returned: {polled:?}"),
+                    _ = &mut offset => panic!("the offset request bypassed the progress barrier"),
+                    sent = f.send_messages() => { sent.unwrap(); }
+                }
+
+                drop(poll);
+
+                let op = f.cluster.received(poll_code)[0].header.request;
+                let blocked = Err(IggyError::PartitionLaneBlocked(PARTITION_ID, op));
+                if code == GET_CONSUMER_OFFSET_CODE {
+                    offset.await.unwrap();
+                } else {
+                    assert_eq!(offset.await, blocked, "{case}");
+                }
+                assert_eq!(f.call_to(poll_code).await, blocked, "{case}");
                 assert_eq!(
-                    f.cluster.count(SEND_MESSAGES_CODE),
-                    1,
-                    "an offset request waiting behind a poll must leave sends unblocked"
+                    f.driver.resolve(op, Instant::now() + CALLER_TIMEOUT).await,
+                    Err(IggyError::FetchProgressUncertain(op)),
+                    "{case}"
                 );
-                assert_eq!(f.cluster.count(code), 0);
-                let poll_id = f.cluster.received(POLL_MESSAGES_DEFERRED_CODE)[0]
-                    .header
-                    .request;
-                assert_eq!(
-                    f.driver.abandon(poll_id),
-                    Err(IggyError::OperationNotRetained(poll_id))
-                );
-                offset.await.unwrap();
-                assert_eq!(f.cluster.count(code), 1);
+                assert_eq!(f.cluster.count(poll_code), 1, "{case}");
+                f.driver.abandon(op).unwrap();
+                f.call(code, f.partition(None)).await.unwrap();
+                f.call_to(poll_code).await.unwrap();
+                assert_eq!(f.cluster.count(poll_code), 2, "{case}");
             }
         }
     }
 
+    /// The server answers a cancelled committing poll once: `TransientNotAccepted` when the
+    /// cancel won before any store, its normal reply when the store won. Either reply proves the
+    /// outcome, so the partition opens again without an abandon.
+    #[tokio::test(start_paused = true)]
+    async fn given_a_committing_poll_dropped_after_the_send_when_its_one_reply_arrives_should_open_its_partition()
+     {
+        let stored = PollMessagesResponseHeader {
+            partition_id: PARTITION_ID,
+            current_offset: 0,
+            messages_count: 0,
+            context: PartitionContext::default(),
+        }
+        .to_bytes()
+        .to_vec();
+        for (case, answer) in [
+            (
+                "cancel won",
+                Answer::Refuse(IggyError::TransientNotAccepted),
+            ),
+            ("store won", Answer::Body(stored)),
+        ] {
+            let f = Fixture::new().await;
+            f.cluster
+                .script(&endpoint(PRIMARY), POLL_MESSAGES_DEFERRED_CODE, [answer]);
+            f.cluster.hold(POLL_MESSAGES_DEFERRED_CODE);
+            let mut poll = Box::pin(f.call_to(POLL_MESSAGES_DEFERRED_CODE));
+            tokio::select! {
+                polled = &mut poll => panic!("{case}: the held poll returned: {polled:?}"),
+                () = eventually("the committing poll arrived", || {
+                    f.cluster.count(POLL_MESSAGES_DEFERRED_CODE) == 1
+                }) => {}
+            }
+
+            drop(poll);
+
+            eventually("the cancel arrived", || {
+                f.cluster.count(CANCEL_DEFERRED_REQUEST_CODE) == 1
+            })
+            .await;
+            let op = f.cluster.received(POLL_MESSAGES_DEFERRED_CODE)[0]
+                .header
+                .request;
+            assert_eq!(
+                f.call(STORE_CONSUMER_OFFSET_CODE, f.partition(None)).await,
+                Err(IggyError::PartitionLaneBlocked(PARTITION_ID, op)),
+                "{case}: the outcome waits for the one reply"
+            );
+            f.cluster.release(POLL_MESSAGES_DEFERRED_CODE);
+            eventually("the reply settled the poll", || {
+                !f.driver.session.retains_operation(op)
+            })
+            .await;
+            f.call(STORE_CONSUMER_OFFSET_CODE, f.partition(None))
+                .await
+                .unwrap();
+            assert_eq!(
+                f.driver.abandon(op),
+                Err(IggyError::OperationNotRetained(op)),
+                "{case}"
+            );
+        }
+    }
+
     #[tokio::test(start_paused = true)]
     async fn given_a_retained_offset_mutation_when_progress_enters_should_return_its_blocking_id() {
         for code in [STORE_CONSUMER_OFFSET_CODE, DELETE_CONSUMER_OFFSET_CODE] {
@@ -2861,12 +3397,22 @@
                 f.call(GET_CONSUMER_OFFSET_CODE, f.partition(None)).await,
                 Err(IggyError::LaneBlocked(op))
             );
-            for (auto_commit, strategy) in [
-                (true, WirePollingStrategy::first()),
-                (false, WirePollingStrategy::next()),
+            // A committing poll enters its lane as a fetch target, which names
+            // the partition.
+            for (auto_commit, strategy, blocked) in [
+                (
+                    true,
+                    WirePollingStrategy::first(),
+                    IggyError::PartitionLaneBlocked(PARTITION_ID, op),
+                ),
+                (
+                    false,
+                    WirePollingStrategy::next(),
+                    IggyError::LaneBlocked(op),
+                ),
             ] {
                 let polled = f.driver.poll(&f.poll(auto_commit, strategy), None).await;
-                assert_eq!(polled, Err(IggyError::LaneBlocked(op)));
+                assert_eq!(polled, Err(blocked));
             }
             assert_eq!(f.cluster.count(POLL_MESSAGES_CODE), 0);
             f.driver
@@ -3340,7 +3886,7 @@
     }
 
     #[tokio::test(start_paused = true)]
-    async fn given_more_partitions_on_one_endpoint_than_a_wait_holds_when_grouping_should_split_them()
+    async fn given_more_partitions_on_one_endpoint_than_a_fetch_holds_when_grouping_should_split_them()
      {
         let f = Fixture::new().await;
         let partitions: Vec<u32> = (0..=MAX_PARTITIONS_PER_REQUEST).collect();
@@ -3398,141 +3944,47 @@
     }
 
     #[tokio::test(start_paused = true)]
-    async fn given_89_on_a_wait_when_one_of_its_partitions_moved_should_regroup_and_keep_the_other_route()
+    async fn given_a_consumer_limit_when_fetch_groups_share_capacity_should_bound_requests_and_release_leases()
      {
-        let f = Fixture::new().await;
-        let options = DeferredPollOptions::default();
-        let wait = WaitForMessagesRequest {
-            consumer: WireConsumer::consumer(WireIdentifier::numeric(1)),
-            stream_id: WireIdentifier::numeric(1),
-            topic_id: WireIdentifier::numeric(2),
-            wait_us: options.max_wait.as_micros(),
-            request_timeout_us: options.request_timeout.as_micros(),
-            partitions: [STAYED, PARTITION_ID]
-                .map(|partition_id| WaitForMessagesTarget {
-                    partition_id,
-                    strategy: WirePollingStrategy::next(),
-                })
-                .to_vec(),
-        };
-        let ready = |partitions: Vec<u32>| {
-            Answer::Body(WaitForMessagesResponse { partitions }.to_bytes().to_vec())
-        };
-        f.cluster.script(
-            &endpoint(PRIMARY),
-            WAIT_FOR_MESSAGES_CODE,
-            [Answer::Refuse(IggyError::NotPrimary), ready(Vec::new())],
-        );
-        // The refusing primary answers the lookups in partition order.
-        f.cluster.script(
-            &endpoint(PRIMARY),
-            GET_PARTITION_ROUTE_CODE,
-            [route_body(ROUTE), route_body(MOVED)],
-        );
-        f.cluster.script(
-            &endpoint(MOVED.primary_replica),
-            WAIT_FOR_MESSAGES_CODE,
-            [ready(vec![PARTITION_ID])],
-        );
-
-        let reply = f.driver.wait_for_messages(&wait).await.unwrap();
-
-        let ready = WaitForMessagesResponse::decode_from(&reply).unwrap();
-        assert_eq!(ready.partitions, [PARTITION_ID]);
-        let waits = f.cluster.received(WAIT_FOR_MESSAGES_CODE);
-        let targets = |received: &Received| -> Vec<u32> {
-            WaitForMessagesRequest::decode_from(&received.body)
-                .unwrap()
-                .partitions
-                .iter()
-                .map(|target| target.partition_id)
-                .collect()
-        };
-        assert_eq!(waits[0].endpoint, endpoint(PRIMARY));
-        assert_eq!(targets(&waits[0]), [STAYED, PARTITION_ID]);
-        let moved: Vec<_> = waits
-            .iter()
-            .filter(|received| received.endpoint == endpoint(MOVED.primary_replica))
-            .map(targets)
-            .collect();
-        assert_eq!(moved, [[PARTITION_ID]]);
-        let stayed = PartitionKey {
-            stream_id: f.stream_id.clone(),
-            topic_id: f.topic_id.clone(),
-            partition_id: STAYED,
-        };
-        let route = f.driver.routes().usable(
-            &stayed,
-            f.driver.session.fence(),
-            f.driver.session.metadata_watermark(),
-        );
-        assert_eq!(route, Some(ROUTE));
-    }
-
-    #[tokio::test(start_paused = true)]
-    async fn given_a_consumer_limit_when_readiness_regroups_should_bound_fetches_and_release_leases()
-     {
+        const QUEUED_TIMEOUT: Duration = Duration::from_secs(2);
         let fixture = Fixture::new().await;
         let capacity = Arc::new(Semaphore::new(1));
         let options = DeferredPollOptions::default();
-        let wait = WaitForMessagesRequest {
-            consumer: WireConsumer::consumer(WireIdentifier::numeric(1)),
-            stream_id: WireIdentifier::numeric(1),
-            topic_id: WireIdentifier::numeric(2),
-            wait_us: options.max_wait.as_micros(),
-            request_timeout_us: options.request_timeout.as_micros(),
-            partitions: [STAYED, PARTITION_ID]
-                .map(|partition_id| WaitForMessagesTarget {
-                    partition_id,
-                    strategy: WirePollingStrategy::next(),
-                })
-                .to_vec(),
-        };
+        let first = fetch([STAYED], false, WirePollingStrategy::offset(0));
+        let second = fetch([PARTITION_ID], false, WirePollingStrategy::offset(0));
         fixture.cluster.script(
-            &endpoint(PRIMARY),
-            WAIT_FOR_MESSAGES_CODE,
-            [Answer::Refuse(IggyError::NotPrimary), Answer::Silence],
-        );
-        fixture.cluster.script(
-            &endpoint(PRIMARY),
+            &endpoint(0),
             GET_PARTITION_ROUTE_CODE,
             [route_body(ROUTE), route_body(MOVED)],
         );
-        fixture.cluster.script(
-            &endpoint(MOVED.primary_replica),
-            WAIT_FOR_MESSAGES_CODE,
-            [Answer::Silence],
+        fixture
+            .cluster
+            .script(&endpoint(PRIMARY), FETCH_MESSAGES_CODE, [Answer::Silence]);
+        let mut parked = Box::pin(
+            DEFERRED_CONSUMER_CAPACITY
+                .scope(Arc::clone(&capacity), fixture.driver.fetch_messages(&first)),
         );
-        let mut waiting = Box::pin(DEFERRED_CONSUMER_CAPACITY.scope(
-            Arc::clone(&capacity),
-            fixture.driver.wait_for_messages(&wait),
-        ));
         tokio::select! {
-            reply = &mut waiting => panic!("a regrouped wait ended: {reply:?}"),
-            () = eventually("one regrouped wait reaches its primary", || {
-                fixture.cluster.count(WAIT_FOR_MESSAGES_CODE) >= 2
+            reply = &mut parked => panic!("a parked fetch ended: {reply:?}"),
+            () = eventually("the first group reaches its primary", || {
+                fixture.cluster.count(FETCH_MESSAGES_CODE) == 1
             }) => {}
         }
-        let poll = fixture.poll(false, WirePollingStrategy::first());
-        let short_options = DeferredPollOptions {
-            max_wait: Duration::ZERO.into(),
-            request_timeout: CALLER_TIMEOUT.into(),
-            ..options
+        let short = FetchMessagesRequest {
+            wait_us: 0,
+            request_timeout_us: QUEUED_TIMEOUT.as_micros().try_into().unwrap(),
+            ..second.clone()
         };
-        let mut fetching = Box::pin(
-            DEFERRED_CONSUMER_CAPACITY.scope(
-                Arc::clone(&capacity),
-                fixture
-                    .driver
-                    .poll_with_options(&poll, None, Some(short_options)),
-            ),
+        let mut queued = Box::pin(
+            DEFERRED_CONSUMER_CAPACITY
+                .scope(Arc::clone(&capacity), fixture.driver.fetch_messages(&short)),
         );
-        let mut other_poll = poll.clone();
+        let mut other_poll = fixture.poll(false, WirePollingStrategy::first());
         other_poll.consumer = WireConsumer::consumer(WireIdentifier::numeric(2));
         tokio::select! {
             biased;
-            reply = &mut waiting => panic!("a parked wait ended: {reply:?}"),
-            reply = &mut fetching => panic!("a fetch passed its consumer's wait: {reply:?}"),
+            reply = &mut parked => panic!("a parked fetch ended: {reply:?}"),
+            reply = &mut queued => panic!("the second group passed its consumer's fetch: {reply:?}"),
             reply = DEFERRED_CONSUMER_CAPACITY.scope(
                 Arc::new(Semaphore::new(1)),
                 fixture.driver.poll_with_options(&other_poll, None, Some(options)),
@@ -3545,18 +3997,14 @@
             "queued work must not reserve another consumer's client slots"
         );
         tokio::select! {
-            reply = &mut waiting => panic!("a parked wait ended before the fetch deadline: {reply:?}"),
-            reply = &mut fetching => assert_eq!(reply, Err(IggyError::TransientNotAccepted)),
+            reply = &mut parked => panic!("a parked fetch ended before the queued deadline: {reply:?}"),
+            reply = &mut queued => assert_eq!(reply, Err(IggyError::TransientNotAccepted)),
         }
-        assert_eq!(
-            fixture.cluster.count(WAIT_FOR_MESSAGES_CODE),
-            2,
-            "regrouping must not admit both primaries at once"
-        );
+        assert_eq!(fixture.cluster.count(FETCH_MESSAGES_CODE), 1);
         assert_eq!(fixture.cluster.count(POLL_MESSAGES_DEFERRED_CODE), 1);
         assert_eq!(capacity.available_permits(), 0);
-        drop(fetching);
-        drop(waiting);
+        drop(queued);
+        drop(parked);
         assert_eq!(capacity.available_permits(), 1);
         assert_eq!(
             fixture.driver.deferred_requests.available_permits(),
@@ -3565,15 +4013,1024 @@
         DEFERRED_CONSUMER_CAPACITY
             .scope(
                 Arc::clone(&capacity),
-                fixture.driver.poll_with_options(&poll, None, Some(options)),
+                fixture.driver.fetch_messages(&second),
             )
             .await
             .unwrap();
-        assert_eq!(fixture.cluster.count(POLL_MESSAGES_DEFERRED_CODE), 2);
+        let fetched = fixture.cluster.received(FETCH_MESSAGES_CODE);
+        assert_eq!(
+            endpoints(&fetched),
+            [endpoint(PRIMARY), endpoint(MOVED.primary_replica)]
+        );
         assert_eq!(capacity.available_permits(), 1);
     }
 
     #[tokio::test(start_paused = true)]
+    async fn given_a_fetch_timeout_equal_to_the_margin_when_called_should_fail_before_routing() {
+        for raw in [false, true] {
+            let fixture = Fixture::new().await;
+            let request = FetchMessagesRequest {
+                wait_us: 0,
+                request_timeout_us: MIN_DEFERRED_POLL_REPLY_MARGIN_US,
+                ..fetch([PARTITION_ID], false, WirePollingStrategy::offset(0))
+            };
+
+            let result = if raw {
+                fixture
+                    .driver
+                    .send_raw(FETCH_MESSAGES_CODE, request.to_bytes(), None)
+                    .await
+            } else {
+                fixture.driver.fetch_messages(&request).await
+            };
+
+            assert_eq!(result, Err(IggyError::InvalidCommand));
+            assert_eq!(fixture.cluster.count(GET_PARTITION_ROUTE_CODE), 0);
+            assert_eq!(fixture.cluster.count(FETCH_MESSAGES_CODE), 0);
+        }
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_a_zero_wait_fetch_when_routing_spends_budget_should_send_only_with_the_reply_margin()
+     {
+        const REQUEST_TIMEOUT: Duration = Duration::from_secs(2);
+        const ROUTE_DELAY: Duration = Duration::from_millis(500);
+        const REPLY_MARGIN: Duration = Duration::from_micros(MIN_DEFERRED_POLL_REPLY_MARGIN_US);
+        for delay in [
+            ROUTE_DELAY,
+            REQUEST_TIMEOUT - REPLY_MARGIN,
+            REQUEST_TIMEOUT - ROUTE_DELAY,
+        ] {
+            let fixture = Fixture::new().await;
+            let request = FetchMessagesRequest {
+                wait_us: 0,
+                request_timeout_us: REQUEST_TIMEOUT.as_micros() as u64,
+                ..fetch([PARTITION_ID], false, WirePollingStrategy::offset(0))
+            };
+            fixture.cluster.hold(GET_PARTITION_ROUTE_CODE);
+
+            let started = Instant::now();
+            let (result, ()) = tokio::join!(fixture.driver.fetch_messages(&request), async {
+                eventually("fetch route is held", || {
+                    fixture.cluster.count(GET_PARTITION_ROUTE_CODE) == 1
+                })
+                .await;
+                sleep_until(started + delay).await;
+                fixture.cluster.release(GET_PARTITION_ROUTE_CODE);
+            });
+
+            let sent = fixture.cluster.received(FETCH_MESSAGES_CODE);
+            if delay <= REQUEST_TIMEOUT - REPLY_MARGIN {
+                result.unwrap();
+                assert_eq!(sent.len(), 1);
+                let sent = FetchMessagesRequest::decode_from(&sent[0].body).unwrap();
+                assert_eq!(sent.wait_us, 0);
+                assert!(sent.request_timeout_us >= MIN_DEFERRED_POLL_REPLY_MARGIN_US);
+                assert!(sent.request_timeout_us <= (REQUEST_TIMEOUT - delay).as_micros() as u64);
+            } else {
+                assert_eq!(result, Err(IggyError::TransientNotAccepted));
+                assert!(sent.is_empty());
+            }
+        }
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_a_fetch_of_1_or_1000_targets_when_sent_should_reach_their_primary_with_context_zero_and_fence_each_target()
+     {
+        const OWNER_GENERATION: u64 = 4;
+        for targets in [1, MAX_PARTITIONS_PER_REQUEST] {
+            for raw in [false, true] {
+                let f = Fixture::new().await;
+                let mut request = fetch(0..targets, false, WirePollingStrategy::offset(0));
+                // The fence of the caller stays, also one of an older route.
+                request.targets[0].incarnation = RECREATED.incarnation;
+                request.targets[0].owner_generation = OWNER_GENERATION;
+
+                let reply = if raw {
+                    f.driver
+                        .send_raw(FETCH_MESSAGES_CODE, request.to_bytes(), None)
+                        .await
+                } else {
+                    f.driver.fetch_messages(&request).await
+                };
+
+                reply.unwrap();
+                let fetches = f.cluster.received(FETCH_MESSAGES_CODE);
+                assert_eq!(endpoints(&fetches), [endpoint(PRIMARY)]);
+                assert_eq!(stamped_context(&fetches[0]), PartitionContext::default());
+                let sent = FetchMessagesRequest::decode_from(&fetches[0].body).unwrap();
+                let fences: Vec<_> = sent
+                    .targets
+                    .iter()
+                    .map(|target| (target.incarnation, target.owner_generation))
+                    .collect();
+                let mut expected = vec![(ROUTE.incarnation, 0); targets as usize];
+                expected[0] = (RECREATED.incarnation, OWNER_GENERATION);
+                assert_eq!(fences, expected);
+                let frame = Frame {
+                    header: fetches[0].header,
+                    body: Bytes::from(fetches[0].body.clone()),
+                };
+                assert_eq!(
+                    frame.response_body_limit(),
+                    Some(
+                        FetchMessagesResponse::head_size(targets as usize)
+                            + (request.max_bytes as usize).max(MAX_RECORD_SIZE)
+                    ),
+                    "a link reads the table and the records, or a first record above max_bytes"
+                );
+            }
+        }
+    }
+
+    #[tokio::test]
+    async fn given_a_deferred_poll_below_the_largest_record_when_bounding_its_reply_should_fit_that_record()
+     {
+        let f = Fixture::new().await;
+        let options = DeferredPollOptions::default();
+        let body = DeferredPollMessagesRequest {
+            poll: f.poll(false, WirePollingStrategy::next()),
+            wait_us: options.max_wait.as_micros(),
+            min_count: options.min_count,
+            max_bytes: options.max_bytes,
+            request_timeout_us: options.request_timeout.as_micros(),
+        }
+        .to_bytes();
+        let frame = Frame {
+            header: iggy_binary_protocol::consensus::RequestHeader::for_request(
+                POLL_MESSAGES_DEFERRED_CODE,
+                1,
+                1,
+                1,
+                &body,
+            )
+            .unwrap(),
+            body,
+        };
+
+        assert_eq!(
+            frame.response_body_limit(),
+            Some(POLL_RESPONSE_HEADER_SIZE + MAX_RECORD_SIZE),
+            "the server returns a first record above max_bytes alone"
+        );
+    }
+
+    #[test]
+    fn given_a_link_reply_limit_when_fitting_a_fetch_of_1_or_1000_targets_should_bound_its_whole_reply()
+     {
+        for targets in [1, MAX_PARTITIONS_PER_REQUEST] {
+            let mut request = fetch(0..targets, false, WirePollingStrategy::offset(0));
+            let unbounded = request.clone();
+            fit_fetch_reply(&mut request, usize::MAX).unwrap();
+            assert_eq!(
+                request, unbounded,
+                "a link that reads more keeps the limits"
+            );
+
+            let limit =
+                FetchMessagesResponse::head_size(targets as usize) + MIN_DEFERRED_POLL_MAX_BYTES;
+            fit_fetch_reply(&mut request, limit).unwrap();
+
+            assert_eq!(request.reply_limit(), limit);
+            assert!(
+                request
+                    .targets
+                    .iter()
+                    .all(|target| target.max_bytes == request.max_bytes)
+            );
+            request.validate().unwrap();
+            assert_eq!(
+                fit_fetch_reply(&mut request, limit - 1),
+                Err(IggyError::InvalidSizeBytes)
+            );
+        }
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_a_live_old_fetch_when_reconnected_should_block_stores_until_explicit_abandon() {
+        for raw in [false, true] {
+            let f = Fixture::new().await;
+            let release = Arc::new(tokio::sync::Notify::new());
+            let completed = Arc::new(std::sync::atomic::AtomicBool::new(false));
+            f.cluster.script(
+                &endpoint(PRIMARY),
+                FETCH_MESSAGES_CODE,
+                [Answer::GatedBody {
+                    release: Arc::clone(&release),
+                    completed: Arc::clone(&completed),
+                    body: 0_u32.to_le_bytes().to_vec(),
+                }],
+            );
+            let request = fetch([STAYED, PARTITION_ID], true, WirePollingStrategy::offset(0));
+            let result = if raw {
+                f.driver
+                    .send_raw(
+                        FETCH_MESSAGES_CODE,
+                        request.to_bytes(),
+                        Some(PartitionContext::default()),
+                    )
+                    .await
+            } else {
+                f.driver.fetch_messages(&request).await
+            };
+            let operation_id = f.cluster.received(FETCH_MESSAGES_CODE)[0].header.request;
+            assert!(
+                matches!(result, Err(IggyError::FetchProgressUncertain(operation)) if operation == operation_id)
+            );
+            assert!(!completed.load(Ordering::SeqCst));
+            f.cluster.drop_connections(&endpoint(PRIMARY));
+            for partition_id in [STAYED, PARTITION_ID] {
+                for context in [None, Some(PartitionContext::default())] {
+                    let target = RequestTarget::Partition(PartitionTarget {
+                        stream_id: &f.stream_id,
+                        topic_id: &f.topic_id,
+                        partition_id,
+                        context,
+                    });
+                    if context.is_some() {
+                        f.cluster.script(
+                            &endpoint(PRIMARY),
+                            GET_CONSUMER_OFFSET_CODE,
+                            [Answer::Refuse(IggyError::HistoryUnavailable)],
+                        );
+                        assert_eq!(
+                            f.call(GET_CONSUMER_OFFSET_CODE, target).await,
+                            Err(IggyError::HistoryUnavailable),
+                        );
+                    } else {
+                        f.call(GET_CONSUMER_OFFSET_CODE, target).await.unwrap();
+                    }
+                    for code in [STORE_CONSUMER_OFFSET_CODE, DELETE_CONSUMER_OFFSET_CODE] {
+                        assert!(matches!(f.call(code, target).await,
+                            Err(IggyError::PartitionLaneBlocked(partition, operation))
+                            if partition == partition_id && operation == operation_id));
+                    }
+                }
+            }
+            for options in [None, Some(DeferredPollOptions::default())] {
+                assert!(matches!(
+                    f.driver.poll_with_options(
+                        &f.poll(true, WirePollingStrategy::next()),
+                        Some(PartitionContext::default()),
+                        options,
+                    ).await,
+                    Err(IggyError::PartitionLaneBlocked(partition, operation))
+                    if partition == PARTITION_ID && operation == operation_id));
+            }
+            let alias = Identifier::from_str("same-stream-alias").unwrap();
+            let alias_target = RequestTarget::Partition(PartitionTarget {
+                stream_id: &alias,
+                topic_id: &f.topic_id,
+                partition_id: PARTITION_ID,
+                context: Some(PartitionContext::default()),
+            });
+            assert!(
+                matches!(f.call(STORE_CONSUMER_OFFSET_CODE, alias_target).await,
+            Err(IggyError::PartitionLaneBlocked(partition, operation))
+            if partition == PARTITION_ID && operation == operation_id)
+            );
+            let sent = &f.cluster.received(FETCH_MESSAGES_CODE)[0].body;
+            let wire = FetchMessagesRequest::decode_from(sent).unwrap();
+            assert!(
+                wire.targets
+                    .iter()
+                    .all(|target| target.incarnation == ROUTE.incarnation)
+            );
+            assert_eq!(f.cluster.count(STORE_CONSUMER_OFFSET_CODE), 0);
+            assert_eq!(f.cluster.count(DELETE_CONSUMER_OFFSET_CODE), 0);
+            assert_eq!(f.cluster.count(POLL_MESSAGES_CODE), 0);
+            assert_eq!(f.cluster.count(POLL_MESSAGES_DEFERRED_CODE), 0);
+            assert!(
+                matches!(f.driver.resolve(operation_id, Instant::now() + CALLER_TIMEOUT).await,
+            Err(IggyError::FetchProgressUncertain(operation)) if operation == operation_id)
+            );
+            assert_eq!(f.cluster.count(FETCH_MESSAGES_CODE), 1);
+            assert!(
+                !completed.load(Ordering::SeqCst),
+                "the old server request has not finished"
+            );
+            f.driver.abandon(operation_id).unwrap();
+            f.cluster.script(
+                &endpoint(PRIMARY),
+                STORE_CONSUMER_OFFSET_CODE,
+                [Answer::Refuse(IggyError::HistoryUnavailable)],
+            );
+            assert_eq!(
+                f.call(
+                    STORE_CONSUMER_OFFSET_CODE,
+                    f.partition(Some(PartitionContext::default())),
+                )
+                .await,
+                Err(IggyError::HistoryUnavailable),
+            );
+            assert_eq!(
+                stamped_context(&f.cluster.received(STORE_CONSUMER_OFFSET_CODE)[0]),
+                PartitionContext::default(),
+            );
+            f.call(STORE_CONSUMER_OFFSET_CODE, f.partition(None))
+                .await
+                .unwrap();
+            assert_eq!(f.cluster.count(STORE_CONSUMER_OFFSET_CODE), 2);
+            assert!(
+                !completed.load(Ordering::SeqCst),
+                "abandon accepts uncertainty without stopping the old request"
+            );
+            release.notify_one();
+            eventually("the old request completed after explicit release", || {
+                completed.load(Ordering::SeqCst)
+            })
+            .await;
+        }
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_a_stale_alias_route_when_a_zero_context_store_is_sent_should_preserve_its_invalid_wire_fence()
+     {
+        let f = Fixture::new().await;
+        let alias = Identifier::from_str("same-stream-alias").unwrap();
+        let alias_target = RequestTarget::Partition(PartitionTarget {
+            stream_id: &alias,
+            topic_id: &f.topic_id,
+            partition_id: PARTITION_ID,
+            context: Some(PartitionContext::default()),
+        });
+        f.call(
+            GET_CONSUMER_OFFSET_CODE,
+            RequestTarget::Partition(PartitionTarget {
+                stream_id: &alias,
+                topic_id: &f.topic_id,
+                partition_id: PARTITION_ID,
+                context: None,
+            }),
+        )
+        .await
+        .unwrap();
+        *f.cluster.route.lock().unwrap() = Some(RECREATED);
+        f.cluster
+            .script(&endpoint(PRIMARY), FETCH_MESSAGES_CODE, [Answer::Silence]);
+        let fetched = f
+            .driver
+            .fetch_messages(&fetch([PARTITION_ID], true, WirePollingStrategy::offset(0)))
+            .await;
+        let operation_id = f.cluster.received(FETCH_MESSAGES_CODE)[0].header.request;
+        assert!(matches!(fetched,
+            Err(IggyError::FetchProgressUncertain(operation)) if operation == operation_id));
+        f.cluster.script(
+            &endpoint(PRIMARY),
+            STORE_CONSUMER_OFFSET_CODE,
+            [Answer::Refuse(IggyError::HistoryUnavailable)],
+        );
+        assert_eq!(
+            f.call(STORE_CONSUMER_OFFSET_CODE, alias_target).await,
+            Err(IggyError::HistoryUnavailable),
+        );
+        let stores = f.cluster.received(STORE_CONSUMER_OFFSET_CODE);
+        assert_eq!(stores.len(), 1);
+        assert_eq!(stamped_context(&stores[0]), PartitionContext::default());
+        assert!(!f.driver.session.retains_operation(stores[0].header.request));
+        assert!(f.driver.session.retains_operation(operation_id));
+        assert!(
+            matches!(f.call(STORE_CONSUMER_OFFSET_CODE, alias_target).await,
+            Err(IggyError::PartitionLaneBlocked(partition, operation))
+            if partition == PARTITION_ID && operation == operation_id)
+        );
+        assert_eq!(f.cluster.count(STORE_CONSUMER_OFFSET_CODE), 1);
+        assert_eq!(f.cluster.count(FETCH_MESSAGES_CODE), 1);
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_a_fetch_reply_after_session_reset_when_classified_should_preserve_known_outcomes_and_caller_isolation()
+     {
+        for automatic in [false, true] {
+            for uncertain in [false, true] {
+                let f = Fixture::new().await;
+                let release = Arc::new(tokio::sync::Notify::new());
+                let completed = Arc::new(std::sync::atomic::AtomicBool::new(false));
+                let status = if uncertain {
+                    IggyError::TransientNotCommitted.as_code()
+                } else {
+                    STATUS_OK
+                };
+                let Answer::Body(body) =
+                    fetched_body(&[(STAYED, STATUS_OK), (PARTITION_ID, status)])
+                else {
+                    unreachable!();
+                };
+                f.cluster.script(
+                    &endpoint(PRIMARY),
+                    FETCH_MESSAGES_CODE,
+                    [Answer::GatedBody {
+                        release: Arc::clone(&release),
+                        completed,
+                        body,
+                    }],
+                );
+                let request = fetch([STAYED, PARTITION_ID], true, WirePollingStrategy::offset(0));
+                let (result, ()) = tokio::join!(f.driver.fetch_sections(&request), async {
+                    eventually("the fetch reached the old session", || {
+                        f.cluster.count(FETCH_MESSAGES_CODE) == 1
+                    })
+                    .await;
+                    if automatic {
+                        f.driver
+                            .session
+                            .reset_if_fence(f.driver.session.fence(), true)
+                            .unwrap();
+                    } else {
+                        f.driver.session.reset().unwrap();
+                    }
+                    release.notify_one();
+                });
+                let operation_id = f.cluster.received(FETCH_MESSAGES_CODE)[0].header.request;
+                if automatic {
+                    let sections = result.unwrap();
+                    assert_eq!(sections.len(), 2);
+                    assert!(sections[0].status.is_ok());
+                    if uncertain {
+                        assert!(matches!(sections[1].status,
+                            Err(IggyError::FetchProgressUncertain(operation)) if operation == operation_id));
+                    } else {
+                        assert!(sections[1].status.is_ok());
+                    }
+                } else {
+                    assert_eq!(result.unwrap_err(), IggyError::Unauthenticated);
+                }
+                assert_eq!(f.driver.session.retains_operation(operation_id), uncertain);
+                for partition_id in [STAYED, PARTITION_ID] {
+                    let entered = f
+                        .driver
+                        .session
+                        .enter_lane(
+                            Some(LaneKey::Progress {
+                                partition_id,
+                                incarnation: ROUTE.incarnation,
+                            }),
+                            Instant::now() + CALLER_TIMEOUT,
+                        )
+                        .await;
+                    if uncertain && partition_id == PARTITION_ID {
+                        assert!(matches!(entered,
+                            Err(IggyError::PartitionLaneBlocked(partition, operation))
+                            if partition == partition_id && operation == operation_id));
+                    } else {
+                        entered.unwrap().complete();
+                    }
+                }
+                assert_eq!(f.cluster.count(FETCH_MESSAGES_CODE), 1);
+            }
+        }
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_partial_uncertain_fetch_sections_when_decoded_should_preserve_healthy_sections_and_recovery_identity()
+     {
+        for status in [3, 57, 59, 90, 11001] {
+            let f = Fixture::new().await;
+            f.cluster.script(
+                &endpoint(PRIMARY),
+                FETCH_MESSAGES_CODE,
+                [fetched_body(&[(STAYED, STATUS_OK), (PARTITION_ID, status)])],
+            );
+            let sections = f
+                .driver
+                .fetch_sections(&fetch(
+                    [STAYED, PARTITION_ID],
+                    true,
+                    WirePollingStrategy::offset(0),
+                ))
+                .await
+                .unwrap();
+            let operation_id = f.cluster.received(FETCH_MESSAGES_CODE)[0].header.request;
+            assert!(sections[0].status.is_ok());
+            assert!(sections[0].decode().is_ok());
+            assert!(
+                matches!(sections[1].status, Err(IggyError::FetchProgressUncertain(operation)) if operation == operation_id)
+            );
+            let target = RequestTarget::Partition(PartitionTarget {
+                stream_id: &f.stream_id,
+                topic_id: &f.topic_id,
+                partition_id: STAYED,
+                context: None,
+            });
+            f.call(STORE_CONSUMER_OFFSET_CODE, target).await.unwrap();
+            assert!(
+                matches!(f.call(STORE_CONSUMER_OFFSET_CODE, f.partition(None)).await,
+                Err(IggyError::PartitionLaneBlocked(partition, operation))
+                if partition == PARTITION_ID && operation == operation_id)
+            );
+            f.driver.abandon(operation_id).unwrap();
+        }
+    }
+
+    /// A fetch that commits nothing guards only the stored offsets that its `Next` targets read.
+    #[tokio::test(start_paused = true)]
+    async fn given_a_fetch_with_one_next_target_when_it_waits_should_hold_only_the_lane_of_that_target()
+     {
+        const SHORT_WAIT: Duration = Duration::from_millis(100);
+        let f = Fixture::new().await;
+        f.cluster
+            .script(&endpoint(PRIMARY), FETCH_MESSAGES_CODE, [Answer::Silence]);
+        let mut request = fetch(
+            [STAYED, PARTITION_ID],
+            false,
+            WirePollingStrategy::offset(0),
+        );
+        request.targets[0].strategy = WirePollingStrategy::next();
+        let mut parked = Box::pin(f.driver.fetch_messages(&request));
+        tokio::select! {
+            reply = &mut parked => panic!("a parked fetch ended: {reply:?}"),
+            () = eventually("the fetch reaches its primary", || {
+                f.cluster.count(FETCH_MESSAGES_CODE) == 1
+            }) => {}
+        }
+        let next_target = RequestTarget::Partition(PartitionTarget {
+            stream_id: &f.stream_id,
+            topic_id: &f.topic_id,
+            partition_id: STAYED,
+            context: None,
+        });
+
+        tokio::select! {
+            biased;
+            reply = &mut parked => panic!("the store waited for the whole fetch: {reply:?}"),
+            stored = f.call(STORE_CONSUMER_OFFSET_CODE, f.partition(None)) => {
+                stored.unwrap();
+            }
+        }
+        tokio::select! {
+            biased;
+            reply = &mut parked => panic!("a parked fetch ended: {reply:?}"),
+            stored = timeout(SHORT_WAIT, f.call(STORE_CONSUMER_OFFSET_CODE, next_target)) => {
+                assert!(stored.is_err(), "the store of the `Next` target waits for the fetch");
+            }
+        }
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_definitive_section_refusals_of_a_committing_fetch_when_decoded_should_leave_their_partitions_unblocked()
+     {
+        for status in [
+            IggyError::TooManyConsumerOffsets.as_code(),
+            IggyError::PartitionNotFound(0, Identifier::default(), Identifier::default()).as_code(),
+        ] {
+            let f = Fixture::new().await;
+            f.cluster.script(
+                &endpoint(PRIMARY),
+                FETCH_MESSAGES_CODE,
+                [fetched_body(&[(STAYED, STATUS_OK), (PARTITION_ID, status)])],
+            );
+
+            let sections = f
+                .driver
+                .fetch_sections(&fetch(
+                    [STAYED, PARTITION_ID],
+                    true,
+                    WirePollingStrategy::offset(0),
+                ))
+                .await
+                .unwrap();
+
+            assert_eq!(
+                sections[1].status.as_ref().map_err(IggyError::as_code),
+                Err(status),
+                "the refusal reaches the caller as it is"
+            );
+            f.call(STORE_CONSUMER_OFFSET_CODE, f.partition(None))
+                .await
+                .unwrap();
+        }
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_a_malformed_committing_fetch_table_when_returned_should_retain_all_targets() {
+        for answer in [
+            Answer::Body(Vec::new()),
+            fetched_body(&[(STAYED, STATUS_OK), (STAYED, STATUS_OK)]),
+            fetched_body(&[(PARTITION_ID + 1, STATUS_OK)]),
+        ] {
+            let f = Fixture::new().await;
+            f.cluster
+                .script(&endpoint(PRIMARY), FETCH_MESSAGES_CODE, [answer]);
+            let result = f
+                .driver
+                .fetch_messages(&fetch(
+                    [STAYED, PARTITION_ID],
+                    true,
+                    WirePollingStrategy::offset(0),
+                ))
+                .await;
+            let operation_id = f.cluster.received(FETCH_MESSAGES_CODE)[0].header.request;
+            assert!(
+                matches!(result, Err(IggyError::FetchProgressUncertain(operation)) if operation == operation_id)
+            );
+            for partition_id in [STAYED, PARTITION_ID] {
+                let target = RequestTarget::Partition(PartitionTarget {
+                    stream_id: &f.stream_id,
+                    topic_id: &f.topic_id,
+                    partition_id,
+                    context: None,
+                });
+                assert!(matches!(f.call(STORE_CONSUMER_OFFSET_CODE, target).await,
+                    Err(IggyError::PartitionLaneBlocked(partition, operation))
+                    if partition == partition_id && operation == operation_id));
+            }
+        }
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_fetch_refusals_when_classified_should_keep_only_unknown_progress_blocked() {
+        for (status, unknown) in [
+            (5, false),
+            (6, false),
+            (58, false),
+            (87, false),
+            (89, false),
+            (90, false),
+            (1009, false),
+            (1010, false),
+            (2010, false),
+            (2011, false),
+            (3007, false),
+            (4100, false),
+            (5009, false),
+            // The server refuses an oversized first message before it stores the offset.
+            (80, false),
+            // A new offset key above the limit refuses the read before it returns messages.
+            (3024, false),
+            (3, true),
+            (30, true),
+            (40, true),
+            (41, true),
+            (57, true),
+            (59, true),
+            (11001, true),
+        ] {
+            let f = Fixture::new().await;
+            let fence = f.driver.session.fence();
+            f.cluster.script(
+                &endpoint(PRIMARY),
+                FETCH_MESSAGES_CODE,
+                [Answer::Refuse(IggyError::from_code(status))],
+            );
+            let result = f
+                .driver
+                .fetch_messages(&fetch(
+                    [STAYED, PARTITION_ID],
+                    true,
+                    WirePollingStrategy::offset(0),
+                ))
+                .await;
+            let operation_id = f.cluster.received(FETCH_MESSAGES_CODE)[0].header.request;
+            if unknown {
+                assert!(
+                    matches!(result, Err(IggyError::FetchProgressUncertain(operation)) if operation == operation_id),
+                    "status {status}: {result:?}"
+                );
+                if status == IggyError::StaleClient.as_code() {
+                    f.next_session(fence).await;
+                }
+                assert!(
+                    matches!(f.call(STORE_CONSUMER_OFFSET_CODE, f.partition(None)).await,
+                    Err(IggyError::PartitionLaneBlocked(partition, operation))
+                    if partition == PARTITION_ID && operation == operation_id),
+                    "status {status}"
+                );
+                assert_eq!(
+                    f.cluster.count(FETCH_MESSAGES_CODE),
+                    1,
+                    "unknown fetches never replay"
+                );
+            } else {
+                assert!(
+                    !matches!(result, Err(IggyError::FetchProgressUncertain(_))),
+                    "status {status}: {result:?}"
+                );
+                assert!(matches!(
+                    f.driver.abandon(operation_id),
+                    Err(IggyError::OperationNotRetained(_))
+                ));
+                f.call(STORE_CONSUMER_OFFSET_CODE, f.partition(None))
+                    .await
+                    .unwrap();
+            }
+        }
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_proved_fetch_sections_when_records_fail_to_decode_should_leave_rewind_available()
+    {
+        let f = Fixture::new().await;
+        let records = b"invalid batch";
+        let body = FetchMessagesResponse {
+            sections: vec![FetchMessagesSection {
+                header: FetchMessagesSectionHeader {
+                    partition_id: PARTITION_ID,
+                    status: STATUS_OK,
+                    current_offset: 7,
+                    messages_count: 1,
+                    context: PartitionContext::default(),
+                    next_offset: 8,
+                    records_len: records.len().try_into().unwrap(),
+                },
+                records,
+            }],
+        }
+        .to_bytes()
+        .unwrap();
+        f.cluster.script(
+            &endpoint(PRIMARY),
+            FETCH_MESSAGES_CODE,
+            [Answer::Body(body.to_vec())],
+        );
+        let sections = f
+            .driver
+            .fetch_sections(&fetch([PARTITION_ID], true, WirePollingStrategy::offset(0)))
+            .await
+            .unwrap();
+        assert!(sections[0].decode().is_err());
+        f.call(STORE_CONSUMER_OFFSET_CODE, f.partition(None))
+            .await
+            .unwrap();
+        assert_eq!(f.cluster.count(STORE_CONSUMER_OFFSET_CODE), 1);
+        for status in [80, 87, 89, 5009] {
+            f.cluster.script(
+                &endpoint(PRIMARY),
+                FETCH_MESSAGES_CODE,
+                [fetched_body(&[(PARTITION_ID, status)])],
+            );
+            let sections = f
+                .driver
+                .fetch_sections(&fetch([PARTITION_ID], true, WirePollingStrategy::offset(0)))
+                .await
+                .unwrap();
+            assert_eq!(sections[0].status.as_ref().unwrap_err().as_code(), status);
+            f.call(STORE_CONSUMER_OFFSET_CODE, f.partition(None))
+                .await
+                .unwrap();
+        }
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_a_lost_fetch_reply_when_retrying_should_resend_only_a_fetch_that_moves_no_consumer()
+     {
+        for (auto_commit, strategy, progress) in [
+            (true, WirePollingStrategy::offset(0), true),
+            (false, WirePollingStrategy::next(), true),
+            (false, WirePollingStrategy::offset(0), false),
+        ] {
+            for (failure, uncertain) in [
+                (Answer::Refuse(IggyError::TransientNotCommitted), true),
+                (Answer::HangUp, true),
+                (Answer::Refuse(IggyError::TransientNotAccepted), false),
+            ] {
+                let f = Fixture::new().await;
+                f.cluster.script(
+                    &endpoint(PRIMARY),
+                    FETCH_MESSAGES_CODE,
+                    [failure, fetched_body(&[])],
+                );
+
+                let result = f
+                    .driver
+                    .fetch_messages(&fetch(
+                        [STAYED, PARTITION_ID],
+                        auto_commit,
+                        strategy.clone(),
+                    ))
+                    .await;
+
+                let fetches = f.cluster.received(FETCH_MESSAGES_CODE);
+                if progress && uncertain {
+                    let operation_id = fetches[0].header.request;
+                    let expected = if auto_commit {
+                        IggyError::FetchProgressUncertain(operation_id)
+                    } else {
+                        IggyError::TransientNotCommitted
+                    };
+                    assert_eq!(result, Err(expected));
+                    assert_eq!(
+                        fetches.len(),
+                        1,
+                        "an uncertain progress fetch is never resent"
+                    );
+                    if auto_commit {
+                        assert!(
+                            matches!(f.call(STORE_CONSUMER_OFFSET_CODE, f.partition(None)).await,
+                            Err(IggyError::PartitionLaneBlocked(partition, operation))
+                            if partition == PARTITION_ID && operation == operation_id)
+                        );
+                        f.driver.abandon(operation_id).unwrap();
+                    } else {
+                        assert_eq!(
+                            f.driver.abandon(operation_id),
+                            Err(IggyError::OperationNotRetained(operation_id))
+                        );
+                    }
+                    f.call(STORE_CONSUMER_OFFSET_CODE, f.partition(None))
+                        .await
+                        .unwrap();
+                } else {
+                    result.unwrap();
+                    assert_eq!(
+                        fetches.len(),
+                        2,
+                        "pure reads and refused fetches go out again"
+                    );
+                }
+            }
+        }
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_a_fetch_in_flight_when_offsets_of_its_targets_change_should_hold_them_only_for_a_progress_fetch()
+     {
+        for (auto_commit, strategy, progress) in [
+            (true, WirePollingStrategy::offset(0), true),
+            (false, WirePollingStrategy::next(), true),
+            (false, WirePollingStrategy::offset(0), false),
+        ] {
+            let f = Fixture::new().await;
+            f.cluster
+                .script(&endpoint(PRIMARY), FETCH_MESSAGES_CODE, [Answer::Silence]);
+            let request = fetch([STAYED, PARTITION_ID], auto_commit, strategy);
+            let mut fetched = Box::pin(f.driver.fetch_messages(&request));
+            tokio::select! {
+                result = &mut fetched => panic!("the unanswered fetch returned: {result:?}"),
+                () = eventually("the fetch arrived", || f.cluster.count(FETCH_MESSAGES_CODE) == 1) => {}
+            }
+            timeout(CALLER_TIMEOUT, f.send_messages())
+                .await
+                .expect("a fetch takes no mutation lane")
+                .unwrap();
+            let store = |partition_id| {
+                f.call(
+                    STORE_CONSUMER_OFFSET_CODE,
+                    RequestTarget::Partition(PartitionTarget {
+                        stream_id: &f.stream_id,
+                        topic_id: &f.topic_id,
+                        partition_id,
+                        context: None,
+                    }),
+                )
+            };
+            let mut stores = Box::pin(futures::future::join_all([
+                store(STAYED),
+                store(PARTITION_ID),
+            ]));
+
+            let stored = timeout(CALLER_TIMEOUT, &mut stores).await;
+
+            if progress {
+                assert!(
+                    stored.is_err(),
+                    "a store waits for a progress fetch of its partition"
+                );
+                assert_eq!(f.cluster.count(STORE_CONSUMER_OFFSET_CODE), 0);
+                drop(fetched);
+                for stored in stores.await {
+                    if auto_commit {
+                        assert!(matches!(stored, Err(IggyError::PartitionLaneBlocked(..))));
+                    } else {
+                        stored.unwrap();
+                    }
+                }
+            } else {
+                for stored in stored.unwrap() {
+                    stored.unwrap();
+                }
+            }
+            assert_eq!(
+                f.cluster.count(STORE_CONSUMER_OFFSET_CODE),
+                if auto_commit { 0 } else { 2 }
+            );
+        }
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_a_retained_offset_mutation_of_a_target_when_a_progress_fetch_enters_should_fail_naming_its_partition()
+     {
+        let f = Fixture::new().await;
+        f.cluster.script(
+            &endpoint(PRIMARY),
+            STORE_CONSUMER_OFFSET_CODE,
+            [
+                Answer::Refuse(IggyError::TransientNotCommitted),
+                Answer::Refuse(IggyError::RequestTooOld),
+            ],
+        );
+        let result = f.call(STORE_CONSUMER_OFFSET_CODE, f.partition(None)).await;
+        let op = f.cluster.received(STORE_CONSUMER_OFFSET_CODE)[0]
+            .header
+            .request;
+        assert_eq!(result, Err(IggyError::OperationUncertain(op)));
+
+        for (auto_commit, strategy) in [
+            (true, WirePollingStrategy::offset(0)),
+            (false, WirePollingStrategy::next()),
+        ] {
+            let fetched = f
+                .driver
+                .fetch_messages(&fetch([STAYED, PARTITION_ID], auto_commit, strategy))
+                .await;
+            assert!(
+                matches!(
+                    fetched,
+                    Err(IggyError::PartitionLaneBlocked(partition_id, blocking))
+                        if partition_id == PARTITION_ID && blocking == op
+                ),
+                "{fetched:?}"
+            );
+        }
+
+        assert_eq!(f.cluster.count(FETCH_MESSAGES_CODE), 0);
+        f.driver
+            .fetch_messages(&fetch(
+                [STAYED, PARTITION_ID],
+                false,
+                WirePollingStrategy::offset(0),
+            ))
+            .await
+            .expect("a fetch that moves no consumer takes no lane");
+        f.driver.abandon(op).unwrap();
+        f.driver
+            .fetch_messages(&fetch(
+                [STAYED, PARTITION_ID],
+                true,
+                WirePollingStrategy::offset(0),
+            ))
+            .await
+            .unwrap();
+        assert_eq!(f.cluster.count(FETCH_MESSAGES_CODE), 2);
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_89_for_a_fetch_or_one_of_its_sections_when_settled_should_mark_the_refused_routes_moved()
+     {
+        // A partition whose section is refused because its route no longer holds.
+        const REFUSED: u32 = 2;
+        let stale_route_refusals = [
+            None,
+            Some(IggyError::HistoryUnavailable),
+            Some(IggyError::ConsumerGroupPartitionNotOwned(0, REFUSED)),
+            Some(IggyError::PartitionNotFound(
+                REFUSED as usize,
+                Identifier::default(),
+                Identifier::default(),
+            )),
+        ];
+        for refusal in stale_route_refusals {
+            let whole_request = refusal.is_none();
+            let f = Fixture::new().await;
+            let answer = match &refusal {
+                None => Answer::Refuse(IggyError::NotPrimary),
+                Some(refusal) => fetched_body(&[
+                    (STAYED, STATUS_OK),
+                    (PARTITION_ID, IggyError::NotPrimary.as_code()),
+                    (REFUSED, refusal.as_code()),
+                ]),
+            };
+            f.cluster
+                .script(&endpoint(PRIMARY), FETCH_MESSAGES_CODE, [answer]);
+
+            let result = f
+                .driver
+                .fetch_messages(&fetch(
+                    [STAYED, PARTITION_ID, REFUSED],
+                    false,
+                    WirePollingStrategy::offset(0),
+                ))
+                .await;
+
+            assert_eq!(
+                f.cluster.count(FETCH_MESSAGES_CODE),
+                1,
+                "the caller regroups a moved fetch"
+            );
+            let usable = |partition_id| {
+                let key = PartitionKey {
+                    stream_id: f.stream_id.clone(),
+                    topic_id: f.topic_id.clone(),
+                    partition_id,
+                };
+                f.driver.routes().usable(
+                    &key,
+                    f.driver.session.fence(),
+                    f.driver.session.metadata_watermark(),
+                )
+            };
+            if whole_request {
+                assert_eq!(result, Err(IggyError::NotPrimary));
+                assert_eq!(usable(STAYED), None);
+            } else {
+                result.unwrap();
+                assert_eq!(usable(STAYED), Some(ROUTE), "{refusal:?}");
+            }
+            assert_eq!(usable(PARTITION_ID), None);
+            assert_eq!(usable(REFUSED), None, "{refusal:?}");
+        }
+    }
+
+    #[tokio::test(start_paused = true)]
     async fn given_a_full_client_when_a_consumer_poll_expires_should_release_its_consumer_lease() {
         let mut fixture = Fixture::new().await;
         fixture.driver.deferred_requests = Semaphore::new(1);
@@ -3633,22 +5090,12 @@
         ));
         f.driver.links.connect().await.unwrap();
         assert!(f.driver.links.ready_replicas().is_empty());
-        let options = DeferredPollOptions::default();
-        let wait = WaitForMessagesRequest {
-            consumer: WireConsumer::consumer(WireIdentifier::numeric(1)),
-            stream_id: WireIdentifier::numeric(1),
-            topic_id: WireIdentifier::numeric(2),
-            wait_us: options.max_wait.as_micros(),
-            request_timeout_us: options.request_timeout.as_micros(),
-            partitions: vec![WaitForMessagesTarget {
-                partition_id: PARTITION_ID,
-                strategy: WirePollingStrategy::next(),
-            }],
-        };
-
         for (code, payload) in [
             (POLL_MESSAGES_DEFERRED_CODE, f.deferred_poll()),
-            (WAIT_FOR_MESSAGES_CODE, wait.to_bytes()),
+            (
+                FETCH_MESSAGES_CODE,
+                fetch([PARTITION_ID], false, WirePollingStrategy::next()).to_bytes(),
+            ),
         ] {
             let result = timeout(RECOVERY_TIMEOUT, f.driver.send_raw(code, payload, None))
                 .await
@@ -3783,6 +5230,35 @@
     }
 
     #[tokio::test(start_paused = true)]
+    async fn given_a_committing_poll_when_its_deadline_passes_before_the_send_should_fail_with_58_and_leave_no_block()
+     {
+        for code in [POLL_MESSAGES_CODE, POLL_MESSAGES_DEFERRED_CODE] {
+            let f = Fixture::new().await;
+            f.cluster.down.lock().unwrap().insert(endpoint(PRIMARY));
+            f.cluster.drop_connections(&endpoint(PRIMARY));
+            eventually("the link to the primary is gone", || {
+                !f.driver.links.ready_replicas().contains(&PRIMARY)
+            })
+            .await;
+
+            let result = f.call_to(code).await;
+
+            assert_eq!(result, Err(IggyError::TransientNotAccepted), "{code}");
+            assert_eq!(f.cluster.count(code), 0, "{code}");
+            f.cluster.down.lock().unwrap().clear();
+            eventually("the link to the primary is back", || {
+                f.driver.links.ready_replicas().contains(&PRIMARY)
+            })
+            .await;
+            f.call(STORE_CONSUMER_OFFSET_CODE, f.partition(None))
+                .await
+                .unwrap();
+            f.call_to(code).await.unwrap();
+            assert_eq!(f.cluster.count(code), 1, "{code}");
+        }
+    }
+
+    #[tokio::test(start_paused = true)]
     async fn given_a_sign_in_that_outlasts_the_call_when_the_call_waits_for_the_session_should_fail_with_58_without_sending()
      {
         let f = Fixture::new().await;
@@ -3940,6 +5416,75 @@
         );
     }
 
+    /// A caller that disconnects while its committing poll waits cannot know what the server
+    /// stored, so the poll ends uncertain and its partition stays blocked in the next session.
+    #[tokio::test(start_paused = true)]
+    async fn given_a_waiting_committing_poll_when_the_caller_disconnects_should_fail_uncertain_and_block_its_partition_in_the_next_session()
+     {
+        let f = Fixture::new().await;
+        f.cluster.hold(POLL_MESSAGES_DEFERRED_CODE);
+        let target = RequestTarget::Partition(PartitionTarget {
+            stream_id: &f.stream_id,
+            topic_id: &f.topic_id,
+            partition_id: PARTITION_ID,
+            context: None,
+        });
+        let client = TcpClient::from_links(f.driver.links);
+        let consumer = Consumer::new(Identifier::numeric(1).unwrap());
+        let next = PollingStrategy::next();
+        let poll = client.poll_messages(
+            &f.stream_id,
+            &f.topic_id,
+            PARTITION_ID,
+            &consumer,
+            &next,
+            PollOptions {
+                commit: CommitPolicy::OnRead,
+                ..PollOptions::wait(IggyDuration::from(Duration::from_secs(10)))
+            },
+        );
+        tokio::pin!(poll);
+        tokio::select! {
+            polled = &mut poll => panic!("the held poll returned: {polled:?}"),
+            () = eventually("the committing poll arrived", || {
+                f.cluster.count(POLL_MESSAGES_DEFERRED_CODE) == 1
+            }) => {}
+        }
+
+        client.disconnect().await.unwrap();
+        let polled = poll.await;
+        let op = f.cluster.received(POLL_MESSAGES_DEFERRED_CODE)[0]
+            .header
+            .request;
+        client.connect().await.unwrap();
+        client.login_user(OTHER_USER, OTHER_PASSWORD).await.unwrap();
+        let stored = client
+            .driver
+            .send(
+                STORE_CONSUMER_OFFSET_CODE,
+                Bytes::from_static(PAYLOAD),
+                target,
+            )
+            .await;
+        let abandoned = client.abandon_operation(op).await;
+        let stored_after_abandon = client
+            .driver
+            .send(
+                STORE_CONSUMER_OFFSET_CODE,
+                Bytes::from_static(PAYLOAD),
+                target,
+            )
+            .await;
+
+        assert_eq!(polled.map(drop), Err(IggyError::FetchProgressUncertain(op)));
+        assert_eq!(
+            stored,
+            Err(IggyError::PartitionLaneBlocked(PARTITION_ID, op))
+        );
+        assert_eq!(abandoned, Ok(()));
+        stored_after_abandon.unwrap();
+    }
+
     #[tokio::test(start_paused = true)]
     async fn given_a_raw_call_waiting_for_a_session_when_the_caller_signs_in_should_fail_with_40_without_sending()
      {
@@ -4266,8 +5811,7 @@
                 GET_PARTITION_ROUTE_CODE,
                 refused.clone(),
             ),
-            (GET_STREAMS_CODE, 0, GET_STREAMS_CODE, refused.clone()),
-            (POLL_MESSAGES_CODE, PRIMARY, POLL_MESSAGES_CODE, refused),
+            (GET_STREAMS_CODE, 0, GET_STREAMS_CODE, refused),
         ];
         for (code, replica, rejected, answer) in cases {
             let f = Fixture::new().await;
@@ -4300,6 +5844,214 @@
         }
     }
 
+    /// The shard watchdog can refuse a read after the read stored the offset,
+    /// so a committing poll treats every refusal outside the deny list of a
+    /// committing fetch as an unknown outcome.
+    #[tokio::test(start_paused = true)]
+    async fn given_a_refusal_that_can_follow_the_read_when_the_poll_commits_should_block_its_partition_until_abandoned()
+     {
+        for code in [POLL_MESSAGES_CODE, POLL_MESSAGES_DEFERRED_CODE] {
+            for refusal in [IggyError::ShardCommunicationError, IggyError::StaleClient] {
+                for auto_commit in [true, false] {
+                    let f = Fixture::new().await;
+                    let fence = f.driver.session.fence();
+                    let case = format!("{code} auto_commit={auto_commit}: {refusal}");
+                    f.cluster
+                        .script(&endpoint(PRIMARY), code, [Answer::Refuse(refusal.clone())]);
+                    let request = f.poll(auto_commit, WirePollingStrategy::next());
+                    let options =
+                        (code == POLL_MESSAGES_DEFERRED_CODE).then(DeferredPollOptions::default);
+
+                    let result = f.driver.poll_with_options(&request, None, options).await;
+
+                    let op = f.cluster.received(code)[0].header.request;
+                    if refusal == IggyError::StaleClient {
+                        f.next_session(fence).await;
+                    }
+                    if auto_commit {
+                        assert_eq!(result, Err(IggyError::FetchProgressUncertain(op)), "{case}");
+                        let blocked = Err(IggyError::PartitionLaneBlocked(PARTITION_ID, op));
+                        assert_eq!(
+                            f.call(STORE_CONSUMER_OFFSET_CODE, f.partition(None)).await,
+                            blocked,
+                            "{case}"
+                        );
+                        assert_eq!(
+                            f.driver.poll_with_options(&request, None, options).await,
+                            blocked,
+                            "{case}"
+                        );
+                        f.driver.abandon(op).unwrap();
+                    } else {
+                        assert_eq!(result, Err(refusal.clone()), "{case}");
+                        assert_eq!(
+                            f.driver.abandon(op),
+                            Err(IggyError::OperationNotRetained(op)),
+                            "{case}"
+                        );
+                    }
+                    f.call(STORE_CONSUMER_OFFSET_CODE, f.partition(None))
+                        .await
+                        .unwrap();
+                    f.driver
+                        .poll_with_options(&request, None, options)
+                        .await
+                        .unwrap();
+                    assert_eq!(f.cluster.count(code), 2, "{case}");
+                }
+            }
+        }
+    }
+
+    /// The server refuses a first message larger than `max_bytes`, and a read that needs a new
+    /// offset key above the limit, before it stores the offset, so each refusal proves the
+    /// outcome of a committing poll.
+    #[tokio::test(start_paused = true)]
+    async fn given_a_refusal_before_the_store_when_a_committing_poll_is_refused_should_return_it_and_leave_its_partition_open()
+     {
+        for refusal in [
+            IggyError::InvalidSizeBytes,
+            IggyError::TooManyConsumerOffsets,
+        ] {
+            let f = Fixture::new().await;
+            f.cluster.script(
+                &endpoint(PRIMARY),
+                POLL_MESSAGES_DEFERRED_CODE,
+                [Answer::Refuse(refusal.clone())],
+            );
+            let request = f.poll(true, WirePollingStrategy::next());
+            let options = Some(DeferredPollOptions::default());
+
+            let result = f.driver.poll_with_options(&request, None, options).await;
+
+            assert_eq!(result, Err(refusal.clone()));
+            let op = f.cluster.received(POLL_MESSAGES_DEFERRED_CODE)[0]
+                .header
+                .request;
+            assert_eq!(
+                f.driver.abandon(op),
+                Err(IggyError::OperationNotRetained(op)),
+                "{refusal}"
+            );
+            f.call(STORE_CONSUMER_OFFSET_CODE, f.partition(None))
+                .await
+                .unwrap();
+            f.driver
+                .poll_with_options(&request, None, options)
+                .await
+                .unwrap();
+        }
+    }
+
+    /// The server returns a first record above `max_bytes` alone in the reply of a poll, so it
+    /// refuses a poll with `InvalidSizeBytes` only for a byte limit it cannot serve, before it
+    /// stores anything. The refusal comes back from one read and leaves no block.
+    #[tokio::test(start_paused = true)]
+    async fn given_a_byte_limit_refusal_when_polled_should_return_it_from_one_read_without_a_block()
+    {
+        for commit in [CommitPolicy::Manual, CommitPolicy::OnRead] {
+            for strategy in [
+                PollingStrategy::offset(COMMIT),
+                PollingStrategy::timestamp(COMMIT.into()),
+                PollingStrategy::first(),
+                PollingStrategy::next(),
+                PollingStrategy::last(),
+            ] {
+                let case = format!("{commit:?} {strategy}");
+                let f = Fixture::new().await;
+                f.cluster.script(
+                    &endpoint(PRIMARY),
+                    POLL_MESSAGES_DEFERRED_CODE,
+                    [Answer::Refuse(IggyError::InvalidSizeBytes)],
+                );
+                let client = TcpClient::from_links(f.driver.links);
+                let options = PollOptions {
+                    commit,
+                    ..PollOptions::default()
+                };
+
+                let polled = client
+                    .poll_messages(
+                        &f.stream_id,
+                        &f.topic_id,
+                        PARTITION_ID,
+                        &Consumer::default(),
+                        &strategy,
+                        options,
+                    )
+                    .await;
+
+                assert_eq!(polled.map(drop), Err(IggyError::InvalidSizeBytes), "{case}");
+                let reads = f.cluster.received(POLL_MESSAGES_DEFERRED_CODE);
+                let [read] = reads.as_slice() else {
+                    panic!("{case}: one read, got {}", reads.len());
+                };
+                let request = DeferredPollMessagesRequest::decode_from(&read.body).unwrap();
+                assert_eq!(
+                    (request.poll.count, request.max_bytes),
+                    (options.count, options.max_bytes),
+                    "{case}"
+                );
+                let op = read.header.request;
+                assert_eq!(
+                    client.driver.abandon(op),
+                    Err(IggyError::OperationNotRetained(op)),
+                    "{case}"
+                );
+            }
+        }
+    }
+
+    /// A partition that cannot serve a poll answers empty with a zero context, and the server
+    /// refuses that context as a fence with `HistoryUnavailable`. A loop that reads on with the
+    /// continuation repeats its start under the context of the route instead.
+    #[tokio::test(start_paused = true)]
+    async fn given_a_fail_fast_empty_reply_when_polling_its_continuation_should_send_the_route_context()
+     {
+        const OFFSET: u64 = 3;
+        let f = Fixture::new().await;
+        let fail_fast = PollMessagesResponseHeader {
+            partition_id: PARTITION_ID,
+            current_offset: 0,
+            messages_count: 0,
+            context: PartitionContext::default(),
+        }
+        .to_bytes()
+        .to_vec();
+        f.cluster.script(
+            &endpoint(PRIMARY),
+            POLL_MESSAGES_DEFERRED_CODE,
+            [Answer::Body(fail_fast.clone()), Answer::Body(fail_fast)],
+        );
+        let client = TcpClient::from_links(f.driver.links);
+        let start = PollingStrategy::offset(OFFSET);
+
+        let mut strategy = start;
+        for _ in 0..2 {
+            strategy = client
+                .poll_messages(
+                    &f.stream_id,
+                    &f.topic_id,
+                    PARTITION_ID,
+                    &Consumer::default(),
+                    &strategy,
+                    PollOptions::default(),
+                )
+                .await
+                .unwrap()
+                .continuation();
+        }
+
+        assert_eq!(strategy, start);
+        let reads = f.cluster.received(POLL_MESSAGES_DEFERRED_CODE);
+        assert_eq!(reads.len(), 2);
+        for read in &reads {
+            let request = DeferredPollMessagesRequest::decode_from(&read.body).unwrap();
+            assert_eq!(request.poll.strategy, WirePollingStrategy::offset(OFFSET));
+            assert_eq!(stamped_context(read), ROUTE.context());
+        }
+    }
+
     #[tokio::test(start_paused = true)]
     async fn given_a_lost_link_when_the_retry_of_a_sent_mutation_is_refused_with_stale_client_should_end_uncertain_and_sign_in_again()
      {
@@ -4495,8 +6247,8 @@
     }
 
     #[tokio::test(start_paused = true)]
-    async fn given_an_evicted_poll_when_it_moves_its_consumer_should_end_with_57_without_retention()
-    {
+    async fn given_an_evicted_poll_when_it_moves_its_consumer_should_end_uncertain_without_a_replay()
+     {
         for (auto_commit, strategy, progress) in [
             (true, WirePollingStrategy::first(), true),
             (false, WirePollingStrategy::next(), true),
@@ -4512,18 +6264,25 @@
             let result = f.driver.poll(&f.poll(auto_commit, strategy), None).await;
 
             let polls = f.cluster.received(POLL_MESSAGES_CODE);
+            let op = polls[0].header.request;
             if progress {
-                assert_eq!(result, Err(IggyError::TransientNotCommitted));
+                let uncertain = if auto_commit {
+                    IggyError::FetchProgressUncertain(op)
+                } else {
+                    IggyError::TransientNotCommitted
+                };
+                assert_eq!(result, Err(uncertain));
                 assert_eq!(polls.len(), 1);
             } else {
                 assert!(result.is_ok(), "{result:?}");
                 assert_eq!(polls.len(), 2);
             }
-            let op = polls[0].header.request;
-            assert_eq!(
-                f.driver.abandon(op),
+            let abandoned = if auto_commit {
+                Ok(())
+            } else {
                 Err(IggyError::OperationNotRetained(op))
-            );
+            };
+            assert_eq!(f.driver.abandon(op), abandoned);
         }
     }
 
@@ -4591,11 +6350,8 @@
                 POLL_MESSAGES_DEFERRED_CODE,
                 IggyError::DeferredPollCapacityExceeded,
             ),
-            (WAIT_FOR_MESSAGES_CODE, IggyError::TransientNotAccepted),
-            (
-                WAIT_FOR_MESSAGES_CODE,
-                IggyError::DeferredPollCapacityExceeded,
-            ),
+            (FETCH_MESSAGES_CODE, IggyError::TransientNotAccepted),
+            (FETCH_MESSAGES_CODE, IggyError::DeferredPollCapacityExceeded),
         ] {
             let fixture = Fixture::new().await;
             let options = DeferredPollOptions {
@@ -4604,16 +6360,10 @@
                 ..DeferredPollOptions::default()
             };
             let poll = fixture.poll(true, WirePollingStrategy::next());
-            let wait = WaitForMessagesRequest {
-                consumer: poll.consumer.clone(),
-                stream_id: poll.stream_id.clone(),
-                topic_id: poll.topic_id.clone(),
+            let fetch_request = FetchMessagesRequest {
                 wait_us: options.max_wait.as_micros(),
                 request_timeout_us: options.request_timeout.as_micros(),
-                partitions: vec![WaitForMessagesTarget {
-                    partition_id: PARTITION_ID,
-                    strategy: poll.strategy.clone(),
-                }],
+                ..fetch([PARTITION_ID], poll.auto_commit, poll.strategy.clone())
             };
             fixture.cluster.hold(GET_PARTITION_ROUTE_CODE);
             fixture.cluster.hold(code);
@@ -4632,24 +6382,23 @@
                 code,
                 [
                     Answer::Refuse(refusal),
-                    Answer::Body(
-                        WaitForMessagesResponse {
-                            partitions: Vec::new(),
-                        }
-                        .to_bytes()
-                        .to_vec(),
-                    ),
+                    if code == FETCH_MESSAGES_CODE {
+                        fetched_body(&[])
+                    } else {
+                        Answer::Body(Vec::new())
+                    },
                 ],
             );
             let (result, ()) = tokio::join!(
                 async {
-                    if code == POLL_MESSAGES_DEFERRED_CODE {
-                        fixture
-                            .driver
-                            .poll_with_options(&poll, None, Some(options))
-                            .await
-                    } else {
-                        fixture.driver.wait_for_messages(&wait).await
+                    match code {
+                        POLL_MESSAGES_DEFERRED_CODE => {
+                            fixture
+                                .driver
+                                .poll_with_options(&poll, None, Some(options))
+                                .await
+                        }
+                        _ => fixture.driver.fetch_messages(&fetch_request).await,
                     }
                 },
                 async {
@@ -4681,17 +6430,22 @@
             );
             let budgets: Vec<_> = attempts
                 .iter()
-                .map(|attempt| {
-                    if code == POLL_MESSAGES_DEFERRED_CODE {
+                .map(|attempt| match code {
+                    POLL_MESSAGES_DEFERRED_CODE => {
                         let request =
                             DeferredPollMessagesRequest::decode_from(&attempt.body).unwrap();
                         assert_eq!(request.poll, poll);
                         assert_eq!(request.min_count, options.min_count);
                         assert_eq!(request.max_bytes, options.max_bytes);
                         (request.wait_us, request.request_timeout_us)
-                    } else {
-                        let request = WaitForMessagesRequest::decode_from(&attempt.body).unwrap();
-                        assert_eq!(request.partitions, wait.partitions);
+                    }
+                    _ => {
+                        let request = FetchMessagesRequest::decode_from(&attempt.body).unwrap();
+                        let fenced = FetchMessagesTarget {
+                            incarnation: ROUTE.incarnation,
+                            ..fetch_request.targets[0].clone()
+                        };
+                        assert_eq!(request.targets, [fenced]);
                         (request.wait_us, request.request_timeout_us)
                     }
                 })
diff --git a/core/sdk/src/driver/route_cache.rs b/core/sdk/src/driver/route_cache.rs
index 34c2bbe..6a8d1b4 100644
--- a/core/sdk/src/driver/route_cache.rs
+++ b/core/sdk/src/driver/route_cache.rs
@@ -245,11 +245,7 @@
         partitions: &[u32],
     ) {
         for &partition_id in partitions {
-            let Some(route) = self
-                .topic(stream_id, topic_id)
-                .and_then(|routes| routes.get(&partition_id))
-                .map(|entry| entry.route)
-            else {
+            let Some(route) = self.route_of(stream_id, topic_id, partition_id) else {
                 continue;
             };
             self.mark_stale(&route, partition_id, |entry| {
@@ -258,6 +254,20 @@
         }
     }
 
+    /// [`Self::invalidate`] for the cached route of each of `partitions`.
+    pub(crate) fn invalidate_partitions(
+        &mut self,
+        stream_id: &Identifier,
+        topic_id: &Identifier,
+        partitions: &[u32],
+    ) {
+        for &partition_id in partitions {
+            if let Some(route) = self.route_of(stream_id, topic_id, partition_id) {
+                self.invalidate(&route, partition_id);
+            }
+        }
+    }
+
     /// Every spelling of one partition history shares its staleness.
     fn mark_stale(
         &mut self,
@@ -302,6 +312,18 @@
         self.streams.get(stream_id)?.get(topic_id)
     }
 
+    /// The cached route of a partition, also a stale one.
+    fn route_of(
+        &self,
+        stream_id: &Identifier,
+        topic_id: &Identifier,
+        partition_id: u32,
+    ) -> Option<PartitionRouteResponse> {
+        self.topic(stream_id, topic_id)?
+            .get(&partition_id)
+            .map(|entry| entry.route)
+    }
+
     fn entry(&self, key: &PartitionKey) -> Option<&Entry> {
         self.topic(&key.stream_id, &key.topic_id)?
             .get(&key.partition_id)
diff --git a/core/sdk/src/http/consumer_offsets.rs b/core/sdk/src/http/consumer_offsets.rs
index fbeb9dd..96da5c4 100644
--- a/core/sdk/src/http/consumer_offsets.rs
+++ b/core/sdk/src/http/consumer_offsets.rs
@@ -15,7 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
-use crate::http::http_client::HttpClient;
+use crate::http::http_client::{HttpClient, ProgressKey};
 use crate::http::http_transport::HttpTransport;
 use crate::http::path::encode_segment;
 use crate::prelude::Identifier;
@@ -24,8 +24,9 @@
 use iggy_common::ConsumerOffsetClient;
 use iggy_common::delete_consumer_offset::DeleteConsumerOffset;
 use iggy_common::get_consumer_offset::GetConsumerOffset;
+use iggy_common::poll_messages::DEFAULT_PARTITION_ID;
 use iggy_common::store_consumer_offset::StoreConsumerOffset;
-use iggy_common::{Consumer, ConsumerOffsetInfo, ConsumerPosition};
+use iggy_common::{Checkpoint, Consumer, ConsumerOffsetInfo};
 
 #[async_trait]
 impl ConsumerOffsetClient for HttpClient {
@@ -34,15 +35,21 @@
         consumer: &Consumer,
         stream_id: &Identifier,
         topic_id: &Identifier,
-        position: ConsumerPosition,
+        checkpoint: &Checkpoint,
     ) -> Result<(), IggyError> {
+        self.fail_if_progress_blocked(&ProgressKey::new(
+            stream_id,
+            topic_id,
+            checkpoint.partition_id(),
+            Some(checkpoint.context()),
+        ))?;
         self.put(
             &get_path(&stream_id.as_cow_str(), &topic_id.as_cow_str()),
             &StoreConsumerOffset {
                 consumer: consumer.clone(),
-                partition_id: Some(position.partition_id),
-                offset: position.offset,
-                context: Some(position.context),
+                partition_id: Some(checkpoint.partition_id()),
+                offset: checkpoint.offset(),
+                context: Some(checkpoint.context()),
             },
         )
         .await?;
@@ -57,6 +64,12 @@
         partition_id: Option<u32>,
         offset: u64,
     ) -> Result<(), IggyError> {
+        self.fail_if_progress_blocked(&ProgressKey::new(
+            stream_id,
+            topic_id,
+            partition_id.unwrap_or(DEFAULT_PARTITION_ID),
+            None,
+        ))?;
         self.put(
             &get_path(&stream_id.as_cow_str(), &topic_id.as_cow_str()),
             &StoreConsumerOffset {
@@ -108,6 +121,12 @@
         topic_id: &Identifier,
         partition_id: Option<u32>,
     ) -> Result<(), IggyError> {
+        self.fail_if_progress_blocked(&ProgressKey::new(
+            stream_id,
+            topic_id,
+            partition_id.unwrap_or(DEFAULT_PARTITION_ID),
+            None,
+        ))?;
         let path = format!(
             "{}/{}",
             get_path(&stream_id.as_cow_str(), &topic_id.as_cow_str()),
@@ -123,6 +142,29 @@
         .await?;
         Ok(())
     }
+
+    /// The HTTP delete has no field for a context.
+    async fn delete_consumer_position(
+        &self,
+        consumer: &Consumer,
+        stream_id: &Identifier,
+        topic_id: &Identifier,
+        checkpoint: &Checkpoint,
+    ) -> Result<(), IggyError> {
+        self.fail_if_progress_blocked(&ProgressKey::new(
+            stream_id,
+            topic_id,
+            checkpoint.partition_id(),
+            Some(checkpoint.context()),
+        ))?;
+        self.delete_consumer_offset(
+            consumer,
+            stream_id,
+            topic_id,
+            Some(checkpoint.partition_id()),
+        )
+        .await
+    }
 }
 
 fn get_path(stream_id: &str, topic_id: &str) -> String {
diff --git a/core/sdk/src/http/http_client.rs b/core/sdk/src/http/http_client.rs
index 46ff9f9..e132e79 100644
--- a/core/sdk/src/http/http_client.rs
+++ b/core/sdk/src/http/http_client.rs
@@ -16,17 +16,21 @@
 // under the License.
 
 use crate::diagnostic_events::diagnostic_event_channel;
-use crate::driver::{DeferredLease, MAX_DEFERRED_REQUESTS};
+use crate::driver::{DeferredLease, MAX_DEFERRED_REQUESTS, fetch_error_is_uncertain};
 use crate::http::http_transport::HttpTransport;
 use crate::prelude::{Client, HttpClientConfig, IggyError, NonZeroIggyDuration};
 use crate::vsr::LIFECYCLE_RETRY_MAX_INTERVAL;
 use async_broadcast::{Receiver, Sender};
 use async_trait::async_trait;
 use bytes::Bytes;
+use iggy_binary_protocol::{MAX_MESSAGE_PAYLOAD_SIZE, MAX_RECORD_SIZE};
 use iggy_common::locking::{IggyRwLock, IggyRwLockFn};
+use iggy_common::poll_messages::DEFAULT_PARTITION_ID;
 use iggy_common::{
-    ConnectionString, ConnectionStringUtils, ConsumerGroupClientState, DiagnosticEvent,
-    HttpConnectionStringOptions, HttpMethod, IdentityInfo, TransportProtocol, validate_api_url,
+    ConnectionString, ConnectionStringUtils, ConsumerGroupClientState, DecodeError,
+    DeferredPollOptions, DiagnosticEvent, HttpConnectionStringOptions, HttpMethod, Identifier,
+    IdentityInfo, PartitionContext, PollMessages, PolledMessages, PollingKind, TransportProtocol,
+    UndecodedRecord, validate_api_url,
 };
 use reqwest::{Method, Response, StatusCode, Url};
 use reqwest_middleware::{ClientBuilder, ClientWithMiddleware, RequestBuilder};
@@ -36,11 +40,12 @@
 };
 use reqwest_tracing::{SpanBackendWithUrl, TracingMiddleware};
 use serde::{Deserialize, Serialize};
+use std::collections::{BTreeMap, HashMap};
 use std::str::FromStr;
-use std::sync::Arc;
-use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
+use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
 use std::time::Duration;
-use tokio::time::{Instant, sleep};
+use tokio::time::{Instant, sleep, timeout_at};
 
 /// The first pause before a request refused with [`IggyError::LifecycleBusy`] is sent again.
 const LIFECYCLE_RETRY_INTERVAL: Duration = Duration::from_millis(50);
@@ -48,6 +53,19 @@
 /// the time the binary transports wait for one reply.
 const LIFECYCLE_RETRY_DEADLINE: Duration = Duration::from_secs(30);
 
+/// The query key of [`iggy_common::PollMessages::auto_commit`].
+const AUTO_COMMIT_QUERY_KEY: &str = "auto_commit";
+
+/// Bounds the JSON of a poll reply per byte of records, and of its error body.
+const MAX_JSON_EXPANSION: usize = 16;
+/// The JSON of the largest record: a base64 payload, and its headers at the full expansion.
+const MAX_RECORD_JSON_SIZE: usize = MAX_MESSAGE_PAYLOAD_SIZE.div_ceil(3) * 4
+    + (MAX_RECORD_SIZE - MAX_MESSAGE_PAYLOAD_SIZE) * MAX_JSON_EXPANSION;
+
+/// The first local operation id. Binary request ids also start at 1, because request 0
+/// registers the session, so no transport reports operation 0.
+const FIRST_OPERATION_ID: u64 = 1;
+
 const PUBLIC_PATHS: &[&str] = &[
     "/",
     "/ping",
@@ -69,6 +87,13 @@
     access_token: IggyRwLock<String>,
     /// The contexts that fence sends to an explicit partition, read from the topic details.
     pub(super) send_contexts: ConsumerGroupClientState,
+    /// The partitions blocked by a poll with `auto_commit` whose outcome is unknown, by the
+    /// local operation id that `abandon_operation` takes.
+    progress_blocks: Mutex<BTreeMap<u64, ProgressKey>>,
+    /// One progress poll of a partition at a time, so a poll checks the block that the poll
+    /// before it left. Each lane is keyed without its incarnation.
+    progress_lanes: Mutex<HashMap<ProgressKey, Arc<tokio::sync::Mutex<()>>>>,
+    next_operation_id: AtomicU64,
     /// HTTP keeps no connection, so no event is ever published here. The
     /// channel exists only because the `Client` trait requires
     /// `subscribe_events`.
@@ -92,6 +117,13 @@
     async fn subscribe_events(&self) -> Receiver<DiagnosticEvent> {
         self.events.1.clone()
     }
+
+    async fn abandon_operation(&self, op: u64) -> Result<(), IggyError> {
+        self.progress_blocks()
+            .remove(&op)
+            .map(drop)
+            .ok_or(IggyError::OperationNotRetained(op))
+    }
 }
 
 unsafe impl Send for HttpClient {}
@@ -116,7 +148,8 @@
     async fn get(&self, path: &str) -> Result<Response, IggyError> {
         let url = self.get_url(path)?;
         self.fail_if_not_authenticated(path).await?;
-        self.execute(Method::GET, url, |request| request).await
+        self.execute(Method::GET, url, false, |request| request)
+            .await
     }
 
     /// Invoke HTTP GET request to the Iggy API with query parameters.
@@ -127,7 +160,7 @@
     ) -> Result<Response, IggyError> {
         let url = self.get_url(path)?;
         self.fail_if_not_authenticated(path).await?;
-        self.execute(Method::GET, url, |request| request.query(query))
+        self.execute(Method::GET, url, false, |request| request.query(query))
             .await
     }
 
@@ -139,7 +172,7 @@
     ) -> Result<Response, IggyError> {
         let url = self.get_url(path)?;
         self.fail_if_not_authenticated(path).await?;
-        self.execute(Method::POST, url, |request| request.json(payload))
+        self.execute(Method::POST, url, true, |request| request.json(payload))
             .await
     }
 
@@ -151,7 +184,7 @@
     ) -> Result<Response, IggyError> {
         let url = self.get_url(path)?;
         self.fail_if_not_authenticated(path).await?;
-        self.execute(Method::PUT, url, |request| request.json(payload))
+        self.execute(Method::PUT, url, true, |request| request.json(payload))
             .await
     }
 
@@ -159,7 +192,8 @@
     async fn delete(&self, path: &str) -> Result<Response, IggyError> {
         let url = self.get_url(path)?;
         self.fail_if_not_authenticated(path).await?;
-        self.execute(Method::DELETE, url, |request| request).await
+        self.execute(Method::DELETE, url, true, |request| request)
+            .await
     }
 
     /// Invoke HTTP DELETE request to the Iggy API with query parameters.
@@ -170,7 +204,7 @@
     ) -> Result<Response, IggyError> {
         let url = self.get_url(path)?;
         self.fail_if_not_authenticated(path).await?;
-        self.execute(Method::DELETE, url, |request| request.query(query))
+        self.execute(Method::DELETE, url, true, |request| request.query(query))
             .await
     }
 
@@ -183,8 +217,13 @@
         let method = Method::from_bytes(<&str>::from(method).as_bytes())
             .map_err(|_| IggyError::InvalidHttpRequest)?;
         let url = self.get_url(path)?;
+        // A raw poll asks for `auto_commit` in its query, so the query tells whether a GET writes.
+        let may_commit = method != Method::GET
+            || url.query_pairs().any(|(key, value)| {
+                key == AUTO_COMMIT_QUERY_KEY && value.parse::<bool>() == Ok(true)
+            });
         let response = self
-            .execute(method, url, |request| match &body {
+            .execute(method, url, may_commit, |request| match &body {
                 Some(body) => request.body(body.clone()),
                 None => request,
             })
@@ -225,26 +264,79 @@
 }
 
 impl HttpClient {
+    /// Sends a deferred poll. Only a refusal that admitted nothing is sent again, within the
+    /// request timeout. A progress poll, one with `auto_commit` or from
+    /// [`PollingKind::Next`], fails with [`IggyError::PartitionLaneBlocked`] while its partition
+    /// is blocked, and the progress polls of one partition run one at a time.
+    ///
+    /// The server stores the offset of a poll with `auto_commit` before it answers, and it can
+    /// still store it after the client gives up. So such a poll that may have reached the
+    /// server fails with [`IggyError::FetchProgressUncertain`] unless its answer proves the
+    /// outcome, and a caller that drops it after the send gets the same block: the partition
+    /// stays blocked until `abandon_operation`. A failure after the connect proves the outcome
+    /// only as a 503 or a 404, or as another 4xx whose error id [`fetch_error_is_uncertain`]
+    /// takes as proof, as on binary.
     pub(super) async fn get_deferred_poll(
         &self,
         path: &str,
-        poll: &iggy_common::PollMessages,
-        options: iggy_common::DeferredPollOptions,
-    ) -> Result<iggy_common::PolledMessages, IggyError> {
-        options.validate(poll.count)?;
-        let started = tokio::time::Instant::now();
+        poll: &PollMessages,
+        options: DeferredPollOptions,
+    ) -> Result<PolledMessages, DecodeError> {
+        options
+            .validate(poll.count)
+            .map_err(DecodeError::without_record)?;
+        let key = ProgressKey::new(
+            &poll.stream_id,
+            &poll.topic_id,
+            poll.partition_id.unwrap_or(DEFAULT_PARTITION_ID),
+            poll.strategy.context(),
+        );
+        let progress = poll.auto_commit || poll.strategy.kind == PollingKind::Next;
+        // Before the lease wait, so a client full of waiting polls cannot hide the block
+        // behind `TransientNotAccepted`.
+        if progress {
+            self.fail_if_progress_blocked(&key)
+                .map_err(DecodeError::without_record)?;
+        }
+        let limit = deferred_reply_limit(options.max_bytes);
+        let started = Instant::now();
         let deadline = started + options.request_timeout.get_duration();
-        let _permit = DeferredLease::acquire(&self.deferred_leases, deadline).await?;
-        let url = self.get_url(path)?;
-        self.fail_if_not_authenticated(path).await?;
+        let _lane = if progress {
+            Some(
+                self.progress_lane(&key, deadline)
+                    .await
+                    .map_err(DecodeError::without_record)?,
+            )
+        } else {
+            None
+        };
+        let _permit = DeferredLease::acquire(&self.deferred_leases, deadline)
+            .await
+            .map_err(DecodeError::without_record)?;
+        let url = self.get_url(path).map_err(DecodeError::without_record)?;
+        self.fail_if_not_authenticated(path)
+            .await
+            .map_err(DecodeError::without_record)?;
         let mut retry_interval = LIFECYCLE_RETRY_INTERVAL;
         loop {
+            // Another call can block the partition during the lane wait or a retry pause.
+            if progress {
+                self.fail_if_progress_blocked(&key)
+                    .map_err(DecodeError::without_record)?;
+            }
             let request = {
                 let token = self.access_token.read().await;
-                let remaining = options.remaining(started.elapsed())?;
-                // Deferred auto-commits cannot replay an ambiguous transport failure.
-                self.client
-                    .get(url.clone())
+                let remaining = options
+                    .remaining(started.elapsed())
+                    .map_err(DecodeError::without_record)?;
+                // Deferred auto-commits cannot replay an ambiguous transport failure. A proxy may
+                // send a GET again, so a poll that stores its offset is a POST.
+                let request = if poll.auto_commit {
+                    self.client.post(url.clone())
+                } else {
+                    self.client.get(url.clone())
+                };
+                request
                     .bearer_auth(token.as_str())
                     .query(poll)
                     .query(&[
@@ -255,64 +347,173 @@
                     ])
                     .timeout(remaining.request_timeout.get_duration())
             };
-            let sent = request
-                .send()
-                .await
-                .map_err(reqwest_middleware::Error::Reqwest);
-            // A poll that may have reached the server has an unknown commit.
-            let uncertain = may_have_committed(&sent);
-            let mut response = sent.map_err(|_| {
-                if uncertain {
-                    IggyError::TransientNotCommitted
-                } else {
-                    IggyError::InvalidHttpRequest
+            // Armed before the send, because HTTP cannot tell when the request leaves.
+            let attempt = self.poll_attempt(&key, poll.auto_commit);
+            let error = match Self::deferred_reply(request, poll, limit, deadline).await {
+                Ok(messages) => {
+                    attempt.settle();
+                    return Ok(messages);
                 }
-            })?;
-            let status = response.status();
-            // Bound JSON expansion and error bodies under the same request budget.
-            const MAX_JSON_EXPANSION: usize = 16;
-            let limit = (options.max_bytes as usize).saturating_mul(MAX_JSON_EXPANSION);
-            if response
-                .content_length()
-                .is_some_and(|length| length > limit as u64)
-            {
-                return Err(IggyError::InvalidSizeBytes);
-            }
-            let mut body = Vec::new();
-            while let Some(chunk) = tokio::time::timeout_at(deadline, response.chunk())
-                .await
-                .map_err(|_| IggyError::TransientNotCommitted)?
-                .map_err(|_| IggyError::TransientNotCommitted)?
-            {
-                if chunk.len() > limit.saturating_sub(body.len()) {
-                    return Err(IggyError::InvalidSizeBytes);
+                Err(PollFailure::Refused(error)) => {
+                    attempt.settle();
+                    error
                 }
-                body.extend_from_slice(&chunk);
-            }
-            if status.is_success() {
-                let messages: iggy_common::PolledMessages =
-                    serde_json::from_slice(&body).map_err(|_| IggyError::InvalidJsonResponse)?;
-                if messages.count > poll.count || messages.messages.len() != messages.count as usize
-                {
-                    return Err(IggyError::InvalidMessagesCount);
+                Err(PollFailure::Unknown(error)) => {
+                    return Err(DecodeError::without_record(attempt.unknown(error)));
                 }
-                return Ok(messages);
-            }
-            let error = Self::response_error(status, String::from_utf8_lossy(&body).into_owned());
+                // The body is not a batch, so it goes out as it came for a diagnosis.
+                Err(PollFailure::Undecoded(body)) => {
+                    return Err(DecodeError {
+                        error: attempt.unknown(IggyError::InvalidJsonResponse),
+                        record: Some(Box::new(UndecodedRecord {
+                            offset: None,
+                            bytes: body,
+                        })),
+                    });
+                }
+            };
             if !matches!(
                 error,
                 IggyError::TransientNotAccepted | IggyError::DeferredPollCapacityExceeded
             ) {
-                return Err(error);
+                return Err(DecodeError::without_record(error));
             }
-            if tokio::time::Instant::now() + retry_interval >= deadline {
-                return Err(error);
+            if Instant::now() + retry_interval >= deadline {
+                return Err(DecodeError::without_record(error));
             }
-            tokio::time::sleep(retry_interval).await;
+            sleep(retry_interval).await;
             retry_interval = (retry_interval * 2).min(LIFECYCLE_RETRY_MAX_INTERVAL);
         }
     }
 
+    /// Sends one attempt of a deferred poll and decodes its answer, whose body may take up to
+    /// `limit` bytes. A decoded batch records `poll.strategy` as its request start.
+    async fn deferred_reply(
+        request: reqwest::RequestBuilder,
+        poll: &PollMessages,
+        limit: usize,
+        deadline: Instant,
+    ) -> Result<PolledMessages, PollFailure> {
+        let sent = request
+            .send()
+            .await
+            .map_err(reqwest_middleware::Error::Reqwest);
+        let uncertain = may_have_committed(&sent);
+        let mut response = sent.map_err(|_| {
+            if uncertain {
+                PollFailure::Unknown(IggyError::TransientNotCommitted)
+            } else {
+                PollFailure::Refused(IggyError::InvalidHttpRequest)
+            }
+        })?;
+        let status = response.status();
+        if response
+            .content_length()
+            .is_some_and(|length| length > limit as u64)
+        {
+            return Err(PollFailure::Unknown(IggyError::InvalidSizeBytes));
+        }
+        let mut body = Vec::new();
+        while let Some(chunk) = timeout_at(deadline, response.chunk())
+            .await
+            .map_err(|_| PollFailure::Unknown(IggyError::TransientNotCommitted))?
+            .map_err(|_| PollFailure::Unknown(IggyError::TransientNotCommitted))?
+        {
+            if chunk.len() > limit.saturating_sub(body.len()) {
+                return Err(PollFailure::Unknown(IggyError::InvalidSizeBytes));
+            }
+            body.extend_from_slice(&chunk);
+        }
+        if !status.is_success() {
+            let reason = String::from_utf8_lossy(&body).into_owned();
+            // A 503 refuses a poll before the server takes it, and the server sends a 404 only
+            // before the read or for a partition that its owner does not have. The server
+            // checks a poll again after the read, so another 4xx proves the outcome only with
+            // an error that binary also takes as proof.
+            let proved = matches!(
+                status,
+                StatusCode::SERVICE_UNAVAILABLE | StatusCode::NOT_FOUND
+            ) || (status.is_client_error()
+                && error_id(&reason)
+                    .is_some_and(|id| !fetch_error_is_uncertain(&IggyError::from_code(id))));
+            let error = Self::response_error(status, reason);
+            return Err(if proved {
+                PollFailure::Refused(error)
+            } else {
+                PollFailure::Unknown(error)
+            });
+        }
+        let mut messages: PolledMessages = match serde_json::from_slice(&body) {
+            Ok(messages) => messages,
+            Err(_) => return Err(PollFailure::Undecoded(Bytes::from(body))),
+        };
+        if messages.count > poll.count || messages.messages.len() != messages.count as usize {
+            return Err(PollFailure::Unknown(IggyError::InvalidMessagesCount));
+        }
+        messages.set_request_start(&poll.strategy);
+        Ok(messages)
+    }
+
+    /// Fails with [`IggyError::PartitionLaneBlocked`] while a poll with `auto_commit` of the
+    /// partition of `key` has an unknown outcome. Polls that store the offset or start at it,
+    /// and offset stores and deletes, check it.
+    pub(super) fn fail_if_progress_blocked(&self, key: &ProgressKey) -> Result<(), IggyError> {
+        let blocking = self
+            .progress_blocks()
+            .iter()
+            .find_map(|(&operation_id, blocked)| blocked.covers(key).then_some(operation_id));
+        blocking.map_or(Ok(()), |operation_id| {
+            Err(IggyError::PartitionLaneBlocked(
+                key.partition_id,
+                operation_id,
+            ))
+        })
+    }
+
+    /// Waits until `deadline` for the progress lane of the partition of `key`, which the poll
+    /// holds until it ends, also while its drop arms a block.
+    async fn progress_lane(
+        &self,
+        key: &ProgressKey,
+        deadline: Instant,
+    ) -> Result<ProgressLane<'_>, IggyError> {
+        let key = key.lane();
+        let lane = Arc::clone(self.progress_lanes().entry(key.clone()).or_default());
+        let guard = timeout_at(deadline, lane.lock_owned())
+            .await
+            .map_err(|_| IggyError::TransientNotAccepted)?;
+        Ok(ProgressLane {
+            client: self,
+            key,
+            guard: Some(guard),
+        })
+    }
+
+    /// Whether the poll `op` still blocks its partition.
+    pub(crate) fn retains_operation(&self, op: u64) -> bool {
+        self.progress_blocks().contains_key(&op)
+    }
+
+    fn poll_attempt(&self, key: &ProgressKey, auto_commit: bool) -> PollAttempt<'_> {
+        PollAttempt {
+            client: self,
+            key: key.clone(),
+            commit: auto_commit.then(|| self.next_operation_id.fetch_add(1, Ordering::Relaxed)),
+        }
+    }
+
+    fn progress_blocks(&self) -> MutexGuard<'_, BTreeMap<u64, ProgressKey>> {
+        self.progress_blocks
+            .lock()
+            .unwrap_or_else(PoisonError::into_inner)
+    }
+
+    fn progress_lanes(&self) -> MutexGuard<'_, HashMap<ProgressKey, Arc<tokio::sync::Mutex<()>>>> {
+        self.progress_lanes
+            .lock()
+            .unwrap_or_else(PoisonError::into_inner)
+    }
+
     /// Create a new HTTP client for interacting with the Iggy API using the provided API URL.
     pub fn new(api_url: &str) -> Result<Self, IggyError> {
         Self::create(Arc::new(HttpClientConfig {
@@ -337,6 +538,9 @@
             heartbeat_interval: config.heartbeat_interval,
             access_token: IggyRwLock::new(access_token),
             send_contexts: ConsumerGroupClientState::new(),
+            progress_blocks: Mutex::default(),
+            progress_lanes: Mutex::default(),
+            next_operation_id: AtomicU64::new(FIRST_OPERATION_ID),
             events: diagnostic_event_channel(),
         })
     }
@@ -395,14 +599,21 @@
     /// Sends one request. Each [`IggyError::LifecycleBusy`] refusal commits, so the request is
     /// sent again as a new one after a pause that doubles up to [`LIFECYCLE_RETRY_MAX_INTERVAL`],
     /// until a retry would start past [`LIFECYCLE_RETRY_DEADLINE`]. A write whose outcome is
-    /// unknown is never sent again and fails with [`IggyError::TransientNotCommitted`].
-    async fn execute<F>(&self, method: Method, url: Url, decorate: F) -> Result<Response, IggyError>
+    /// unknown is never sent again and fails with [`IggyError::TransientNotCommitted`]. The
+    /// caller marks a write with `may_commit`, because a poll with `auto_commit` is a GET that
+    /// writes.
+    async fn execute<F>(
+        &self,
+        method: Method,
+        url: Url,
+        may_commit: bool,
+        decorate: F,
+    ) -> Result<Response, IggyError>
     where
         F: Fn(RequestBuilder) -> RequestBuilder + Send + Sync,
     {
         let outcome_unknown = Arc::new(AtomicBool::new(false));
-        // Reads commit nothing, so only a write can leave an unknown outcome behind.
-        let client = self.retrying_client(method != Method::GET, &outcome_unknown);
+        let client = self.retrying_client(may_commit, &outcome_unknown);
         let deadline = Instant::now() + LIFECYCLE_RETRY_DEADLINE;
         let mut pause = LIFECYCLE_RETRY_INTERVAL;
         loop {
@@ -496,6 +707,113 @@
     token: String,
 }
 
+/// Why an attempt of a deferred poll returned no messages.
+enum PollFailure {
+    /// Nothing was sent, or the answer proves the outcome.
+    Refused(IggyError),
+    /// The attempt may have reached the server, and no answer proves what it did.
+    Unknown(IggyError),
+    /// The answer is not a batch. Its body goes out with the error.
+    Undecoded(Bytes),
+}
+
+/// The partition of a progress call. HTTP has no partition id independent of the spelling of
+/// the stream and the topic, so a key names them as the call spells them, and adds the
+/// incarnation of the partition when the call carries its context. An incarnation names one
+/// partition in every spelling.
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub(super) struct ProgressKey {
+    stream: String,
+    topic: String,
+    partition_id: u32,
+    incarnation: Option<u64>,
+}
+
+/// The progress lane of one partition, held by one progress poll.
+struct ProgressLane<'a> {
+    client: &'a HttpClient,
+    key: ProgressKey,
+    guard: Option<tokio::sync::OwnedMutexGuard<()>>,
+}
+
+/// One attempt of a deferred poll. An attempt with `auto_commit` that its caller drops before
+/// it settles blocks its partition, because the server can still store the offset.
+struct PollAttempt<'a> {
+    client: &'a HttpClient,
+    key: ProgressKey,
+    /// The local operation id of an attempt with `auto_commit`.
+    commit: Option<u64>,
+}
+
+impl ProgressKey {
+    pub(super) fn new(
+        stream: &Identifier,
+        topic: &Identifier,
+        partition_id: u32,
+        context: Option<PartitionContext>,
+    ) -> Self {
+        Self {
+            stream: stream.as_cow_str().into_owned(),
+            topic: topic.as_cow_str().into_owned(),
+            partition_id,
+            // Metadata revisions start at 1, so incarnation 0 is a context that was never read.
+            incarnation: context
+                .map(|context| context.incarnation)
+                .filter(|incarnation| *incarnation != 0),
+        }
+    }
+
+    /// Whether a block of `self` covers a call of `other`.
+    fn covers(&self, other: &Self) -> bool {
+        self.partition_id == other.partition_id
+            && ((self.stream == other.stream && self.topic == other.topic)
+                || (self.incarnation.is_some() && self.incarnation == other.incarnation))
+    }
+
+    fn lane(&self) -> Self {
+        Self {
+            incarnation: None,
+            ..self.clone()
+        }
+    }
+}
+
+impl Drop for ProgressLane<'_> {
+    fn drop(&mut self) {
+        drop(self.guard.take());
+        let mut lanes = self.client.progress_lanes();
+        if lanes
+            .get(&self.key)
+            .is_some_and(|lane| Arc::strong_count(lane) == 1)
+        {
+            lanes.remove(&self.key);
+        }
+    }
+}
+
+impl PollAttempt<'_> {
+    /// The answer proved the outcome, so the partition stays open.
+    fn settle(mut self) {
+        self.commit = None;
+    }
+
+    /// The error of an attempt with an unknown outcome. With `auto_commit`, it is
+    /// [`IggyError::FetchProgressUncertain`], and the partition stays blocked.
+    fn unknown(self, error: IggyError) -> IggyError {
+        self.commit.map_or(error, IggyError::FetchProgressUncertain)
+    }
+}
+
+impl Drop for PollAttempt<'_> {
+    fn drop(&mut self) {
+        if let Some(operation_id) = self.commit {
+            self.client
+                .progress_blocks()
+                .insert(operation_id, self.key.clone());
+        }
+    }
+}
+
 /// Resends what the default strategy calls transient, but never a write that may have committed.
 struct SafeResends {
     may_commit: bool,
@@ -528,26 +846,43 @@
     }
 }
 
+/// The largest body of a poll reply, which holds up to `max_bytes` of records or one larger
+/// first record.
+fn deferred_reply_limit(max_bytes: u32) -> usize {
+    (max_bytes as usize)
+        .saturating_mul(MAX_JSON_EXPANSION)
+        .max(MAX_RECORD_JSON_SIZE)
+}
+
 /// Decodes the refusals the client acts on: a producer never repeats `RequestTooOld`, a send
-/// refreshes its context once on `HistoryUnavailable`, `LifecycleBusy` is retried, and
-/// non-admission and capacity refusals permit a retry without risking duplicate progress.
+/// refreshes its context once on `HistoryUnavailable`, `LifecycleBusy` is retried,
+/// non-admission and capacity refusals permit a retry without risking duplicate progress, and a
+/// poll refused for its byte limit returns `InvalidSizeBytes`.
 fn typed_refusal(body: &str) -> Option<IggyError> {
-    #[derive(Deserialize)]
-    struct ErrorId {
-        id: u32,
-    }
-    let id = serde_json::from_str::<ErrorId>(body).ok()?.id;
+    let id = error_id(body)?;
     [
         IggyError::RequestTooOld,
         IggyError::HistoryUnavailable,
         IggyError::LifecycleBusy,
         IggyError::TransientNotAccepted,
         IggyError::DeferredPollCapacityExceeded,
+        IggyError::InvalidSizeBytes,
     ]
     .into_iter()
     .find(|error| error.as_code() == id)
 }
 
+/// The id of the error that the server rendered into an error body.
+fn error_id(body: &str) -> Option<u32> {
+    #[derive(Deserialize)]
+    struct ErrorId {
+        id: u32,
+    }
+    serde_json::from_str::<ErrorId>(body)
+        .ok()
+        .map(|error| error.id)
+}
+
 /// Unit tests for HttpClient.
 /// TODO: Add complete unit tests for HttpClient.
 #[cfg(test)]
@@ -688,6 +1023,36 @@
         );
     }
 
+    /// A raw poll that asks for `auto_commit` is a write, so only a raw poll without it is sent
+    /// again after a timeout.
+    #[tokio::test(start_paused = true)]
+    async fn given_raw_poll_when_it_times_out_should_resend_it_only_without_auto_commit() {
+        for auto_commit in [true, false] {
+            let server = ScriptedServer::start(vec![Reply::empty(504), Reply::empty(200)]).await;
+
+            let polled = server
+                .client()
+                .send_http_request(
+                    HttpMethod::Get,
+                    &format!("/streams/1/topics/1/messages?auto_commit={auto_commit}"),
+                    None,
+                )
+                .await;
+
+            let requests = server.requests().len();
+            if auto_commit {
+                assert!(
+                    matches!(polled, Err(IggyError::TransientNotCommitted)),
+                    "{polled:?}"
+                );
+                assert_eq!(requests, 1);
+            } else {
+                polled.unwrap();
+                assert_eq!(requests, 2);
+            }
+        }
+    }
+
     #[test]
     fn typed_refusals_survive_http_decoding() {
         for error in [
@@ -696,6 +1061,7 @@
             IggyError::LifecycleBusy,
             IggyError::TransientNotAccepted,
             IggyError::DeferredPollCapacityExceeded,
+            IggyError::InvalidSizeBytes,
         ] {
             let body = format!(r#"{{"id":{},"reason":"unavailable"}}"#, error.as_code());
             assert_eq!(typed_refusal(&body), Some(error));
@@ -731,7 +1097,7 @@
             )
             .await;
         assert!(
-            matches!(result, Err(IggyError::HttpResponseError(503, body))
+            matches!(result, Err(DecodeError { error: IggyError::HttpResponseError(503, body), .. })
             if serde_json::from_str::<serde_json::Value>(&body).unwrap()["id"].as_u64().unwrap() as u32
                 == IggyError::TransientNotCommitted.as_code())
         );
@@ -764,7 +1130,13 @@
                 },
             )
             .await;
-        assert!(matches!(result, Err(IggyError::TransientNotAccepted)));
+        assert!(matches!(
+            result,
+            Err(DecodeError {
+                error: IggyError::TransientNotAccepted,
+                ..
+            })
+        ));
         assert_eq!(
             tokio::time::timeout(std::time::Duration::from_secs(5), requests)
                 .await
@@ -775,48 +1147,25 @@
         );
     }
 
-    #[tokio::test]
-    async fn deferred_poll_lost_after_sending_reports_an_unknown_commit() {
-        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
-        let client = HttpClient::create(Arc::new(HttpClientConfig {
-            api_url: format!("http://{}", listener.local_addr().unwrap()),
-            jwt: Some("test-token".to_owned()),
-            ..HttpClientConfig::default()
-        }))
-        .unwrap();
-        let server = tokio::spawn(async move {
-            let (stream, _) = listener.accept().await.unwrap();
-            let mut stream = BufReader::new(stream);
-            let mut line = String::new();
-            while stream.read_line(&mut line).await.unwrap() > 0 && line != "\r\n" {
-                line.clear();
-            }
-        });
-        let result = client
-            .get_deferred_poll(
-                "messages/deferred",
-                &deferred_request(),
-                iggy_common::DeferredPollOptions::default(),
-            )
-            .await;
-        assert!(
-            matches!(result, Err(IggyError::TransientNotCommitted)),
-            "{result:?}"
-        );
-        server.await.unwrap();
-    }
-
+    /// A poll that stores nothing reports the bound itself. With `auto_commit`, a body it cannot
+    /// read leaves the outcome unknown.
     #[tokio::test]
     async fn deferred_poll_bounds_success_and_error_bodies() {
         const MAX_BYTES: u32 = iggy_common::MIN_DEFERRED_POLL_MAX_BYTES as u32;
         for status in [200, 503] {
-            let (client, requests) =
-                deferred_response_server(vec![(status, " ".repeat(16 * MAX_BYTES as usize + 1))])
-                    .await;
+            let (client, requests) = deferred_server(vec![(
+                status,
+                String::new(),
+                deferred_reply_limit(MAX_BYTES) + 1,
+            )])
+            .await;
             let result = client
                 .get_deferred_poll(
                     "messages/deferred",
-                    &deferred_request(),
+                    &PollMessages {
+                        auto_commit: false,
+                        ..deferred_request()
+                    },
                     iggy_common::DeferredPollOptions {
                         max_bytes: MAX_BYTES,
                         ..Default::default()
@@ -824,7 +1173,13 @@
                 )
                 .await;
             assert!(
-                matches!(result, Err(IggyError::InvalidSizeBytes)),
+                matches!(
+                    result,
+                    Err(DecodeError {
+                        error: IggyError::InvalidSizeBytes,
+                        ..
+                    })
+                ),
                 "status {status}: {result:?}"
             );
             assert_eq!(requests.await.unwrap().len(), 1);
@@ -846,6 +1201,22 @@
     async fn deferred_response_server(
         responses: Vec<(u16, String)>,
     ) -> (HttpClient, JoinHandle<Vec<u64>>) {
+        deferred_server(
+            responses
+                .into_iter()
+                .map(|(status, body)| {
+                    let length = body.len();
+                    (status, body, length)
+                })
+                .collect(),
+        )
+        .await
+    }
+
+    /// Answers each request with its status and body under a head that declares its length.
+    async fn deferred_server(
+        responses: Vec<(u16, String, usize)>,
+    ) -> (HttpClient, JoinHandle<Vec<u64>>) {
         let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
         let client = HttpClient::create(Arc::new(HttpClientConfig {
             api_url: format!("http://{}", listener.local_addr().unwrap()),
@@ -855,7 +1226,7 @@
         .unwrap();
         let requests = tokio::spawn(async move {
             let mut waits = Vec::new();
-            for (status, body) in responses {
+            for (status, body, length) in responses {
                 let (stream, _) = listener.accept().await.unwrap();
                 let mut stream = BufReader::new(stream);
                 let mut line = String::new();
@@ -878,8 +1249,7 @@
                     }
                 }
                 let response = format!(
-                    "HTTP/1.1 {status} Test\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
-                    body.len()
+                    "HTTP/1.1 {status} Test\r\nContent-Type: application/json\r\nContent-Length: {length}\r\nConnection: close\r\n\r\n{body}"
                 );
                 stream
                     .get_mut()
diff --git a/core/sdk/src/http/messages.rs b/core/sdk/src/http/messages.rs
index ae61020..eb11663 100644
--- a/core/sdk/src/http/messages.rs
+++ b/core/sdk/src/http/messages.rs
@@ -23,95 +23,78 @@
     PollMessages, PolledMessages, PollingStrategy, SendMessages, SendMessagesResponse,
 };
 use async_trait::async_trait;
+use iggy_common::CommitPolicy;
+use iggy_common::DecodeError;
 use iggy_common::IggyMessagesBatch;
 use iggy_common::MessageClient;
 use iggy_common::PartitioningKind;
+use iggy_common::PollOptions;
 use iggy_common::SendMessagesConfirmations;
 
 #[async_trait]
 impl MessageClient for HttpClient {
-    async fn poll_messages_with_strategy_for_and_options(
-        &self,
-        stream_id: &Identifier,
-        topic_id: &Identifier,
-        partition_id: Option<u32>,
-        consumer: &Consumer,
-        strategy_for: &(dyn Fn(u32) -> PollingStrategy + Send + Sync),
-        count: u32,
-        auto_commit: bool,
-        options: Option<iggy_common::DeferredPollOptions>,
-    ) -> Result<PolledMessages, IggyError> {
-        let Some(options) = options else {
-            return self
-                .poll_messages_with_strategy_for(
-                    stream_id,
-                    topic_id,
-                    partition_id,
-                    consumer,
-                    strategy_for,
-                    count,
-                    auto_commit,
-                )
-                .await;
-        };
-        if consumer.kind == ConsumerKind::ExternalGroup
-            || (consumer.kind == ConsumerKind::ConsumerGroup && partition_id.is_none())
-        {
-            return Err(IggyError::FeatureUnavailable);
-        }
-        let path = format!(
-            "{}/deferred",
-            get_path(&stream_id.as_cow_str(), &topic_id.as_cow_str())
-        );
-        self.get_deferred_poll(
-            &path,
-            &PollMessages {
-                stream_id: stream_id.clone(),
-                topic_id: topic_id.clone(),
-                partition_id,
-                consumer: consumer.clone(),
-                strategy: strategy_for(partition_id.unwrap_or(0)),
-                count,
-                auto_commit,
-            },
-            options,
-        )
-        .await
-    }
-
+    /// Every poll goes to the deferred endpoint, also with a zero wait, because only that
+    /// endpoint honors `min_count`, `max_bytes` and the request timeout. A first message
+    /// larger than `max_bytes` comes alone in the reply.
     async fn poll_messages(
         &self,
         stream_id: &Identifier,
         topic_id: &Identifier,
-        partition_id: Option<u32>,
+        partition_id: u32,
         consumer: &Consumer,
         strategy: &PollingStrategy,
-        count: u32,
-        auto_commit: bool,
+        options: PollOptions,
     ) -> Result<PolledMessages, IggyError> {
-        if matches!(consumer.kind, ConsumerKind::ExternalGroup) {
-            return Err(IggyError::FeatureUnavailable);
-        }
+        self.poll_messages_raw(
+            stream_id,
+            topic_id,
+            partition_id,
+            consumer,
+            strategy,
+            options,
+        )
+        .await
+        .map_err(|error| error.error)
+    }
 
-        let response = self
-            .get_with_query(
-                &get_path(&stream_id.as_cow_str(), &topic_id.as_cow_str()),
-                &PollMessages {
-                    stream_id: stream_id.clone(),
-                    topic_id: topic_id.clone(),
-                    partition_id,
-                    consumer: consumer.clone(),
-                    strategy: *strategy,
-                    count,
-                    auto_commit,
-                },
-            )
-            .await?;
-        let messages = response
-            .json()
-            .await
-            .map_err(|_| IggyError::InvalidJsonResponse)?;
-        Ok(messages)
+    async fn poll_messages_raw(
+        &self,
+        stream_id: &Identifier,
+        topic_id: &Identifier,
+        partition_id: u32,
+        consumer: &Consumer,
+        strategy: &PollingStrategy,
+        options: PollOptions,
+    ) -> Result<PolledMessages, DecodeError> {
+        options.validate().map_err(DecodeError::without_record)?;
+        strategy
+            .validate_partition(partition_id)
+            .map_err(DecodeError::without_record)?;
+        // HTTP cannot join a consumer group, and an external group only holds offsets.
+        if consumer.kind != ConsumerKind::Consumer {
+            return Err(DecodeError::without_record(IggyError::FeatureUnavailable));
+        }
+        let auto_commit = match options.commit {
+            CommitPolicy::Manual => false,
+            CommitPolicy::OnRead => true,
+            CommitPolicy::Auto(_) => {
+                return Err(DecodeError::without_record(IggyError::InvalidConfiguration));
+            }
+        };
+        let path = format!(
+            "{}/deferred",
+            get_path(&stream_id.as_cow_str(), &topic_id.as_cow_str())
+        );
+        let poll = PollMessages {
+            stream_id: stream_id.clone(),
+            topic_id: topic_id.clone(),
+            partition_id: Some(partition_id),
+            consumer: consumer.clone(),
+            strategy: *strategy,
+            count: options.count,
+            auto_commit,
+        };
+        self.get_deferred_poll(&path, &poll, options.into()).await
     }
 
     async fn send_messages(
@@ -275,18 +258,25 @@
 
 #[cfg(test)]
 mod tests {
-    use super::{SendMessagesConfirmations, SendMessagesResponse};
+    use super::{PolledMessages, SendMessagesConfirmations, SendMessagesResponse};
     use crate::http::http_client::HttpClient;
     use crate::http::scripted_server::{Recorded, Reply, ScriptedServer};
     use crate::prelude::{
-        Consumer, DeferredPollOptions, Identifier, IggyError, IggyMessage, MessageClient,
-        PartitionClient, PartitionContext, Partitioning, PollingStrategy,
+        Client, Consumer, ConsumerOffsetClient, Identifier, IggyError, IggyMessage, MessageClient,
+        PartitionClient, PartitionContext, Partitioning, PollingKind, PollingStrategy,
         SendMessagesConfirmationResponse, StreamClient, StreamCreateOptions, TopicClient,
         TopicCreateOptions,
     };
     use bytes::Bytes;
+    use iggy_common::poll_messages::DEFAULT_PARTITION_ID;
+    use iggy_common::{
+        AutoCommit, Checkpoint, CommitPolicy, MIN_DEFERRED_POLL_MAX_BYTES, PollOptions,
+    };
+    use std::time::{Duration, Instant};
+    use tokio::time::timeout;
 
     const PARTITION_ID: u32 = 2;
+    const TEST_BUDGET: Duration = Duration::from_secs(5);
     const OTHER_PARTITION_ID: u32 = 3;
     const CURRENT: PartitionContext = PartitionContext {
         incarnation: 7,
@@ -298,8 +288,13 @@
         owner_generation: 0,
         metadata_op: 14,
     };
+    const START: u64 = 5;
+    const STREAM_NAME: &str = "stream";
+    const TOPIC_NAME: &str = "topic";
     const TOPIC_LINE: &str = "GET /streams/1/topics/1 HTTP/1.1";
     const SEND_LINE: &str = "POST /streams/1/topics/1/messages HTTP/1.1";
+    const DEFERRED_POLL_LINE: &str = "GET /streams/1/topics/1/messages/deferred?";
+    const COMMITTING_POLL_LINE: &str = "POST /streams/1/topics/1/messages/deferred?";
 
     fn ids() -> (Identifier, Identifier) {
         (
@@ -308,6 +303,14 @@
         )
     }
 
+    /// The names of the stream and topic of [`ids`].
+    fn named_ids() -> (Identifier, Identifier) {
+        (
+            Identifier::named(STREAM_NAME).unwrap(),
+            Identifier::named(TOPIC_NAME).unwrap(),
+        )
+    }
+
     async fn send(
         client: &HttpClient,
         partitioning: &Partitioning,
@@ -324,6 +327,36 @@
             .await
     }
 
+    async fn poll(
+        client: &HttpClient,
+        strategy: &PollingStrategy,
+        commit: CommitPolicy,
+    ) -> Result<PolledMessages, IggyError> {
+        poll_partition(client, &ids(), PARTITION_ID, strategy, commit).await
+    }
+
+    async fn poll_partition(
+        client: &HttpClient,
+        (stream_id, topic_id): &(Identifier, Identifier),
+        partition_id: u32,
+        strategy: &PollingStrategy,
+        commit: CommitPolicy,
+    ) -> Result<PolledMessages, IggyError> {
+        client
+            .poll_messages(
+                stream_id,
+                topic_id,
+                partition_id,
+                &Consumer::default(),
+                strategy,
+                PollOptions {
+                    commit,
+                    ..PollOptions::default()
+                },
+            )
+            .await
+    }
+
     fn lines(requests: &[Recorded]) -> Vec<&str> {
         requests
             .iter()
@@ -331,27 +364,582 @@
             .collect()
     }
 
+    /// Only the deferred endpoint honors every poll option, so a poll that answers at once goes
+    /// there too. An empty batch continues from the start of its request.
     #[tokio::test]
-    async fn given_an_external_group_when_polling_deferred_should_refuse_it_before_sending() {
+    async fn given_a_zero_wait_when_polling_should_use_the_deferred_endpoint_and_keep_its_start() {
+        let server = ScriptedServer::start(vec![Reply::polled()]).await;
+
+        let polled = poll(
+            &server.client(),
+            &PollingStrategy::offset(START),
+            CommitPolicy::Manual,
+        )
+        .await
+        .unwrap();
+
+        let requests = server.requests();
+        assert_eq!(requests.len(), 1);
+        assert!(
+            requests[0].line.starts_with(DEFERRED_POLL_LINE),
+            "{}",
+            requests[0].line
+        );
+        assert_eq!(requests[0].query("wait_us").as_deref(), Some("0"));
+        assert_eq!(
+            requests[0].query("partition_id"),
+            Some(PARTITION_ID.to_string())
+        );
+        assert_eq!(requests[0].query("auto_commit").as_deref(), Some("false"));
+        let continuation = polled.continuation();
+        assert_eq!(
+            (continuation.kind, continuation.value),
+            (PollingKind::Offset, START)
+        );
+    }
+
+    /// HTTP cannot join a consumer group, and an external group only holds offsets.
+    #[tokio::test]
+    async fn given_a_group_consumer_when_polling_should_refuse_it_before_sending() {
         let server = ScriptedServer::start(vec![]).await;
         let client = server.client();
         let (stream_id, topic_id) = ids();
+        let group_id = Identifier::numeric(1).unwrap();
+
+        for consumer in [
+            Consumer::group(group_id.clone()),
+            Consumer::external_group(group_id.clone()),
+        ] {
+            let polled = client
+                .poll_messages(
+                    &stream_id,
+                    &topic_id,
+                    PARTITION_ID,
+                    &consumer,
+                    &PollingStrategy::next(),
+                    PollOptions::default(),
+                )
+                .await;
+
+            assert_eq!(
+                polled.map(drop),
+                Err(IggyError::FeatureUnavailable),
+                "{consumer:?}"
+            );
+        }
+        assert!(server.requests().is_empty());
+    }
+
+    /// A proxy may send a GET again, so a poll that stores its offset goes as a POST with the
+    /// query of the GET and an empty body.
+    #[tokio::test]
+    async fn given_on_read_when_polling_should_post_the_deferred_query() {
+        let server = ScriptedServer::start(vec![Reply::polled()]).await;
+
+        poll(
+            &server.client(),
+            &PollingStrategy::offset(START),
+            CommitPolicy::OnRead,
+        )
+        .await
+        .unwrap();
+
+        let requests = server.requests();
+        assert_eq!(requests.len(), 1);
+        assert!(
+            requests[0].line.starts_with(COMMITTING_POLL_LINE),
+            "{}",
+            requests[0].line
+        );
+        assert_eq!(requests[0].query("auto_commit").as_deref(), Some("true"));
+        assert_eq!(requests[0].query("wait_us").as_deref(), Some("0"));
+        assert!(requests[0].body.is_empty());
+    }
+
+    /// One poll has nothing to count, so it takes no client commit.
+    #[tokio::test]
+    async fn given_auto_commit_when_polling_should_refuse_it_before_sending() {
+        let server = ScriptedServer::start(vec![]).await;
+        let client = server.client();
+
+        let polled = poll(
+            &client,
+            &PollingStrategy::next(),
+            CommitPolicy::Auto(AutoCommit::default()),
+        )
+        .await;
+
+        assert_eq!(polled.map(drop), Err(IggyError::InvalidConfiguration));
+        assert!(server.requests().is_empty());
+    }
+
+    /// A continuation of another partition would read this partition at an offset of the
+    /// other one. A continuation of this partition fences the poll with its context.
+    #[tokio::test]
+    async fn given_a_continuation_when_polling_should_send_it_only_to_its_own_partition() {
+        let server = ScriptedServer::start(vec![Reply::polled()]).await;
+        let client = server.client();
+        let other =
+            PollingStrategy::continuation(OTHER_PARTITION_ID, PollingKind::Offset, START, CURRENT);
+        let own = PollingStrategy::continuation(PARTITION_ID, PollingKind::Offset, START, CURRENT);
+
+        let refused = poll(&client, &other, CommitPolicy::Manual).await;
+        let polled = poll(&client, &own, CommitPolicy::Manual).await;
+
+        assert_eq!(refused.map(drop), Err(IggyError::InvalidCommand));
+        polled.unwrap();
+        let requests = server.requests();
+        assert_eq!(requests.len(), 1);
+        let context = requests[0]
+            .query("context")
+            .map(|context| serde_json::from_str::<PartitionContext>(&context).unwrap());
+        assert_eq!(context, Some(CURRENT));
+    }
+
+    /// The server stores the offset of an `OnRead` poll before it answers, and it can still
+    /// store it after the client gives up. So a poll whose answer does not prove the outcome
+    /// is never sent again, and it blocks the partition. The server checks a poll again after
+    /// the read, so a 4xx other than 404 settles nothing unless binary takes its error as proof
+    /// too.
+    #[tokio::test]
+    async fn given_an_on_read_poll_when_its_outcome_is_unknown_should_block_the_partition() {
+        for (case, unknown) in [
+            ("lost reply", Reply::hang_up()),
+            ("cut body", Reply::polled().cut()),
+            ("401", Reply::error(401, &IggyError::Unauthenticated)),
+            ("403", Reply::error(403, &IggyError::Unauthorized)),
+            ("other 4xx", Reply::error(400, &IggyError::InvalidCommand)),
+            ("4xx without an error id", Reply::empty(400)),
+            ("500", Reply::empty(500)),
+            ("502", Reply::empty(502)),
+            ("504", Reply::error(504, &IggyError::TransientNotCommitted)),
+            ("2xx that does not parse", Reply::empty(200)),
+        ] {
+            let server = ScriptedServer::start(vec![unknown, Reply::polled()]).await;
+            let client = server.client();
+
+            let polled = poll(&client, &PollingStrategy::next(), CommitPolicy::OnRead).await;
+            let next = poll(&client, &PollingStrategy::next(), CommitPolicy::OnRead).await;
+
+            let Err(IggyError::FetchProgressUncertain(op)) = polled else {
+                panic!("{case}: {polled:?}");
+            };
+            assert!(client.retains_operation(op), "{case}");
+            assert_eq!(
+                next.map(drop),
+                Err(IggyError::PartitionLaneBlocked(PARTITION_ID, op)),
+                "{case}"
+            );
+            let requests = server.requests();
+            assert_eq!(requests.len(), 1, "{case}");
+            assert_eq!(
+                requests[0].query("auto_commit").as_deref(),
+                Some("true"),
+                "{case}"
+            );
+        }
+    }
+
+    /// A 503 refuses a poll before the server takes it, and the server sends a 404 only before
+    /// the read. Another 4xx proves the outcome when binary takes its error as proof too, also
+    /// when the client returns it as `HttpResponseError`. None of them leaves a block.
+    /// `HistoryUnavailable` after the read means that the history the poll read is gone. A
+    /// non-admission refusal is sent again within the request timeout.
+    #[tokio::test]
+    async fn given_an_on_read_poll_when_its_refusal_proves_the_outcome_should_leave_no_block() {
+        let server = ScriptedServer::start(vec![
+            Reply::error(503, &IggyError::TransientNotAccepted),
+            Reply::polled(),
+            Reply::empty(503),
+            Reply::error(400, &IggyError::HistoryUnavailable),
+            Reply::error(400, &IggyError::InvalidOffset(START)),
+            Reply::empty(404),
+            Reply::polled(),
+        ])
+        .await;
+        let client = server.client();
+
+        let retried = poll(&client, &PollingStrategy::next(), CommitPolicy::OnRead).await;
+        let refused = poll(&client, &PollingStrategy::next(), CommitPolicy::OnRead).await;
+        let history_gone = poll(&client, &PollingStrategy::next(), CommitPolicy::OnRead).await;
+        let invalid_offset = poll(&client, &PollingStrategy::next(), CommitPolicy::OnRead).await;
+        let not_found = poll(&client, &PollingStrategy::next(), CommitPolicy::OnRead).await;
+        let next = poll(&client, &PollingStrategy::next(), CommitPolicy::OnRead).await;
+
+        retried.unwrap();
+        assert!(
+            matches!(refused, Err(IggyError::HttpResponseError(503, _))),
+            "{refused:?}"
+        );
+        assert_eq!(history_gone.map(drop), Err(IggyError::HistoryUnavailable));
+        assert!(
+            matches!(invalid_offset, Err(IggyError::HttpResponseError(400, _))),
+            "{invalid_offset:?}"
+        );
+        assert!(
+            matches!(not_found, Err(IggyError::ResourceNotFound(_))),
+            "{not_found:?}"
+        );
+        next.unwrap();
+        assert_eq!(server.requests().len(), 7);
+    }
+
+    /// The server returns a first message above `max_bytes` alone in the reply, so it refuses a
+    /// poll with `InvalidSizeBytes` only for a byte limit it cannot serve, before it stores
+    /// anything. The refusal comes back from one read and the partition polls on.
+    #[tokio::test]
+    async fn given_a_byte_limit_refusal_when_polled_should_return_it_from_one_read() {
+        for commit in [CommitPolicy::Manual, CommitPolicy::OnRead] {
+            for strategy in [
+                PollingStrategy::offset(START),
+                PollingStrategy::first(),
+                PollingStrategy::next(),
+                PollingStrategy::last(),
+            ] {
+                let case = format!("{commit:?} {strategy}");
+                let server = ScriptedServer::start(vec![
+                    Reply::error(400, &IggyError::InvalidSizeBytes),
+                    Reply::polled(),
+                ])
+                .await;
+                let client = server.client();
+
+                let refused = poll(&client, &strategy, commit).await;
+                let next = poll(&client, &strategy, commit).await;
+
+                assert_eq!(
+                    refused.map(drop),
+                    Err(IggyError::InvalidSizeBytes),
+                    "{case}"
+                );
+                next.unwrap();
+                let requests = server.requests();
+                assert_eq!(
+                    requests.len(),
+                    2,
+                    "{case}: no second read of the refused poll"
+                );
+                for request in &requests {
+                    for (key, value) in [
+                        ("count", PollOptions::default().count),
+                        ("max_bytes", PollOptions::default().max_bytes),
+                    ] {
+                        assert_eq!(request.query(key), Some(value.to_string()), "{case}: {key}");
+                    }
+                }
+            }
+        }
+    }
+
+    /// A reply holds up to `max_bytes` of records, or one larger first record, so the client
+    /// takes the JSON of one record far above its byte limit.
+    #[tokio::test]
+    async fn given_one_record_above_max_bytes_when_polled_should_take_its_reply() {
+        const PAYLOAD_SIZE: usize = 64 * 1024;
+        let max_bytes = u32::try_from(MIN_DEFERRED_POLL_MAX_BYTES).unwrap();
+        let mut message = IggyMessage::builder()
+            .payload(Bytes::from(vec![b'x'; PAYLOAD_SIZE]))
+            .build()
+            .unwrap();
+        message.header.offset = START;
+        let oversized = PolledMessages::new(CURRENT, PARTITION_ID, START, 1, vec![message]);
+        let server = ScriptedServer::start(vec![Reply::batch(&oversized)]).await;
+        let client = server.client();
+        let (stream_id, topic_id) = ids();
 
         let polled = client
-            .poll_messages_deferred(
+            .poll_messages(
                 &stream_id,
                 &topic_id,
+                PARTITION_ID,
+                &Consumer::default(),
+                &PollingStrategy::offset(START),
+                PollOptions {
+                    max_bytes,
+                    ..PollOptions::default()
+                },
+            )
+            .await
+            .expect("one record above max_bytes comes in its reply");
+
+        assert_eq!(polled.messages.len(), 1);
+        assert_eq!(polled.messages[0].payload.len(), PAYLOAD_SIZE);
+        assert_eq!(server.requests().len(), 1);
+    }
+
+    /// The server can still store the offset of a sent `OnRead` poll that its caller dropped.
+    #[tokio::test]
+    async fn given_a_sent_on_read_poll_when_its_caller_drops_it_should_block_the_partition() {
+        let server = ScriptedServer::start(vec![Reply::stall()]).await;
+        let client = server.client();
+        let strategy = PollingStrategy::next();
+
+        tokio::select! {
+            polled = poll(&client, &strategy, CommitPolicy::OnRead) => {
+                panic!("a poll without an answer ended: {polled:?}");
+            }
+            () = server.request_arrived() => {}
+        }
+        let next = poll(&client, &strategy, CommitPolicy::Manual).await;
+
+        let Err(IggyError::PartitionLaneBlocked(PARTITION_ID, op)) = next else {
+            panic!("{next:?}");
+        };
+        assert!(client.retains_operation(op));
+        assert_eq!(server.requests().len(), 1);
+    }
+
+    /// A call that carries the context of the partition meets its block in any spelling of the
+    /// stream and the topic, and a call without one in the spelling of the lost poll. Offset
+    /// reads and polls that neither store the offset nor start at it go on, and so do other
+    /// partitions.
+    #[tokio::test]
+    async fn given_a_blocked_partition_when_reading_on_should_refuse_its_progress_calls() {
+        let server = ScriptedServer::start(vec![
+            Reply::hang_up(),
+            Reply::empty(404),
+            Reply::polled(),
+            Reply::polled(),
+        ])
+        .await;
+        let client = server.client();
+        let consumer = Consumer::default();
+        let (stream_id, topic_id) = ids();
+        let named = named_ids();
+        let checkpoint = Checkpoint::new(PARTITION_ID, START, CURRENT);
+        let next_start =
+            PollingStrategy::continuation(PARTITION_ID, PollingKind::Next, START, CURRENT);
+        let offset_start =
+            PollingStrategy::continuation(PARTITION_ID, PollingKind::Offset, START, CURRENT);
+        let lost = poll(&client, &next_start, CommitPolicy::OnRead).await;
+        let Err(IggyError::FetchProgressUncertain(op)) = lost else {
+            panic!("{lost:?}");
+        };
+        let blocked = Err(IggyError::PartitionLaneBlocked(PARTITION_ID, op));
+
+        let next = poll_partition(
+            &client,
+            &named,
+            PARTITION_ID,
+            &next_start,
+            CommitPolicy::Manual,
+        )
+        .await;
+        let on_read = poll_partition(
+            &client,
+            &named,
+            PARTITION_ID,
+            &offset_start,
+            CommitPolicy::OnRead,
+        )
+        .await;
+        let stored_position = client
+            .store_consumer_position(&consumer, &named.0, &named.1, &checkpoint)
+            .await;
+        let stored_offset = client
+            .store_consumer_offset(&consumer, &stream_id, &topic_id, Some(PARTITION_ID), START)
+            .await;
+        let deleted_offset = client
+            .delete_consumer_offset(&consumer, &stream_id, &topic_id, Some(PARTITION_ID))
+            .await;
+        let deleted_position = client
+            .delete_consumer_position(&consumer, &named.0, &named.1, &checkpoint)
+            .await;
+        let offset = client
+            .get_consumer_offset(&consumer, &named.0, &named.1, Some(PARTITION_ID))
+            .await;
+        let read = poll_partition(
+            &client,
+            &named,
+            PARTITION_ID,
+            &PollingStrategy::offset(START),
+            CommitPolicy::Manual,
+        )
+        .await;
+        let other = poll_partition(
+            &client,
+            &named,
+            OTHER_PARTITION_ID,
+            &PollingStrategy::next(),
+            CommitPolicy::OnRead,
+        )
+        .await;
+
+        assert_eq!(next.map(drop), blocked);
+        assert_eq!(on_read.map(drop), blocked);
+        assert_eq!(stored_position, blocked);
+        assert_eq!(stored_offset, blocked);
+        assert_eq!(deleted_offset, blocked);
+        assert_eq!(deleted_position, blocked);
+        assert!(matches!(offset, Ok(None)), "{offset:?}");
+        read.unwrap();
+        other.unwrap();
+        assert_eq!(server.requests().len(), 4);
+    }
+
+    /// The server reads an offset call without a partition as one of `DEFAULT_PARTITION_ID`.
+    #[tokio::test]
+    async fn given_a_blocked_default_partition_when_an_offset_call_names_none_should_refuse_it() {
+        let server = ScriptedServer::start(vec![Reply::hang_up()]).await;
+        let client = server.client();
+        let consumer = Consumer::default();
+        let (stream_id, topic_id) = ids();
+        let lost = poll_partition(
+            &client,
+            &ids(),
+            DEFAULT_PARTITION_ID,
+            &PollingStrategy::next(),
+            CommitPolicy::OnRead,
+        )
+        .await;
+        let Err(IggyError::FetchProgressUncertain(op)) = lost else {
+            panic!("{lost:?}");
+        };
+
+        let stored = client
+            .store_consumer_offset(&consumer, &stream_id, &topic_id, None, START)
+            .await;
+        let deleted = client
+            .delete_consumer_offset(&consumer, &stream_id, &topic_id, None)
+            .await;
+
+        let blocked = Err(IggyError::PartitionLaneBlocked(DEFAULT_PARTITION_ID, op));
+        assert_eq!(stored, blocked);
+        assert_eq!(deleted, blocked);
+        assert_eq!(server.requests().len(), 1);
+    }
+
+    /// HTTP cannot learn the outcome of a lost poll, so only an abandon frees its partition.
+    /// The block covers the stream and the topic of the lost poll, so another topic reads the
+    /// same partition id and stores its offsets.
+    #[tokio::test]
+    async fn given_a_lost_on_read_poll_when_another_topic_reads_the_same_partition_id_should_go_on()
+    {
+        let server =
+            ScriptedServer::start(vec![Reply::hang_up(), Reply::polled(), Reply::empty(200)]).await;
+        let client = server.client();
+        let other_topic = (
+            Identifier::numeric(1).unwrap(),
+            Identifier::numeric(2).unwrap(),
+        );
+        let lost = poll(&client, &PollingStrategy::next(), CommitPolicy::OnRead).await;
+        assert!(
+            matches!(lost, Err(IggyError::FetchProgressUncertain(_))),
+            "{lost:?}"
+        );
+
+        let polled = poll_partition(
+            &client,
+            &other_topic,
+            PARTITION_ID,
+            &PollingStrategy::next(),
+            CommitPolicy::OnRead,
+        )
+        .await;
+        let stored = client
+            .store_consumer_offset(
+                &Consumer::default(),
+                &other_topic.0,
+                &other_topic.1,
                 Some(PARTITION_ID),
-                &Consumer::external_group(Identifier::numeric(1).unwrap()),
-                &PollingStrategy::next(),
-                1,
-                false,
-                DeferredPollOptions::default(),
+                START,
             )
             .await;
 
-        assert_eq!(polled.map(|_| ()), Err(IggyError::FeatureUnavailable));
-        assert!(server.requests().is_empty());
+        polled.unwrap();
+        stored.unwrap();
+        assert_eq!(server.requests().len(), 3);
+    }
+
+    /// One progress poll of a partition runs at a time, so the next one sees the block that the
+    /// first one leaves.
+    #[tokio::test]
+    async fn given_an_on_read_poll_in_flight_when_another_progress_poll_of_its_partition_starts_should_wait_for_it()
+     {
+        const LANE_WAIT: Duration = Duration::from_millis(200);
+        let server = ScriptedServer::start(vec![Reply::stall(), Reply::polled()]).await;
+        let client = server.client();
+        let next = PollingStrategy::next();
+        let mut first = Box::pin(poll(&client, &next, CommitPolicy::OnRead));
+        tokio::select! {
+            polled = &mut first => panic!("the stalled poll returned: {polled:?}"),
+            () = server.request_arrived() => {}
+        }
+        let mut second = Box::pin(poll(&client, &next, CommitPolicy::OnRead));
+
+        let waited = timeout(LANE_WAIT, &mut second).await;
+        assert!(waited.is_err(), "the second poll went out: {waited:?}");
+        drop(first);
+        let second = timeout(TEST_BUDGET, second).await.unwrap();
+
+        let Err(IggyError::PartitionLaneBlocked(PARTITION_ID, op)) = second else {
+            panic!("{second:?}");
+        };
+        assert!(client.retains_operation(op));
+        assert_eq!(server.requests().len(), 1);
+    }
+
+    #[tokio::test]
+    async fn given_a_blocked_partition_when_abandoned_should_poll_it_again() {
+        let server = ScriptedServer::start(vec![Reply::hang_up(), Reply::polled()]).await;
+        let client = server.client();
+        let lost = poll(&client, &PollingStrategy::next(), CommitPolicy::OnRead).await;
+        let Err(IggyError::FetchProgressUncertain(op)) = lost else {
+            panic!("{lost:?}");
+        };
+
+        let resolved = client.resolve_operation(op, Instant::now()).await;
+        let retained_after_resolve = client.retains_operation(op);
+        let abandoned = client.abandon_operation(op).await;
+        let retained_after_abandon = client.retains_operation(op);
+        let abandoned_again = client.abandon_operation(op).await;
+        let polled = poll(&client, &PollingStrategy::next(), CommitPolicy::OnRead).await;
+
+        assert_eq!(resolved, Err(IggyError::FeatureUnavailable));
+        assert!(retained_after_resolve);
+        assert_eq!(abandoned, Ok(()));
+        assert!(!retained_after_abandon);
+        assert_eq!(abandoned_again, Err(IggyError::OperationNotRetained(op)));
+        polled.unwrap();
+        assert_eq!(server.requests().len(), 2);
+    }
+
+    /// A poll without `OnRead` stores nothing, so a failure leaves no block and keeps its error.
+    /// It is not sent again, so a lost `Next` poll reads the same start at the next call.
+    #[tokio::test]
+    async fn given_a_poll_without_on_read_when_it_fails_should_keep_its_error_without_a_block() {
+        for (strategy, failure, expected) in [
+            (
+                PollingStrategy::next(),
+                Reply::hang_up(),
+                IggyError::TransientNotCommitted,
+            ),
+            (
+                PollingStrategy::offset(START),
+                Reply::polled().cut(),
+                IggyError::TransientNotCommitted,
+            ),
+            (
+                PollingStrategy::offset(START),
+                Reply::empty(200),
+                IggyError::InvalidJsonResponse,
+            ),
+            (
+                PollingStrategy::next(),
+                Reply::empty(500),
+                IggyError::HttpResponseError(500, String::new()),
+            ),
+        ] {
+            let server = ScriptedServer::start(vec![failure, Reply::polled()]).await;
+            let client = server.client();
+
+            let polled = poll(&client, &strategy, CommitPolicy::Manual).await;
+            let next = poll(&client, &PollingStrategy::next(), CommitPolicy::Manual).await;
+
+            assert_eq!(polled.map(drop), Err(expected), "{strategy}");
+            next.unwrap();
+            assert_eq!(server.requests().len(), 2, "{strategy}");
+        }
     }
 
     #[tokio::test]
diff --git a/core/sdk/src/http/scripted_server.rs b/core/sdk/src/http/scripted_server.rs
index 2333e7b..8e001aa 100644
--- a/core/sdk/src/http/scripted_server.rs
+++ b/core/sdk/src/http/scripted_server.rs
@@ -21,12 +21,14 @@
 use crate::http::http_client::HttpClient;
 use crate::prelude::{
     HttpClientConfig, IggyByteSize, IggyError, IggyTimestamp, Partition, PartitionContext,
-    StreamDetails, TopicDetails,
+    PolledMessages, StreamDetails, TopicDetails,
 };
+use reqwest::Url;
 use std::sync::{Arc, Mutex};
 use std::time::Duration;
 use tokio::io::{AsyncReadExt, AsyncWriteExt};
 use tokio::net::{TcpListener, TcpStream};
+use tokio::sync::Notify;
 use tokio::task::JoinHandle;
 use tokio::time::Instant;
 
@@ -36,6 +38,8 @@
 const HEAD_END: &[u8] = b"\r\n\r\n";
 const STREAM_ID: u32 = 1;
 const TOPIC_ID: u32 = 1;
+/// Resolves the target of a request line, which holds only a path and a query.
+const REQUEST_ORIGIN: &str = "http://localhost";
 
 /// One request the client sent.
 #[derive(Debug)]
@@ -54,12 +58,26 @@
         body.get("context")
             .map(|context| serde_json::from_value(context.clone()).unwrap())
     }
+
+    /// The value of `key` in the query of the request, `None` when the key is absent.
+    pub(super) fn query(&self, key: &str) -> Option<String> {
+        let target = self.line.split_whitespace().nth(1).unwrap();
+        Url::parse(&format!("{REQUEST_ORIGIN}{target}"))
+            .unwrap()
+            .query_pairs()
+            .find(|(name, _)| name == key)
+            .map(|(_, value)| value.into_owned())
+    }
 }
 
 pub(super) struct Reply {
     /// `None` closes the connection without an answer.
     status: Option<u16>,
     body: String,
+    /// Closes the connection after the head, so the body it announces never arrives.
+    cut: bool,
+    /// Without a status, keeps the connection open instead of closing it.
+    stall: bool,
 }
 
 impl Reply {
@@ -67,6 +85,8 @@
         Self {
             status: Some(status),
             body: String::new(),
+            cut: false,
+            stall: false,
         }
     }
 
@@ -75,6 +95,16 @@
         Self {
             status: None,
             body: String::new(),
+            cut: false,
+            stall: false,
+        }
+    }
+
+    /// A server that never answers the request it read.
+    pub(super) fn stall() -> Self {
+        Self {
+            stall: true,
+            ..Self::hang_up()
         }
     }
 
@@ -89,6 +119,8 @@
                 "field": null,
             })
             .to_string(),
+            cut: false,
+            stall: false,
         }
     }
 
@@ -96,9 +128,31 @@
         Self {
             status: Some(200),
             body: topic_details_json(contexts),
+            cut: false,
+            stall: false,
         }
     }
 
+    /// The answer to a poll that found no messages.
+    pub(super) fn polled() -> Self {
+        Self::batch(&PolledMessages::empty())
+    }
+
+    /// The answer to a poll that found `batch`.
+    pub(super) fn batch(batch: &PolledMessages) -> Self {
+        Self {
+            status: Some(200),
+            body: serde_json::to_string(batch).unwrap(),
+            cut: false,
+            stall: false,
+        }
+    }
+
+    /// This answer with its body lost after the head.
+    pub(super) fn cut(self) -> Self {
+        Self { cut: true, ..self }
+    }
+
     pub(super) fn stream() -> Self {
         let stream = StreamDetails {
             id: STREAM_ID,
@@ -113,6 +167,8 @@
         Self {
             status: Some(200),
             body: serde_json::to_string(&stream).unwrap(),
+            cut: false,
+            stall: false,
         }
     }
 }
@@ -120,6 +176,7 @@
 pub(super) struct ScriptedServer {
     api_url: String,
     requests: Arc<Mutex<Vec<Recorded>>>,
+    arrived: Arc<Notify>,
     task: JoinHandle<()>,
 }
 
@@ -129,9 +186,12 @@
         let api_url = format!("http://{}", listener.local_addr().unwrap());
         let requests = Arc::new(Mutex::new(Vec::new()));
         let recorded = Arc::clone(&requests);
+        let arrived = Arc::new(Notify::new());
+        let notify = Arc::clone(&arrived);
         let started = Instant::now();
         let task = tokio::spawn(async move {
             let mut replies = replies.into_iter();
+            let mut stalled = Vec::new();
             loop {
                 let (mut stream, _) = listener.accept().await.unwrap();
                 let (line, body) = read_request(&mut stream).await;
@@ -140,29 +200,42 @@
                     body,
                     at: started.elapsed(),
                 });
+                notify.notify_one();
                 let reply = replies
                     .next()
                     .unwrap_or_else(|| Reply::empty(UNSCRIPTED_STATUS));
                 let Some(status) = reply.status else {
+                    if reply.stall {
+                        stalled.push(stream);
+                    }
                     continue;
                 };
-                let response = format!(
+                let head = format!(
                     "HTTP/1.1 {} Scripted\r\ncontent-type: application/json\r\n\
-                     content-length: {}\r\nconnection: close\r\n\r\n{}",
+                     content-length: {}\r\nconnection: close\r\n\r\n",
                     status,
                     reply.body.len(),
-                    reply.body
                 );
-                stream.write_all(response.as_bytes()).await.unwrap();
+                stream.write_all(head.as_bytes()).await.unwrap();
+                if !reply.cut {
+                    stream.write_all(reply.body.as_bytes()).await.unwrap();
+                }
             }
         });
         Self {
             api_url,
             requests,
+            arrived,
             task,
         }
     }
 
+    /// Waits until the server read a request, or returns at once if it read one since the last
+    /// wait.
+    pub(super) async fn request_arrived(&self) {
+        self.arrived.notified().await;
+    }
+
     /// A client logged in with a token, so every path passes the authentication check.
     pub(super) fn client(&self) -> HttpClient {
         HttpClient::create(Arc::new(HttpClientConfig {
diff --git a/core/sdk/src/lib.rs b/core/sdk/src/lib.rs
index e61817d..4680ef5 100644
--- a/core/sdk/src/lib.rs
+++ b/core/sdk/src/lib.rs
@@ -110,27 +110,32 @@
 //!
 //! The [`IggyConsumer`] has:
 //! - A [`futures::Stream`] implementation, so a `while let Some(message) =
-//!   consumer.next().await` loop drives polling, paging, and the poll interval
-//!   for you.
-//! - A **polling strategy** ([`PollingStrategy`]: `next`, `offset`, or
-//!   `timestamp`) that tracks position.
-//! - **Auto-commit** ([`AutoCommit`]) stores the offset on an interval, or when
-//!   messages are polled, consumed one by one, consumed in full, or every Nth
-//!   message. A restart resumes from that last commit.
+//!   consumer.next().await` loop drives the reads, the prefetch, and the retries
+//!   of a [`PartitionReader`] for you.
+//! - A **start** ([`PollingStrategy`]: `next`, `offset`, `first`, `last`, or
+//!   `timestamp`) for each partition without a stored offset.
+//! - **Commits**: the [`CommitPolicy`] of the poll options. With `Manual`,
+//!   [`commit`] stores the checkpoint of a yielded message. With `Auto`, the
+//!   consumer stores the latest message that counted, after it was processed,
+//!   delivered or marked, on an interval or after each message, every n
+//!   messages or each batch. With [`CommitPolicy::OnRead`], the server stores
+//!   the offset of each read instead. A restart resumes from the last commit.
 //! - **Manual offset control** to store, read, or delete the offset of any
-//!   partition yourself when auto-commit is disabled or not enough.
+//!   partition yourself.
 //! - A **shared state handle** ([`IggyConsumerState`]) that another task can clone
 //!   to read offsets or commit while the consuming loop holds the consumer.
 //! - **Replay control** drops messages this consumer already consumed, unless you
 //!   opt into replaying them with `allow_replay`.
-//! - **Auto-join** of the consumer group, optionally creating it, plus a rejoin
-//!   once the server revokes membership or the connection comes back.
+//! - **Consumer group membership**: `init()` joins the group, optionally creating
+//!   it, the consumer joins again after a session loss, and `shutdown()` leaves it.
 //! - **Reconnection handling** pauses polling while the client is disconnected and
 //!   resumes it after the reconnect and the rejoin have gone through.
 //! - **Init retries** wait for the stream and topic to appear instead of failing
 //!   right away when the consumer starts before they exist.
 //! - Payload **decryption**.
-//! - **Graceful shutdown** flushes pending offsets and leaves the consumer group.
+//! - **Graceful shutdown** commits the last yielded positions under a client
+//!   commit policy, moves the offsets of `OnRead` back to them, and leaves the
+//!   consumer group.
 //!
 //! For details on the specific behavior of each feature, reach for the type-level
 //! documentation.
@@ -167,8 +172,8 @@
 //!   type, not changing a connection-string scheme.
 //! - **No producer or consumer helpers.** [`IggyProducer`] and [`IggyConsumer`]
 //!   are spawned from an [`IggyClient`], so a raw transport client gives you no
-//!   background batching, retries, polling loop, auto-commit, consumer-group
-//!   auto-join, or payload encryption. You get the request-response primitives
+//!   background batching, retries, polling loop, commit policies, consumer-group
+//!   membership, or payload encryption. You get the request-response primitives
 //!   ([`send_messages`], [`poll_messages`]) and nothing layered on top.
 //! - **Raw wire access.** [`BinaryTransport::send_raw_with_response`] sends an
 //!   arbitrary command code and payload and returns the raw response bytes.
@@ -201,7 +206,7 @@
 //! Tokio.
 //! The SDK also spawns its own background work with [`tokio::spawn`] (the
 //! [`IggyClient::connect`] heartbeat, and the [`IggyProducer`] and
-//! [`IggyConsumer`] tasks) and drives timeouts, retries, and poll intervals with
+//! [`IggyConsumer`] tasks) and drives timeouts, retries, and commit intervals with
 //! [`tokio::time`].
 //! Note that dropping down to the low-level transport clients does not change this.
 //! **Thus, everything you do with the Rust SDK must happen inside a Tokio runtime.**
@@ -228,7 +233,8 @@
 //!         .await?;
 //!
 //!     let mut consumer = client
-//!         .consumer("consumer_name", "stream_name", "topic_name", 1)?
+//!         .consumer("consumer_name", "stream_name", "topic_name")?
+//!         .partitions([1])
 //!         .build();
 //!     consumer.init().await?;
 //!     while let Some(message) = consumer.next().await {
@@ -258,7 +264,10 @@
 //! [`ErrorCallback`]: crate::clients::producer_error_callback::ErrorCallback
 //! [`Partitioner`]: crate::prelude::Partitioner
 //! [`PollingStrategy`]: crate::prelude::PollingStrategy
-//! [`AutoCommit`]: crate::prelude::AutoCommit
+//! [`PartitionReader`]: crate::prelude::PartitionReader
+//! [`commit`]: crate::prelude::IggyConsumer::commit
+//! [`CommitPolicy`]: crate::prelude::CommitPolicy
+//! [`CommitPolicy::OnRead`]: crate::prelude::CommitPolicy::OnRead
 //! [`IggyConsumerState`]: crate::prelude::IggyConsumerState
 //! [`futures::Stream`]: https://docs.rs/futures/latest/futures/stream/trait.Stream.html
 //! [`TcpClient`]: crate::prelude::TcpClient
diff --git a/core/sdk/src/links/dial.rs b/core/sdk/src/links/dial.rs
index 43d7389..3864ed9 100644
--- a/core/sdk/src/links/dial.rs
+++ b/core/sdk/src/links/dial.rs
@@ -75,6 +75,16 @@
                     .unwrap_or(usize::MAX)
                     .saturating_sub(iggy_binary_protocol::HEADER_SIZE),
             ),
+            // The server sends each reply as one frame of one message, and a reply above
+            // either cap fails the whole link.
+            Self::WebSocket(config) => [
+                config.ws_config.max_frame_size,
+                config.ws_config.max_message_size,
+            ]
+            .into_iter()
+            .flatten()
+            .min()
+            .map(|size| size.saturating_sub(iggy_binary_protocol::HEADER_SIZE)),
             _ => None,
         }
     }
@@ -359,6 +369,8 @@
 mod tests {
     use super::*;
     use crate::links::test_support::{LOCALHOST, SERVER_NAME, quic_server};
+    use iggy_binary_protocol::{HEADER_SIZE, MAX_RECORD_REPLY_SIZE};
+    use iggy_common::WebSocketConfig;
     use rustls::RootCertStore;
     use tokio::net::TcpListener;
 
@@ -499,6 +511,49 @@
         );
     }
 
+    #[tokio::test]
+    async fn given_default_configs_when_limiting_replies_should_hold_the_largest_record() {
+        for dialer in [
+            Dialer::WebSocket(Arc::new(WebSocketClientConfig::default())),
+            quic_dialer(QuicClientConfig::default()),
+        ] {
+            assert_eq!(
+                dialer.reply_body_limit(),
+                Some(MAX_RECORD_REPLY_SIZE - HEADER_SIZE),
+                "{:?}",
+                dialer.protocol()
+            );
+        }
+    }
+
+    #[test]
+    fn given_websocket_frame_and_message_caps_when_limiting_replies_should_take_the_smaller_less_the_header()
+     {
+        const FRAME: usize = 1 << 20;
+        const MESSAGE: usize = 4 << 20;
+        for (max_frame_size, max_message_size, limit) in [
+            (Some(FRAME), Some(MESSAGE), Some(FRAME - HEADER_SIZE)),
+            (Some(MESSAGE), Some(FRAME), Some(FRAME - HEADER_SIZE)),
+            (None, Some(MESSAGE), Some(MESSAGE - HEADER_SIZE)),
+            (None, None, None),
+        ] {
+            let dialer = Dialer::WebSocket(Arc::new(WebSocketClientConfig {
+                ws_config: WebSocketConfig {
+                    max_frame_size,
+                    max_message_size,
+                    ..WebSocketConfig::default()
+                },
+                ..WebSocketClientConfig::default()
+            }));
+
+            assert_eq!(
+                dialer.reply_body_limit(),
+                limit,
+                "frame: {max_frame_size:?}, message: {max_message_size:?}"
+            );
+        }
+    }
+
     #[test]
     fn given_dns_ipv4_and_ipv6_endpoints_when_naming_the_tls_server_should_drop_the_port() {
         assert_eq!(tls_server_name("iggy-1:8090"), "iggy-1");
diff --git a/core/sdk/src/links/fake_cluster.rs b/core/sdk/src/links/fake_cluster.rs
index 1358b31..1e05e89 100644
--- a/core/sdk/src/links/fake_cluster.rs
+++ b/core/sdk/src/links/fake_cluster.rs
@@ -24,8 +24,9 @@
 use crate::session_owner::SessionOwner;
 use futures::FutureExt;
 use iggy_binary_protocol::codes::{
-    BIND_SESSION_CODE, GET_CLUSTER_METADATA_CODE, GET_PARTITION_ROUTE_CODE, GET_STATS_CODE,
-    LOGIN_REGISTER_CODE, LOGOUT_USER_CODE,
+    BIND_SESSION_CODE, CANCEL_DEFERRED_REQUEST_CODE, FETCH_MESSAGES_CODE,
+    GET_CLUSTER_METADATA_CODE, GET_PARTITION_ROUTE_CODE, GET_STATS_CODE, LOGIN_REGISTER_CODE,
+    LOGOUT_USER_CODE,
 };
 use iggy_binary_protocol::consensus::{
     EvictionHeader, EvictionReason, HEADER_SIZE, NON_REPLICATED_CODE_RANGE, Operation,
@@ -41,10 +42,11 @@
 use iggy_common::{AutoLogin, ClusterNodeRole, ClusterNodeStatus, Credentials, IggyError};
 use secrecy::SecretString;
 use std::collections::{HashMap, HashSet, VecDeque};
-use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
+use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
 use std::sync::{Arc, Mutex};
 use std::time::Duration;
 use tokio::io::{AsyncReadExt, AsyncWriteExt, DuplexStream};
+use tokio::sync::Notify;
 use tokio::task::AbortHandle;
 use tokio::time::{Instant, sleep, timeout};
 
@@ -62,6 +64,12 @@
 #[derive(Debug, Clone)]
 pub(crate) enum Answer {
     Body(Vec<u8>),
+    /// Keeps the old operation live independently of its connection until released.
+    GatedBody {
+        release: Arc<Notify>,
+        completed: Arc<AtomicBool>,
+        body: Vec<u8>,
+    },
     /// A refusal with the status of this error.
     Refuse(IggyError),
     /// The reply of a committed metadata request at this commit.
@@ -297,6 +305,19 @@
                 ));
                 let frame = match self.answer(&endpoint, &request, code, username.as_deref()) {
                     Answer::Body(body) => reply(request.request, &body),
+                    Answer::GatedBody {
+                        release,
+                        completed,
+                        body,
+                    } => {
+                        let replies = replies.clone();
+                        tokio::spawn(async move {
+                            release.notified().await;
+                            completed.store(true, Ordering::SeqCst);
+                            let _ = replies.send(reply(request.request, &body));
+                        });
+                        continue;
+                    }
                     Answer::Refuse(error) => refusal(request.request, &error),
                     Answer::Detached => refusal(request.request, &IggyError::StaleClient),
                     Answer::Commit(commit) => committed(&request, commit, None, &[]),
@@ -391,6 +412,9 @@
                 None => Answer::Refuse(IggyError::TransientNotAccepted),
             },
             UNANSWERED_CODE => Answer::Silence,
+            // The server answers the cancelled request, never the cancel.
+            CANCEL_DEFERRED_REQUEST_CODE => Answer::Silence,
+            FETCH_MESSAGES_CODE => Answer::Body(0_u32.to_le_bytes().to_vec()),
             _ if !matches!(
                 request.operation,
                 Operation::NonReplicated | Operation::Logout
diff --git a/core/sdk/src/links/mod.rs b/core/sdk/src/links/mod.rs
index 1639f14..50b3b5c 100644
--- a/core/sdk/src/links/mod.rs
+++ b/core/sdk/src/links/mod.rs
@@ -20,16 +20,21 @@
 use crate::session_owner::SessionOwner;
 use crate::vsr::{decode_reply, encode_request_header};
 use bytes::Bytes;
+use iggy_binary_protocol::MAX_RECORD_SIZE;
+use iggy_binary_protocol::WireDecode;
+use iggy_binary_protocol::codes::{FETCH_MESSAGES_CODE, POLL_MESSAGES_DEFERRED_CODE};
 use iggy_binary_protocol::consensus::{HEADER_SIZE, RequestHeader};
-use iggy_binary_protocol::{
-    WireDecode, codes::POLL_MESSAGES_DEFERRED_CODE, requests::messages::DeferredPollMessagesRequest,
-};
+use iggy_binary_protocol::requests::messages::{DeferredPollMessagesRequest, FetchMessagesRequest};
+use iggy_binary_protocol::responses::messages::FetchMessagesResponse;
+use iggy_binary_protocol::responses::messages::poll_messages::POLL_RESPONSE_HEADER_SIZE;
 #[cfg(test)]
 use iggy_common::DiagnosticEvent;
 use iggy_common::IggyError;
 use mux::MuxLink;
 use quic::QuicLink;
+use std::future::pending;
 use std::time::Duration;
+use tokio::sync::watch;
 use tokio::time::{Instant, sleep};
 
 pub(crate) mod connect;
@@ -56,6 +61,33 @@
 pub(crate) const RETRY_BACKOFF_BASE: Duration = Duration::from_millis(50);
 pub(crate) const RETRY_BACKOFF_MAX: Duration = Duration::from_secs(1);
 
+/// Takes the one reply of a cancelled committing request, or the error that ended its link.
+pub(crate) type LateReply = Box<dyn FnOnce(Result<Reply, IggyError>) + Send>;
+
+tokio::task_local! {
+    /// Turns true when the reader of the committing reads of this task closes. A committing
+    /// deferred request then sends its cancel and still takes its one reply, which tells whether
+    /// the server stored.
+    pub(crate) static CANCEL_COMMITTING: watch::Receiver<bool>;
+}
+
+/// Resolves once the committing reads of this task are to cancel. Never without the signal.
+pub(crate) async fn committing_cancelled() {
+    let Ok(mut cancel) = CANCEL_COMMITTING.try_with(Clone::clone) else {
+        return pending().await;
+    };
+    if cancel.wait_for(|cancelled| *cancelled).await.is_err() {
+        pending::<()>().await;
+    }
+}
+
+/// Whether the committing reads of this task are to cancel.
+pub(crate) fn committing_cancel_requested() -> bool {
+    CANCEL_COMMITTING
+        .try_with(|cancel| *cancel.borrow())
+        .unwrap_or(false)
+}
+
 /// A connection to one replica.
 #[derive(Debug)]
 pub(crate) enum Link {
@@ -83,12 +115,25 @@
         deadline: Instant,
         sent: &mut bool,
     ) -> Result<Reply, IggyError> {
+        self.send_settled(frame, deadline, sent, None).await
+    }
+
+    /// [`Self::send`] of a committing deferred request. When its caller leaves after the send,
+    /// the link cancels it, and the one reply that the server sends for the cancel goes to
+    /// `late`, or the error that ends the link first.
+    pub(crate) async fn send_settled(
+        &self,
+        frame: Frame,
+        deadline: Instant,
+        sent: &mut bool,
+        late: Option<LateReply>,
+    ) -> Result<Reply, IggyError> {
         if Instant::now() >= deadline {
             return Err(IggyError::NotConnected);
         }
         let result = match self {
-            Self::Mux(link) => link.send(frame, deadline, sent).await,
-            Self::Quic(link) => link.send(frame, deadline, sent).await,
+            Self::Mux(link) => link.send(frame, deadline, sent, late).await,
+            Self::Quic(link) => link.send(frame, deadline, sent, late).await,
         };
         result.map_err(|error| match error {
             IggyError::StaleClient => IggyError::Disconnected,
@@ -193,12 +238,23 @@
 impl Frame {
     pub(crate) fn response_body_limit(&self) -> Option<usize> {
         let code = u32::from_le_bytes(self.header.reserved[..size_of::<u32>()].try_into().ok()?);
-        if code != POLL_MESSAGES_DEFERRED_CODE {
-            return None;
+        match code {
+            // The server returns a first record above `max_bytes` alone.
+            POLL_MESSAGES_DEFERRED_CODE => DeferredPollMessagesRequest::decode_from(&self.body)
+                .ok()
+                .map(|request| {
+                    (request.max_bytes as usize).max(POLL_RESPONSE_HEADER_SIZE + MAX_RECORD_SIZE)
+                }),
+            FETCH_MESSAGES_CODE => {
+                FetchMessagesRequest::decode_from(&self.body)
+                    .ok()
+                    .map(|request| {
+                        FetchMessagesResponse::head_size(request.targets.len())
+                            + (request.max_bytes as usize).max(MAX_RECORD_SIZE)
+                    })
+            }
+            _ => None,
         }
-        DeferredPollMessagesRequest::decode_from(&self.body)
-            .ok()
-            .map(|request| request.max_bytes as usize)
     }
 }
 
diff --git a/core/sdk/src/links/mux.rs b/core/sdk/src/links/mux.rs
index 1733282..5a7f40d 100644
--- a/core/sdk/src/links/mux.rs
+++ b/core/sdk/src/links/mux.rs
@@ -21,7 +21,7 @@
 //! each reply goes to the request with its id. So a caller that goes away
 //! never leaves a frame half written or half read on the connection.
 
-use crate::links::{Frame, Reply};
+use crate::links::{Frame, LateReply, Reply, committing_cancelled};
 use crate::vsr::{decode_eviction, eviction_reason, peek_command, reply_request_id, response_size};
 use bytes::{Buf, BufMut, Bytes, BytesMut};
 use futures_util::stream::{SplitSink, SplitStream};
@@ -30,6 +30,7 @@
 use iggy_common::IggyError;
 use std::collections::HashMap;
 use std::collections::hash_map::Entry;
+use std::fmt;
 use std::future::Future;
 use std::io;
 use std::net::SocketAddr;
@@ -89,7 +90,34 @@
 struct Pending {
     /// Cleared when the link closes. A closed link never opens again.
     open: bool,
-    waiters: HashMap<u64, (Waiter, Option<usize>)>,
+    waiters: HashMap<u64, (Awaiting, Option<usize>)>,
+}
+
+/// What waits for the reply of a request.
+enum Awaiting {
+    Caller(Waiter),
+    /// A committing request whose caller left after the cancel. Its one reply settles it.
+    Cancelled(LateReply),
+}
+
+impl fmt::Debug for Awaiting {
+    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+        formatter.write_str(match self {
+            Self::Caller(_) => "Caller",
+            Self::Cancelled(_) => "Cancelled",
+        })
+    }
+}
+
+impl Awaiting {
+    fn answer(self, result: Result<Reply, IggyError>) {
+        match self {
+            Self::Caller(waiter) => {
+                let _ = waiter.send(result);
+            }
+            Self::Cancelled(late) => late(result),
+        }
+    }
 }
 
 #[derive(Debug)]
@@ -113,6 +141,10 @@
     /// answer the next request with the same id.
     close_if_abandoned: bool,
     cancellation: Option<(mpsc::OwnedPermit<QueuedFrame>, Frame)>,
+    /// The reply of the request, until the caller takes it.
+    reply: Option<oneshot::Receiver<Result<Reply, IggyError>>>,
+    /// Takes the reply that the cancel of a committing request leaves.
+    late: Option<LateReply>,
 }
 
 impl MuxLink {
@@ -158,6 +190,7 @@
         frame: Frame,
         deadline: Instant,
         sent: &mut bool,
+        late: Option<LateReply>,
     ) -> Result<Reply, IggyError> {
         let request = frame.header.request;
         let unbound = frame.header.session == 0;
@@ -194,6 +227,7 @@
         let mut entry = self
             .shared
             .wait_for(request, waiter, frame.response_body_limit())?;
+        entry.reply = Some(reply);
         if !matches!(
             timeout_at(
                 deadline,
@@ -212,8 +246,34 @@
         write_permit.forget();
         *sent = true;
         entry.close_if_abandoned = unbound;
+        entry.late = late.filter(|_| cancellation.is_some());
         entry.cancellation = cancellation;
-        match timeout_at(deadline, reply).await {
+        let committing = entry.late.is_some();
+        let Some(reply) = entry.reply.as_mut() else {
+            return Err(IggyError::Disconnected);
+        };
+        let cancellation = &mut entry.cancellation;
+        let replied = timeout_at(deadline, async {
+            if committing {
+                tokio::select! {
+                    biased;
+                    replied = &mut *reply => return replied,
+                    () = committing_cancelled() => {}
+                }
+                // The server answers the request once more, which settles it.
+                if let Some((permit, frame)) = cancellation.take() {
+                    permit.send(QueuedFrame {
+                        frame,
+                        uses_write_permit: false,
+                    });
+                }
+            }
+            reply.await
+        })
+        .await;
+        // The caller takes the reply, or ends at its deadline without one.
+        entry.late = None;
+        match replied {
             Ok(Ok(result)) => result,
             Ok(Err(_)) => Err(IggyError::Disconnected),
             Err(_) => {
@@ -310,12 +370,14 @@
                 Err(IggyError::NotConnected)
             }
             Entry::Vacant(entry) => {
-                entry.insert((waiter, limit));
+                entry.insert((Awaiting::Caller(waiter), limit));
                 Ok(PendingEntry {
                     shared: self,
                     request,
                     close_if_abandoned: false,
                     cancellation: None,
+                    reply: None,
+                    late: None,
                 })
             }
         }
@@ -382,9 +444,7 @@
                 let request = reply_request_id(&reply.header);
                 let waiter = self.lock_pending().waiters.remove(&request);
                 match waiter {
-                    Some((waiter, _)) => {
-                        let _ = waiter.send(Ok(reply));
-                    }
+                    Some((waiter, _)) => waiter.answer(Ok(reply)),
                     None => trace!(
                         "Dropped a reply to request {request} from {}, which nothing waits for.",
                         self.peer
@@ -421,7 +481,7 @@
         self.write_permits.close();
         self.closed.send_replace(true);
         for (waiter, _) in waiters.into_values() {
-            let _ = waiter.send(Err(reason.clone()));
+            waiter.answer(Err(reason.clone()));
         }
     }
 
@@ -452,12 +512,37 @@
 
 impl Drop for PendingEntry<'_> {
     fn drop(&mut self) {
-        let abandoned = self
-            .shared
-            .lock_pending()
-            .waiters
-            .remove(&self.request)
-            .is_some();
+        let late = self.late.take();
+        let (abandoned, late) = {
+            let mut pending = self.shared.lock_pending();
+            match pending.waiters.remove(&self.request) {
+                // The cancel follows, and the server answers it once.
+                Some((_, limit)) if late.is_some() && pending.open => {
+                    if let Some(late) = late {
+                        pending
+                            .waiters
+                            .insert(self.request, (Awaiting::Cancelled(late), limit));
+                    }
+                    (true, None)
+                }
+                Some(_) => (true, None),
+                None => (false, late),
+            }
+        };
+        // The reply arrived before the caller left, so it settles the request now.
+        if let Some(late) = late
+            && let Some(mut reply) = self.reply.take()
+        {
+            match reply.try_recv() {
+                Ok(result) => late(result),
+                Err(oneshot::error::TryRecvError::Empty) => {
+                    tokio::spawn(async move {
+                        late(reply.await.unwrap_or(Err(IggyError::Disconnected)));
+                    });
+                }
+                Err(oneshot::error::TryRecvError::Closed) => late(Err(IggyError::Disconnected)),
+            }
+        }
         if abandoned && let Some((permit, frame)) = self.cancellation.take() {
             // Reserved before sending the poll, so cancellation cannot lose a
             // race with queue backpressure and always follows the original frame.
@@ -752,7 +837,7 @@
     }
 
     async fn send(link: &MuxLink, frame: Frame, deadline: Instant) -> Result<Reply, IggyError> {
-        link.send(frame, deadline, &mut false).await
+        link.send(frame, deadline, &mut false, None).await
     }
 
     #[tokio::test]
@@ -1206,6 +1291,28 @@
     }
 
     #[tokio::test]
+    async fn given_a_reply_being_dispatched_when_its_caller_leaves_should_still_settle_it() {
+        let (link, _peer) = tcp_link();
+        let (waiter, reply) = oneshot::channel();
+        let (settled, late_reply) = oneshot::channel();
+        let mut entry = link.shared.wait_for(1, waiter, None).unwrap();
+        entry.reply = Some(reply);
+        entry.late = Some(Box::new(move |result| {
+            let _ = settled.send(result);
+        }));
+
+        let (dispatching, _) = link.shared.lock_pending().waiters.remove(&1).unwrap();
+        drop(entry);
+        dispatching.answer(Ok(Reply {
+            header: [0; HEADER_SIZE],
+            body: Bytes::from_static(b"stored"),
+        }));
+
+        let result = timeout(TEST_BUDGET, late_reply).await.unwrap().unwrap();
+        assert_eq!(result.unwrap().body[..], *b"stored");
+    }
+
+    #[tokio::test]
     async fn given_whole_replies_in_one_websocket_message_should_hand_over_slices_of_it() {
         let (link, _server) = websocket_link().await;
         let mut message = reply(1, &1u64.to_le_bytes());
diff --git a/core/sdk/src/links/quic.rs b/core/sdk/src/links/quic.rs
index c1b06f0..9d97ef8 100644
--- a/core/sdk/src/links/quic.rs
+++ b/core/sdk/src/links/quic.rs
@@ -21,15 +21,16 @@
 //! leaves nothing half read on the connection. The echoed request id must
 //! still match before its result can settle that request.
 
-use crate::links::{Frame, Reply};
+use crate::links::{Frame, LateReply, Reply, committing_cancelled};
 use crate::vsr::{decode_eviction, eviction_reason, peek_command, reply_request_id};
 use bytes::Bytes;
-use iggy_binary_protocol::consensus::{Command, EvictionReason, HEADER_SIZE};
+use futures_util::future::BoxFuture;
+use iggy_binary_protocol::consensus::{Command, EvictionReason, HEADER_SIZE, RequestHeader};
 use iggy_common::IggyError;
-use quinn::{Connection, VarInt, WriteError};
+use quinn::{Connection, ReadToEndError, VarInt, WriteError};
 use std::sync::atomic::{AtomicBool, Ordering};
 use tokio::time::{Instant, timeout_at};
-use tracing::error;
+use tracing::{debug, error};
 
 /// The application error code of a connection that the client closes.
 const CLIENT_CLOSE_CODE: VarInt = VarInt::from_u32(0);
@@ -68,8 +69,10 @@
         frame: Frame,
         deadline: Instant,
         sent: &mut bool,
+        late: Option<LateReply>,
     ) -> Result<Reply, IggyError> {
         let request = frame.header.request;
+        let cancellation = frame.header.deferred_cancellation();
         let (mut send, mut recv) = match timeout_at(deadline, self.connection.open_bi()).await {
             Ok(Ok(streams)) => streams,
             Ok(Err(error)) => {
@@ -120,7 +123,50 @@
                         .saturating_add(iggy_binary_protocol::HEADER_SIZE)
                         .min(self.response_buffer_size)
                 });
-        let reply = match timeout_at(deadline, recv.read_to_end(response_limit)).await {
+        // A stopped stream cancels the request and loses its reply, so a committing request
+        // whose caller leaves cancels on a new stream and keeps reading this one.
+        let (cancel, late) = cancellation.zip(late).unzip();
+        let mut reading = ReplyStream {
+            connection: self.connection.clone(),
+            request,
+            reply: Some(Box::pin(
+                async move { recv.read_to_end(response_limit).await },
+            )),
+            cancel,
+            late,
+        };
+        let committing = reading.late.is_some();
+        let ReplyStream {
+            connection,
+            reply,
+            cancel,
+            ..
+        } = &mut reading;
+        let Some(reply) = reply.as_mut() else {
+            return Err(IggyError::Disconnected);
+        };
+        let read = timeout_at(deadline, async {
+            if committing {
+                tokio::select! {
+                    biased;
+                    read = &mut *reply => return read,
+                    () = committing_cancelled() => {}
+                }
+                // The server answers the request once more on its own stream, which settles it.
+                if let Some(cancel) = cancel.take()
+                    && let Err(error) = send_cancel(connection, &cancel).await
+                {
+                    debug!(
+                        "Failed to cancel request {request} on {}: {error}",
+                        connection.remote_address()
+                    );
+                }
+            }
+            reply.await
+        })
+        .await;
+        reading.late = None;
+        let reply = match read {
             Ok(Ok(reply)) => reply,
             Ok(Err(error)) => {
                 error!(
@@ -171,6 +217,71 @@
     }
 }
 
+/// The reply stream of a request. Dropped while `late` holds the settle callback of a committing
+/// request, it sends the cancel on a new stream unless it went out already, and hands the one
+/// reply of this stream to the callback.
+struct ReplyStream {
+    connection: Connection,
+    request: u64,
+    reply: Option<BoxFuture<'static, Result<Vec<u8>, ReadToEndError>>>,
+    cancel: Option<RequestHeader>,
+    late: Option<LateReply>,
+}
+
+impl Drop for ReplyStream {
+    fn drop(&mut self) {
+        let (Some(late), Some(reply)) = (self.late.take(), self.reply.take()) else {
+            return;
+        };
+        let connection = self.connection.clone();
+        let request = self.request;
+        let cancel = self.cancel.take();
+        tokio::spawn(async move {
+            if let Some(cancel) = cancel
+                && let Err(error) = send_cancel(&connection, &cancel).await
+            {
+                debug!(
+                    "Failed to cancel request {request} on {}: {error}",
+                    connection.remote_address()
+                );
+            }
+            let reply = match reply.await {
+                Ok(reply) => late_reply(request, reply),
+                Err(_) => Err(IggyError::Disconnected),
+            };
+            late(reply);
+        });
+    }
+}
+
+/// Sends the cancel of a deferred request on a stream of its own, which the server finishes
+/// without an answer.
+async fn send_cancel(connection: &Connection, cancel: &RequestHeader) -> Result<(), WriteError> {
+    let (mut send, _no_answer) = connection
+        .open_bi()
+        .await
+        .map_err(WriteError::ConnectionLost)?;
+    send.write_all(bytemuck::bytes_of(cancel)).await?;
+    send.finish().map_err(|_| WriteError::ClosedStream)
+}
+
+/// The reply to request `request` in `bytes`, read after its caller left.
+fn late_reply(request: u64, bytes: Vec<u8>) -> Result<Reply, IggyError> {
+    let Some(&header) = bytes.first_chunk::<HEADER_SIZE>() else {
+        return Err(IggyError::EmptyResponse);
+    };
+    if peek_command(&header) == Command::Eviction {
+        return Err(decode_eviction(&header));
+    }
+    if peek_command(&header) != Command::Reply || reply_request_id(&header) != request {
+        return Err(IggyError::Disconnected);
+    }
+    Ok(Reply {
+        header,
+        body: Bytes::from(bytes).slice(HEADER_SIZE..),
+    })
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -178,11 +289,14 @@
         CLIENT, LOCALHOST, SERVER_NAME, quic_server, read_request_header, reply, request,
         request_with_body,
     };
+    use crate::links::{CANCEL_COMMITTING, LateReply, Link};
+    use iggy_binary_protocol::codes::{CANCEL_DEFERRED_REQUEST_CODE, POLL_MESSAGES_DEFERRED_CODE};
     use iggy_binary_protocol::consensus::{EvictionHeader, EvictionReason, RequestHeader};
     use quinn::{ClientConfig, Endpoint, SendStream};
     use rustls::RootCertStore;
     use std::sync::Arc;
     use std::time::Duration;
+    use tokio::sync::{oneshot, watch};
     use tokio::time::timeout;
 
     const BOUND_SESSION: u64 = 3;
@@ -216,7 +330,7 @@
     }
 
     async fn send(link: &QuicLink, frame: Frame, deadline: Instant) -> Result<Reply, IggyError> {
-        link.send(frame, deadline, &mut false).await
+        link.send(frame, deadline, &mut false, None).await
     }
 
     async fn quic_link() -> (QuicLink, Peer) {
@@ -268,6 +382,112 @@
         assert_eq!(second.unwrap().body[..], 2u64.to_le_bytes());
     }
 
+    /// The server answers a cancelled committing request once on its own stream, so the link
+    /// sends the cancel on a new stream and keeps reading the stream of the request.
+    #[tokio::test]
+    async fn given_a_committing_deferred_request_when_its_caller_leaves_should_cancel_on_a_new_stream_and_settle_with_its_reply()
+     {
+        let (link, peer) = quic_link().await;
+        let link = Link::Quic(link);
+        let mut frame = request(1, BOUND_SESSION);
+        frame.header.reserved = POLL_MESSAGES_DEFERRED_CODE.to_le_bytes();
+        let (settled, late_reply) = oneshot::channel();
+        let late: LateReply = Box::new(move |reply| {
+            let _ = settled.send(reply);
+        });
+        let mut left = false;
+        let stream = {
+            let sent =
+                link.send_settled(frame, Instant::now() + TEST_BUDGET, &mut left, Some(late));
+            tokio::pin!(sent);
+            tokio::select! {
+                result = &mut sent => panic!("no reply was sent: {result:?}"),
+                (stream, header) = peer.read_request() => {
+                    assert_eq!(header.request, 1);
+                    stream
+                }
+            }
+        };
+
+        let (_, cancel) = timeout(TEST_BUDGET, peer.read_request()).await.unwrap();
+        answer(stream, &reply(1, b"stored")).await;
+        let settled = timeout(TEST_BUDGET, late_reply).await.unwrap().unwrap();
+
+        assert_eq!(cancel.reserved, CANCEL_DEFERRED_REQUEST_CODE.to_le_bytes());
+        assert_eq!((cancel.request, cancel.session), (1, BOUND_SESSION));
+        assert_eq!(settled.unwrap().body[..], *b"stored");
+    }
+
+    /// A reader that closes cancels its committing request, which ends one that waits on the
+    /// server at once, and its caller still takes the one reply.
+    #[tokio::test]
+    async fn given_a_committing_deferred_request_when_its_reader_closes_should_cancel_on_a_new_stream_and_return_its_reply()
+     {
+        let (link, peer) = quic_link().await;
+        let link = Link::Quic(link);
+        let mut frame = request(1, BOUND_SESSION);
+        frame.header.reserved = POLL_MESSAGES_DEFERRED_CODE.to_le_bytes();
+        let late: LateReply = Box::new(|_| panic!("the caller takes the reply"));
+        let (close, closing) = watch::channel(false);
+        let mut left = false;
+        let sent = CANCEL_COMMITTING.scope(
+            closing,
+            link.send_settled(frame, Instant::now() + TEST_BUDGET, &mut left, Some(late)),
+        );
+        tokio::pin!(sent);
+        let stream = tokio::select! {
+            result = &mut sent => panic!("no reply was sent: {result:?}"),
+            (stream, header) = peer.read_request() => {
+                assert_eq!(header.request, 1);
+                stream
+            }
+        };
+
+        close.send_replace(true);
+        let cancel = tokio::select! {
+            result = &mut sent => panic!("no reply was sent: {result:?}"),
+            (_, cancel) = peer.read_request() => cancel,
+        };
+        answer(stream, &reply(1, b"cancelled")).await;
+        let replied = timeout(TEST_BUDGET, sent).await.unwrap();
+
+        assert_eq!(cancel.reserved, CANCEL_DEFERRED_REQUEST_CODE.to_le_bytes());
+        assert_eq!((cancel.request, cancel.session), (1, BOUND_SESSION));
+        assert_eq!(replied.unwrap().body[..], *b"cancelled");
+    }
+
+    #[tokio::test]
+    async fn given_a_partial_committing_reply_when_its_caller_leaves_should_keep_the_received_bytes()
+     {
+        let (link, peer) = quic_link().await;
+        let link = Link::Quic(link);
+        let mut frame = request(1, BOUND_SESSION);
+        frame.header.reserved = POLL_MESSAGES_DEFERRED_CODE.to_le_bytes();
+        let (settled, late_reply) = oneshot::channel();
+        let late: LateReply = Box::new(move |reply| {
+            let _ = settled.send(reply);
+        });
+        let mut left = false;
+        let mut stream = {
+            let sent =
+                link.send_settled(frame, Instant::now() + TEST_BUDGET, &mut left, Some(late));
+            tokio::pin!(sent);
+            let mut stream = tokio::select! {
+                result = &mut sent => panic!("no reply was sent: {result:?}"),
+                (stream, _) = peer.read_request() => stream,
+            };
+            stream.write_all(&reply(1, b"stored")).await.unwrap();
+            assert!(timeout(LOST_REPLY_BUDGET, &mut sent).await.is_err());
+            stream
+        };
+
+        let (_, cancel) = timeout(TEST_BUDGET, peer.read_request()).await.unwrap();
+        assert_eq!(cancel.reserved, CANCEL_DEFERRED_REQUEST_CODE.to_le_bytes());
+        stream.finish().unwrap();
+        let settled = timeout(TEST_BUDGET, late_reply).await.unwrap().unwrap();
+        assert_eq!(settled.unwrap().body[..], *b"stored");
+    }
+
     #[tokio::test]
     async fn given_a_closed_connection_when_sending_should_fail_before_the_send_point() {
         let (link, peer) = quic_link().await;
diff --git a/core/sdk/src/prelude.rs b/core/sdk/src/prelude.rs
index e0b945e..ea38be6 100644
--- a/core/sdk/src/prelude.rs
+++ b/core/sdk/src/prelude.rs
@@ -32,14 +32,14 @@
 pub use crate::client_wrappers::connection_info::ConnectionInfo;
 pub use crate::clients::client::IggyClient;
 pub use crate::clients::client_builder::IggyClientBuilder;
-pub use crate::clients::consumer::{
-    AutoCommit, AutoCommitAfter, AutoCommitWhen, IggyConsumer, IggyConsumerState, ReceivedMessage,
-};
+pub use crate::clients::consumer::{IggyConsumer, IggyConsumerState, ReceivedMessage};
 pub use crate::clients::consumer_builder::IggyConsumerBuilder;
 pub use crate::clients::producer::IggyProducer;
 pub use crate::clients::producer_builder::IggyProducerBuilder;
 pub use crate::clients::producer_config::{BackgroundConfig, DirectConfig};
 pub use crate::clients::producer_sharding::{BalancedSharding, OrderedSharding, Sharding};
+pub use crate::clients::reader::{PartitionReader, ReadError};
+pub use crate::clients::reader_builder::ReaderBuilder;
 pub use crate::consumer_ext::IggyConsumerMessageExt;
 pub use crate::http::http_client::HttpClient;
 pub use crate::quic::quic_client::QuicClient;
@@ -50,27 +50,28 @@
 pub use crate::tcp::tcp_client::TcpClient;
 pub use crate::websocket::websocket_client::WebSocketClient;
 pub use iggy_common::{
-    Aes256GcmEncryptor, Args, ArgsOptional, AutoLogin, CacheMetrics, CacheMetricsKey, ClientError,
-    ClientInfoDetails, ClusterMetadata, ClusterNode, ClusterNodeRole, ClusterNodeStatus,
-    CompressionAlgorithm, Consumer, ConsumerGroup, ConsumerGroupDetails, ConsumerGroupMember,
-    ConsumerKind, ConsumerPosition, Credentials, DeferredPollOptions, Durability, EncryptorKind,
-    GlobalPermissions, HeaderField, HeaderKey, HeaderKind, HeaderValue, HttpClientConfig,
-    HttpClientConfigBuilder, HttpMethod, IdKind, Identifier, IdentityInfo, IggyByteSize,
-    IggyDuration, IggyError, IggyExpiry, IggyIndexView, IggyMessage, IggyMessageHeader,
-    IggyMessageHeaderView, IggyMessageView, IggyMessageViewIterator, IggyTimestamp, MaxTopicSize,
-    NonZeroDurationError, NonZeroIggyDuration, OptionKey, OptionSpec, OptionValue, OptionsScope,
-    Partition, PartitionContext, PartitionResizePolicy, Partitioner, Partitioning,
-    PendingOperationsConfig, Permissions, PersonalAccessTokenExpiry, PollMessages, PolledMessages,
-    PollingKind, PollingStrategy, QuicClientConfig, QuicClientConfigBuilder,
-    QuicClientReconnectionConfig, ResourceOptions, SendMessages, SendMessagesConfirmationResponse,
-    SendMessagesResponse, Sizeable, SnapshotCompression, Stats, Stream, StreamCreateOptions,
-    StreamDetails, StreamEncryptionKey, StreamEncryptionSource, StreamPermissions,
-    StreamUpdateOptions, SystemSnapshotType, TcpClientConfig, TcpClientConfigBuilder,
-    TcpClientReconnectionConfig, Topic, TopicCreateOptions, TopicDetails, TopicPermissions,
-    TopicResolvedOptions, TopicUpdateOptions, TransportEndpoints, TransportProtocol,
-    UserCreateOptions, UserId, UserInfo, UserInfoDetails, UserStatus, UserUpdateOptions,
-    Validatable, WebSocketClientConfig, WebSocketClientConfigBuilder,
-    WebSocketClientReconnectionConfig, WebSocketConfig, defaults, locking, topic_option_keys,
+    Aes256GcmEncryptor, Args, ArgsOptional, AutoCommit, AutoLogin, CacheMetrics, CacheMetricsKey,
+    Checkpoint, ClientError, ClientInfoDetails, ClusterMetadata, ClusterNode, ClusterNodeRole,
+    ClusterNodeStatus, CommitAfter, CommitEvery, CommitPolicy, CompressionAlgorithm, Consumer,
+    ConsumerGroup, ConsumerGroupDetails, ConsumerGroupMember, ConsumerKind, Credentials,
+    Durability, EncryptorKind, GlobalPermissions, HeaderField, HeaderKey, HeaderKind, HeaderValue,
+    HttpClientConfig, HttpClientConfigBuilder, HttpMethod, IdKind, Identifier, IdentityInfo,
+    IggyByteSize, IggyDuration, IggyError, IggyExpiry, IggyIndexView, IggyMessage,
+    IggyMessageHeader, IggyMessageHeaderView, IggyMessageView, IggyMessageViewIterator,
+    IggyTimestamp, MaxTopicSize, NonZeroDurationError, NonZeroIggyDuration, OptionKey, OptionSpec,
+    OptionValue, OptionsScope, Partition, PartitionContext, PartitionResizePolicy, Partitioner,
+    Partitioning, PendingOperationsConfig, Permissions, PersonalAccessTokenExpiry, PollMessages,
+    PollOptions, PolledMessages, PollingKind, PollingStrategy, QuicClientConfig,
+    QuicClientConfigBuilder, QuicClientReconnectionConfig, ResourceOptions, SendMessages,
+    SendMessagesConfirmationResponse, SendMessagesResponse, Sizeable, SnapshotCompression, Stats,
+    Stream, StreamCreateOptions, StreamDetails, StreamEncryptionKey, StreamEncryptionSource,
+    StreamPermissions, StreamUpdateOptions, SystemSnapshotType, TcpClientConfig,
+    TcpClientConfigBuilder, TcpClientReconnectionConfig, Topic, TopicCreateOptions, TopicDetails,
+    TopicPermissions, TopicResolvedOptions, TopicUpdateOptions, TransportEndpoints,
+    TransportProtocol, UndecodedRecord, UserCreateOptions, UserId, UserInfo, UserInfoDetails,
+    UserStatus, UserUpdateOptions, Validatable, WebSocketClientConfig,
+    WebSocketClientConfigBuilder, WebSocketClientReconnectionConfig, WebSocketConfig, defaults,
+    locking, topic_option_keys,
 };
 pub use iggy_common::{
     Client, ClusterClient, ConsumerGroupClient, ConsumerOffsetClient, MessageClient,
@@ -82,8 +83,8 @@
     IGGY_MESSAGE_HEADERS_LENGTH_OFFSET_RANGE, IGGY_MESSAGE_ID_OFFSET_RANGE,
     IGGY_MESSAGE_OFFSET_OFFSET_RANGE, IGGY_MESSAGE_ORIGIN_TIMESTAMP_OFFSET_RANGE,
     IGGY_MESSAGE_PAYLOAD_LENGTH_OFFSET_RANGE, IGGY_MESSAGE_TIMESTAMP_OFFSET_RANGE, INDEX_SIZE,
-    MAX_DEFERRED_POLL_WAIT_US, MAX_PAYLOAD_SIZE, MAX_USER_HEADERS_SIZE, NO_ASSIGNED_PARTITION,
-    RESYNC_REQUIRED_PARTITION_SENTINEL, SEC_IN_MICRO,
+    MAX_DEFERRED_POLL_WAIT_US, MAX_PAYLOAD_SIZE, MAX_USER_HEADERS_SIZE,
+    MIN_DEFERRED_POLL_MAX_BYTES, RESYNC_REQUIRED_PARTITION_SENTINEL, SEC_IN_MICRO,
     defaults::{DEFAULT_ROOT_PASSWORD, DEFAULT_ROOT_USER_ID, DEFAULT_ROOT_USERNAME},
 };
 pub use iggy_common::{
diff --git a/core/sdk/src/quic/quic_client.rs b/core/sdk/src/quic/quic_client.rs
index d161de7..58357be 100644
--- a/core/sdk/src/quic/quic_client.rs
+++ b/core/sdk/src/quic/quic_client.rs
@@ -177,6 +177,7 @@
 #[cfg(test)]
 mod tests {
     use super::*;
+    use iggy_binary_protocol::MAX_RECORD_REPLY_SIZE;
     use iggy_common::{
         BinaryTransport, Client, ClientState, Credentials, DiagnosticEvent, IggyDuration,
         NonZeroIggyDuration,
@@ -399,7 +400,10 @@
             other => panic!("expected UsernamePassword auto_login, got {other:?}"),
         }
 
-        assert_eq!(quic_client_config.response_buffer_size, 10_000_000);
+        assert_eq!(
+            quic_client_config.response_buffer_size,
+            MAX_RECORD_REPLY_SIZE as u64
+        );
         assert_eq!(quic_client_config.max_concurrent_bidi_streams, 10_000);
         assert_eq!(quic_client_config.datagram_send_buffer_size, 100_000);
         assert_eq!(quic_client_config.initial_mtu, 1200);
@@ -454,7 +458,10 @@
             other => panic!("expected UsernamePassword auto_login, got {other:?}"),
         }
 
-        assert_eq!(quic_client_config.response_buffer_size, 10_000_000);
+        assert_eq!(
+            quic_client_config.response_buffer_size,
+            MAX_RECORD_REPLY_SIZE as u64
+        );
         assert_eq!(quic_client_config.max_concurrent_bidi_streams, 10_000);
         assert_eq!(quic_client_config.datagram_send_buffer_size, 100_000);
         assert_eq!(
@@ -506,7 +513,10 @@
             other => panic!("expected PersonalAccessToken auto_login, got {other:?}"),
         }
 
-        assert_eq!(quic_client_config.response_buffer_size, 10_000_000);
+        assert_eq!(
+            quic_client_config.response_buffer_size,
+            MAX_RECORD_REPLY_SIZE as u64
+        );
         assert_eq!(quic_client_config.max_concurrent_bidi_streams, 10_000);
         assert_eq!(quic_client_config.datagram_send_buffer_size, 100_000);
         assert_eq!(quic_client_config.initial_mtu, 1200);
diff --git a/core/sdk/src/session_owner.rs b/core/sdk/src/session_owner.rs
index 7a7bfdc..276c156 100644
--- a/core/sdk/src/session_owner.rs
+++ b/core/sdk/src/session_owner.rs
@@ -54,6 +54,8 @@
     lanes: Mutex<Lanes>,
     proved: Mutex<ProvedOutcomes>,
     pending: Arc<PendingOperations>,
+    /// Fetch blocks outlive sessions, unlike replayable mutation reservations.
+    pending_fetches: Arc<PendingOperations>,
     consumer_group_state: Arc<ConsumerGroupClientState>,
     /// The highest metadata commit a reply showed. A reset keeps it.
     metadata_watermark: AtomicU64,
@@ -119,6 +121,7 @@
             lanes: Mutex::default(),
             proved: Mutex::new(ProvedOutcomes::new(pending_operations.max_operations)),
             pending: Arc::new(PendingOperations::new(pending_operations)),
+            pending_fetches: Arc::new(PendingOperations::new(pending_operations)),
             consumer_group_state: Arc::default(),
             metadata_watermark: AtomicU64::new(0),
             sign_in: Mutex::default(),
@@ -177,8 +180,9 @@
         Ok(first_bind)
     }
 
-    /// Replaces the session, raises the fence and drops every retained
-    /// operation. The request counter, the proved outcomes, the watermark and
+    /// Replaces the session, raises the fence and drops replayable retained
+    /// mutations. Local fetch progress blocks survive. The request counter,
+    /// the proved outcomes, the watermark and
     /// the sign-in stay. The caller ended the session, so no sign-in is owed,
     /// and the lineage ends.
     pub(crate) fn reset(&self) -> Result<(), IggyError> {
diff --git a/core/sdk/src/session_owner/lanes.rs b/core/sdk/src/session_owner/lanes.rs
index 6185a0b..109d10d 100644
--- a/core/sdk/src/session_owner/lanes.rs
+++ b/core/sdk/src/session_owner/lanes.rs
@@ -25,7 +25,12 @@
 //! Offset reads, stores, deletes, auto-commit and Next polls share a separate
 //! progress gate per partition. Stores and deletes take it before the mutation
 //! gate, so waiting for a parked poll does not block sends. Retained offset
-//! mutations also block progress until resolved or abandoned.
+//! mutations also block progress until resolved or abandoned. A fetch and an
+//! auto-commit poll take the progress gates of all their partitions in
+//! ascending partition order and no mutation gate, so no two calls wait for
+//! each other in a cycle. Unknown committing fetches and polls retain only
+//! local progress blocks across sessions. Offset inspection bypasses those
+//! blocks. Explicit abandon accepts uncertainty.
 
 use super::SessionOwner;
 use super::pending::Reservation;
@@ -40,7 +45,7 @@
 use iggy_common::IggyError;
 use std::collections::{HashMap, VecDeque};
 use std::num::NonZeroUsize;
-use std::sync::Arc;
+use std::sync::{Arc, OnceLock};
 use tokio::sync::{Mutex, OwnedMutexGuard};
 use tokio::time::{Instant, timeout_at};
 
@@ -127,6 +132,49 @@
     /// the table when none is left and nothing is retained.
     tickets: usize,
     retained: Option<RetainedOperation>,
+    fetch_block: Option<Arc<FetchBlock>>,
+}
+
+/// Local recovery metadata only. A fetch must never retain a replayable frame.
+#[derive(Debug)]
+struct FetchBlock {
+    operation_id: u64,
+    _reservation: Arc<Reservation>,
+    /// Set once the one reply of a cancelled request proved the outcome: the partitions whose
+    /// outcome it left unknown.
+    settled: OnceLock<Vec<u32>>,
+}
+
+/// Lifts the progress blocks of a committing fetch or poll whose caller left, once the reply
+/// that the server sends for its cancel proves the outcome. It works before and after the call
+/// drops its ticket.
+#[derive(Debug)]
+pub(crate) struct FetchSettlement {
+    owner: Arc<SessionOwner>,
+    block: Arc<FetchBlock>,
+}
+
+impl FetchSettlement {
+    /// Every target keeps its block only when it is in `uncertain`.
+    pub(crate) fn settle(self, uncertain: Vec<u32>) {
+        let mut lanes = self.owner.lanes();
+        if self.block.settled.set(uncertain).is_err() {
+            return;
+        }
+        let uncertain = self.block.settled.get().map_or(&[][..], Vec::as_slice);
+        lanes.retain(|key, lane| {
+            if let LaneKey::Progress { partition_id, .. } = key
+                && !uncertain.contains(partition_id)
+                && lane
+                    .fetch_block
+                    .as_ref()
+                    .is_some_and(|block| Arc::ptr_eq(block, &self.block))
+            {
+                lane.fetch_block = None;
+            }
+            lane.tickets > 0 || lane.retained.is_some() || lane.fetch_block.is_some()
+        });
+    }
 }
 
 impl Lane {
@@ -163,18 +211,24 @@
 
 /// The right of one call to send in its lane. Dropping the ticket frees the
 /// lane, except that a mutation which left the client without a definitive
-/// outcome stays retained. A progress poll holds its progress gate only while
-/// active; replaying its frame would not resolve its earlier progress.
+/// outcome stays retained. A committing fetch or poll instead publishes
+/// nonreplayable progress blocks before opening its gates. Other progress polls
+/// retain nothing.
 #[derive(Debug)]
 pub(crate) struct LaneTicket {
     owner: Arc<SessionOwner>,
     /// `None` for a call that takes no lane.
     key: Option<LaneKey>,
     guard: Option<OwnedMutexGuard<()>>,
-    /// Offset mutations keep this gate even if abandoned during their replay.
-    progress: Option<Box<LaneTicket>>,
+    /// The progress gates that the call holds besides its own lane. Offset
+    /// mutations keep theirs even if abandoned during their replay.
+    progress: Vec<LaneTicket>,
     operation: Option<RetainedOperation>,
     reservation: Option<Arc<Reservation>>,
+    fetch_reservation: Option<Arc<Reservation>>,
+    fetch_block: Option<Arc<FetchBlock>>,
+    /// None retains all targets. A valid partial reply narrows that set.
+    uncertain_targets: Option<Vec<u32>>,
     fence: u64,
     /// A resume replays an operation that its lane still retains.
     resumed: bool,
@@ -186,7 +240,13 @@
 
 impl SessionOwner {
     pub(crate) fn retains_operation(&self, op: u64) -> bool {
-        self.lanes().values().any(|lane| lane.holds(op))
+        self.lanes().values().any(|lane| {
+            lane.holds(op)
+                || lane
+                    .fetch_block
+                    .as_ref()
+                    .is_some_and(|block| block.operation_id == op)
+        })
     }
 
     /// Waits for the lane of `key` in arrival order. A lane that retains an
@@ -198,7 +258,16 @@
         key: Option<LaneKey>,
         deadline: Instant,
     ) -> Result<LaneTicket, IggyError> {
-        self.enter_lane_for(key, deadline, None).await
+        self.enter_lane_for(key, deadline, None, false).await
+    }
+
+    /// Offset inspection must remain possible while a fetch outcome is unknown.
+    pub(crate) async fn enter_progress_inspection(
+        self: &Arc<Self>,
+        key: LaneKey,
+        deadline: Instant,
+    ) -> Result<LaneTicket, IggyError> {
+        self.enter_lane_for(Some(key), deadline, None, true).await
     }
 
     pub(crate) async fn enter_progress_lane(
@@ -212,7 +281,38 @@
             return Ok(progress);
         };
         let mut ticket = self.enter_lane(Some(key), deadline).await?;
-        ticket.progress = Some(Box::new(progress));
+        ticket.progress.push(progress);
+        Ok(ticket)
+    }
+
+    /// Enters the progress lane of every `(partition_id, incarnation)` in
+    /// ascending partition order, and no mutation lane. A retained offset
+    /// mutation fails the call with `PartitionLaneBlocked`, which names the
+    /// partition of its lane.
+    pub(crate) async fn enter_progress_lanes(
+        self: &Arc<Self>,
+        partitions: &[(u32, u64)],
+        deadline: Instant,
+    ) -> Result<LaneTicket, IggyError> {
+        let mut partitions = partitions.to_vec();
+        partitions.sort_unstable();
+        let mut ticket = LaneTicket::new(Arc::clone(self), None, false);
+        for (partition_id, incarnation) in partitions {
+            let key = LaneKey::Progress {
+                incarnation,
+                partition_id,
+            };
+            let progress =
+                self.enter_lane(Some(key), deadline)
+                    .await
+                    .map_err(|error| match error {
+                        IggyError::LaneBlocked(op) => {
+                            IggyError::PartitionLaneBlocked(partition_id, op)
+                        }
+                        error => error,
+                    })?;
+            ticket.progress.push(progress);
+        }
         Ok(ticket)
     }
 
@@ -221,12 +321,16 @@
         key: Option<LaneKey>,
         deadline: Instant,
         resuming: Option<u64>,
+        inspecting: bool,
     ) -> Result<LaneTicket, IggyError> {
         let Some(key) = key else {
             return Ok(LaneTicket::new(Arc::clone(self), None, false));
         };
         let (mut ticket, gate) = {
             let mut lanes = self.lanes();
+            if !inspecting && let Some(error) = fetch_blocked(&lanes, key) {
+                return Err(error);
+            }
             if let Some(op) = key
                 .blocking_operation(&lanes)
                 .filter(|op| Some(*op) != resuming)
@@ -244,6 +348,9 @@
             .await
             .map_err(|_| IggyError::TransientNotAccepted)?;
         ticket.guard = Some(guard);
+        if !inspecting && let Some(error) = fetch_blocked(&self.lanes(), key) {
+            return Err(error);
+        }
         // The call that held the gate can have left its operation retained.
         let blocked = key
             .blocking_operation(&self.lanes())
@@ -264,6 +371,13 @@
     ) -> Result<(LaneTicket, RetainedOperation), IggyError> {
         let (mut ticket, gate, progress_key) = {
             let mut lanes = self.lanes();
+            if lanes.values().any(|lane| {
+                lane.fetch_block
+                    .as_ref()
+                    .is_some_and(|block| block.operation_id == op)
+            }) {
+                return Err(IggyError::FetchProgressUncertain(op));
+            }
             let Some((key, lane)) = lanes.iter_mut().find(|(_, lane)| lane.holds(op)) else {
                 return Err(IggyError::OperationNotRetained(op));
             };
@@ -292,14 +406,14 @@
         };
         if let Some(key) = progress_key {
             let progress = self
-                .enter_lane_for(Some(key), deadline, Some(op))
+                .enter_lane_for(Some(key), deadline, Some(op), false)
                 .await
                 .map_err(|error| match error {
                     IggyError::TransientNotAccepted => IggyError::OperationUncertain(op),
                     IggyError::LaneBlocked(_) => IggyError::OperationNotRetained(op),
                     error => error,
                 })?;
-            ticket.progress = Some(Box::new(progress));
+            ticket.progress.push(progress);
         }
         let guard = timeout_at(deadline, gate.lock_owned())
             .await
@@ -329,6 +443,21 @@
     /// unknown.
     pub(crate) fn abandon(&self, op: u64) -> Result<(), IggyError> {
         let mut lanes = self.lanes();
+        let mut abandoned_fetch = false;
+        lanes.retain(|_, lane| {
+            if lane
+                .fetch_block
+                .as_ref()
+                .is_some_and(|block| block.operation_id == op)
+            {
+                lane.fetch_block = None;
+                abandoned_fetch = true;
+            }
+            lane.tickets > 0 || lane.retained.is_some() || lane.fetch_block.is_some()
+        });
+        if abandoned_fetch {
+            return Ok(());
+        }
         let Some((&key, lane)) = lanes.iter_mut().find(|(_, lane)| lane.holds(op)) else {
             return Err(IggyError::OperationNotRetained(op));
         };
@@ -353,24 +482,38 @@
     }
 }
 
-/// The outcome of every retained operation stays unknown after a session
-/// reset, and none of them can replay under the new session.
+/// Replays cannot cross a session reset. Local fetch blocks do not depend on a
+/// replay receipt and remain until explicit abandonment.
 pub(super) fn drop_retained(lanes: &mut Lanes) {
     lanes.retain(|_, lane| {
         lane.retained = None;
-        lane.tickets > 0
+        lane.tickets > 0 || lane.fetch_block.is_some()
     });
 }
 
+fn fetch_blocked(lanes: &Lanes, key: LaneKey) -> Option<IggyError> {
+    let LaneKey::Progress { partition_id, .. } = key else {
+        return None;
+    };
+    lanes
+        .get(&key)?
+        .fetch_block
+        .as_ref()
+        .map(|block| IggyError::PartitionLaneBlocked(partition_id, block.operation_id))
+}
+
 impl LaneTicket {
     fn new(owner: Arc<SessionOwner>, key: Option<LaneKey>, resumed: bool) -> Self {
         Self {
             owner,
             key,
             guard: None,
-            progress: None,
+            progress: Vec::new(),
             operation: None,
             reservation: None,
+            fetch_reservation: None,
+            fetch_block: None,
+            uncertain_targets: None,
             fence: 0,
             resumed,
             sent: false,
@@ -409,6 +552,32 @@
         Ok(())
     }
 
+    /// Reserve the bounded local block before a committing fetch or poll can be
+    /// sent.
+    pub(crate) async fn reserve_fetch(&mut self, deadline: Instant) -> Result<(), IggyError> {
+        if self.fetch_reservation.is_none() {
+            let bytes = size_of::<FetchBlock>()
+                + self.progress.len()
+                    * (size_of::<LaneKey>() + size_of::<Lane>() + size_of::<Mutex<()>>());
+            self.fetch_reservation =
+                Some(self.owner.pending_fetches.reserve(bytes, deadline).await?);
+        }
+        Ok(())
+    }
+
+    /// The settlement of the frozen committing request, for the reply of its cancel.
+    pub(crate) fn settlement(&self) -> Option<FetchSettlement> {
+        self.fetch_block.as_ref().map(|block| FetchSettlement {
+            owner: Arc::clone(&self.owner),
+            block: Arc::clone(block),
+        })
+    }
+
+    pub(crate) fn retain_fetch_targets(&mut self, partitions: Vec<u32>) -> Option<u64> {
+        self.uncertain_targets = Some(partitions);
+        self.fetch_block.as_ref().map(|block| block.operation_id)
+    }
+
     /// [`Self::freeze_in`] in the current lineage.
     #[cfg(test)]
     pub(crate) fn freeze(
@@ -463,6 +632,13 @@
             None => body,
         };
         let frame = Frame { header, body };
+        if let Some(reservation) = &self.fetch_reservation {
+            self.fetch_block = Some(Arc::new(FetchBlock {
+                operation_id: frame.header.request,
+                _reservation: Arc::clone(reservation),
+                settled: OnceLock::new(),
+            }));
+        }
         self.operation = reservation.map(|reservation| RetainedOperation {
             code,
             frame: frame.clone(),
@@ -504,9 +680,12 @@
         self.complete();
     }
 
-    /// Ends the call with an unknown outcome. Only mutations remain retained;
-    /// a progress poll's weak acknowledgement cannot be recovered by replay.
+    /// Ends the call with an unknown outcome. Mutations retain replay frames.
+    /// Committing fetches and polls retain only local progress blocks.
     pub(crate) fn retain(self) -> IggyError {
+        if let Some(block) = &self.fetch_block {
+            return IggyError::FetchProgressUncertain(block.operation_id);
+        }
         self.operation
             .as_ref()
             .map_or(IggyError::TransientNotCommitted, |operation| {
@@ -525,6 +704,25 @@
 
 impl Drop for LaneTicket {
     fn drop(&mut self) {
+        if self.sent
+            && !self.done
+            && let Some(block) = &self.fetch_block
+        {
+            let mut lanes = self.owner.lanes();
+            let settled = block.settled.get();
+            for progress in &self.progress {
+                if let Some(key @ LaneKey::Progress { partition_id, .. }) = progress.key
+                    && self
+                        .uncertain_targets
+                        .as_ref()
+                        .is_none_or(|targets| targets.contains(&partition_id))
+                    && settled.is_none_or(|uncertain| uncertain.contains(&partition_id))
+                    && let Some(lane) = lanes.get_mut(&key)
+                {
+                    lane.fetch_block = Some(Arc::clone(block));
+                }
+            }
+        }
         let Some(key) = self.key else {
             return;
         };
@@ -555,7 +753,7 @@
             }
             retained = op.is_some_and(|op| lane.holds(op));
             lane.tickets = lane.tickets.saturating_sub(1);
-            if lane.tickets == 0 && lane.retained.is_none() {
+            if lane.tickets == 0 && lane.retained.is_none() && lane.fetch_block.is_none() {
                 lanes.remove(&key);
             }
         }
@@ -573,7 +771,8 @@
 mod tests {
     use super::*;
     use iggy_binary_protocol::codes::{
-        CREATE_STREAM_CODE, LOGOUT_USER_CODE, PING_CODE, POLL_MESSAGES_CODE, SEND_MESSAGES_CODE,
+        CREATE_STREAM_CODE, FETCH_MESSAGES_CODE, LOGOUT_USER_CODE, PING_CODE, POLL_MESSAGES_CODE,
+        SEND_MESSAGES_CODE,
     };
     use iggy_common::{AutoLogin, PendingOperationsConfig};
     use std::num::NonZeroUsize;
@@ -1111,44 +1310,217 @@
     }
 
     #[tokio::test(start_paused = true)]
-    async fn given_a_sent_progress_poll_when_cancelled_or_uncertain_should_release_without_retaining()
+    async fn given_a_sent_progress_poll_when_cancelled_or_uncertain_should_block_only_a_committing_one()
      {
-        for cancelled in [true, false] {
+        for committing in [false, true] {
+            for cancelled in [true, false] {
+                let owner = bound_owner();
+                let mut poll = if committing {
+                    let mut poll = owner
+                        .enter_progress_lanes(&[(PARTITION_ID, INCARNATION)], deadline())
+                        .await
+                        .unwrap();
+                    poll.reserve_fetch(deadline()).await.unwrap();
+                    poll
+                } else {
+                    owner
+                        .enter_lane(Some(progress_lane()), deadline())
+                        .await
+                        .unwrap()
+                };
+                let frame = poll
+                    .freeze(
+                        POLL_MESSAGES_CODE,
+                        Bytes::new(),
+                        PartitionContext {
+                            incarnation: INCARNATION,
+                            ..PartitionContext::default()
+                        },
+                        None,
+                    )
+                    .unwrap();
+                *poll.sent_flag() = true;
+                let op = frame.header.request;
+
+                if cancelled {
+                    drop(poll);
+                } else {
+                    let uncertain = if committing {
+                        IggyError::FetchProgressUncertain(op)
+                    } else {
+                        IggyError::TransientNotCommitted
+                    };
+                    assert_eq!(poll.retain(), uncertain);
+                }
+
+                if committing {
+                    assert!(matches!(
+                        owner.resume(op, deadline()).await,
+                        Err(IggyError::FetchProgressUncertain(blocking)) if blocking == op
+                    ));
+                    assert!(matches!(
+                        owner.enter_lane(Some(progress_lane()), deadline()).await,
+                        Err(IggyError::PartitionLaneBlocked(partition, blocking))
+                        if partition == PARTITION_ID && blocking == op
+                    ));
+                    owner.abandon(op).unwrap();
+                } else {
+                    assert!(matches!(
+                        owner.resume(op, deadline()).await,
+                        Err(IggyError::OperationNotRetained(missing)) if missing == op
+                    ));
+                    assert!(matches!(
+                        owner.abandon(op),
+                        Err(IggyError::OperationNotRetained(missing)) if missing == op
+                    ));
+                }
+                owner
+                    .enter_lane(Some(progress_lane()), deadline())
+                    .await
+                    .unwrap()
+                    .complete();
+                assert_eq!(lane_count(&owner), 0);
+            }
+        }
+    }
+
+    /// The reply to the cancel of a committing fetch proves the outcome of each target but the
+    /// uncertain ones, whether it arrives before or after the call drops its ticket.
+    #[tokio::test(start_paused = true)]
+    async fn given_a_dropped_committing_fetch_when_its_reply_settles_it_should_block_only_its_uncertain_targets()
+     {
+        for settle_before_drop in [false, true] {
             let owner = bound_owner();
-            let mut poll = owner
-                .enter_lane(Some(progress_lane()), deadline())
+            let targets = [
+                (PARTITION_ID, INCARNATION),
+                (OTHER_PARTITION_ID, INCARNATION),
+            ];
+            let mut fetch = owner
+                .enter_progress_lanes(&targets, deadline())
                 .await
                 .unwrap();
-            let frame = poll
+            fetch.reserve_fetch(deadline()).await.unwrap();
+            let frame = fetch
                 .freeze(
-                    POLL_MESSAGES_CODE,
+                    FETCH_MESSAGES_CODE,
                     Bytes::new(),
-                    PartitionContext {
-                        incarnation: INCARNATION,
-                        ..PartitionContext::default()
-                    },
+                    PartitionContext::default(),
                     None,
                 )
                 .unwrap();
-            *poll.sent_flag() = true;
-
-            if cancelled {
-                drop(poll);
+            *fetch.sent_flag() = true;
+            let settlement = fetch.settlement().unwrap();
+            if settle_before_drop {
+                settlement.settle(vec![OTHER_PARTITION_ID]);
+                drop(fetch);
             } else {
-                assert_eq!(poll.retain(), IggyError::TransientNotCommitted);
+                drop(fetch);
+                settlement.settle(vec![OTHER_PARTITION_ID]);
             }
 
-            let op = frame.header.request;
-            assert!(matches!(
-                owner.resume(op, deadline()).await,
-                Err(IggyError::OperationNotRetained(missing)) if missing == op
-            ));
-            assert!(matches!(
-                owner.abandon(op),
-                Err(IggyError::OperationNotRetained(missing)) if missing == op
-            ));
+            let lane = |partition_id| {
+                Some(LaneKey::Progress {
+                    partition_id,
+                    incarnation: INCARNATION,
+                })
+            };
             owner
-                .enter_lane(Some(progress_lane()), deadline())
+                .enter_lane(lane(PARTITION_ID), deadline())
+                .await
+                .unwrap()
+                .complete();
+            let operation_id = frame.header.request;
+            assert!(
+                matches!(
+                    owner.enter_lane(lane(OTHER_PARTITION_ID), deadline()).await,
+                    Err(IggyError::PartitionLaneBlocked(OTHER_PARTITION_ID, operation))
+                        if operation == operation_id
+                ),
+                "settled before the drop: {settle_before_drop}"
+            );
+            owner.abandon(operation_id).unwrap();
+            owner
+                .enter_lane(lane(OTHER_PARTITION_ID), deadline())
+                .await
+                .unwrap()
+                .complete();
+        }
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_a_sent_committing_fetch_when_dropped_across_reset_should_block_every_target_until_abandoned()
+     {
+        for reset_before_drop in [false, true] {
+            let owner = bound_owner();
+            let targets = [
+                (PARTITION_ID, INCARNATION),
+                (OTHER_PARTITION_ID, INCARNATION),
+            ];
+            let mut fetch = owner
+                .enter_progress_lanes(&targets, deadline())
+                .await
+                .unwrap();
+            fetch.reserve_fetch(deadline()).await.unwrap();
+            let frame = fetch
+                .freeze(
+                    FETCH_MESSAGES_CODE,
+                    Bytes::new(),
+                    PartitionContext::default(),
+                    None,
+                )
+                .unwrap();
+            *fetch.sent_flag() = true;
+            if reset_before_drop {
+                owner.reset().unwrap();
+                owner.bind(NEXT_SESSION).unwrap();
+            }
+            drop(fetch);
+            if !reset_before_drop {
+                owner.reset().unwrap();
+                owner.bind(NEXT_SESSION).unwrap();
+            }
+            let operation_id = frame.header.request;
+            for (partition_id, incarnation) in targets {
+                let progress = LaneKey::Progress {
+                    partition_id,
+                    incarnation,
+                };
+                assert!(matches!(owner.enter_lane(Some(progress), deadline()).await,
+                    Err(IggyError::PartitionLaneBlocked(partition, operation))
+                    if partition == partition_id && operation == operation_id));
+                owner
+                    .enter_progress_inspection(progress, deadline())
+                    .await
+                    .unwrap()
+                    .complete();
+                owner
+                    .enter_lane(
+                        Some(LaneKey::Partition {
+                            partition_id,
+                            incarnation,
+                        }),
+                        deadline(),
+                    )
+                    .await
+                    .unwrap()
+                    .complete();
+                owner
+                    .enter_lane(
+                        Some(LaneKey::Progress {
+                            partition_id,
+                            incarnation: incarnation + 1,
+                        }),
+                        deadline(),
+                    )
+                    .await
+                    .unwrap()
+                    .complete();
+            }
+            assert!(matches!(owner.resume(operation_id, deadline()).await,
+                Err(IggyError::FetchProgressUncertain(operation)) if operation == operation_id));
+            owner.abandon(operation_id).unwrap();
+            owner
+                .enter_progress_lanes(&targets, deadline())
                 .await
                 .unwrap()
                 .complete();
@@ -1157,6 +1529,105 @@
     }
 
     #[tokio::test(start_paused = true)]
+    async fn given_a_partial_unknown_fetch_when_retained_should_keep_only_uncertain_targets() {
+        let owner = bound_owner();
+        let mut fetch = owner
+            .enter_progress_lanes(
+                &[
+                    (PARTITION_ID, INCARNATION),
+                    (OTHER_PARTITION_ID, INCARNATION),
+                ],
+                deadline(),
+            )
+            .await
+            .unwrap();
+        fetch.reserve_fetch(deadline()).await.unwrap();
+        let frame = fetch
+            .freeze(
+                FETCH_MESSAGES_CODE,
+                Bytes::new(),
+                PartitionContext::default(),
+                None,
+            )
+            .unwrap();
+        *fetch.sent_flag() = true;
+        assert_eq!(
+            fetch.retain_fetch_targets(vec![OTHER_PARTITION_ID]),
+            Some(frame.header.request)
+        );
+        drop(fetch);
+        owner
+            .enter_lane(Some(progress_lane()), deadline())
+            .await
+            .unwrap()
+            .complete();
+        assert!(
+            matches!(owner.enter_progress_lanes(&[(OTHER_PARTITION_ID, INCARNATION)], deadline()).await,
+            Err(IggyError::PartitionLaneBlocked(partition, operation))
+            if partition == OTHER_PARTITION_ID && operation == frame.header.request)
+        );
+        owner.abandon(frame.header.request).unwrap();
+        assert_eq!(lane_count(&owner), 0);
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_full_fetch_retention_when_session_resets_should_refuse_before_send_until_abandoned()
+     {
+        const BYTES: usize = 4096;
+        let owner = owner_with_limits(1, BYTES);
+        let mut fetch = owner
+            .enter_progress_lanes(&[(PARTITION_ID, INCARNATION)], deadline())
+            .await
+            .unwrap();
+        fetch.reserve_fetch(deadline()).await.unwrap();
+        let frame = fetch
+            .freeze(
+                FETCH_MESSAGES_CODE,
+                Bytes::new(),
+                PartitionContext::default(),
+                None,
+            )
+            .unwrap();
+        *fetch.sent_flag() = true;
+        drop(fetch);
+        owner.reset().unwrap();
+        owner.bind(NEXT_SESSION).unwrap();
+        let next_id = owner.session().unwrap().current_request_id();
+        let mut next = owner
+            .enter_progress_lanes(&[(OTHER_PARTITION_ID, INCARNATION)], deadline())
+            .await
+            .unwrap();
+        assert_eq!(
+            next.reserve_fetch(Instant::now() + SHORT_DEADLINE).await,
+            Err(IggyError::TransientNotAccepted)
+        );
+        assert_eq!(owner.session().unwrap().current_request_id(), next_id);
+        owner.abandon(frame.header.request).unwrap();
+        next.reserve_fetch(deadline()).await.unwrap();
+        drop(next);
+        let mut cancelled = owner
+            .enter_progress_lanes(&[(PARTITION_ID, INCARNATION)], deadline())
+            .await
+            .unwrap();
+        cancelled.reserve_fetch(deadline()).await.unwrap();
+        cancelled
+            .freeze(
+                FETCH_MESSAGES_CODE,
+                Bytes::new(),
+                PartitionContext::default(),
+                None,
+            )
+            .unwrap();
+        drop(cancelled);
+        owner
+            .enter_progress_lanes(&[(PARTITION_ID, INCARNATION)], deadline())
+            .await
+            .unwrap()
+            .complete();
+        assert_eq!(lane_count(&owner), 0);
+    }
+
+    #[tokio::test(start_paused = true)]
     async fn given_a_retained_offset_mutation_when_progress_poll_enters_should_remain_blocked() {
         for code in [STORE_CONSUMER_OFFSET_CODE, DELETE_CONSUMER_OFFSET_CODE] {
             for reset in [false, true] {
@@ -1225,6 +1696,112 @@
     }
 
     #[tokio::test(start_paused = true)]
+    async fn given_a_fetch_of_several_partitions_when_entering_should_take_their_progress_gates_in_ascending_order()
+     {
+        let owner = bound_owner();
+        let higher = LaneKey::Progress {
+            incarnation: INCARNATION,
+            partition_id: OTHER_PARTITION_ID,
+        };
+        let held = owner.enter_lane(Some(higher), deadline()).await.unwrap();
+        let fetch = owner.enter_progress_lanes(
+            &[
+                (OTHER_PARTITION_ID, INCARNATION),
+                (PARTITION_ID, INCARNATION),
+            ],
+            deadline(),
+        );
+        tokio::pin!(fetch);
+        assert!(timeout(SHORT_DEADLINE, &mut fetch).await.is_err());
+
+        let lower = owner
+            .enter_lane(Some(progress_lane()), Instant::now() + SHORT_DEADLINE)
+            .await;
+        assert_eq!(
+            lower.unwrap_err(),
+            IggyError::TransientNotAccepted,
+            "the fetch holds the lower gate while it waits for the higher one"
+        );
+        owner
+            .enter_lane(partition_lane(), deadline())
+            .await
+            .expect("a fetch takes no mutation lane")
+            .complete();
+        held.complete();
+        let fetch = fetch.await.unwrap();
+        let busy = owner
+            .enter_lane(Some(higher), Instant::now() + SHORT_DEADLINE)
+            .await;
+        assert_eq!(busy.unwrap_err(), IggyError::TransientNotAccepted);
+
+        fetch.complete();
+        assert_eq!(lane_count(&owner), 0);
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_a_retained_offset_mutation_of_one_partition_when_a_fetch_enters_should_fail_naming_that_partition()
+     {
+        let owner = bound_owner();
+        let mut store = owner
+            .enter_progress_lane(
+                LaneKey::Progress {
+                    incarnation: INCARNATION,
+                    partition_id: OTHER_PARTITION_ID,
+                },
+                Some(LaneKey::Partition {
+                    incarnation: INCARNATION,
+                    partition_id: OTHER_PARTITION_ID,
+                }),
+                deadline(),
+            )
+            .await
+            .unwrap();
+        store
+            .reserve(STORE_CONSUMER_OFFSET_CODE, 0, deadline())
+            .await
+            .unwrap();
+        let frame = store
+            .freeze(
+                STORE_CONSUMER_OFFSET_CODE,
+                Bytes::new(),
+                PartitionContext {
+                    incarnation: INCARNATION,
+                    ..PartitionContext::default()
+                },
+                None,
+            )
+            .unwrap();
+        *store.sent_flag() = true;
+        drop(store);
+        let next_id = owner.session().unwrap().current_request_id();
+
+        let fetch = owner
+            .enter_progress_lanes(
+                &[
+                    (OTHER_PARTITION_ID, INCARNATION),
+                    (PARTITION_ID, INCARNATION),
+                ],
+                deadline(),
+            )
+            .await;
+
+        assert!(
+            matches!(
+                fetch,
+                Err(IggyError::PartitionLaneBlocked(partition_id, op))
+                    if partition_id == OTHER_PARTITION_ID && op == frame.header.request
+            ),
+            "{fetch:?}"
+        );
+        assert_eq!(owner.session().unwrap().current_request_id(), next_id);
+        owner
+            .enter_lane(Some(progress_lane()), deadline())
+            .await
+            .expect("a failed fetch frees the gates that it took")
+            .complete();
+    }
+
+    #[tokio::test(start_paused = true)]
     async fn given_a_store_waiting_for_mutation_when_session_resets_should_keep_its_progress_gate()
     {
         let owner = bound_owner();
diff --git a/core/sdk/src/stream_builder/build/build_iggy_consumer.rs b/core/sdk/src/stream_builder/build/build_iggy_consumer.rs
index a4bdf54..a91ce66 100644
--- a/core/sdk/src/stream_builder/build/build_iggy_consumer.rs
+++ b/core/sdk/src/stream_builder/build/build_iggy_consumer.rs
@@ -45,30 +45,26 @@
     trace!("Extract config fields.");
     let stream = config.stream_name();
     let topic = config.topic_name();
-    let auto_commit = config.auto_commit();
     let consumer_kind = config.consumer_kind();
     let consumer_name = config.consumer_name();
-    let batch_length = config.batch_length();
     let poll_options = config.poll_options();
     let polling_strategy = config.polling_strategy();
-    let partition = config.partition_id();
     let polling_retry_interval = config.polling_retry_interval();
 
     trace!("Build iggy consumer");
     let mut builder = match consumer_kind {
-        ConsumerKind::Consumer => client.consumer(consumer_name, stream, topic, partition)?,
+        ConsumerKind::Consumer => client
+            .consumer(consumer_name, stream, topic)?
+            .partitions(config.partitions().iter().copied()),
         ConsumerKind::ConsumerGroup => client.consumer_group(consumer_name, stream, topic)?,
         // Holds offsets only, it cannot poll. `IggyConsumerConfig::validate` refuses it, with a
         // log, before the entry points make any server call.
         ConsumerKind::ExternalGroup => return Err(IggyError::FeatureUnavailable),
     }
-    .auto_commit(auto_commit)
     .create_consumer_group_if_not_exists()
-    .auto_join_consumer_group()
     .polling_strategy(polling_strategy)
     .poll_options(poll_options)
     .prefetch_bytes(config.prefetch_bytes())
-    .batch_length(batch_length)
     .polling_retry_interval(polling_retry_interval);
 
     if let Some(prefetch_messages) = config.prefetch_messages() {
diff --git a/core/sdk/src/stream_builder/config/config_iggy_consumer.rs b/core/sdk/src/stream_builder/config/config_iggy_consumer.rs
index 293466f..e62cc0a 100644
--- a/core/sdk/src/stream_builder/config/config_iggy_consumer.rs
+++ b/core/sdk/src/stream_builder/config/config_iggy_consumer.rs
@@ -15,10 +15,9 @@
 // specific language governing permissions and limitations
 // under the License.
 
-use crate::clients::consumer::AutoCommit;
-use crate::clients::consumer::deferred::DEFAULT_PREFETCH_BYTES;
+use crate::clients::reader_builder::{DEFAULT_MAX_WAIT, DEFAULT_PREFETCH_BYTES};
 use crate::prelude::{
-    ConsumerKind, DeferredPollOptions, EncryptorKind, Identifier, IggyError, NonZeroIggyDuration,
+    ConsumerKind, EncryptorKind, Identifier, IggyError, NonZeroIggyDuration, PollOptions,
     PollingStrategy, Validatable,
 };
 use bon::Builder;
@@ -35,17 +34,11 @@
     stream_name: String,
     /// Name of the topic. Must be unique.
     topic_name: String,
-    /// The auto-commit configuration for storing the message offset on the server. See  `AutoCommit` for details.
-    #[builder(default = AutoCommit::Disabled)]
-    auto_commit: AutoCommit,
-    /// The max number of messages to poll in a batch. The greater the batch length, the higher the throughput for bulk data.
-    /// Note, there is a tradeoff between batch size and latency, so you want to benchmark your setup.
-    batch_length: u32,
     /// Maximum encoded bytes reserved for buffered and in-flight batches.
     #[builder(default = DEFAULT_PREFETCH_BYTES)]
     prefetch_bytes: u32,
     /// Maximum messages reserved for buffered and in-flight batches. Defaults to 16,000, or
-    /// `batch_length` when that is larger.
+    /// the `count` of the poll options when that is larger.
     prefetch_messages: Option<u32>,
     /// Create the stream if it doesn't exist.
     create_stream_if_not_exists: bool,
@@ -58,16 +51,20 @@
     consumer_kind: ConsumerKind,
     /// Partition count when creating a topic.
     partitions_count: u32,
-    /// Partition ID for an ordinary consumer. Defaults to 0 and is ignored by consumer groups.
-    #[builder(default = DEFAULT_PARTITION_ID)]
-    partition_id: u32,
-    /// Readiness, response size and request timeout limits.
-    #[builder(default)]
-    poll_options: DeferredPollOptions,
+    /// Partitions of an ordinary consumer. Defaults to partition 0 and is ignored by consumer
+    /// groups.
+    #[builder(default = vec![DEFAULT_PARTITION_ID])]
+    partitions: Vec<u32>,
+    /// The limits of each request and the `CommitPolicy` of the consumer, the defaults of
+    /// `IggyConsumerBuilder`. The greater the `count`, the higher the throughput for bulk data.
+    /// Note, there is a tradeoff between batch size and latency, so you want to benchmark your
+    /// setup.
+    #[builder(default = default_poll_options())]
+    poll_options: PollOptions,
     /// `PollingStrategy` specifies from where to start polling messages. See `PollingStrategy` for details.
     #[builder(default = PollingStrategy::next())]
     polling_strategy: PollingStrategy,
-    /// Sets the polling retry interval in case of server disconnection.
+    /// Sets how long a refused partition waits before the next read.
     polling_retry_interval: NonZeroIggyDuration,
     /// Sets the number of retries and the interval when initializing the consumer if the stream or topic is not found.
     /// Might be useful when the stream or topic is created dynamically by the producer.
@@ -83,18 +80,16 @@
         Self {
             stream_name: "test_stream".to_string(),
             topic_name: "test_topic".to_string(),
-            auto_commit: AutoCommit::Disabled,
-            batch_length: 100,
             prefetch_bytes: DEFAULT_PREFETCH_BYTES,
             prefetch_messages: None,
             create_stream_if_not_exists: false,
             create_topic_if_not_exists: false,
             consumer_name: "test_consumer".to_string(),
             consumer_kind: ConsumerKind::ConsumerGroup,
-            poll_options: DeferredPollOptions::default(),
+            poll_options: default_poll_options(),
             polling_strategy: PollingStrategy::next(),
             partitions_count: 1,
-            partition_id: DEFAULT_PARTITION_ID,
+            partitions: vec![DEFAULT_PARTITION_ID],
             encryptor: None,
             polling_retry_interval: NonZeroIggyDuration::ONE_SECOND,
             init_retries: Some(5),
@@ -110,13 +105,11 @@
     ///
     /// * `stream_name` - The stream name.
     /// * `topic_name` - The topic name.
-    /// * `auto_commit` - The auto commit config.
-    /// * `batch_length` - The max number of messages to poll in a batch.
     /// * `create_stream_if_not_exists` - Whether to create the stream if it does not exists.
     /// * `create_topic_if_not_exists` - Whether to create the topic if it does not exists.
     /// * `consumer_name` - The consumer name.
     /// * `consumer_kind` - The consumer kind.
-    /// * `poll_options` - Readiness, response size and request timeout limits.
+    /// * `poll_options` - The limits of each request and the commit policy.
     /// * `polling_strategy` - The polling strategy.
     /// * `partitions_count` - Topic creation count.
     /// * `encryptor` - The encryptor.
@@ -128,19 +121,17 @@
     /// Returns:
     /// A new `IggyConsumerConfig`.
     ///
-    /// Ordinary consumers use partition 0. Use [`Self::with_partition_id`] to select another partition.
+    /// Ordinary consumers read partition 0. Use [`Self::with_partitions`] to select others.
     ///
     #[allow(clippy::too_many_arguments)]
     pub fn new(
         stream_name: String,
         topic_name: String,
-        auto_commit: AutoCommit,
-        batch_length: u32,
         create_stream_if_not_exists: bool,
         create_topic_if_not_exists: bool,
         consumer_name: String,
         consumer_kind: ConsumerKind,
-        poll_options: DeferredPollOptions,
+        poll_options: PollOptions,
         polling_strategy: PollingStrategy,
         partitions_count: u32,
         encryptor: Option<Arc<EncryptorKind>>,
@@ -151,8 +142,6 @@
         Self {
             stream_name,
             topic_name,
-            auto_commit,
-            batch_length,
             prefetch_bytes: DEFAULT_PREFETCH_BYTES,
             prefetch_messages: None,
             create_stream_if_not_exists,
@@ -162,7 +151,7 @@
             poll_options,
             polling_strategy,
             partitions_count,
-            partition_id: DEFAULT_PARTITION_ID,
+            partitions: vec![DEFAULT_PARTITION_ID],
             encryptor,
             polling_retry_interval,
             init_retries,
@@ -176,8 +165,7 @@
     ///
     /// * `stream` - The stream name.
     /// * `topic` - The topic name.
-    /// * `batch_length` - The max number of messages to poll in a batch.
-    /// * `poll_options` - Readiness, response size and request timeout limits.
+    /// * `poll_options` - The limits of each request and the commit policy.
     ///
     /// Returns:
     /// A new `IggyConsumerConfig`.
@@ -185,8 +173,7 @@
     pub fn from_stream_topic(
         stream: &str,
         topic: &str,
-        batch_length: u32,
-        poll_options: DeferredPollOptions,
+        poll_options: PollOptions,
     ) -> Result<Self, IggyError> {
         // Validate stream and topic names. Returns IggyError if names are not allowed.
         Identifier::named(stream)?;
@@ -195,8 +182,6 @@
         Ok(Self {
             stream_name: stream.to_string(),
             topic_name: topic.to_string(),
-            auto_commit: AutoCommit::Disabled,
-            batch_length,
             prefetch_bytes: DEFAULT_PREFETCH_BYTES,
             prefetch_messages: None,
             create_stream_if_not_exists: false,
@@ -206,7 +191,7 @@
             poll_options,
             polling_strategy: PollingStrategy::next(),
             partitions_count: 1,
-            partition_id: DEFAULT_PARTITION_ID,
+            partitions: vec![DEFAULT_PARTITION_ID],
             encryptor: None,
             polling_retry_interval: NonZeroIggyDuration::ONE_SECOND,
             init_retries: Some(5),
@@ -216,15 +201,15 @@
 }
 
 impl IggyConsumerConfig {
-    /// Selects the partition for an ordinary consumer. Consumer groups ignore this setting.
-    pub fn with_partition_id(mut self, partition_id: u32) -> Self {
-        self.partition_id = partition_id;
+    /// Selects the partitions of an ordinary consumer. Consumer groups ignore this setting.
+    pub fn with_partitions(mut self, partitions: impl IntoIterator<Item = u32>) -> Self {
+        self.partitions = partitions.into_iter().collect();
         self
     }
 
-    /// Sets the auto-commit configuration, which `default` and `from_stream_topic` leave disabled.
-    pub fn with_auto_commit(mut self, auto_commit: AutoCommit) -> Self {
-        self.auto_commit = auto_commit;
+    /// Sets the limits of each request and whether the server stores the offset of a read.
+    pub fn with_poll_options(mut self, poll_options: PollOptions) -> Self {
+        self.poll_options = poll_options;
         self
     }
 
@@ -236,10 +221,6 @@
         &self.topic_name
     }
 
-    pub fn auto_commit(&self) -> AutoCommit {
-        self.auto_commit
-    }
-
     pub fn prefetch_bytes(&self) -> u32 {
         self.prefetch_bytes
     }
@@ -248,9 +229,6 @@
         self.prefetch_messages
     }
 
-    pub fn batch_length(&self) -> u32 {
-        self.batch_length
-    }
     pub fn create_stream_if_not_exists(&self) -> bool {
         self.create_stream_if_not_exists
     }
@@ -267,7 +245,7 @@
         self.consumer_kind
     }
 
-    pub fn poll_options(&self) -> DeferredPollOptions {
+    pub fn poll_options(&self) -> PollOptions {
         self.poll_options
     }
 
@@ -279,8 +257,8 @@
         self.partitions_count
     }
 
-    pub fn partition_id(&self) -> u32 {
-        self.partition_id
+    pub fn partitions(&self) -> &[u32] {
+        &self.partitions
     }
 
     pub fn encryptor(&self) -> Option<Arc<EncryptorKind>> {
@@ -315,6 +293,12 @@
     }
 }
 
+/// The default of `IggyConsumerBuilder`: at most 1000 messages, a wait of up to five seconds, and
+/// no commit.
+fn default_poll_options() -> PollOptions {
+    PollOptions::wait(DEFAULT_MAX_WAIT.into())
+}
+
 #[cfg(test)]
 impl IggyConsumerConfig {
     /// The default configuration with the one kind that no consumer can poll with.
@@ -329,6 +313,18 @@
 #[cfg(test)]
 mod tests {
     use super::*;
+    use crate::prelude::{CommitPolicy, IggyDuration};
+    use std::time::Duration;
+
+    const POLL_COUNT: u32 = 100;
+    const PARTITIONS: [u32; 2] = [1, 3];
+
+    fn poll_options() -> PollOptions {
+        PollOptions {
+            count: POLL_COUNT,
+            ..PollOptions::wait(IggyDuration::ONE_SECOND)
+        }
+    }
 
     #[test]
     fn given_external_group_kind_when_validated_should_refuse_it() {
@@ -345,7 +341,6 @@
         let config = IggyConsumerConfig::builder()
             .stream_name("test_stream".to_string())
             .topic_name("test_topic".to_string())
-            .batch_length(100)
             .create_stream_if_not_exists(true)
             .create_topic_if_not_exists(true)
             .consumer_name("test_consumer".to_string())
@@ -358,15 +353,18 @@
 
         assert_eq!(config.stream_name(), "test_stream");
         assert_eq!(config.topic_name(), "test_topic");
-        assert_eq!(config.auto_commit(), AutoCommit::Disabled);
-        assert_eq!(config.batch_length(), 100);
         assert!(config.create_stream_if_not_exists());
         assert!(config.create_topic_if_not_exists());
         assert_eq!(config.consumer_name(), "test_consumer");
         assert_eq!(config.consumer_kind(), ConsumerKind::ConsumerGroup);
-        assert_eq!(config.poll_options(), DeferredPollOptions::default());
+        assert_eq!(
+            config.poll_options(),
+            PollOptions::wait(IggyDuration::from(Duration::from_secs(5))),
+            "the defaults of IggyConsumerBuilder"
+        );
         assert_eq!(config.polling_strategy(), PollingStrategy::next());
         assert_eq!(config.partitions_count(), 1);
+        assert_eq!(config.partitions(), [DEFAULT_PARTITION_ID]);
 
         assert_eq!(
             config.polling_retry_interval(),
@@ -385,15 +383,18 @@
         let config = IggyConsumerConfig::default();
         assert_eq!(config.stream_name(), "test_stream");
         assert_eq!(config.topic_name(), "test_topic");
-        assert_eq!(config.auto_commit(), AutoCommit::Disabled);
-        assert_eq!(config.batch_length(), 100);
         assert!(!config.create_stream_if_not_exists());
         assert!(!config.create_topic_if_not_exists());
         assert_eq!(config.consumer_name(), "test_consumer");
         assert_eq!(config.consumer_kind(), ConsumerKind::ConsumerGroup);
-        assert_eq!(config.poll_options(), DeferredPollOptions::default());
+        assert_eq!(
+            config.poll_options(),
+            PollOptions::wait(IggyDuration::from(Duration::from_secs(5))),
+            "the defaults of IggyConsumerBuilder"
+        );
         assert_eq!(config.polling_strategy(), PollingStrategy::next());
         assert_eq!(config.partitions_count(), 1);
+        assert_eq!(config.partitions(), [DEFAULT_PARTITION_ID]);
 
         assert_eq!(
             config.polling_retry_interval(),
@@ -411,13 +412,11 @@
         let config = IggyConsumerConfig::new(
             "test_stream".to_string(),
             "test_topic".to_string(),
-            AutoCommit::Disabled,
-            100,
             false,
             false,
             "test_consumer".to_string(),
             ConsumerKind::ConsumerGroup,
-            DeferredPollOptions::default(),
+            poll_options(),
             PollingStrategy::last(),
             1,
             None,
@@ -427,13 +426,11 @@
         );
         assert_eq!(config.stream_name(), "test_stream");
         assert_eq!(config.topic_name(), "test_topic");
-        assert_eq!(config.auto_commit(), AutoCommit::Disabled);
-        assert_eq!(config.batch_length(), 100);
         assert!(!config.create_stream_if_not_exists());
         assert!(!config.create_topic_if_not_exists());
         assert_eq!(config.consumer_name(), "test_consumer");
         assert_eq!(config.consumer_kind(), ConsumerKind::ConsumerGroup);
-        assert_eq!(config.poll_options(), DeferredPollOptions::default());
+        assert_eq!(config.poll_options(), poll_options());
         assert_eq!(config.polling_strategy(), PollingStrategy::last());
         assert_eq!(config.partitions_count(), 1);
 
@@ -450,24 +447,40 @@
 
     #[test]
     fn should_be_from_stream_topic() {
-        let res = IggyConsumerConfig::from_stream_topic(
-            "test_stream",
-            "test_topic",
-            100,
-            DeferredPollOptions::default(),
-        );
+        let options = PollOptions {
+            commit: CommitPolicy::OnRead,
+            ..poll_options()
+        };
+        let res = IggyConsumerConfig::from_stream_topic("test_stream", "test_topic", options);
         assert!(res.is_ok());
         let config = res.unwrap();
 
         assert_eq!(config.stream_name(), "test_stream");
         assert_eq!(config.topic_name(), "test_topic");
-        assert_eq!(config.batch_length(), 100);
         assert!(!config.create_stream_if_not_exists());
         assert!(!config.create_topic_if_not_exists());
         assert_eq!(config.consumer_name(), "consumer-test_stream-test_topic");
         assert_eq!(config.consumer_kind(), ConsumerKind::ConsumerGroup);
-        assert_eq!(config.poll_options(), DeferredPollOptions::default());
+        assert_eq!(config.poll_options(), options);
         assert_eq!(config.polling_strategy(), PollingStrategy::next());
         assert_eq!(config.partitions_count(), 1);
     }
+
+    #[test]
+    fn given_partitions_when_set_should_replace_the_default() {
+        let config = IggyConsumerConfig::default().with_partitions(PARTITIONS);
+
+        assert_eq!(config.partitions(), PARTITIONS);
+    }
+
+    #[test]
+    fn given_poll_options_when_set_should_replace_the_defaults() {
+        let options = PollOptions {
+            commit: CommitPolicy::OnRead,
+            ..poll_options()
+        };
+        let config = IggyConsumerConfig::default().with_poll_options(options);
+
+        assert_eq!(config.poll_options(), options);
+    }
 }
diff --git a/core/sdk/src/stream_builder/config/config_iggy_stream.rs b/core/sdk/src/stream_builder/config/config_iggy_stream.rs
index e1a5107..800a349 100644
--- a/core/sdk/src/stream_builder/config/config_iggy_stream.rs
+++ b/core/sdk/src/stream_builder/config/config_iggy_stream.rs
@@ -15,7 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
-use crate::prelude::{DeferredPollOptions, IggyDuration, IggyError};
+use crate::prelude::{IggyDuration, IggyError, PollOptions};
 use crate::stream_builder::{IggyConsumerConfig, IggyProducerConfig};
 use bon::Builder;
 
@@ -52,7 +52,8 @@
     /// * `topic` - The topic name.
     /// * `batch_length` - The max number of messages to send in a batch.
     /// * `linger_time` - The interval between messages sent.
-    /// * `poll_options` - Readiness, response size and request timeout limits.
+    /// * `poll_options` - The limits of each poll request, and whether the server stores the
+    ///   offset of a read.
     ///
     /// Returns:
     /// A new `IggyStreamConfig`.
@@ -62,10 +63,9 @@
         topic: &str,
         batch_length: u32,
         linger_time: IggyDuration,
-        poll_options: DeferredPollOptions,
+        poll_options: PollOptions,
     ) -> Result<Self, IggyError> {
-        let consumer_config =
-            IggyConsumerConfig::from_stream_topic(stream, topic, batch_length, poll_options)?;
+        let consumer_config = IggyConsumerConfig::from_stream_topic(stream, topic, poll_options)?;
 
         let producer_config =
             IggyProducerConfig::from_stream_topic(stream, topic, batch_length, linger_time)?;
@@ -89,33 +89,35 @@
 mod tests {
     use super::*;
     use std::str::FromStr;
+    use std::time::Duration;
+
+    const BATCH_LENGTH: u32 = 100;
+
+    fn poll_options() -> PollOptions {
+        PollOptions {
+            count: BATCH_LENGTH,
+            ..PollOptions::wait(IggyDuration::ONE_SECOND)
+        }
+    }
 
     #[test]
     fn should_be_equal() {
-        let consumer_config = IggyConsumerConfig::from_stream_topic(
-            "test_stream",
-            "test_topic",
-            100,
-            DeferredPollOptions::default(),
-        )
-        .unwrap();
+        let consumer_config =
+            IggyConsumerConfig::from_stream_topic("test_stream", "test_topic", poll_options())
+                .unwrap();
 
         let producer_config = IggyProducerConfig::from_stream_topic(
             "test_stream",
             "test_topic",
-            100,
+            BATCH_LENGTH,
             IggyDuration::from_str("5ms").unwrap(),
         )
         .unwrap();
         let config = IggyStreamConfig::new(consumer_config, producer_config);
         assert_eq!(config.consumer_config().stream_name(), "test_stream");
         assert_eq!(config.producer_config().topic_name(), "test_topic");
-        assert_eq!(config.consumer_config().batch_length(), 100);
-        assert_eq!(config.producer_config().batch_length(), 100);
-        assert_eq!(
-            config.consumer_config().poll_options(),
-            DeferredPollOptions::default()
-        );
+        assert_eq!(config.consumer_config().poll_options(), poll_options());
+        assert_eq!(config.producer_config().batch_length(), BATCH_LENGTH);
         assert_eq!(
             config.producer_config().linger_time(),
             IggyDuration::from_str("5ms").unwrap()
@@ -127,12 +129,11 @@
         let config = IggyStreamConfig::default();
         assert_eq!(config.consumer_config().stream_name(), "test_stream");
         assert_eq!(config.producer_config().topic_name(), "test_topic");
-        assert_eq!(config.consumer_config().batch_length(), 100);
-        assert_eq!(config.producer_config().batch_length(), 100);
         assert_eq!(
             config.consumer_config().poll_options(),
-            DeferredPollOptions::default()
+            PollOptions::wait(IggyDuration::from(Duration::from_secs(5)))
         );
+        assert_eq!(config.producer_config().batch_length(), BATCH_LENGTH);
         assert_eq!(
             config.producer_config().linger_time(),
             IggyDuration::from_str("5ms").unwrap()
@@ -144,9 +145,9 @@
         let res = IggyStreamConfig::from_stream_topic(
             "test_stream",
             "test_topic",
-            100,
+            BATCH_LENGTH,
             IggyDuration::from_str("5ms").unwrap(),
-            DeferredPollOptions::default(),
+            poll_options(),
         );
 
         assert!(res.is_ok());
@@ -154,12 +155,8 @@
 
         assert_eq!(config.consumer_config().stream_name(), "test_stream");
         assert_eq!(config.producer_config().topic_name(), "test_topic");
-        assert_eq!(config.consumer_config().batch_length(), 100);
-        assert_eq!(config.producer_config().batch_length(), 100);
-        assert_eq!(
-            config.consumer_config().poll_options(),
-            DeferredPollOptions::default()
-        );
+        assert_eq!(config.consumer_config().poll_options(), poll_options());
+        assert_eq!(config.producer_config().batch_length(), BATCH_LENGTH);
         assert_eq!(
             config.producer_config().linger_time(),
             IggyDuration::from_str("5ms").unwrap()
diff --git a/core/sdk/src/tcp/tcp_client.rs b/core/sdk/src/tcp/tcp_client.rs
index 25dd570..f326d71 100644
--- a/core/sdk/src/tcp/tcp_client.rs
+++ b/core/sdk/src/tcp/tcp_client.rs
@@ -122,23 +122,21 @@
     use iggy_binary_protocol::codes::{
         BIND_SESSION_CODE, CHANGE_PASSWORD_CODE, GET_CLUSTER_METADATA_CODE, GET_STREAMS_CODE,
         GET_TOPIC_CODE, LOGIN_REGISTER_CODE, LOGIN_REGISTER_WITH_PAT_CODE, LOGOUT_USER_CODE,
-        PING_CODE, POLL_MESSAGES_CODE, SEND_MESSAGES_CODE, SYNC_CONSUMER_GROUP_CODE,
-        UPDATE_RESOURCE_CODE,
+        PING_CODE, POLL_MESSAGES_DEFERRED_CODE, SEND_MESSAGES_CODE, UPDATE_RESOURCE_CODE,
     };
     use iggy_binary_protocol::consensus::EvictionReason;
     use iggy_binary_protocol::requests::users::{
         ChangePasswordRequest, LoginRegisterRequest, LoginRegisterWithPatRequest,
     };
-    use iggy_binary_protocol::responses::consumer_groups::SyncConsumerGroupResponse;
     use iggy_binary_protocol::responses::messages::{
         PartitionRouteResponse, PollMessagesResponseHeader,
     };
     use iggy_binary_protocol::{WireDecode, WireEncode, WireIdentifier};
     use iggy_common::{
-        BinaryTransport, Client, ClientState, Consumer, Credentials, Identifier, IggyDuration,
-        IggyMessage, MessageClient, Partitioning, PersonalAccessTokenClient, PollingStrategy,
-        SystemClient, TcpClientReconnectionConfig, TopicClient, TopicUpdateOptions, UserClient,
-        UserUpdateOptions, with_binary_deadline,
+        BinaryTransport, Client, ClientState, CommitPolicy, Consumer, Credentials, Identifier,
+        IggyDuration, IggyMessage, MessageClient, Partitioning, PersonalAccessTokenClient,
+        PollOptions, PollingStrategy, SystemClient, TcpClientReconnectionConfig, TopicClient,
+        TopicUpdateOptions, UserClient, UserUpdateOptions, with_binary_deadline,
     };
     use secrecy::{ExposeSecret, SecretString};
     use std::time::Duration;
@@ -603,9 +601,9 @@
 
     #[tokio::test(start_paused = true)]
     async fn given_a_sent_progress_poll_when_signing_in_should_keep_its_lineage_uncertain() {
-        for (auto_commit, strategy) in [
-            (true, PollingStrategy::offset(0)),
-            (false, PollingStrategy::next()),
+        for (commit, strategy) in [
+            (CommitPolicy::OnRead, PollingStrategy::offset(0)),
+            (CommitPolicy::Manual, PollingStrategy::next()),
         ] {
             for answer in [
                 Answer::Body(
@@ -629,37 +627,46 @@
                     partitions_count: 1,
                     primary_replica: 0,
                 });
-                cluster.script(&endpoint(0), POLL_MESSAGES_CODE, [answer]);
-                cluster.hold(POLL_MESSAGES_CODE);
+                cluster.script(&endpoint(0), POLL_MESSAGES_DEFERRED_CODE, [answer]);
+                cluster.hold(POLL_MESSAGES_DEFERRED_CODE);
                 let pending = tokio::spawn({
                     let client = Arc::clone(&client);
                     async move {
                         let id = Identifier::numeric(1).unwrap();
+                        let options = PollOptions {
+                            count: 1,
+                            commit,
+                            ..PollOptions::default()
+                        };
                         client
                             .poll_messages(
                                 &id,
                                 &id,
-                                Some(0),
+                                0,
                                 &Consumer::new(id.clone()),
                                 &strategy,
-                                1,
-                                auto_commit,
+                                options,
                             )
                             .await
                     }
                 });
                 eventually("the progress poll arrives", || {
-                    cluster.count(POLL_MESSAGES_CODE) == 1
+                    cluster.count(POLL_MESSAGES_DEFERRED_CODE) == 1
                 })
                 .await;
                 client.login_user(USERNAME, PASSWORD).await.unwrap();
-                cluster.release(POLL_MESSAGES_CODE);
+                cluster.release(POLL_MESSAGES_DEFERRED_CODE);
 
-                assert_eq!(
-                    pending.await.unwrap().unwrap_err(),
-                    IggyError::TransientNotCommitted
-                );
-                assert_eq!(cluster.count(POLL_MESSAGES_CODE), 1);
+                let error = pending.await.unwrap().unwrap_err();
+                if commit == CommitPolicy::OnRead {
+                    let operation = cluster.received(POLL_MESSAGES_DEFERRED_CODE)[0]
+                        .header
+                        .request;
+                    assert_eq!(error, IggyError::FetchProgressUncertain(operation));
+                } else {
+                    assert_eq!(error, IggyError::TransientNotCommitted);
+                }
+                assert_eq!(cluster.count(POLL_MESSAGES_DEFERRED_CODE), 1);
             }
         }
     }
@@ -712,144 +719,6 @@
     }
 
     #[tokio::test(start_paused = true)]
-    async fn given_a_group_sync_when_signing_in_should_end_the_poll_lineage() {
-        let cluster = FakeCluster::new(1);
-        let client = Arc::new(signed_in_client(&cluster).await);
-        *cluster.route.lock().unwrap() = Some(PartitionRouteResponse {
-            incarnation: 7,
-            metadata_op: 11,
-            view: 0,
-            partitions_count: 1,
-            primary_replica: 0,
-        });
-        cluster.script(
-            &endpoint(0),
-            SYNC_CONSUMER_GROUP_CODE,
-            [Answer::Body(
-                SyncConsumerGroupResponse {
-                    generation: 1,
-                    partitions: vec![0],
-                }
-                .to_bytes()
-                .to_vec(),
-            )],
-        );
-        cluster.script(
-            &endpoint(0),
-            POLL_MESSAGES_CODE,
-            [Answer::Body(
-                PollMessagesResponseHeader {
-                    partition_id: 0,
-                    current_offset: 0,
-                    messages_count: 0,
-                    context: Default::default(),
-                }
-                .to_bytes()
-                .to_vec(),
-            )],
-        );
-        cluster.hold(SYNC_CONSUMER_GROUP_CODE);
-        let pending = tokio::spawn({
-            let client = Arc::clone(&client);
-            async move {
-                let id = Identifier::numeric(1).unwrap();
-                client
-                    .poll_messages(
-                        &id,
-                        &id,
-                        None,
-                        &Consumer::group(id.clone()),
-                        &PollingStrategy::offset(0),
-                        1,
-                        false,
-                    )
-                    .await
-            }
-        });
-        eventually("the group sync arrives", || {
-            cluster.count(SYNC_CONSUMER_GROUP_CODE) == 1
-        })
-        .await;
-        client.login_user(USERNAME, PASSWORD).await.unwrap();
-        cluster.release(SYNC_CONSUMER_GROUP_CODE);
-
-        assert_eq!(
-            pending.await.unwrap().unwrap_err(),
-            IggyError::Unauthenticated
-        );
-        assert_eq!(cluster.count(POLL_MESSAGES_CODE), 0);
-        assert!(!client.consumer_group_state().has_assignment("1|1|1"));
-    }
-
-    #[tokio::test(start_paused = true)]
-    async fn given_a_group_sync_when_the_session_recovers_should_preserve_the_poll_lineage() {
-        let cluster = FakeCluster::new(1);
-        let client = signed_in_client(&cluster).await;
-        *cluster.route.lock().unwrap() = Some(PartitionRouteResponse {
-            incarnation: 7,
-            metadata_op: 11,
-            view: 0,
-            partitions_count: 1,
-            primary_replica: 0,
-        });
-        cluster.script(
-            &endpoint(0),
-            SYNC_CONSUMER_GROUP_CODE,
-            [
-                Answer::Refuse(IggyError::Unauthenticated),
-                Answer::Body(
-                    SyncConsumerGroupResponse {
-                        generation: 1,
-                        partitions: vec![0],
-                    }
-                    .to_bytes()
-                    .to_vec(),
-                ),
-                Answer::Body(
-                    SyncConsumerGroupResponse {
-                        generation: 1,
-                        partitions: vec![0],
-                    }
-                    .to_bytes()
-                    .to_vec(),
-                ),
-            ],
-        );
-        cluster.script(
-            &endpoint(0),
-            POLL_MESSAGES_CODE,
-            [Answer::Body(
-                PollMessagesResponseHeader {
-                    partition_id: 0,
-                    current_offset: 0,
-                    messages_count: 0,
-                    context: Default::default(),
-                }
-                .to_bytes()
-                .to_vec(),
-            )],
-        );
-        *cluster.session.lock().unwrap() = None;
-        let id = Identifier::numeric(1).unwrap();
-        client
-            .poll_messages(
-                &id,
-                &id,
-                None,
-                &Consumer::group(id.clone()),
-                &PollingStrategy::offset(0),
-                1,
-                false,
-            )
-            .await
-            .unwrap();
-
-        assert_eq!(cluster.count(LOGIN_REGISTER_CODE), 2);
-        assert_eq!(cluster.count(SYNC_CONSUMER_GROUP_CODE), 3);
-        assert_eq!(cluster.count(POLL_MESSAGES_CODE), 1);
-    }
-
-    #[tokio::test(start_paused = true)]
     async fn given_no_connection_when_explicitly_signing_in_should_bootstrap_before_register() {
         for token in [false, true] {
             let cluster = FakeCluster::new(1);
diff --git a/core/server/Cargo.toml b/core/server/Cargo.toml
index c90ed37..1a4982b 100644
--- a/core/server/Cargo.toml
+++ b/core/server/Cargo.toml
@@ -17,7 +17,7 @@
 
 [package]
 name = "server"
-version = "0.9.1-edge.4"
+version = "0.9.1-edge.5"
 edition = "2024"
 license = "Apache-2.0"
 publish = false
diff --git a/core/server/src/dispatch/authz.rs b/core/server/src/dispatch/authz.rs
index 36b1b33..7a71893 100644
--- a/core/server/src/dispatch/authz.rs
+++ b/core/server/src/dispatch/authz.rs
@@ -232,6 +232,26 @@
     S: 'static,
     SB: SuperblockStore + 'static,
 {
+    authorize_served_topic_read(shard, attachment, namespace, rule)?;
+    authorize_served_incarnation(shard, namespace, incarnation)
+}
+
+/// The session and permission half of [`authorize_served_partition_read`]. A
+/// fetch runs it once, because all of its partitions share one stream and
+/// topic.
+pub(in crate::dispatch) fn authorize_served_topic_read<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
+    attachment: Option<&SessionAttachment>,
+    namespace: IggyNamespace,
+    rule: impl FnOnce(&Permissioner, u32, usize, usize) -> Result<(), IggyError>,
+) -> Result<(), IggyError>
+where
+    B: ShellBus,
+    MJ: JournalHandle + 'static,
+    MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+    S: 'static,
+    SB: SuperblockStore + 'static,
+{
     authorize_attached(attachment, || {
         authorize_uid(
             shard,
@@ -244,21 +264,37 @@
                     namespace.topic_id(),
                 )
             },
-        )?;
-        let current = shard
-            .plane
-            .metadata()
-            .mux_stm
-            .streams()
-            .created_revision_for_namespace(namespace);
-        if current == Some(incarnation) {
-            Ok(())
-        } else {
-            Err(IggyError::TransientNotAccepted)
-        }
+        )
     })
 }
 
+/// The history half of [`authorize_served_partition_read`], which a fetch
+/// runs for each of its sections.
+pub(in crate::dispatch) fn authorize_served_incarnation<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
+    namespace: IggyNamespace,
+    incarnation: u64,
+) -> Result<(), IggyError>
+where
+    B: ShellBus,
+    MJ: JournalHandle + 'static,
+    MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+    S: 'static,
+    SB: SuperblockStore + 'static,
+{
+    let current = shard
+        .plane
+        .metadata()
+        .mux_stm
+        .streams()
+        .created_revision_for_namespace(namespace);
+    if current == Some(incarnation) {
+        Ok(())
+    } else {
+        Err(IggyError::TransientNotAccepted)
+    }
+}
+
 /// Authorize a non-replicated read routed through `build_non_replicated_response`
 /// (`handle_default_non_replicated`). `Ok(())` allows -- including a resolution
 /// miss, which falls through to the builder's own not-found reply so the legacy
@@ -462,17 +498,18 @@
     use crate::dispatch::test_support::{FIRST_BOOT, SpyBus, TestShard, test_shard};
     use iggy_binary_protocol::COMMAND_TABLE;
     use iggy_binary_protocol::codes::{
-        BIND_SESSION_CODE, CANCEL_DEFERRED_REQUEST_CODE, CREATE_STREAM_CODE, GET_CLIENT_CODE,
-        GET_CLIENTS_CODE, GET_CONSUMER_OFFSET_CODE, GET_ME_CODE, GET_METADATA_CHANGES_CODE,
-        GET_PARTITION_ROUTE_CODE, GET_SNAPSHOT_FILE_CODE, LOGIN_REGISTER_CODE,
-        LOGIN_REGISTER_WITH_PAT_CODE, LOGIN_USER_CODE, LOGIN_WITH_PERSONAL_ACCESS_TOKEN_CODE,
-        LOGOUT_USER_CODE, PING_CODE, POLL_MESSAGES_CODE, POLL_MESSAGES_DEFERRED_CODE,
-        SYNC_CONSUMER_GROUP_CODE, WAIT_FOR_MESSAGES_CODE,
+        BIND_SESSION_CODE, CANCEL_DEFERRED_REQUEST_CODE, CREATE_STREAM_CODE, FETCH_MESSAGES_CODE,
+        GET_CLIENT_CODE, GET_CLIENTS_CODE, GET_CONSUMER_OFFSET_CODE, GET_ME_CODE,
+        GET_METADATA_CHANGES_CODE, GET_PARTITION_ROUTE_CODE, GET_SNAPSHOT_FILE_CODE,
+        LOGIN_REGISTER_CODE, LOGIN_REGISTER_WITH_PAT_CODE, LOGIN_USER_CODE,
+        LOGIN_WITH_PERSONAL_ACCESS_TOKEN_CODE, LOGOUT_USER_CODE, PING_CODE, POLL_MESSAGES_CODE,
+        POLL_MESSAGES_DEFERRED_CODE, SYNC_CONSUMER_GROUP_CODE,
     };
     use iggy_common::defaults::DEFAULT_ROOT_USER_ID;
 
     /// A code no `COMMAND_TABLE` entry claims.
     const UNKNOWN_CODE: u32 = 9999;
+    const RETIRED_WAIT_FOR_MESSAGES_CODE: u32 = 107;
 
     /// The gate's answer as the wire sees it: status 0 allows, anything else
     /// is the deny code stamped into `ReplyHeader.status`.
@@ -542,7 +579,7 @@
                 invalid_command,
                 invalid_command,
             ),
-            (WAIT_FOR_MESSAGES_CODE, invalid_command, invalid_command),
+            (FETCH_MESSAGES_CODE, invalid_command, invalid_command),
             (
                 CANCEL_DEFERRED_REQUEST_CODE,
                 invalid_command,
@@ -605,18 +642,20 @@
     }
 
     #[test]
-    fn unknown_code_is_refused_as_invalid_command() {
-        assert!(
-            lookup_command(UNKNOWN_CODE).is_none(),
-            "test needs a code absent from COMMAND_TABLE"
-        );
+    fn unknown_or_retired_code_is_refused_as_invalid_command() {
         let shard = gate_shard();
         let invalid_command = Verdict::Deny(IggyError::InvalidCommand.as_code());
-        assert_eq!(gate(&shard, UNKNOWN_CODE, None), invalid_command);
-        assert_eq!(
-            gate(&shard, UNKNOWN_CODE, Some(DEFAULT_ROOT_USER_ID)),
-            invalid_command
-        );
+        for code in [UNKNOWN_CODE, RETIRED_WAIT_FOR_MESSAGES_CODE] {
+            assert!(
+                lookup_command(code).is_none(),
+                "test needs a code absent from COMMAND_TABLE"
+            );
+            assert_eq!(gate(&shard, code, None), invalid_command);
+            assert_eq!(
+                gate(&shard, code, Some(DEFAULT_ROOT_USER_ID)),
+                invalid_command
+            );
+        }
     }
 
     #[test]
diff --git a/core/server/src/dispatch/failure.rs b/core/server/src/dispatch/failure.rs
index e7cf10b..a1e02a4 100644
--- a/core/server/src/dispatch/failure.rs
+++ b/core/server/src/dispatch/failure.rs
@@ -111,6 +111,8 @@
     /// The connection's drain took the request from its queue, so a frame
     /// for it waits for room in a full reply queue.
     queued: bool,
+    /// The monotonic time the frame arrived, where its client's timeout runs from.
+    arrived_us: u64,
 }
 
 impl ClientOrigin {
@@ -121,9 +123,14 @@
             id,
             instance: bus.client_instance(id),
             queued: false,
+            arrived_us: bus.monotonic_micros(),
         }
     }
 
+    pub(in crate::dispatch) const fn arrived_us(self) -> u64 {
+        self.arrived_us
+    }
+
     /// This origin for a request that the connection's drain runs. The drain
     /// runs at most `client_queue_capacity` request tasks and answers one
     /// frame itself at a time, so the replies that wait for room are bounded.
diff --git a/core/server/src/dispatch/fetch.rs b/core/server/src/dispatch/fetch.rs
new file mode 100644
index 0000000..f66023a
--- /dev/null
+++ b/core/server/src/dispatch/fetch.rs
@@ -0,0 +1,3845 @@
+// 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.
+
+//! `FetchMessages` (109): one request names partitions of one topic, waits
+//! until they hold messages, reads them and answers with one section per
+//! target.
+
+use crate::dispatch::authz::{
+    authorize_partition_read, authorize_served_incarnation, authorize_served_topic_read,
+};
+use crate::dispatch::partition::{fence_poll_context, polling_consumer_from_wire};
+use crate::namespace::resolve_partition_namespace;
+use crate::reply_frame::{
+    build_fetch_messages_reply, current_metadata_commit, fetch_section_records,
+};
+use crate::session_manager::{FetchReadGuard, SessionManager};
+use crate::shell::{ShellBus, ShellShard};
+use ahash::AHashMap;
+use consensus::MetadataHandle;
+use consensus::client_table::SessionAttachment;
+use futures::future::{Either, join, select};
+use futures::stream::{FuturesUnordered, StreamExt};
+use iggy_binary_protocol::primitives::partition_history::PartitionContext;
+use iggy_binary_protocol::primitives::polling_strategy::WirePollingStrategy;
+use iggy_binary_protocol::requests::messages::{
+    FETCH_REPLY_MARGIN_US, FetchMessagesRequest, MIN_DEFERRED_POLL_MAX_BYTES,
+};
+use iggy_binary_protocol::responses::messages::FetchMessagesSectionHeader;
+use iggy_binary_protocol::{
+    KIND_CONSUMER_GROUP, KIND_EXTERNAL_GROUP, PrepareHeader, RoutedRequestHeader, STATUS_OK,
+};
+use iggy_common::{DeferredPollOptions, EncryptorKind, IggyError, PollingStrategy};
+use journal::superblock::SuperblockStore;
+use journal::{Journal, JournalHandle};
+use message_bus::BusMessage;
+use metadata::impls::metadata::StreamsFrontend;
+use metadata::permissioner::Permissioner;
+use metadata::stm::stream::{PollMetadata, PollTarget};
+use partitions::{Fragment, PollFragments, PollingArgs, PollingConsumer};
+use server_common::iobuf::Frozen;
+use server_common::sharding::IggyNamespace;
+use server_common::{MESSAGE_ALIGN, Message};
+use shard::metrics::FetchRecord;
+use shard::shards_table::ShardsTable;
+use shard::{
+    ANY_MESSAGE, CommitGate, ConsumerOffsetRead, DeferredPollContext, DeferredPollRequest,
+    KNOWN_BYTES_SLACK, OversizedTurn, PartitionReadReply, PollQuotaIdentity, ReadinessTarget,
+    ReadyTarget,
+};
+use std::cell::RefCell;
+use std::collections::VecDeque;
+use std::iter;
+use std::mem;
+use std::pin::pin;
+use std::rc::Rc;
+use std::time::Duration;
+use tracing::error;
+
+#[allow(clippy::cast_possible_truncation)]
+const MIN_READ_BYTES: u32 = MIN_DEFERRED_POLL_MAX_BYTES as u32;
+
+/// The partition ids of the targets that a condition of their own refuses,
+/// with that condition.
+type RefusedTargets = Vec<(u32, IggyError)>;
+
+type Section = (FetchMessagesSectionHeader, Vec<Frozen<MESSAGE_ALIGN>>);
+
+/// Answers a fetch in four steps: resolve the targets once (D9), resolve the
+/// start of each `Next` target from its stored offset (D4), wait until the
+/// targets hold `min_count` messages, unless those reads already showed them,
+/// then read the ready targets that the budgets allot (D3, D10, D22). When any
+/// message ends the wait of `Next` targets, the read of the first one goes out
+/// with the stored-offset reads, and one that returns messages answers the
+/// fetch (see [`early_read`]).
+///
+/// The request timeout `T` starts with this future. Each read must commit
+/// before `T - 2 x FETCH_REPLY_MARGIN_US`, and the reply leaves at the latest
+/// at `T - FETCH_REPLY_MARGIN_US` with the sections that arrived, so a read
+/// that committed reaches the client (D6). Only a condition of the caller
+/// fails the request (see [`is_caller_condition`]). A condition of one target
+/// is its error section, and the other targets go on (D7). A wait that a
+/// target condition ends reads every target, so each read reports its own.
+#[allow(clippy::future_not_send)]
+pub(super) async fn fetch_messages<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
+    sessions: &Rc<RefCell<SessionManager>>,
+    request: &Message<RoutedRequestHeader>,
+    (wire, commit_gate): (&FetchMessagesRequest, Option<CommitGate>),
+    (consumer_client_id, attachment, user_id): (u128, &SessionAttachment, u32),
+) -> Result<(BusMessage, Vec<FetchReadGuard>), IggyError>
+where
+    B: ShellBus,
+    MJ: JournalHandle + 'static,
+    MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+    S: 'static,
+    SB: SuperblockStore + 'static,
+{
+    let started = shard.bus.monotonic_micros();
+    let timeout = started.saturating_add(wire.request_timeout_us);
+    let (mut targets, mut refused) =
+        resolve_fetch(shard, wire, request.header(), consumer_client_id, user_id)?;
+    let caller = DeferredPollContext {
+        session: Some(attachment.clone()),
+        user_id,
+        user_created_at: None,
+        quota: PollQuotaIdentity::Session(consumer_client_id),
+        primary: true,
+    };
+    let turn = OversizedTurn::default();
+    let early = early_read(shard, sessions, &targets, wire, consumer_client_id);
+    let (starts, (mut replies, mut held_reads)) = join(
+        next_starts(shard, &targets),
+        read_allotted(
+            shard,
+            &targets,
+            early.into_iter().collect(),
+            (caller.clone(), wire.auto_commit, commit_gate.clone()),
+            (started, timeout, turn.clone()),
+        ),
+    )
+    .await;
+    let first = replies.first_mut().and_then(Option::take);
+    let first_served =
+        matches!(&first, Some(PartitionReadReply::Poll { fragments, .. }) if !fragments.is_empty());
+    let (next_refused, first_kept) = apply_next_starts(&mut targets, starts, first_served)?;
+    refused.extend(next_refused);
+    let (replies, budget_waits, fired) = if first_served {
+        // The reply carries what that read found, and the next fetch reads the others first.
+        replies = iter::repeat_with(|| None).take(targets.len()).collect();
+        replies[0] = first;
+        (replies, 0, shard.bus.monotonic_micros())
+    } else {
+        let (replies, held, budget_waits, fired) = wait_and_read(
+            shard,
+            (sessions, consumer_client_id),
+            &targets,
+            wire,
+            (caller, commit_gate),
+            (started, timeout),
+            (turn, first.filter(|_| first_kept)),
+        )
+        .await?;
+        held_reads.extend(held);
+        (replies, budget_waits, fired)
+    };
+    let served = targets.first().map_or(Ok(()), |target| {
+        authorize_served_topic_read(
+            shard,
+            Some(attachment),
+            target.namespace,
+            Permissioner::poll_messages,
+        )
+    });
+    let commit = current_metadata_commit(shard);
+    let mut fetch = FetchRecord {
+        budget_waits,
+        ..FetchRecord::default()
+    };
+    let sections = served_sections(
+        fetch_sections(shard, &targets, replies, commit, &mut fetch),
+        served,
+        wire.auto_commit,
+    )?;
+    if fetch.bytes == 0
+        && fetch.quota_refusals > 0
+        && sections
+            .iter()
+            .all(|(header, _)| header.status == STATUS_OK)
+    {
+        shard.metrics().record_fetch(&fetch);
+        return Err(IggyError::DeferredPollCapacityExceeded);
+    }
+    let sections = in_request_order(wire, sections, &refused);
+    let reply = build_fetch_messages_reply(request.header(), commit, sections)?;
+    fetch.fire_to_reply = Duration::from_micros(shard.bus.monotonic_micros().saturating_sub(fired));
+    shard.metrics().record_fetch(&fetch);
+    Ok((reply, held_reads))
+}
+
+/// The wait and the reads of a fetch after its stored-offset reads, when a
+/// read of the first target beside them returned no messages. That read's
+/// reply `first` keeps its target out of the wait and the reads, unless it
+/// found the target empty, which stored nothing. Returns the replies by
+/// target, the room they hold, the reads that the budgets left for a later
+/// fetch and when the wait ended.
+#[allow(clippy::future_not_send, clippy::type_complexity)]
+async fn wait_and_read<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
+    (sessions, consumer_client_id): (&Rc<RefCell<SessionManager>>, u128),
+    targets: &[FetchTarget],
+    wire: &FetchMessagesRequest,
+    (caller, commit_gate): (DeferredPollContext, Option<CommitGate>),
+    (started, timeout): (u64, u64),
+    (turn, first): (OversizedTurn, Option<PartitionReadReply>),
+) -> Result<
+    (
+        Vec<Option<PartitionReadReply>>,
+        Vec<FetchReadGuard>,
+        u64,
+        u64,
+    ),
+    IggyError,
+>
+where
+    B: ShellBus,
+    MJ: JournalHandle + 'static,
+    MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+    S: 'static,
+    SB: SuperblockStore + 'static,
+{
+    let first = first.filter(
+        |reply| !matches!(reply, PartitionReadReply::Poll { fragments, .. } if fragments.is_empty()),
+    );
+    // A read that may have taken the oversized turn keeps it, any other read
+    // stored nothing and leaves a turn of its own to the reads after it.
+    let turn = match &first {
+        Some(PartitionReadReply::Rejected(IggyError::ShardCommunicationError)) => turn,
+        _ => OversizedTurn::default(),
+    };
+    // The wait ends where the request asked, whatever the stored-offset reads
+    // took, and never after the read deadline.
+    let wait_end = started
+        .saturating_add(shard.deferred_wait_us(wire.wait_us))
+        .min(timeout.saturating_sub(2 * FETCH_REPLY_MARGIN_US));
+    let waiting = &targets[usize::from(first.is_some()).min(targets.len())..];
+    let (ready_targets, every_target) =
+        wait_for_ready(shard, &caller, waiting, wire, wait_end).await?;
+    let fired = shard.bus.monotonic_micros();
+    let (allotted, budget_waits) = allot(
+        shard,
+        sessions,
+        targets,
+        (&ready_targets, every_target),
+        wire,
+        consumer_client_id,
+    );
+    // An empty reply would bring the next fetch back at once while the quota is still full.
+    if allotted.is_empty() && budget_waits > 0 {
+        shard.metrics().record_fetch(&FetchRecord {
+            budget_waits,
+            ..FetchRecord::default()
+        });
+        return Err(IggyError::DeferredPollCapacityExceeded);
+    }
+    let (mut replies, held_reads) = read_allotted(
+        shard,
+        targets,
+        allotted,
+        (caller, wire.auto_commit, commit_gate),
+        (started, timeout, turn),
+    )
+    .await;
+    if let (Some(first), Some(slot)) = (first, replies.first_mut()) {
+        *slot = Some(first);
+    }
+    Ok((replies, held_reads, budget_waits, fired))
+}
+
+/// The read of the first target of a fetch that goes out with its
+/// stored-offset reads: when every target is a `Next` target and any message
+/// ends the wait, a target that is behind returns its messages one owner round
+/// trip earlier, and the read of an empty one stores nothing. The targets come
+/// least recently read first, so the next fetch reads the others first. `None`
+/// when the fetch waits for more, or the session has no room for the read.
+fn early_read<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
+    sessions: &Rc<RefCell<SessionManager>>,
+    targets: &[FetchTarget],
+    wire: &FetchMessagesRequest,
+    consumer_client_id: u128,
+) -> Option<Read>
+where
+    B: ShellBus,
+    MJ: JournalHandle + 'static,
+    MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+    S: 'static,
+    SB: SuperblockStore + 'static,
+{
+    let first = targets.first()?;
+    if wire.min_count > ANY_MESSAGE || !targets.iter().all(|target| target.next) {
+        return None;
+    }
+    let limits = shard.deferred_poll_config();
+    let max_bytes = wire
+        .max_bytes
+        .min(u32::try_from(limits.max_read_bytes).unwrap_or(u32::MAX))
+        .min(first.max_bytes);
+    if max_bytes < MIN_READ_BYTES {
+        return None;
+    }
+    let owner = shard.shards_table().shard_for(first.namespace)?;
+    let reservation = SessionManager::reserve_fetch_read(
+        sessions,
+        (consumer_client_id, owner),
+        max_bytes as usize,
+        &limits,
+    )?;
+    Some(Read {
+        index: 0,
+        count: wire.count,
+        max_bytes,
+        covered: false,
+        reservation: Some(reservation),
+    })
+}
+
+/// The ready targets of a fetch at the end of its wait, and whether each
+/// target reads a share because a condition of a target ended the wait, so
+/// that each read reports its own. Only a condition of the caller fails.
+#[allow(clippy::future_not_send)]
+async fn wait_for_ready<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
+    caller: &DeferredPollContext,
+    targets: &[FetchTarget],
+    wire: &FetchMessagesRequest,
+    wait_end: u64,
+) -> Result<(Vec<ReadyTarget>, bool), IggyError>
+where
+    B: ShellBus,
+    MJ: JournalHandle + 'static,
+    MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+    S: 'static,
+    SB: SuperblockStore + 'static,
+{
+    if targets.is_empty() {
+        return Ok((Vec::new(), false));
+    }
+    if let Some(ready) = next_backlog(targets, wire.min_count) {
+        return Ok((ready, false));
+    }
+    match shard
+        .wait_for_messages(
+            caller.clone(),
+            targets.iter().map(FetchTarget::readiness).collect(),
+            Duration::from_micros(wait_end.saturating_sub(shard.bus.monotonic_micros())),
+            wire.min_count,
+        )
+        .await
+    {
+        Ok(ready) => Ok((ready, false)),
+        Err(error) if is_caller_condition(&error) => Err(error),
+        Err(_) => Ok((
+            targets
+                .iter()
+                .map(|target| ReadyTarget {
+                    partition_id: target.partition_id,
+                    available: u64::from(wire.count),
+                    known_bytes: None,
+                })
+                .collect(),
+            true,
+        )),
+    }
+}
+
+/// The sections of a fetch whose caller check after the reads ended with
+/// `served`. A session or permission lost during the reads fails the fetch
+/// only while no read stored an offset. Otherwise the stored sections reach
+/// the client and the others report the loss (D15).
+fn served_sections(
+    sections: Vec<Section>,
+    served: Result<(), IggyError>,
+    auto_commit: bool,
+) -> Result<Vec<Section>, IggyError> {
+    let Err(error) = served else {
+        return Ok(sections);
+    };
+    if !auto_commit
+        || sections
+            .iter()
+            .all(|(header, _)| header.messages_count == 0)
+    {
+        return Err(error);
+    }
+    Ok(sections
+        .into_iter()
+        .map(|section| {
+            if section.0.status == STATUS_OK && section.0.messages_count > 0 {
+                section
+            } else {
+                error_section(section.0.partition_id, &error)
+            }
+        })
+        .collect())
+}
+
+/// Runs the allotted reads and collects the replies that arrive before the
+/// reply deadline, by target, with the session room of each answered read
+/// settled to the bytes it returned. The reads that did not answer by then are
+/// dropped.
+///
+/// Only the first read holds the turn to return a first record above its
+/// bytes, alone, and every other read follows it. A read whose bytes
+/// cover the known bytes of its target cannot return one, so when the first
+/// read is such a read, every read starts with it. Otherwise the others start
+/// together once it answered, and after a first read that stored nothing the
+/// next one holds a turn of its own.
+#[allow(clippy::future_not_send)]
+async fn read_allotted<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
+    targets: &[FetchTarget],
+    allotted: Vec<Read>,
+    (caller, auto_commit, commit_gate): (DeferredPollContext, bool, Option<CommitGate>),
+    (started, timeout, oversized_turn): (u64, u64, OversizedTurn),
+) -> (Vec<Option<PartitionReadReply>>, Vec<FetchReadGuard>)
+where
+    B: ShellBus,
+    MJ: JournalHandle + 'static,
+    MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+    S: 'static,
+    SB: SuperblockStore + 'static,
+{
+    let read_deadline = timeout.saturating_sub(2 * FETCH_REPLY_MARGIN_US);
+    let reply_at = timeout.saturating_sub(FETCH_REPLY_MARGIN_US);
+    let mut replies: Vec<Option<PartitionReadReply>> =
+        iter::repeat_with(|| None).take(targets.len()).collect();
+    let mut waiting = VecDeque::from(allotted);
+    let start_read =
+        |read: Read, oversized_turn: OversizedTurn, replies: &mut [Option<PartitionReadReply>]| {
+            replies[read.index] = Some(PartitionReadReply::Rejected(
+                IggyError::ShardCommunicationError,
+            ));
+            let target = &targets[read.index];
+            let mut poll = target.read(&read, caller.clone(), auto_commit);
+            poll.commit_gate.clone_from(&commit_gate);
+            poll.stores_fenced_from = Some(started);
+            poll.oversized_turn = Some(oversized_turn);
+            async move {
+                let reply = shard
+                    .deferred_read(target.namespace, poll, read_deadline)
+                    .await;
+                (read.index, read.max_bytes, reply, read.reservation)
+            }
+        };
+    let mut pending = FuturesUnordered::new();
+    let mut first_answered = true;
+    if let Some(first) = waiting.pop_front() {
+        let covered = first.covered;
+        pending.push(start_read(first, oversized_turn.clone(), &mut replies));
+        if covered {
+            let follower = oversized_turn.follower();
+            pending.extend(
+                mem::take(&mut waiting)
+                    .into_iter()
+                    .map(|read| start_read(read, follower.clone(), &mut replies)),
+            );
+        } else {
+            first_answered = false;
+        }
+    }
+    let mut reply_timer = pin!(shard.bus.sleep(Duration::from_micros(
+        reply_at.saturating_sub(shard.bus.monotonic_micros())
+    )));
+    let mut held_reads = Vec::new();
+    while let Either::Left((Some((index, max_bytes, reply, reservation)), _)) =
+        select(pending.next(), reply_timer.as_mut()).await
+    {
+        let bytes = match &reply {
+            Some(PartitionReadReply::Poll { fragments, .. }) => {
+                fragments.iter().map(Fragment::len).sum()
+            }
+            _ => 0,
+        };
+        let uncommitted = match &reply {
+            Some(PartitionReadReply::Rejected(_)) => true,
+            Some(PartitionReadReply::Poll { fragments, .. }) => fragments.is_empty(),
+            _ => false,
+        };
+        if let Some(mut reservation) = reservation {
+            reservation.settle(bytes);
+            held_reads.push(reservation);
+        }
+        if let Some(reply) = reply {
+            replies[index] = Some(reply);
+        }
+        if !first_answered {
+            first_answered = true;
+            if bytes > (max_bytes as usize).saturating_sub(KNOWN_BYTES_SLACK as usize) {
+                // A first record above its bytes comes alone.
+                waiting.clear();
+            }
+            let turn = if uncommitted {
+                OversizedTurn::default()
+            } else {
+                oversized_turn.follower()
+            };
+            let mut rest = mem::take(&mut waiting).into_iter();
+            if let Some(next) = rest.next() {
+                pending.push(start_read(next, turn.clone(), &mut replies));
+            }
+            let follower = turn.follower();
+            pending.extend(rest.map(|read| start_read(read, follower.clone(), &mut replies)));
+        }
+    }
+    (replies, held_reads)
+}
+
+/// The sections of a fetch in request order, from the read replies (D4, D7).
+/// A served section must still hold the incarnation that its target named,
+/// else it answers `HistoryUnavailable`.
+fn fetch_sections<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
+    targets: &[FetchTarget],
+    replies: Vec<Option<PartitionReadReply>>,
+    commit: u64,
+    fetch: &mut FetchRecord,
+) -> Vec<Section>
+where
+    B: ShellBus,
+    MJ: JournalHandle + 'static,
+    MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+    S: 'static,
+    SB: SuperblockStore + 'static,
+{
+    let sections: Vec<Section> = targets
+        .iter()
+        .zip(replies)
+        .filter_map(|(target, reply)| {
+            let section = match reply {
+                Some(PartitionReadReply::Poll {
+                    context,
+                    fragments,
+                    current_offset,
+                    encryptor,
+                    next_offset,
+                }) => target.read_section(
+                    PartitionContext {
+                        metadata_op: context.metadata_op.max(commit),
+                        ..context
+                    },
+                    fragments,
+                    encryptor.as_deref(),
+                    current_offset,
+                    next_offset,
+                ),
+                Some(PartitionReadReply::Rejected(IggyError::DeferredPollCapacityExceeded)) => {
+                    fetch.quota_refusals += 1;
+                    Ok(target.start_section(commit)?)
+                }
+                Some(PartitionReadReply::Rejected(error)) => Err(error),
+                Some(PartitionReadReply::NotFound) => Err(IggyError::TransientNotAccepted),
+                Some(_) => Err(IggyError::ShardCommunicationError),
+                None => Ok(target.start_section(commit)?),
+            };
+            // The read may have stored into the named incarnation, so not `TransientNotAccepted`.
+            let section = section.and_then(|section| {
+                authorize_served_incarnation(shard, target.namespace, target.incarnation)
+                    .map(|()| section)
+                    .map_err(|_| IggyError::HistoryUnavailable)
+            });
+            Some(section.unwrap_or_else(|error| error_section(target.partition_id, &error)))
+        })
+        .collect();
+    fetch.sections = sections.len() as u64;
+    fetch.bytes = sections
+        .iter()
+        .map(|(header, _)| u64::from(header.records_len))
+        .sum();
+    sections
+}
+
+/// One target of a fetch, resolved once per request.
+struct FetchTarget {
+    partition_id: u32,
+    namespace: IggyNamespace,
+    consumer: PollingConsumer,
+    metadata: PollMetadata,
+    /// The incarnation that the request names, which a served section must
+    /// still hold.
+    incarnation: u64,
+    max_bytes: u32,
+    next: bool,
+    start: u64,
+    /// The current offset that the stored-offset read of a `Next` target
+    /// returned, for the section that carries its start.
+    resolved: Option<u64>,
+}
+
+impl FetchTarget {
+    /// The read of `read` from this target. The owner resolves a `Next`
+    /// start again when it admits the read and refuses a commit once another
+    /// session stored an offset, as for a deferred poll.
+    fn read(
+        &self,
+        read: &Read,
+        caller: DeferredPollContext,
+        auto_commit: bool,
+    ) -> DeferredPollRequest {
+        let strategy = if self.next {
+            PollingStrategy::next()
+        } else {
+            PollingStrategy::offset(self.start)
+        };
+        DeferredPollRequest::new(
+            self.consumer,
+            PollingArgs::new(strategy, read.count, auto_commit),
+            self.metadata,
+            caller,
+            DeferredPollOptions {
+                max_bytes: read.max_bytes,
+                ..Default::default()
+            },
+        )
+    }
+
+    fn readiness(&self) -> ReadinessTarget {
+        ReadinessTarget {
+            namespace: self.namespace,
+            partition_id: self.partition_id,
+            consumer: self.consumer,
+            strategy: PollingStrategy::offset(self.start),
+            metadata: self.metadata,
+        }
+    }
+
+    fn read_section(
+        &self,
+        context: PartitionContext,
+        fragments: PollFragments,
+        encryptor: Option<&EncryptorKind>,
+        current_offset: u64,
+        next_offset: Option<u64>,
+    ) -> Result<Section, IggyError> {
+        // An offset read always reports where the next one starts.
+        let Some(next_offset) = next_offset else {
+            error!(
+                partition_id = self.partition_id,
+                "fetch: an offset read returned no next offset"
+            );
+            return Err(IggyError::ShardCommunicationError);
+        };
+        let (records, messages_count) = fetch_section_records(fragments, encryptor)?;
+        let records_len = u32::try_from(records.iter().map(Frozen::len).sum::<usize>())
+            .map_err(|_| IggyError::InvalidCommand)?;
+        Ok((
+            FetchMessagesSectionHeader {
+                partition_id: self.partition_id,
+                status: STATUS_OK,
+                current_offset,
+                messages_count,
+                context,
+                next_offset,
+                records_len,
+            },
+            records,
+        ))
+    }
+
+    /// The section of a `Next` target that got no other one: no records, and
+    /// its resolved start as the next offset, so the client sends it as an
+    /// offset from now on (D4).
+    fn start_section(&self, commit: u64) -> Option<Section> {
+        Some((
+            FetchMessagesSectionHeader {
+                partition_id: self.partition_id,
+                status: STATUS_OK,
+                current_offset: self.resolved?,
+                messages_count: 0,
+                context: self.metadata.context(commit),
+                next_offset: self.start,
+                records_len: 0,
+            },
+            Vec::new(),
+        ))
+    }
+}
+
+/// A read that [`allot`] gave a target.
+struct Read {
+    index: usize,
+    count: u32,
+    max_bytes: u32,
+    /// Whether `max_bytes` covers the known bytes of the target with their
+    /// slack, so that the read cannot return a first record above it.
+    covered: bool,
+    /// `None` for a target that no shard owns, which `deferred_read` refuses.
+    reservation: Option<FetchReadGuard>,
+}
+
+/// The targets of a fetch that it can read, and those that a condition of
+/// their own refuses, with that condition.
+#[allow(clippy::cast_possible_truncation)]
+fn resolve_fetch<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
+    wire: &FetchMessagesRequest,
+    header: &RoutedRequestHeader,
+    consumer_client_id: u128,
+    user_id: u32,
+) -> Result<(Vec<FetchTarget>, RefusedTargets), IggyError>
+where
+    B: ShellBus,
+    MJ: JournalHandle + 'static,
+    MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+    S: 'static,
+    SB: SuperblockStore + 'static,
+{
+    if let Some(status) = authorize_partition_read(
+        shard,
+        &wire.stream_id,
+        &wire.topic_id,
+        Some(user_id),
+        |permissioner, user_id, stream_id, topic_id| {
+            permissioner.poll_messages(user_id, stream_id, topic_id)
+        },
+    ) {
+        return Err(IggyError::from_code(status));
+    }
+    if wire.consumer.kind == KIND_EXTERNAL_GROUP {
+        return Err(IggyError::FeatureUnavailable);
+    }
+    let group = (wire.consumer.kind == KIND_CONSUMER_GROUP).then_some(&wire.consumer.id);
+    let consumer = match group {
+        Some(_) => None,
+        None => Some(polling_consumer_from_wire(&wire.consumer, 0)?),
+    };
+    let partition_ids: Vec<u32> = wire
+        .targets
+        .iter()
+        .map(|target| target.partition_id)
+        .collect();
+    let (group_id, resolved) = shard.plane.metadata().mux_stm.streams().poll_targets(
+        &wire.stream_id,
+        &wire.topic_id,
+        group,
+        consumer_client_id,
+        &partition_ids,
+    );
+    let commit = current_metadata_commit(shard);
+    let mut targets = Vec::with_capacity(wire.targets.len());
+    let mut refused = Vec::new();
+    for (target, resolved) in wire.targets.iter().zip(resolved) {
+        let partition_id = target.partition_id;
+        let target = (|| {
+            let not_owned =
+                IggyError::ConsumerGroupPartitionNotOwned(consumer_client_id, partition_id);
+            let (namespace, metadata) = match resolved {
+                PollTarget::Pollable {
+                    namespace,
+                    metadata,
+                } => (namespace, metadata),
+                PollTarget::NotOwned => return Err(not_owned),
+                // The typed error names the level that is missing. The
+                // partition can appear between the two reads.
+                PollTarget::NotFound => {
+                    return Err(resolve_partition_namespace(
+                        shard,
+                        &wire.stream_id,
+                        &wire.topic_id,
+                        Some(partition_id),
+                    )
+                    .map_or_else(|error| error, |_| IggyError::TransientNotAccepted));
+                }
+                PollTarget::Unavailable => return Err(IggyError::TransientNotAccepted),
+            };
+            let local = metadata.context(commit);
+            fence_poll_context(
+                PartitionContext {
+                    incarnation: target.incarnation,
+                    owner_generation: if target.owner_generation == 0 {
+                        local.owner_generation
+                    } else {
+                        target.owner_generation
+                    },
+                    metadata_op: header.minimum_metadata_op,
+                },
+                local,
+                consumer_client_id,
+                partition_id,
+            )?;
+            let consumer = match (consumer, group_id) {
+                (Some(PollingConsumer::Consumer(id, _)), _) => {
+                    PollingConsumer::Consumer(id, partition_id as usize)
+                }
+                (None, Some(group_id)) => {
+                    PollingConsumer::ConsumerGroup(group_id as usize, partition_id as usize)
+                }
+                // A group that does not resolve owns no partition.
+                _ => return Err(not_owned),
+            };
+            let next = target.strategy.kind == WirePollingStrategy::next().kind;
+            Ok(FetchTarget {
+                partition_id,
+                namespace,
+                consumer,
+                metadata,
+                incarnation: target.incarnation,
+                max_bytes: target.max_bytes,
+                next,
+                start: if next { 0 } else { target.strategy.value },
+                resolved: None,
+            })
+        })();
+        match target {
+            Ok(target) => targets.push(target),
+            Err(error) if is_caller_condition(&error) => return Err(error),
+            Err(error) => refused.push((partition_id, error)),
+        }
+    }
+    Ok((targets, refused))
+}
+
+/// The start after the stored offset of each `Next` target, in target
+/// order, with the current offset of its partition (D4). One read for each
+/// owner shard, in place on this one.
+#[allow(clippy::future_not_send)]
+async fn next_starts<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
+    targets: &[FetchTarget],
+) -> Vec<Result<(u64, u64), IggyError>>
+where
+    B: ShellBus,
+    MJ: JournalHandle + 'static,
+    MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+    S: 'static,
+    SB: SuperblockStore + 'static,
+{
+    shard
+        .consumer_offsets_on_primary(
+            targets
+                .iter()
+                .filter(|target| target.next)
+                .map(|target| ConsumerOffsetRead {
+                    namespace: target.namespace,
+                    consumer: Some(target.consumer),
+                    metadata: target.metadata,
+                })
+                .collect(),
+        )
+        .await
+        .into_iter()
+        .map(|reply| match reply {
+            Some(PartitionReadReply::ConsumerOffset {
+                stored,
+                current_offset,
+            }) => stored
+                .map_or(Ok(0), |offset| {
+                    offset
+                        .checked_add(1)
+                        .ok_or(IggyError::InvalidOffset(offset))
+                })
+                .map(|start| (start, current_offset)),
+            Some(PartitionReadReply::Rejected(error)) => Err(error),
+            Some(PartitionReadReply::NotFound) => Err(IggyError::TransientNotAccepted),
+            _ => Err(IggyError::ShardCommunicationError),
+        })
+        .collect()
+}
+
+/// Sets the start of each `Next` target from `starts`. A target whose read
+/// was refused leaves `targets` and comes back with its refusal, unless its
+/// read already returned messages (`first_served` for the first target), and
+/// a refusal of the caller fails the fetch only while no read stored. Also
+/// returns whether the first target stays.
+fn apply_next_starts(
+    targets: &mut Vec<FetchTarget>,
+    starts: Vec<Result<(u64, u64), IggyError>>,
+    first_served: bool,
+) -> Result<(RefusedTargets, bool), IggyError> {
+    let mut refused = Vec::new();
+    for ((index, target), start) in targets
+        .iter_mut()
+        .enumerate()
+        .filter(|(_, target)| target.next)
+        .zip(starts)
+    {
+        match start {
+            Ok((start, current_offset)) => {
+                target.start = start;
+                target.resolved = Some(current_offset);
+            }
+            Err(_) if index == 0 && first_served => {}
+            Err(error) if is_caller_condition(&error) && !first_served => return Err(error),
+            Err(error) => refused.push((target.partition_id, error)),
+        }
+    }
+    let first = targets.first().map(|target| target.partition_id);
+    targets.retain(|target| {
+        !refused
+            .iter()
+            .any(|(partition_id, _)| *partition_id == target.partition_id)
+    });
+    let first_kept = first.is_some() && targets.first().map(|target| target.partition_id) == first;
+    Ok((refused, first_kept))
+}
+
+/// The ready targets of a fetch whose targets are all `Next` targets, when
+/// the current offsets that their stored-offset reads returned already hold
+/// `min_count` messages after their starts, so that a wait would end at once.
+fn next_backlog(targets: &[FetchTarget], min_count: u32) -> Option<Vec<ReadyTarget>> {
+    let mut held: u64 = 0;
+    let mut ready = Vec::with_capacity(targets.len());
+    for target in targets {
+        let current = target.resolved.filter(|_| target.next)?;
+        // An empty partition also reports the current offset 0.
+        let available = if current == 0 {
+            0
+        } else {
+            current.saturating_add(1).saturating_sub(target.start)
+        };
+        if available > 0 {
+            held = held.saturating_add(available);
+            ready.push(ReadyTarget {
+                partition_id: target.partition_id,
+                available,
+                known_bytes: None,
+            });
+        }
+    }
+    (held >= u64::from(min_count.max(1))).then_some(ready)
+}
+
+/// The reads of a fetch, and the ready targets that wait for a later fetch,
+/// in request order (D3, D22, D10).
+///
+/// [`split_fetch`] gives each ready target its messages and bytes, out of at
+/// most `max_read_bytes`, the reply cap of a deferred poll. A target wants its
+/// known bytes plus `KNOWN_BYTES_SLACK`, else its cap. The session must also
+/// have room for the read's bytes in what this shard holds for it at the
+/// target's owner. With `every_target`, each target gets a share.
+fn allot<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
+    sessions: &Rc<RefCell<SessionManager>>,
+    targets: &[FetchTarget],
+    (ready_targets, every_target): (&[ReadyTarget], bool),
+    wire: &FetchMessagesRequest,
+    consumer_client_id: u128,
+) -> (Vec<Read>, u64)
+where
+    B: ShellBus,
+    MJ: JournalHandle + 'static,
+    MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+    S: 'static,
+    SB: SuperblockStore + 'static,
+{
+    let ready: AHashMap<u32, &ReadyTarget> = ready_targets
+        .iter()
+        .map(|target| (target.partition_id, target))
+        .collect();
+    let wants: Vec<ReadWant> = targets
+        .iter()
+        .enumerate()
+        .filter_map(|(index, target)| {
+            let readiness = ready.get(&target.partition_id)?;
+            let bytes = readiness.known_bytes.map_or(target.max_bytes, |known| {
+                u32::try_from(known)
+                    .unwrap_or(u32::MAX)
+                    .saturating_add(KNOWN_BYTES_SLACK)
+                    .min(target.max_bytes)
+                    .max(MIN_READ_BYTES)
+            });
+            Some(ReadWant {
+                index,
+                available: u32::try_from(readiness.available).unwrap_or(u32::MAX),
+                bytes,
+            })
+        })
+        .collect();
+    let limits = shard.deferred_poll_config();
+    let reply_cap = u32::try_from(limits.max_read_bytes).unwrap_or(u32::MAX);
+    let planned = split_fetch(
+        &wants,
+        (wire.count, wire.max_bytes.min(reply_cap)),
+        every_target,
+    );
+    let mut budget_waits = (wants.len() - planned.len()) as u64;
+    let mut allotted = Vec::with_capacity(planned.len());
+    for read in planned {
+        let target = &targets[read.index];
+        let reservation =
+            shard
+                .shards_table()
+                .shard_for(target.namespace)
+                .map_or(Some(None), |owner| {
+                    SessionManager::reserve_fetch_read(
+                        sessions,
+                        (consumer_client_id, owner),
+                        read.max_bytes as usize,
+                        &limits,
+                    )
+                    .map(Some)
+                });
+        let Some(reservation) = reservation else {
+            budget_waits += 1;
+            continue;
+        };
+        let covered = ready
+            .get(&target.partition_id)
+            .and_then(|readiness| readiness.known_bytes)
+            .is_some_and(|known| {
+                u64::from(read.max_bytes) >= known.saturating_add(u64::from(KNOWN_BYTES_SLACK))
+            });
+        allotted.push(Read {
+            index: read.index,
+            count: read.count,
+            max_bytes: read.max_bytes,
+            covered,
+            reservation,
+        });
+    }
+    (allotted, budget_waits)
+}
+
+/// What one ready target of a fetch can read: its index among the targets, the
+/// messages it holds and the bytes it wants.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+struct ReadWant {
+    index: usize,
+    available: u32,
+    bytes: u32,
+}
+
+/// The messages and bytes that [`split_fetch`] gives one target.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+struct PlannedRead {
+    index: usize,
+    count: u32,
+    max_bytes: u32,
+}
+
+/// Splits `count` messages and `max_bytes` bytes over `wants`, in request
+/// order, where readers put the targets that they read last. First each
+/// target whose whole read fits an equal share of both gets it, so a backlog
+/// cannot hold back a target that holds little. Then the other targets read
+/// in request order, each as many messages as it holds and the part of the
+/// bytes left that its messages are of those the rest can take, at least the
+/// smallest read. A backlog that takes the count reads whole, so its read
+/// loads each batch once, and the next fetch, which sends it last, reads
+/// another. A target that the bytes left cannot read waits for a later fetch.
+/// With `every_target`, as after a wait that a target condition ended, each
+/// target reads at most a share, so that each reports its own condition.
+fn split_fetch(
+    wants: &[ReadWant],
+    (count, max_bytes): (u32, u32),
+    every_target: bool,
+) -> Vec<PlannedRead> {
+    let Ok(targets) = u32::try_from(wants.len()) else {
+        return Vec::new();
+    };
+    if targets == 0 {
+        return Vec::new();
+    }
+    let count_share = count.div_ceil(targets);
+    let bytes_share = (max_bytes / targets).max(MIN_READ_BYTES);
+    let shared = |want: &ReadWant| {
+        every_target || (want.available <= count_share && want.bytes <= bytes_share)
+    };
+    let mut count_left = count;
+    let mut bytes_left = max_bytes;
+    let mut planned: Vec<Option<PlannedRead>> = vec![None; wants.len()];
+    for (slot, want) in planned.iter_mut().zip(wants) {
+        let bytes = want.bytes.min(bytes_share).min(bytes_left);
+        if !shared(want) || count_left == 0 || bytes < MIN_READ_BYTES {
+            continue;
+        }
+        let read_count = want.available.min(count_share).min(count_left);
+        count_left -= read_count;
+        bytes_left -= bytes;
+        *slot = Some(PlannedRead {
+            index: want.index,
+            count: read_count,
+            max_bytes: bytes,
+        });
+    }
+    let mut held: u64 = wants
+        .iter()
+        .filter(|want| !shared(want))
+        .map(|want| u64::from(want.available))
+        .sum();
+    for (slot, want) in planned.iter_mut().zip(wants) {
+        if shared(want) {
+            continue;
+        }
+        let taken = held.min(u64::from(count_left)).max(1);
+        held -= u64::from(want.available);
+        let read_count = want.available.min(count_left);
+        let part = u64::from(bytes_left) * u64::from(read_count) / taken;
+        let bytes = want
+            .bytes
+            .min(u32::try_from(part).unwrap_or(u32::MAX).max(MIN_READ_BYTES))
+            .min(bytes_left);
+        if read_count == 0 || bytes < MIN_READ_BYTES {
+            continue;
+        }
+        count_left -= read_count;
+        bytes_left -= bytes;
+        *slot = Some(PlannedRead {
+            index: want.index,
+            count: read_count,
+            max_bytes: bytes,
+        });
+    }
+    planned.into_iter().flatten().collect()
+}
+
+/// Whether `error` concerns the caller of a fetch rather than one of its
+/// targets, so it fails the whole request.
+const fn is_caller_condition(error: &IggyError) -> bool {
+    matches!(
+        error,
+        IggyError::Unauthenticated
+            | IggyError::StaleClient
+            | IggyError::Unauthorized
+            | IggyError::InvalidCommand
+            | IggyError::FeatureUnavailable
+            | IggyError::StreamIdNotFound(..)
+            | IggyError::StreamNameNotFound(..)
+            | IggyError::TopicIdNotFound(..)
+            | IggyError::TopicNameNotFound(..)
+    )
+}
+
+/// The sections of `served` and the error sections of `refused`, in the order
+/// of the targets of `wire`.
+fn in_request_order(
+    wire: &FetchMessagesRequest,
+    served: Vec<Section>,
+    refused: &[(u32, IggyError)],
+) -> Vec<Section> {
+    if refused.is_empty() {
+        return served;
+    }
+    let mut sections: AHashMap<u32, Section> = served
+        .into_iter()
+        .map(|section| (section.0.partition_id, section))
+        .chain(
+            refused
+                .iter()
+                .map(|(partition_id, error)| (*partition_id, error_section(*partition_id, error))),
+        )
+        .collect();
+    wire.targets
+        .iter()
+        .filter_map(|target| sections.remove(&target.partition_id))
+        .collect()
+}
+
+fn error_section(partition_id: u32, error: &IggyError) -> Section {
+    (
+        FetchMessagesSectionHeader {
+            partition_id,
+            status: error.as_code(),
+            current_offset: 0,
+            messages_count: 0,
+            context: PartitionContext::default(),
+            next_offset: 0,
+            records_len: 0,
+        },
+        Vec::new(),
+    )
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::dispatch::failure::ClientOrigin;
+    use crate::dispatch::reads::handle_non_replicated_request;
+    use crate::dispatch::test_support::{
+        FIRST_BOOT, SpyBus, TestShard, attach_root_session, recreate_topic_in, request_message,
+        route_partitions_to, stored_batch_record, test_shard,
+    };
+    use crate::reply_frame::build_polled_messages_body;
+    use crate::session_manager::ConnectionContext;
+    use bytes::Bytes;
+    use configs::server::ServerConfig;
+    use futures::channel::oneshot;
+    use iggy_binary_protocol::codes::FETCH_MESSAGES_CODE;
+    use iggy_binary_protocol::requests::messages::{
+        FetchMessagesTarget, MIN_DEFERRED_POLL_REPLY_MARGIN_US,
+    };
+    use iggy_binary_protocol::responses::messages::poll_messages::POLL_RESPONSE_HEADER_SIZE;
+    use iggy_binary_protocol::responses::messages::{FetchMessagesResponse, FetchMessagesSection};
+    use iggy_binary_protocol::{Operation, ReplyHeader, WireConsumer, WireEncode, WireIdentifier};
+    use iggy_common::defaults::DEFAULT_ROOT_USER_ID;
+    use iggy_common::{Aes256GcmEncryptor, PollingKind};
+    use message_bus::MessageBus;
+    use message_bus::installer::conn_info::ClientTransportKind;
+    use partitions::Fragment;
+    use prometheus_client::encoding::text::encode;
+    use prometheus_client::registry::Registry;
+    use server_common::iobuf::Owned;
+    use server_common::send_messages::{
+        COMMAND_HEADER_SIZE, IggyMessage, IggyMessageHeader, IggyMessages, SendMessagesOwned,
+    };
+    use shard::config::DeferredPollConfig;
+    use shard::{
+        ANY_MESSAGE, LifecycleFrame, PartitionRead, ReadinessWait, Receiver, Sender, ShardFrame,
+        shard_channel,
+    };
+    use std::collections::BTreeMap;
+    use std::future::Future;
+    use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4};
+    use std::pin::Pin;
+    use std::sync::Arc;
+    use std::task::Poll;
+    use std::time::Instant;
+
+    const CLIENT: u128 = 1;
+    const SESSION: u64 = 1;
+    /// The shard that owns the partitions of a fixture, which is not the
+    /// shard that serves the fetch.
+    const OWNER: u16 = 1;
+    const REQUEST: u64 = 1;
+    /// The connection that sends a fetch on the deferred path.
+    const TRANSPORT: u128 = 91;
+    const PEER: SocketAddr = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0));
+    const CONSUMER_ID: u32 = 1;
+    /// Room for every frame that one fetch sends to the owner at once.
+    const INBOX_CAPACITY: usize = 16;
+    const WAIT_US: u64 = 1_000_000;
+    const REQUEST_TIMEOUT_US: u64 = WAIT_US + MIN_DEFERRED_POLL_REPLY_MARGIN_US;
+    const MIN_COUNT: u32 = 3;
+    const COUNT: u32 = 100;
+    const MAX_BYTES: u32 = 1024 * 1024;
+    /// Small enough that the request bytes hold a read of every target.
+    const TARGET_MAX_BYTES: u32 = 64 * 1024;
+    /// The applied metadata commit of the shard.
+    const COMMIT: u64 = 17;
+    /// The current offset that the owner reports with a served read.
+    const CURRENT_OFFSET: u64 = 99;
+    /// The messages of [`stored_batch_record`], at offsets 0 and 1.
+    const STORED_BATCH_MESSAGES: u32 = 2;
+
+    #[test]
+    fn given_ready_targets_with_backlogs_when_split_should_give_the_first_its_whole_read() {
+        // Each backlog got an equal share, so each read loaded whole batches
+        // from disk to return a fraction of one, and the next fetch loaded
+        // them again.
+        let mib = 1024 * 1024;
+        let wants = [0, 1, 2].map(|index| ReadWant {
+            index,
+            available: 10_000,
+            bytes: 4 * mib,
+        });
+
+        let planned = split_fetch(&wants, (900, 4 * mib), false);
+
+        assert_eq!(
+            planned,
+            vec![PlannedRead {
+                index: 0,
+                count: 900,
+                max_bytes: 4 * mib,
+            }]
+        );
+    }
+
+    #[test]
+    fn given_a_count_below_the_targets_when_split_should_read_it_from_one_backlog() {
+        // The count went one message per target, so ten reads loaded a batch
+        // each to return one message.
+        const TARGETS: usize = 24;
+        let mib = 1024 * 1024;
+        let wants: Vec<_> = (0..TARGETS)
+            .map(|index| ReadWant {
+                index,
+                available: 10_000,
+                bytes: mib,
+            })
+            .collect();
+
+        let planned = split_fetch(&wants, (10, mib), false);
+
+        assert_eq!(
+            planned,
+            vec![PlannedRead {
+                index: 0,
+                count: 10,
+                max_bytes: mib,
+            }]
+        );
+    }
+
+    #[test]
+    fn given_small_backlogs_when_split_should_give_each_the_bytes_of_the_messages_it_takes() {
+        // A backlog of a few messages without known bytes took every byte, so
+        // the next backlog read nothing although the count had room for it.
+        let mib = 1024 * 1024;
+        let wants = [0, 1, 2].map(|index| ReadWant {
+            index,
+            available: 5,
+            bytes: mib,
+        });
+
+        let planned = split_fetch(&wants, (10, mib), false);
+
+        assert_eq!(
+            planned,
+            vec![
+                PlannedRead {
+                    index: 0,
+                    count: 5,
+                    max_bytes: mib / 2,
+                },
+                PlannedRead {
+                    index: 1,
+                    count: 5,
+                    max_bytes: mib / 2,
+                },
+            ]
+        );
+    }
+
+    #[test]
+    fn given_backlogs_sent_least_recently_read_first_when_split_across_fetches_should_give_each_a_turn()
+     {
+        const TARGETS: usize = 24;
+        let mib = 1024 * 1024;
+        let mut order: Vec<usize> = (0..TARGETS).collect();
+        let mut turns = [0_u32; TARGETS];
+        for _ in 0..TARGETS {
+            let wants: Vec<_> = order
+                .iter()
+                .map(|&index| ReadWant {
+                    index,
+                    available: 10_000,
+                    bytes: mib,
+                })
+                .collect();
+
+            let planned = split_fetch(&wants, (1000, mib), false);
+
+            assert_eq!(planned.len(), 1, "a whole read takes the fetch");
+            turns[planned[0].index] += 1;
+            // A reader sends the targets that it read last at the end.
+            order.rotate_left(1);
+        }
+        assert_eq!(
+            turns, [1; TARGETS],
+            "every target reads once in as many fetches as there are targets"
+        );
+    }
+
+    #[test]
+    fn given_a_backlog_before_a_small_target_when_split_should_read_the_small_one_first() {
+        let mib = 1024 * 1024;
+        let wants = [
+            ReadWant {
+                index: 0,
+                available: 10_000,
+                bytes: 4 * mib,
+            },
+            ReadWant {
+                index: 1,
+                available: 5,
+                bytes: MIN_READ_BYTES,
+            },
+        ];
+
+        let planned = split_fetch(&wants, (1000, 4 * mib), false);
+
+        assert_eq!(
+            planned,
+            vec![
+                PlannedRead {
+                    index: 0,
+                    count: 995,
+                    max_bytes: 4 * mib - MIN_READ_BYTES,
+                },
+                PlannedRead {
+                    index: 1,
+                    count: 5,
+                    max_bytes: MIN_READ_BYTES,
+                },
+            ],
+            "a target that fits an equal share reads all it holds beside a backlog"
+        );
+    }
+
+    #[test]
+    fn given_a_small_target_beside_a_backlog_when_split_should_give_the_rest_in_request_order() {
+        let mib = 1024 * 1024;
+        let wants = [
+            ReadWant {
+                index: 0,
+                available: 5,
+                bytes: MIN_READ_BYTES,
+            },
+            ReadWant {
+                index: 1,
+                available: 10_000,
+                bytes: 4 * mib,
+            },
+        ];
+
+        let planned = split_fetch(&wants, (1000, 4 * mib), false);
+
+        assert_eq!(
+            planned,
+            vec![
+                PlannedRead {
+                    index: 0,
+                    count: 5,
+                    max_bytes: MIN_READ_BYTES,
+                },
+                PlannedRead {
+                    index: 1,
+                    count: 995,
+                    max_bytes: 4 * mib - MIN_READ_BYTES,
+                },
+            ]
+        );
+    }
+
+    #[test]
+    fn given_more_targets_than_bytes_when_split_should_leave_the_last_for_a_later_fetch() {
+        let wants = [0, 1, 2].map(|index| ReadWant {
+            index,
+            available: 10,
+            bytes: 2 * MIN_READ_BYTES,
+        });
+
+        let planned = split_fetch(&wants, (30, 2 * MIN_READ_BYTES), false);
+
+        assert_eq!(
+            planned,
+            [0, 1]
+                .map(|index| PlannedRead {
+                    index,
+                    count: 10,
+                    max_bytes: MIN_READ_BYTES,
+                })
+                .to_vec(),
+            "a target that the bytes left cannot read waits"
+        );
+    }
+
+    #[test]
+    fn given_a_wait_that_a_target_condition_ended_when_split_should_give_every_target_a_share() {
+        // Each target reads after such a wait to report its own condition, so
+        // a whole read of the first must not leave the others unread.
+        let mib = 1024 * 1024;
+        let wants = [0, 1, 2].map(|index| ReadWant {
+            index,
+            available: 900,
+            bytes: 4 * mib,
+        });
+
+        let planned = split_fetch(&wants, (900, 4 * mib), true);
+
+        assert_eq!(
+            planned,
+            [0, 1, 2]
+                .map(|index| PlannedRead {
+                    index,
+                    count: 300,
+                    max_bytes: 4 * mib / 3,
+                })
+                .to_vec()
+        );
+    }
+
+    #[compio::test]
+    async fn given_a_fetch_that_does_not_decode_when_served_should_deny_invalid_command() {
+        let fixture = Fixture::new(2);
+        let valid = fetch_of(vec![
+            fixture.target(0, WirePollingStrategy::offset(0)),
+            fixture.target(1, WirePollingStrategy::offset(0)),
+        ]);
+        let mut truncated = valid.to_bytes().to_vec();
+        truncated.pop();
+        let mut first = valid.clone();
+        first.targets[0].strategy = WirePollingStrategy::first();
+        let mut repeated = valid;
+        repeated.targets[1].partition_id = repeated.targets[0].partition_id;
+        for (body, case) in [
+            (truncated, "a truncated body"),
+            (first.to_bytes().to_vec(), "a First target"),
+            (repeated.to_bytes().to_vec(), "a repeated partition"),
+        ] {
+            fixture.bus.client_replies.borrow_mut().clear();
+            fixture.serve(&body).await;
+            let replies = fixture.bus.client_replies.borrow();
+            assert_eq!(replies.len(), 1, "{case} gets one reply");
+            let (client, frame) = &replies[0];
+            assert_eq!(*client, TRANSPORT);
+            assert_eq!(
+                reply_status(frame),
+                IggyError::InvalidCommand.as_code(),
+                "{case} is denied as an invalid command"
+            );
+        }
+        assert!(
+            fixture.owner_frames().is_empty(),
+            "a fetch that does not decode reaches no owner"
+        );
+    }
+
+    #[compio::test]
+    async fn given_a_committing_fetch_queued_past_its_timeout_when_served_should_refuse_it_unread()
+    {
+        // The timeout ran from the start of the handler, so a frame that waited
+        // in the drain after its client gave up still waited, read and stored
+        // offsets for a reply nobody took.
+        const ARRIVED: u64 = 1_000_000;
+        let fixture = Fixture::new(1);
+        fixture.sessions.borrow_mut().ensure_connection(
+            TRANSPORT,
+            PEER,
+            ClientTransportKind::Tcp,
+            Instant::now(),
+        );
+        fixture.bus.monotonic.set(Some(ARRIVED));
+        let origin = ClientOrigin::capture(&fixture.bus, TRANSPORT);
+        fixture
+            .bus
+            .monotonic
+            .set(Some(ARRIVED + REQUEST_TIMEOUT_US));
+        let wire = FetchMessagesRequest {
+            auto_commit: true,
+            ..fetch_of(vec![fixture.target(0, WirePollingStrategy::next())])
+        };
+
+        fixture.serve_from(origin, &wire.to_bytes()).await;
+
+        let replies = fixture.bus.client_replies.borrow();
+        assert_eq!(replies.len(), 1);
+        assert_eq!(
+            reply_status(&replies[0].1),
+            IggyError::TransientNotAccepted.as_code()
+        );
+        assert!(
+            fixture.owner_frames().is_empty(),
+            "a request its client gave up on reaches no owner"
+        );
+    }
+
+    #[compio::test]
+    async fn given_a_fetch_when_served_on_the_deferred_path_should_reply_with_its_sections() {
+        let fixture = Fixture::new(1);
+        fixture.sessions.borrow_mut().ensure_connection(
+            TRANSPORT,
+            PEER,
+            ClientTransportKind::Tcp,
+            Instant::now(),
+        );
+        let record = stored_batch_record(None);
+        let body = fetch_of(vec![fixture.target(0, WirePollingStrategy::offset(0))]).to_bytes();
+        let served_request = fixture.serve(&body);
+        let mut served_request = pin!(served_request);
+        assert!(futures::poll!(served_request.as_mut()).is_pending());
+        fixture.answer_wait(vec![ready(0, u64::from(STORED_BATCH_MESSAGES))]);
+        assert!(futures::poll!(served_request.as_mut()).is_pending());
+        fixture
+            .reads()
+            .remove(&0)
+            .expect("the ready target is read")
+            .answer(served(fixture.context(0), whole(&record)));
+        served_request.await;
+
+        let replies = fixture.bus.client_replies.borrow();
+        assert_eq!(replies.len(), 1, "the fetch gets one reply");
+        let (client, frame) = &replies[0];
+        assert_eq!(*client, TRANSPORT);
+        assert_eq!(reply_status(frame), STATUS_OK);
+        let sections = sections(frame);
+        let headers: Vec<_> = sections.iter().map(|section| section.header).collect();
+        assert_eq!(headers, [fixture.served_header(0, record.len())]);
+        assert_eq!(sections[0].records, record.as_slice());
+    }
+
+    #[compio::test]
+    async fn given_a_target_that_does_not_resolve_when_fetching_should_answer_its_error_section() {
+        const MISSING_PARTITION: u32 = 5;
+        let fixture = Fixture::new(2);
+        let target = |partition_id| fixture.target(partition_id, WirePollingStrategy::offset(0));
+        let missing = FetchMessagesTarget {
+            partition_id: MISSING_PARTITION,
+            ..target(1)
+        };
+        let wire = fetch_of(vec![target(0), missing]);
+        let fetch = fixture.fetch(&wire);
+        let mut fetch = pin!(fetch);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+
+        let wait = fixture.answer_wait(Vec::new());
+
+        assert_eq!(
+            wait.targets
+                .iter()
+                .map(|target| target.partition_id)
+                .collect::<Vec<_>>(),
+            [0],
+            "the other target still waits"
+        );
+        let Poll::Ready(reply) = futures::poll!(fetch.as_mut()) else {
+            panic!("a fetch without ready targets answers at the end of its wait");
+        };
+        let reply = reply.expect("the fetch succeeds").into_contiguous();
+        let statuses: Vec<_> = sections(reply.as_slice())
+            .iter()
+            .map(|section| section.header.partition_id)
+            .collect();
+        assert_eq!(
+            statuses,
+            [MISSING_PARTITION],
+            "a missing partition is its own section"
+        );
+
+        let stale = FetchMessagesTarget {
+            incarnation: fixture.context(1).incarnation + 1,
+            ..target(1)
+        };
+        let wire = fetch_of(vec![target(0), stale]);
+        let fetch = fixture.fetch(&wire);
+        let mut fetch = pin!(fetch);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        fixture.answer_wait(Vec::new());
+        let Poll::Ready(reply) = futures::poll!(fetch.as_mut()) else {
+            panic!("a fetch without ready targets answers at the end of its wait");
+        };
+        let reply = reply.expect("the fetch succeeds").into_contiguous();
+        let statuses: Vec<_> = sections(reply.as_slice())
+            .iter()
+            .map(|section| (section.header.partition_id, section.header.status))
+            .collect();
+        assert_eq!(
+            statuses,
+            [(1, IggyError::HistoryUnavailable.as_code())],
+            "a target fenced to another incarnation is its own error section"
+        );
+
+        let external = FetchMessagesRequest {
+            consumer: WireConsumer::external_group(WireIdentifier::numeric(CONSUMER_ID)),
+            ..fetch_of(vec![target(0)])
+        };
+        assert_eq!(
+            fixture.fetch(&external).await.err(),
+            Some(IggyError::FeatureUnavailable),
+            "an external group reads no partition"
+        );
+        assert!(
+            fixture.owner_frames().is_empty(),
+            "a fetch that does not resolve reaches no owner"
+        );
+    }
+
+    #[compio::test]
+    async fn given_a_ready_next_target_when_read_should_let_the_owner_resolve_and_fence_its_start()
+    {
+        // Every read went out as an offset, so the owner never checked the
+        // stored offset again and a committing read overwrote an offset that
+        // another session stored during the wait.
+        const STORED: u64 = 41;
+        let fixture = Fixture::new(1);
+        let wire = FetchMessagesRequest {
+            auto_commit: true,
+            ..fetch_of(vec![fixture.target(0, WirePollingStrategy::next())])
+        };
+        let fetch = fixture.fetch(&wire);
+        let mut fetch = pin!(fetch);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        fixture.answer_stored_offsets(BTreeMap::from([(
+            0,
+            PartitionReadReply::ConsumerOffset {
+                stored: Some(STORED),
+                current_offset: STORED,
+            },
+        )]));
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        fixture.answer_wait(vec![ready(0, 10)]);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+
+        let reads = fixture.reads();
+
+        assert_eq!(reads[&0].request.args.kind, PollingKind::Next);
+        assert!(reads[&0].request.args.auto_commit);
+    }
+
+    #[compio::test]
+    async fn given_next_targets_behind_and_any_message_ending_the_wait_when_fetching_should_read_the_first_with_the_stored_offsets()
+     {
+        // The read waited for the stored-offset reads, which only confirmed
+        // the backlog, so every read of a committing reader paid two owner
+        // round trips.
+        const STORED: u64 = 3;
+        let fixture = Fixture::new(2);
+        let record = stored_batch_record(None);
+        let wire = FetchMessagesRequest {
+            min_count: 1,
+            auto_commit: true,
+            ..fetch_of(vec![
+                fixture.target(0, WirePollingStrategy::next()),
+                fixture.target(1, WirePollingStrategy::next()),
+            ])
+        };
+        let fetch = fixture.fetch(&wire);
+        let mut fetch = pin!(fetch);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+
+        let (offsets, mut reads) = fixture.offsets_and_reads();
+
+        assert_eq!(
+            reads.keys().copied().collect::<Vec<_>>(),
+            [0],
+            "the first target is read beside the stored-offset reads"
+        );
+        let read = reads.remove(&0).expect("the first target is read");
+        assert_eq!(read.request.args.kind, PollingKind::Next);
+        assert!(read.request.args.auto_commit);
+        assert_eq!(read.request.args.count, COUNT);
+        read.answer(served(fixture.context(0), whole(&record)));
+        offsets.answer(BTreeMap::from([
+            (
+                0,
+                PartitionReadReply::ConsumerOffset {
+                    stored: Some(STORED),
+                    current_offset: CURRENT_OFFSET,
+                },
+            ),
+            (
+                1,
+                PartitionReadReply::ConsumerOffset {
+                    stored: Some(STORED),
+                    current_offset: CURRENT_OFFSET,
+                },
+            ),
+        ]));
+        let Poll::Ready(reply) = futures::poll!(fetch.as_mut()) else {
+            panic!("a read that returned messages answers the fetch");
+        };
+        let reply = reply.expect("the fetch succeeds").into_contiguous();
+        let headers: Vec<_> = sections(reply.as_slice())
+            .iter()
+            .map(|section| section.header)
+            .collect();
+        assert_eq!(
+            headers,
+            [
+                fixture.served_header(0, record.len()),
+                FetchMessagesSectionHeader {
+                    partition_id: 1,
+                    status: STATUS_OK,
+                    current_offset: CURRENT_OFFSET,
+                    messages_count: 0,
+                    context: fixture.context(1),
+                    next_offset: STORED + 1,
+                    records_len: 0,
+                },
+            ],
+            "the other target gets its resolved start"
+        );
+        assert!(
+            fixture.owner_frames().is_empty(),
+            "the fetch neither waits nor reads again"
+        );
+    }
+
+    #[compio::test]
+    async fn given_a_first_read_beside_the_stored_offsets_that_finds_nothing_when_fetching_should_wait_and_read_as_before()
+     {
+        let fixture = Fixture::new(2);
+        let record = stored_batch_record(None);
+        let wire = FetchMessagesRequest {
+            min_count: 1,
+            ..fetch_of(vec![
+                fixture.target(0, WirePollingStrategy::next()),
+                fixture.target(1, WirePollingStrategy::next()),
+            ])
+        };
+        let fetch = fixture.fetch(&wire);
+        let mut fetch = pin!(fetch);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        let (offsets, mut reads) = fixture.offsets_and_reads();
+        reads
+            .remove(&0)
+            .expect("the first target is read")
+            .answer(served(fixture.context(0), PollFragments::new()));
+        offsets.answer(BTreeMap::from([
+            (
+                0,
+                PartitionReadReply::ConsumerOffset {
+                    stored: Some(4),
+                    current_offset: 4,
+                },
+            ),
+            (
+                1,
+                PartitionReadReply::ConsumerOffset {
+                    stored: Some(4),
+                    current_offset: 4,
+                },
+            ),
+        ]));
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+
+        let wait = fixture.answer_wait(vec![ready(1, 1)]);
+
+        assert_eq!(
+            wait.targets
+                .iter()
+                .map(|target| target.partition_id)
+                .collect::<Vec<_>>(),
+            [0, 1],
+            "an empty first read stored nothing, so its target waits too"
+        );
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        fixture
+            .reads()
+            .remove(&1)
+            .expect("the ready target is read")
+            .answer(served(fixture.context(1), whole(&record)));
+        let Poll::Ready(reply) = futures::poll!(fetch.as_mut()) else {
+            panic!("the fetch answers once its read answered");
+        };
+        let reply = reply.expect("the fetch succeeds").into_contiguous();
+        let counts: Vec<_> = sections(reply.as_slice())
+            .iter()
+            .map(|section| (section.header.partition_id, section.header.messages_count))
+            .collect();
+        assert_eq!(counts, [(0, 0), (1, STORED_BATCH_MESSAGES)]);
+    }
+
+    #[compio::test]
+    async fn given_next_targets_whose_stored_offsets_show_min_count_messages_when_fetching_should_read_without_a_wait()
+     {
+        // The wait after the stored-offset reads found at once what those reads
+        // had already seen, so a backlog paid one more owner round trip.
+        for (current_offsets, waits) in [([9, 7], false), ([4, 0], true), ([5, 0], true)] {
+            let fixture = Fixture::new(2);
+            let wire = fetch_of(vec![
+                fixture.target(0, WirePollingStrategy::next()),
+                fixture.target(1, WirePollingStrategy::next()),
+            ]);
+            let fetch = fixture.fetch(&wire);
+            let mut fetch = pin!(fetch);
+            assert!(futures::poll!(fetch.as_mut()).is_pending());
+            fixture.answer_stored_offsets(BTreeMap::from([
+                (
+                    0,
+                    PartitionReadReply::ConsumerOffset {
+                        stored: Some(3),
+                        current_offset: current_offsets[0],
+                    },
+                ),
+                (
+                    1,
+                    PartitionReadReply::ConsumerOffset {
+                        stored: None,
+                        current_offset: current_offsets[1],
+                    },
+                ),
+            ]));
+            assert!(futures::poll!(fetch.as_mut()).is_pending());
+
+            let frames = fixture.owner_frames();
+
+            let waited = frames
+                .iter()
+                .any(|frame| matches!(frame, LifecycleFrame::ReadinessWait { .. }));
+            assert_eq!(waited, waits, "current offsets {current_offsets:?}");
+            if !waits {
+                let read: Vec<_> = frames
+                    .iter()
+                    .filter_map(|frame| match frame {
+                        LifecycleFrame::PartitionRead {
+                            namespace,
+                            read: PartitionRead::DeferredPoll(request),
+                            ..
+                        } => Some((namespace.partition_id(), request.args.kind)),
+                        _ => None,
+                    })
+                    .collect();
+                assert_eq!(read, [(0, PollingKind::Next)], "the first backlog is read");
+            }
+        }
+    }
+
+    #[compio::test]
+    async fn given_next_targets_when_no_target_is_ready_should_answer_each_at_its_resolved_start() {
+        const STORED: u64 = 41;
+        const STORED_CURRENT: u64 = 50;
+        const UNSTORED_CURRENT: u64 = 7;
+        const OFFSET_START: u64 = 3;
+        let fixture = Fixture::new(3);
+        let wire = fetch_of(vec![
+            fixture.target(0, WirePollingStrategy::next()),
+            fixture.target(1, WirePollingStrategy::next()),
+            fixture.target(2, WirePollingStrategy::offset(OFFSET_START)),
+        ]);
+        let fetch = fixture.fetch(&wire);
+        let mut fetch = pin!(fetch);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        fixture.answer_stored_offsets(BTreeMap::from([
+            (
+                0,
+                PartitionReadReply::ConsumerOffset {
+                    stored: Some(STORED),
+                    current_offset: STORED_CURRENT,
+                },
+            ),
+            (
+                1,
+                PartitionReadReply::ConsumerOffset {
+                    stored: None,
+                    current_offset: UNSTORED_CURRENT,
+                },
+            ),
+        ]));
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+
+        let wait = fixture.answer_wait(Vec::new());
+        let starts: Vec<_> = wait
+            .targets
+            .iter()
+            .map(|target| (target.partition_id, target.strategy))
+            .collect();
+        assert_eq!(
+            starts,
+            [
+                (0, PollingStrategy::offset(STORED + 1)),
+                (1, PollingStrategy::offset(0)),
+                (2, PollingStrategy::offset(OFFSET_START)),
+            ],
+            "the wait names each next target at the offset after its stored one"
+        );
+        assert_eq!(
+            wait.min_count, ANY_MESSAGE,
+            "an owner on another shard answers on its first ready target"
+        );
+        let Poll::Ready(reply) = futures::poll!(fetch.as_mut()) else {
+            panic!("a fetch without ready targets answers at the end of its wait");
+        };
+        let reply = reply.expect("the fetch succeeds").into_contiguous();
+        let start = |partition_id, current_offset, next_offset| FetchMessagesSectionHeader {
+            partition_id,
+            status: STATUS_OK,
+            current_offset,
+            messages_count: 0,
+            context: fixture.context(partition_id),
+            next_offset,
+            records_len: 0,
+        };
+        let headers: Vec<_> = sections(reply.as_slice())
+            .iter()
+            .map(|section| section.header)
+            .collect();
+        assert_eq!(
+            headers,
+            [
+                start(0, STORED_CURRENT, STORED + 1),
+                start(1, UNSTORED_CURRENT, 0)
+            ],
+            "each next target gets a section at its resolved start, an offset target none"
+        );
+        assert!(
+            fixture.owner_frames().is_empty(),
+            "a target that is not ready gets no read"
+        );
+    }
+
+    #[compio::test]
+    async fn given_ready_targets_when_the_count_is_short_should_read_the_small_ones_then_the_rest_in_order()
+     {
+        const FETCH_COUNT: u32 = 5;
+        const FIRST_AVAILABLE: u32 = 3;
+        const STARTS: [u64; 3] = [10, 20, 30];
+        let fixture = Fixture::new(3);
+        let wire = FetchMessagesRequest {
+            count: FETCH_COUNT,
+            auto_commit: true,
+            ..fetch_of(vec![
+                fixture.target(0, WirePollingStrategy::offset(STARTS[0])),
+                fixture.target(1, WirePollingStrategy::offset(STARTS[1])),
+                fixture.target(2, WirePollingStrategy::offset(STARTS[2])),
+            ])
+        };
+        let fetch = fixture.fetch(&wire);
+        let mut fetch = pin!(fetch);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        fixture.answer_wait(vec![
+            ready(0, u64::from(FIRST_AVAILABLE)),
+            // More messages than the count of one read can name.
+            ready(1, u64::from(u32::MAX) + 1),
+            ready(2, 1),
+        ]);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+
+        let reads = fixture.refuse_reads(fetch.as_mut(), 3).await;
+        let allotments: BTreeMap<_, _> = reads
+            .iter()
+            .map(|(partition_id, read)| (*partition_id, read.allotment()))
+            .collect();
+        assert_eq!(
+            allotments,
+            BTreeMap::from([
+                (0, (FIRST_AVAILABLE, STARTS[0], TARGET_MAX_BYTES)),
+                (1, (1, STARTS[1], TARGET_MAX_BYTES)),
+                (2, (1, STARTS[2], TARGET_MAX_BYTES)),
+            ]),
+            "a target that fits an equal share of the count reads it all, the others read in order"
+        );
+        for (partition_id, read) in &reads {
+            assert_eq!(
+                read.request.args.kind,
+                PollingKind::Offset,
+                "a read starts at its target offset"
+            );
+            assert!(
+                read.request.args.auto_commit,
+                "a read commits as the fetch asks"
+            );
+            assert_eq!(
+                read.request.consumer,
+                offset_key(*partition_id),
+                "a read commits under the offset key of its target"
+            );
+            assert_eq!(
+                read.request.context.quota,
+                PollQuotaIdentity::Session(CLIENT),
+                "a read is charged to the session"
+            );
+        }
+        drop(reads);
+        let Poll::Ready(reply) = futures::poll!(fetch.as_mut()) else {
+            panic!("the fetch answers once its reads ended");
+        };
+        assert!(reply.is_ok());
+        assert_scraped(&fixture.shard, "fetch_budget_waits_total 0");
+    }
+
+    #[compio::test]
+    async fn given_ready_targets_when_reserving_bytes_should_read_the_small_ones_then_share_the_rest_by_messages()
+     {
+        const CAP: u32 = 2048;
+        const NARROW_CAP: u32 = 1024;
+        // Below the smallest read, also with the slack.
+        const SMALL_KNOWN: u64 = 100;
+        // Above `NARROW_CAP`.
+        const LARGE_KNOWN: u64 = 10_000;
+        const FITTING_KNOWN: u32 = 500;
+        let fitting = FITTING_KNOWN + KNOWN_BYTES_SLACK;
+        let fixture = Fixture::new(5);
+        let capped = |partition_id: u32, max_bytes: u32| FetchMessagesTarget {
+            max_bytes,
+            ..fixture.target(partition_id, WirePollingStrategy::offset(0))
+        };
+        let max_bytes = MIN_READ_BYTES + NARROW_CAP + CAP + fitting;
+        let wire = FetchMessagesRequest {
+            max_bytes,
+            ..fetch_of(vec![
+                capped(0, CAP),
+                capped(1, NARROW_CAP),
+                capped(2, CAP),
+                capped(3, CAP),
+                capped(4, CAP),
+            ])
+        };
+        let known = |partition_id, known_bytes| ReadyTarget {
+            partition_id,
+            available: 1,
+            known_bytes: Some(known_bytes),
+        };
+        let fetch = fixture.fetch(&wire);
+        let mut fetch = pin!(fetch);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        fixture.answer_wait(vec![
+            known(0, SMALL_KNOWN),
+            known(1, LARGE_KNOWN),
+            ready(2, 1),
+            ready(3, 1),
+            known(4, u64::from(FITTING_KNOWN)),
+        ]);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+
+        let caps: BTreeMap<_, _> = fixture
+            .refuse_reads(fetch.as_mut(), 5)
+            .await
+            .iter()
+            .map(|(partition_id, read)| (*partition_id, read.request.options.max_bytes))
+            .collect();
+        // Known bytes and the slack, within the target cap and above the
+        // smallest read, fit an equal share and read first. The other targets
+        // hold one message each, so each takes a third of the bytes left,
+        // within its cap.
+        let third = (max_bytes - MIN_READ_BYTES - fitting) / 3;
+        assert_eq!(
+            caps,
+            BTreeMap::from([
+                (0, MIN_READ_BYTES),
+                (1, NARROW_CAP.min(third)),
+                (2, third),
+                (3, third),
+                (4, fitting)
+            ]),
+        );
+        let Poll::Ready(reply) = futures::poll!(fetch.as_mut()) else {
+            panic!("the fetch answers once its reads ended");
+        };
+        assert!(reply.is_ok());
+        assert_scraped(&fixture.shard, "fetch_budget_waits_total 0");
+    }
+
+    #[compio::test]
+    async fn given_a_committing_fetch_whose_session_ends_during_its_reads_should_keep_the_stored_sections()
+     {
+        // The check after the reads failed the whole fetch, so a section whose
+        // offset its read already stored never reached the client.
+        let fixture = Fixture::new(2);
+        let record = stored_batch_record(None);
+        let wire = FetchMessagesRequest {
+            auto_commit: true,
+            ..fetch_of(vec![
+                fixture.target(0, WirePollingStrategy::offset(0)),
+                fixture.target(1, WirePollingStrategy::offset(0)),
+            ])
+        };
+        let fetch = fixture.fetch(&wire);
+        let mut fetch = pin!(fetch);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        fixture.answer_wait(vec![ready(0, 1), ready(1, 1)]);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        let mut reads = fixture.reads();
+        reads
+            .remove(&0)
+            .expect("the ready target is read")
+            .answer(served(fixture.context(0), whole(&record)));
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        reads.extend(fixture.reads());
+        reads
+            .remove(&1)
+            .expect("the ready target is read")
+            .answer(served(fixture.context(1), PollFragments::new()));
+        assert!(
+            fixture
+                .shard
+                .plane
+                .metadata()
+                .client_table
+                .borrow_mut()
+                .end_session(CLIENT, DEFAULT_ROOT_USER_ID, SESSION, SESSION + 1)
+        );
+
+        let Poll::Ready(reply) = futures::poll!(fetch.as_mut()) else {
+            panic!("the fetch answers once its reads answered");
+        };
+        let reply = reply
+            .expect("a fetch whose reads stored offsets answers with them")
+            .into_contiguous();
+        let statuses: Vec<_> = sections(reply.as_slice())
+            .iter()
+            .map(|section| {
+                (
+                    section.header.partition_id,
+                    section.header.status,
+                    section.header.messages_count,
+                )
+            })
+            .collect();
+        assert_eq!(
+            statuses,
+            [
+                (0, STATUS_OK, STORED_BATCH_MESSAGES),
+                (1, IggyError::StaleClient.as_code(), 0),
+            ]
+        );
+    }
+
+    #[compio::test]
+    async fn given_a_plain_fetch_whose_session_ends_during_its_reads_should_fail_whole() {
+        let fixture = Fixture::new(1);
+        let record = stored_batch_record(None);
+        let wire = fetch_of(vec![fixture.target(0, WirePollingStrategy::offset(0))]);
+        let fetch = fixture.fetch(&wire);
+        let mut fetch = pin!(fetch);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        fixture.answer_wait(vec![ready(0, 1)]);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        fixture
+            .reads()
+            .remove(&0)
+            .expect("the ready target is read")
+            .answer(served(fixture.context(0), whole(&record)));
+        assert!(
+            fixture
+                .shard
+                .plane
+                .metadata()
+                .client_table
+                .borrow_mut()
+                .end_session(CLIENT, DEFAULT_ROOT_USER_ID, SESSION, SESSION + 1)
+        );
+
+        let Poll::Ready(reply) = futures::poll!(fetch.as_mut()) else {
+            panic!("the fetch answers once its read answered");
+        };
+        assert_eq!(
+            reply.err(),
+            Some(IggyError::StaleClient),
+            "a read that stored nothing needs no reply"
+        );
+    }
+
+    #[compio::test]
+    async fn given_slow_stored_offset_reads_when_waiting_should_end_the_wait_when_the_request_wait_ends()
+     {
+        // The wait got its whole time after the stored-offset reads, so a slow
+        // owner pushed its end past the read deadline.
+        const STARTED: u64 = 1_000_000;
+        const READS_TOOK: u64 = 300_000;
+        let fixture = Fixture::new(1);
+        fixture.bus.monotonic.set(Some(STARTED));
+        let wire = fetch_of(vec![fixture.target(0, WirePollingStrategy::next())]);
+        let fetch = fixture.fetch(&wire);
+        let mut fetch = pin!(fetch);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        fixture.bus.monotonic.set(Some(STARTED + READS_TOOK));
+        fixture.answer_stored_offsets(BTreeMap::from([(
+            0,
+            PartitionReadReply::ConsumerOffset {
+                stored: Some(0),
+                current_offset: 0,
+            },
+        )]));
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+
+        let wait = fixture.answer_wait(Vec::new());
+        assert_eq!(wait.deadline, STARTED + WAIT_US);
+    }
+
+    #[compio::test]
+    async fn given_a_delayed_first_section_when_fetching_should_resolve_it_before_starting_the_others_at_once()
+     {
+        // After an empty first read the next one started alone, so every
+        // empty target cost one more owner round trip.
+        let fixture = Fixture::new(4);
+        let record = stored_batch_record(None);
+        let wire = fetch_of(
+            (0..4)
+                .map(|partition| fixture.target(partition, WirePollingStrategy::offset(0)))
+                .collect(),
+        );
+        let fetch = fixture.fetch(&wire);
+        let mut fetch = pin!(fetch);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        fixture.answer_wait((0..4).map(|partition| ready(partition, 1)).collect());
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        let mut first = fixture.reads();
+        assert_eq!(first.keys().copied().collect::<Vec<_>>(), [0]);
+        first
+            .remove(&0)
+            .unwrap()
+            .answer(served(fixture.context(0), PollFragments::new()));
+
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        let remaining = fixture.reads();
+        assert_eq!(remaining.keys().copied().collect::<Vec<_>>(), [1, 2, 3]);
+        assert!(
+            remaining[&1]
+                .request
+                .oversized_turn
+                .as_ref()
+                .is_some_and(OversizedTurn::take),
+            "after a first read that stored nothing the next one holds a turn"
+        );
+        assert!(
+            [2, 3].iter().all(|partition| !remaining[partition]
+                .request
+                .oversized_turn
+                .as_ref()
+                .is_some_and(OversizedTurn::take)),
+            "the others follow it"
+        );
+        for (partition, read) in remaining {
+            read.answer(served(
+                fixture.context(u32::try_from(partition).unwrap()),
+                whole(&record),
+            ));
+        }
+        let Poll::Ready(reply) = futures::poll!(fetch.as_mut()) else {
+            panic!("all reads answered");
+        };
+        let reply = reply.unwrap().into_contiguous();
+        let count: u32 = sections(reply.as_slice())
+            .iter()
+            .map(|section| section.header.messages_count)
+            .sum();
+        assert_eq!(count, 3 * STORED_BATCH_MESSAGES);
+    }
+
+    #[compio::test]
+    async fn given_a_first_read_that_covers_the_known_bytes_of_its_target_when_fetching_should_start_every_read_at_once()
+     {
+        // Each read waited for the one before it, so every ready target cost
+        // one more owner round trip.
+        let fixture = Fixture::new(3);
+        let record = stored_batch_record(None);
+        let known_bytes = Some(u64::try_from(record.len()).expect("a record fits u64"));
+        let wire = fetch_of(
+            (0..3)
+                .map(|partition| fixture.target(partition, WirePollingStrategy::offset(0)))
+                .collect(),
+        );
+        let known = |partition_id| ReadyTarget {
+            partition_id,
+            available: 1,
+            known_bytes,
+        };
+        let fetch = fixture.fetch(&wire);
+        let mut fetch = pin!(fetch);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        fixture.answer_wait(vec![known(0), ready(1, 1), known(2)]);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+
+        let reads = fixture.reads();
+        assert_eq!(
+            reads.keys().copied().collect::<Vec<_>>(),
+            [0, 1, 2],
+            "a first read that cannot return a record above its bytes starts every read"
+        );
+        assert!(
+            [1, 2].iter().all(|partition| !reads[partition]
+                .request
+                .oversized_turn
+                .as_ref()
+                .is_some_and(OversizedTurn::take)),
+            "only the first read may take the turn"
+        );
+        for (partition, read) in reads {
+            read.answer(served(
+                fixture.context(u32::try_from(partition).expect("the partition fits u32")),
+                whole(&record),
+            ));
+        }
+        let Poll::Ready(reply) = futures::poll!(fetch.as_mut()) else {
+            panic!("all reads answered");
+        };
+        let reply = reply.expect("the fetch succeeds").into_contiguous();
+        let headers: Vec<_> = sections(reply.as_slice())
+            .iter()
+            .map(|section| section.header)
+            .collect();
+        assert_eq!(
+            headers,
+            (0..3)
+                .map(|partition| fixture.served_header(partition, record.len()))
+                .collect::<Vec<_>>()
+        );
+    }
+
+    #[compio::test]
+    async fn given_a_missing_first_reply_after_claiming_its_turn_when_fetching_should_keep_the_exception_closed()
+     {
+        let fixture = Fixture::new(2);
+        let wire = fetch_of(vec![
+            fixture.target(0, WirePollingStrategy::offset(0)),
+            fixture.target(1, WirePollingStrategy::offset(0)),
+        ]);
+        let fetch = fixture.fetch(&wire);
+        let mut fetch = pin!(fetch);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        fixture.answer_wait(vec![ready(0, 1), ready(1, 1)]);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        let first = fixture.reads().remove(&0).unwrap();
+        assert!(first.request.oversized_turn.as_ref().unwrap().take());
+        drop(first);
+
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        let next = fixture.reads().remove(&1).unwrap();
+        assert!(!next.request.oversized_turn.as_ref().unwrap().take());
+        next.answer(PartitionReadReply::Rejected(
+            IggyError::DeferredPollCapacityExceeded,
+        ));
+        let Poll::Ready(reply) = futures::poll!(fetch.as_mut()) else {
+            panic!("both reads ended");
+        };
+        let reply = reply
+            .expect("capacity must not hide an uncertain read")
+            .into_contiguous();
+        assert_eq!(
+            sections(reply.as_slice())[0].header.status,
+            IggyError::ShardCommunicationError.as_code()
+        );
+    }
+
+    #[compio::test]
+    async fn given_a_missing_owner_reply_when_fetching_should_report_uncertainty_only_for_the_started_target()
+     {
+        for timeout in [false, true] {
+            for auto_commit in [false, true] {
+                for next in [false, true] {
+                    let fixture = Fixture::new(3);
+                    fixture.bus.monotonic.set(Some(1_000_000));
+                    let strategy = if next {
+                        WirePollingStrategy::next()
+                    } else {
+                        WirePollingStrategy::offset(0)
+                    };
+                    let wire = FetchMessagesRequest {
+                        auto_commit,
+                        ..fetch_of(vec![
+                            fixture.target(0, WirePollingStrategy::offset(0)),
+                            fixture.target(1, strategy),
+                            fixture.target(2, WirePollingStrategy::next()),
+                        ])
+                    };
+                    let fetch = fixture.fetch(&wire);
+                    let mut fetch = pin!(fetch);
+                    assert!(futures::poll!(fetch.as_mut()).is_pending());
+                    let mut stored = BTreeMap::from([(
+                        2,
+                        PartitionReadReply::ConsumerOffset {
+                            stored: Some(2),
+                            current_offset: 9,
+                        },
+                    )]);
+                    if next {
+                        stored.insert(
+                            1,
+                            PartitionReadReply::ConsumerOffset {
+                                stored: Some(2),
+                                current_offset: 9,
+                            },
+                        );
+                    }
+                    fixture.answer_stored_offsets(stored);
+                    assert!(futures::poll!(fetch.as_mut()).is_pending());
+                    fixture.answer_wait(vec![ready(0, 2), ready(1, 1)]);
+                    let (deadline, held) = oneshot::channel();
+                    *fixture.bus.held_timer.borrow_mut() = Some(held);
+                    assert!(futures::poll!(fetch.as_mut()).is_pending());
+                    fixture.reads().remove(&0).unwrap().answer(served(
+                        fixture.context(0),
+                        whole(&stored_batch_record(None)),
+                    ));
+                    assert!(futures::poll!(fetch.as_mut()).is_pending());
+                    let missing = fixture.reads().remove(&1).unwrap();
+                    let held_read = if timeout {
+                        deadline.send(()).unwrap();
+                        Some(missing)
+                    } else {
+                        drop(missing);
+                        None
+                    };
+                    let Poll::Ready(reply) = futures::poll!(fetch.as_mut()) else {
+                        panic!("the missing reply ends or reaches its deadline");
+                    };
+                    let reply = reply.unwrap().into_contiguous();
+                    let sections = sections(reply.as_slice());
+                    let statuses: Vec<_> = sections
+                        .iter()
+                        .map(|section| {
+                            (
+                                section.header.partition_id,
+                                section.header.status,
+                                section.header.messages_count,
+                            )
+                        })
+                        .collect();
+                    assert_eq!(
+                        statuses,
+                        [
+                            (0, STATUS_OK, STORED_BATCH_MESSAGES),
+                            (1, IggyError::ShardCommunicationError.as_code(), 0),
+                            (2, STATUS_OK, 0)
+                        ],
+                        "timeout={timeout}, auto_commit={auto_commit}, next={next}"
+                    );
+                    assert_eq!(sections[2].header.next_offset, 3);
+                    assert!(fixture.owner_frames().is_empty());
+                    drop(held_read);
+                }
+            }
+        }
+    }
+
+    #[compio::test]
+    async fn given_a_first_read_refused_after_claiming_its_turn_when_fetching_should_preserve_the_exception()
+     {
+        let fixture = Fixture::new(2);
+        let wire = fetch_of(vec![
+            fixture.target(0, WirePollingStrategy::offset(0)),
+            fixture.target(1, WirePollingStrategy::offset(0)),
+        ]);
+        let fetch = fixture.fetch(&wire);
+        let mut fetch = pin!(fetch);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        fixture.answer_wait(vec![ready(0, 1), ready(1, 1)]);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        let first = fixture.reads().remove(&0).unwrap();
+        assert!(first.request.oversized_turn.as_ref().unwrap().take());
+        first.answer(PartitionReadReply::Rejected(IggyError::NotPrimary));
+
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        let next = fixture.reads().remove(&1).unwrap();
+        assert!(next.request.oversized_turn.as_ref().unwrap().take());
+        next.answer(served(
+            fixture.context(1),
+            whole(&stored_batch_record(None)),
+        ));
+        let Poll::Ready(reply) = futures::poll!(fetch.as_mut()) else {
+            panic!("both reads answered");
+        };
+        assert!(reply.is_ok());
+    }
+
+    #[compio::test]
+    async fn given_an_oversized_first_section_when_fetching_should_leave_later_targets_unread() {
+        let fixture = Fixture::new(3);
+        let cap = 1024;
+        fixture.shard.set_deferred_poll_config(DeferredPollConfig {
+            max_read_bytes: cap as usize,
+            ..Default::default()
+        });
+        let mut messages = IggyMessages::with_capacity(1);
+        messages.push(IggyMessage {
+            header: IggyMessageHeader::default(),
+            payload: Bytes::from(vec![42; 4096]),
+            user_headers: None,
+        });
+        let batch = SendMessagesOwned::from_messages(fixture.namespaces[0], &messages).unwrap();
+        let mut bytes = vec![0; batch.header.total_size()];
+        batch.header.encode_into(&mut bytes);
+        bytes[COMMAND_HEADER_SIZE..].copy_from_slice(&batch.blob);
+        let record: Frozen<MESSAGE_ALIGN> = Owned::copy_from_slice(&bytes).into();
+        let wire = FetchMessagesRequest {
+            max_bytes: cap,
+            auto_commit: true,
+            ..fetch_of(vec![
+                fixture.target(0, WirePollingStrategy::offset(0)),
+                fixture.target(1, WirePollingStrategy::next()),
+                fixture.target(2, WirePollingStrategy::offset(0)),
+            ])
+        };
+        let fetch = fixture.fetch_holding(&wire);
+        let mut fetch = pin!(fetch);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        fixture.answer_stored_offsets(BTreeMap::from([(
+            1,
+            PartitionReadReply::ConsumerOffset {
+                stored: Some(4),
+                current_offset: 9,
+            },
+        )]));
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        fixture.answer_wait((0..3).map(|partition| ready(partition, 1)).collect());
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        let mut reads = fixture.reads();
+        assert_eq!(reads.keys().copied().collect::<Vec<_>>(), [0]);
+        reads
+            .remove(&0)
+            .unwrap()
+            .answer(served(fixture.context(0), whole(&record)));
+        let Poll::Ready(reply) = futures::poll!(fetch.as_mut()) else {
+            panic!("the oversized record completes the fetch alone");
+        };
+        let (reply, held) = reply.unwrap();
+        let reply = reply.into_contiguous();
+        let sections = sections(reply.as_slice());
+        assert_eq!(sections.len(), 2);
+        assert_eq!(sections[0].header.messages_count, 1);
+        assert_eq!(sections[0].records.len(), record.len());
+        assert!(record.len() > cap as usize);
+        assert_eq!(sections[1].header.messages_count, 0);
+        assert_eq!(sections[1].header.next_offset, 5);
+        assert_eq!(held.len(), 1);
+        assert!(fixture.owner_frames().is_empty());
+    }
+
+    #[compio::test]
+    async fn given_a_session_read_cap_when_targets_share_an_owner_should_read_up_to_the_cap() {
+        assert_one_read_under(|| DeferredPollConfig {
+            max_pending_per_session: 1,
+            ..DeferredPollConfig::default()
+        })
+        .await;
+    }
+
+    #[compio::test]
+    async fn given_a_session_byte_budget_when_targets_share_an_owner_should_read_what_it_holds() {
+        assert_one_read_under(|| DeferredPollConfig {
+            max_inflight_bytes: TARGET_MAX_BYTES as usize,
+            ..DeferredPollConfig::default()
+        })
+        .await;
+    }
+
+    #[compio::test]
+    async fn given_a_fetch_above_the_reply_cap_when_split_should_read_at_most_the_cap() {
+        // A 109 reply held up to the request bytes, while a 106 reply stops at
+        // the server read cap.
+        let fixture = Fixture::new(2);
+        fixture.shard.set_deferred_poll_config(DeferredPollConfig {
+            max_read_bytes: TARGET_MAX_BYTES as usize,
+            ..DeferredPollConfig::default()
+        });
+        let wire = fetch_of(vec![
+            fixture.target(0, WirePollingStrategy::offset(0)),
+            fixture.target(1, WirePollingStrategy::offset(0)),
+        ]);
+        let fetch = fixture.fetch(&wire);
+        let mut fetch = pin!(fetch);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        fixture.answer_wait(vec![ready(0, 1), ready(1, 1)]);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+
+        let bytes: Vec<u32> = fixture
+            .refuse_reads(fetch.as_mut(), 2)
+            .await
+            .values()
+            .map(|read| read.allotment().2)
+            .collect();
+        assert_eq!(
+            bytes,
+            [TARGET_MAX_BYTES / 2, TARGET_MAX_BYTES / 2],
+            "two reads of one message each take half the cap each"
+        );
+    }
+
+    #[compio::test]
+    async fn given_an_answered_read_when_its_reply_is_held_should_charge_what_it_returned_until_dropped()
+     {
+        // A read was charged the cost of a disk read, so a session room of one
+        // reply could not hold even a small resident read.
+        let fixture = Fixture::new(1);
+        let limits = DeferredPollConfig {
+            max_inflight_bytes: TARGET_MAX_BYTES as usize,
+            ..DeferredPollConfig::default()
+        };
+        fixture.shard.set_deferred_poll_config(limits);
+        let record = stored_batch_record(None);
+        let wire = fetch_of(vec![fixture.target(0, WirePollingStrategy::offset(0))]);
+        let fetch = fixture.fetch_holding(&wire);
+        let mut fetch = pin!(fetch);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        fixture.answer_wait(vec![ready(0, 1)]);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        fixture
+            .reads()
+            .remove(&0)
+            .expect("the ready target is read")
+            .answer(served(fixture.context(0), whole(&record)));
+        let Poll::Ready(reply) = futures::poll!(fetch.as_mut()) else {
+            panic!("the fetch answers once its read answered");
+        };
+        let (_reply, held_reads) = reply.expect("the fetch succeeds");
+
+        let room = TARGET_MAX_BYTES as usize - record.len();
+        let reserve = |cost| {
+            SessionManager::reserve_fetch_read(&fixture.sessions, (CLIENT, OWNER), cost, &limits)
+        };
+        assert!(
+            reserve(room + 1).is_none(),
+            "the held reply keeps the bytes it returned"
+        );
+        assert!(
+            reserve(room).is_some(),
+            "the held reply keeps only the bytes it returned"
+        );
+        drop(held_reads);
+        assert!(
+            reserve(TARGET_MAX_BYTES as usize).is_some(),
+            "a written reply gives its room back"
+        );
+    }
+
+    /// Two ready targets on one owner, under `limits` that hold one read of
+    /// the session there. The second target waits for a later fetch, and the
+    /// read gives its room back when it ends.
+    async fn assert_one_read_under(limits: impl FnOnce() -> DeferredPollConfig) {
+        let fixture = Fixture::new(2);
+        let limits = limits();
+        fixture.shard.set_deferred_poll_config(limits);
+        let cost = TARGET_MAX_BYTES as usize;
+        let wire = fetch_of(vec![
+            fixture.target(0, WirePollingStrategy::offset(0)),
+            fixture.target(1, WirePollingStrategy::offset(0)),
+        ]);
+        let fetch = fixture.fetch(&wire);
+        let mut fetch = pin!(fetch);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        fixture.answer_wait(vec![ready(0, 1), ready(1, 1)]);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+
+        let reads = fixture.reads();
+        assert_eq!(
+            reads.keys().copied().collect::<Vec<_>>(),
+            [0],
+            "the first target in request order takes the only read"
+        );
+        assert!(
+            SessionManager::reserve_fetch_read(&fixture.sessions, (CLIENT, OWNER), cost, &limits)
+                .is_none(),
+            "the running read holds the room"
+        );
+        drop(reads);
+        let Poll::Ready(reply) = futures::poll!(fetch.as_mut()) else {
+            panic!("the fetch answers once its read ended");
+        };
+        assert!(reply.is_ok());
+        assert!(
+            SessionManager::reserve_fetch_read(&fixture.sessions, (CLIENT, OWNER), cost, &limits)
+                .is_some(),
+            "an ended read gives its room back"
+        );
+        assert_scraped(&fixture.shard, "fetch_budget_waits_total 1");
+    }
+
+    #[test]
+    fn given_fetch_reads_of_a_session_when_reserving_should_stop_at_the_read_cap_and_the_byte_budget()
+     {
+        const COST: usize = 1024;
+        const BUDGET: usize = 3 * COST;
+        const READ_CAP: usize = 2;
+        const OWNER: u16 = 0;
+        const OTHER_OWNER: u16 = 1;
+        const OTHER_CLIENT: u128 = CLIENT + 1;
+        let sessions = Rc::new(RefCell::new(SessionManager::new()));
+        let limits = DeferredPollConfig {
+            max_pending_per_session: READ_CAP,
+            max_inflight_bytes: BUDGET,
+            ..DeferredPollConfig::default()
+        };
+        let reserve = |key, cost| SessionManager::reserve_fetch_read(&sessions, key, cost, &limits);
+
+        let first = reserve((CLIENT, OWNER), COST).expect("a first read fits");
+        let second = reserve((CLIENT, OWNER), COST).expect("a second read fits");
+        assert!(
+            reserve((CLIENT, OWNER), COST).is_none(),
+            "the read cap of the session on its owner is reached"
+        );
+        assert!(
+            reserve((CLIENT, OTHER_OWNER), COST).is_some(),
+            "another owner counts its own reads"
+        );
+        assert!(
+            reserve((OTHER_CLIENT, OWNER), COST).is_some(),
+            "another session counts its own reads"
+        );
+
+        drop(first);
+        assert!(
+            reserve((CLIENT, OWNER), BUDGET - COST + 1).is_none(),
+            "a read past the byte budget is refused below the read cap"
+        );
+        assert!(
+            reserve((CLIENT, OWNER), usize::MAX).is_none(),
+            "a cost that overflows the running bytes is refused"
+        );
+        let third =
+            reserve((CLIENT, OWNER), BUDGET - COST).expect("a read that fills the budget fits");
+        drop((second, third));
+        assert!(
+            reserve((CLIENT, OWNER), BUDGET).is_some(),
+            "ended reads give the whole budget back"
+        );
+    }
+
+    #[compio::test]
+    async fn given_quota_refusals_of_every_read_when_reading_should_answer_capacity_exceeded() {
+        // An empty reply brought the next fetch back at once while the owner
+        // quota was still full.
+        const STORED: u64 = 4;
+        const STORED_CURRENT: u64 = 9;
+        let fixture = Fixture::new(2);
+        let wire = fetch_of(vec![
+            fixture.target(0, WirePollingStrategy::offset(0)),
+            fixture.target(1, WirePollingStrategy::next()),
+        ]);
+        let fetch = fixture.fetch(&wire);
+        let mut fetch = pin!(fetch);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        fixture.answer_stored_offsets(BTreeMap::from([(
+            1,
+            PartitionReadReply::ConsumerOffset {
+                stored: Some(STORED),
+                current_offset: STORED_CURRENT,
+            },
+        )]));
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        fixture.answer_wait(vec![ready(0, 1), ready(1, 1)]);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+
+        for partition in 0..2 {
+            let mut reads = fixture.reads();
+            assert_eq!(reads.len(), 1);
+            reads
+                .remove(&partition)
+                .unwrap()
+                .answer(PartitionReadReply::Rejected(
+                    IggyError::DeferredPollCapacityExceeded,
+                ));
+            if partition == 0 {
+                assert!(futures::poll!(fetch.as_mut()).is_pending());
+            }
+        }
+        let Poll::Ready(reply) = futures::poll!(fetch.as_mut()) else {
+            panic!("the fetch answers once its reads answered");
+        };
+        assert_eq!(
+            reply.err(),
+            Some(IggyError::DeferredPollCapacityExceeded),
+            "a fetch that read nothing for quota tells the client to back off"
+        );
+        assert_scraped(&fixture.shard, "fetch_quota_refusals_total 2");
+    }
+
+    #[compio::test]
+    async fn given_a_refused_stored_offset_read_when_resolving_next_should_refuse_only_that_target()
+    {
+        // One target that moved or vanished failed the whole fetch, so every
+        // other partition of the group lost its turn too.
+        for (answer, expected) in [
+            (
+                PartitionReadReply::Rejected(IggyError::NotPrimary),
+                IggyError::NotPrimary,
+            ),
+            (
+                PartitionReadReply::NotFound,
+                IggyError::TransientNotAccepted,
+            ),
+        ] {
+            let fixture = Fixture::new(2);
+            let wire = fetch_of(vec![
+                fixture.target(0, WirePollingStrategy::offset(0)),
+                fixture.target(1, WirePollingStrategy::next()),
+            ]);
+            let fetch = fixture.fetch(&wire);
+            let mut fetch = pin!(fetch);
+            assert!(futures::poll!(fetch.as_mut()).is_pending());
+            fixture.answer_stored_offsets(BTreeMap::from([(1, answer)]));
+            assert!(futures::poll!(fetch.as_mut()).is_pending());
+
+            let wait = fixture.answer_wait(Vec::new());
+
+            assert_eq!(
+                wait.targets
+                    .iter()
+                    .map(|target| target.partition_id)
+                    .collect::<Vec<_>>(),
+                [0],
+                "the other target still waits"
+            );
+            let Poll::Ready(reply) = futures::poll!(fetch.as_mut()) else {
+                panic!("a fetch without ready targets answers at the end of its wait");
+            };
+            let reply = reply.expect("the fetch succeeds").into_contiguous();
+            let statuses: Vec<_> = sections(reply.as_slice())
+                .iter()
+                .map(|section| (section.header.partition_id, section.header.status))
+                .collect();
+            assert_eq!(statuses, [(1, expected.as_code())]);
+        }
+    }
+
+    #[compio::test]
+    async fn given_a_wait_refused_for_a_target_when_waiting_should_read_every_target() {
+        // A target that changed its history failed the wait of all of them,
+        // and the fetch failed without a partition, so no client could tell
+        // which one to move on.
+        let fixture = Fixture::new(2);
+        let wire = fetch_of(vec![
+            fixture.target(0, WirePollingStrategy::offset(0)),
+            fixture.target(1, WirePollingStrategy::offset(0)),
+        ]);
+        let fetch = fixture.fetch(&wire);
+        let mut fetch = pin!(fetch);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        fixture.answer_wait_with(PartitionReadReply::Rejected(IggyError::HistoryUnavailable));
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+
+        for partition_id in 0..2 {
+            let mut reads = fixture.reads();
+            assert_eq!(reads.len(), 1);
+            let read = reads.remove(&partition_id).unwrap();
+            read.answer(if partition_id == 0 {
+                PartitionReadReply::Rejected(IggyError::HistoryUnavailable)
+            } else {
+                PartitionReadReply::NotFound
+            });
+            if partition_id == 0 {
+                assert!(futures::poll!(fetch.as_mut()).is_pending());
+            }
+        }
+        let Poll::Ready(reply) = futures::poll!(fetch.as_mut()) else {
+            panic!("the fetch answers once its reads did");
+        };
+        let reply = reply.expect("the fetch succeeds").into_contiguous();
+        let statuses: Vec<_> = sections(reply.as_slice())
+            .iter()
+            .map(|section| (section.header.partition_id, section.header.status))
+            .collect();
+        assert_eq!(
+            statuses,
+            [
+                (0, IggyError::HistoryUnavailable.as_code()),
+                (1, IggyError::TransientNotAccepted.as_code())
+            ]
+        );
+    }
+
+    #[compio::test]
+    async fn given_a_wait_refused_for_the_caller_when_waiting_should_fail_the_fetch_before_any_read()
+     {
+        let fixture = Fixture::new(2);
+        let wire = fetch_of(vec![
+            fixture.target(0, WirePollingStrategy::offset(0)),
+            fixture.target(1, WirePollingStrategy::offset(0)),
+        ]);
+        let fetch = fixture.fetch(&wire);
+        let mut fetch = pin!(fetch);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        fixture.answer_wait_with(PartitionReadReply::Rejected(IggyError::StaleClient));
+        let Poll::Ready(result) = futures::poll!(fetch.as_mut()) else {
+            panic!("a wait refused for the caller ends the fetch");
+        };
+        assert_eq!(result.err(), Some(IggyError::StaleClient));
+        assert!(
+            fixture.owner_frames().is_empty(),
+            "a fetch whose caller lost its session reads nothing"
+        );
+    }
+
+    #[compio::test]
+    async fn given_refused_reads_when_serving_should_answer_error_sections_beside_the_served_one() {
+        const PARTITIONS: u32 = 5;
+        let fixture = Fixture::new(PARTITIONS);
+        let record = stored_batch_record(None);
+        let wire = fetch_of(
+            (0..PARTITIONS)
+                .map(|partition_id| fixture.target(partition_id, WirePollingStrategy::offset(0)))
+                .collect(),
+        );
+        let fetch = fixture.fetch(&wire);
+        let mut fetch = pin!(fetch);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        fixture.answer_wait(
+            (0..PARTITIONS)
+                .map(|partition_id| ready(partition_id, u64::from(STORED_BATCH_MESSAGES)))
+                .collect(),
+        );
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+
+        let mut reads = fixture.reads();
+        reads
+            .remove(&0)
+            .unwrap()
+            .answer(served(fixture.context(0), whole(&record)));
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        reads.extend(fixture.reads());
+        let mut answer = |partition_id: usize, reply: PartitionReadReply| {
+            reads
+                .remove(&partition_id)
+                .expect("each ready target is read")
+                .answer(reply);
+        };
+        answer(1, PartitionReadReply::Rejected(IggyError::NotPrimary));
+        answer(2, PartitionReadReply::NotFound);
+        // A reply of another read kind.
+        answer(
+            3,
+            PartitionReadReply::Primary {
+                view: 0,
+                primary: 0,
+            },
+        );
+        answer(
+            4,
+            PartitionReadReply::Poll {
+                context: fixture.context(4),
+                fragments: whole(&record),
+                current_offset: CURRENT_OFFSET,
+                encryptor: None,
+                next_offset: None,
+            },
+        );
+        let Poll::Ready(reply) = futures::poll!(fetch.as_mut()) else {
+            panic!("the fetch answers once its reads answered");
+        };
+        let reply = reply
+            .expect("an error after the reads is a section")
+            .into_contiguous();
+        let sections = sections(reply.as_slice());
+        let headers: Vec<_> = sections.iter().map(|section| section.header).collect();
+        assert_eq!(
+            headers,
+            [
+                fixture.served_header(0, record.len()),
+                error_header(1, &IggyError::NotPrimary),
+                error_header(2, &IggyError::TransientNotAccepted),
+                error_header(3, &IggyError::ShardCommunicationError),
+                error_header(4, &IggyError::ShardCommunicationError),
+            ]
+        );
+        assert_eq!(sections[0].records, record.as_slice());
+        assert!(
+            sections[1..]
+                .iter()
+                .all(|section| section.records.is_empty()),
+            "an error section holds no records"
+        );
+    }
+
+    #[compio::test]
+    async fn given_a_session_that_ends_during_the_reads_when_serving_should_fail_the_fetch() {
+        let fixture = Fixture::new(1);
+        let wire = fetch_of(vec![fixture.target(0, WirePollingStrategy::offset(0))]);
+        let fetch = fixture.fetch(&wire);
+        let mut fetch = pin!(fetch);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        fixture.answer_wait(vec![ready(0, u64::from(STORED_BATCH_MESSAGES))]);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+
+        let mut reads = fixture.reads();
+        assert!(
+            fixture
+                .shard
+                .plane
+                .metadata()
+                .client_table
+                .borrow_mut()
+                .end_session(CLIENT, DEFAULT_ROOT_USER_ID, SESSION, COMMIT),
+            "the session ends while its read runs"
+        );
+        reads
+            .remove(&0)
+            .expect("the ready target is read")
+            .answer(served(
+                fixture.context(0),
+                whole(&stored_batch_record(None)),
+            ));
+        let Poll::Ready(result) = futures::poll!(fetch.as_mut()) else {
+            panic!("the fetch answers once its read answered");
+        };
+        assert_eq!(
+            result.err(),
+            Some(IggyError::StaleClient),
+            "a session that ended fails the whole fetch"
+        );
+    }
+
+    #[compio::test]
+    async fn given_a_committing_fetch_whose_topic_is_recreated_during_its_read_when_serving_should_answer_history_unavailable()
+     {
+        // The section answered 58, which a client takes as nothing stored,
+        // while the read may have stored into the incarnation it named.
+        let fixture = Fixture::new(1);
+        let context = fixture.context(0);
+        let wire = FetchMessagesRequest {
+            auto_commit: true,
+            ..fetch_of(vec![fixture.target(0, WirePollingStrategy::offset(0))])
+        };
+        let fetch = fixture.fetch(&wire);
+        let mut fetch = pin!(fetch);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        fixture.answer_wait(vec![ready(0, u64::from(STORED_BATCH_MESSAGES))]);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+
+        let mut reads = fixture.reads();
+        recreate_topic_in(
+            &fixture.shard.plane.metadata().mux_stm,
+            fixture.namespaces[0],
+        );
+        reads
+            .remove(&0)
+            .expect("the ready target is read")
+            .answer(served(context, whole(&stored_batch_record(None))));
+        let Poll::Ready(reply) = futures::poll!(fetch.as_mut()) else {
+            panic!("the fetch answers once its read answered");
+        };
+        let reply = reply
+            .expect("a section of an old incarnation does not fail the fetch")
+            .into_contiguous();
+        let headers: Vec<_> = sections(reply.as_slice())
+            .iter()
+            .map(|section| section.header)
+            .collect();
+        assert_eq!(
+            headers,
+            [error_header(0, &IggyError::HistoryUnavailable)],
+            "records of the incarnation that the target named are not served from another"
+        );
+    }
+
+    #[compio::test]
+    async fn given_records_split_across_fragments_when_serving_should_answer_them_whole_without_a_copy()
+     {
+        // More than the stored batch holds, so the count of a section can
+        // only come from its batch headers.
+        const AVAILABLE: u64 = 5;
+        // The owner served the batch after a retention cut, so its next
+        // offset is not the start plus the count.
+        const NEXT_OFFSET: u64 = 9;
+        const AHEAD: u64 = 5;
+        let fixture = Fixture::new(2);
+        let record = stored_batch_record(None);
+        // Inside the batch header, so that the count reads a header that
+        // spans both fragments.
+        let split = COMMAND_HEADER_SIZE / 2;
+        let split_fragments = PollFragments::from_iter([
+            Fragment::slice(record.clone(), 0, split),
+            Fragment::slice(record.clone(), split, record.len()),
+        ]);
+        let stored_fragments = split_fragments.len() + 1;
+        let wire = fetch_of(vec![
+            fixture.target(0, WirePollingStrategy::offset(0)),
+            fixture.target(1, WirePollingStrategy::offset(0)),
+        ]);
+        let fetch = fixture.fetch(&wire);
+        let mut fetch = pin!(fetch);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        fixture.answer_wait(vec![ready(0, AVAILABLE), ready(1, AVAILABLE)]);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+
+        let mut reads = fixture.reads();
+        reads
+            .remove(&0)
+            .expect("the ready target is read")
+            .answer(PartitionReadReply::Poll {
+                context: PartitionContext {
+                    metadata_op: 0,
+                    ..fixture.context(0)
+                },
+                fragments: split_fragments,
+                current_offset: CURRENT_OFFSET,
+                encryptor: None,
+                next_offset: Some(NEXT_OFFSET),
+            });
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        reads.extend(fixture.reads());
+        let ahead = PartitionContext {
+            metadata_op: COMMIT + AHEAD,
+            ..fixture.context(1)
+        };
+        reads
+            .remove(&1)
+            .expect("the ready target is read")
+            .answer(served(ahead, whole(&record)));
+        let Poll::Ready(reply) = futures::poll!(fetch.as_mut()) else {
+            panic!("the fetch answers once its reads answered");
+        };
+        let reply = reply.expect("the fetch succeeds");
+        assert_eq!(
+            reply.fragments().len(),
+            1 + stored_fragments,
+            "the reply is its head, then every stored fragment as it is"
+        );
+        let reply = reply.into_contiguous();
+        let sections = sections(reply.as_slice());
+        let headers: Vec<_> = sections.iter().map(|section| section.header).collect();
+        assert_eq!(
+            headers,
+            [
+                FetchMessagesSectionHeader {
+                    next_offset: NEXT_OFFSET,
+                    ..fixture.served_header(0, record.len())
+                },
+                FetchMessagesSectionHeader {
+                    context: ahead,
+                    ..fixture.served_header(1, record.len())
+                },
+            ],
+            "a section counts the messages of its records, names at least the applied \
+             commit, and the offsets of its owner"
+        );
+        assert!(
+            sections
+                .iter()
+                .all(|section| section.records == record.as_slice()),
+            "a record split across fragments arrives whole"
+        );
+        assert_scraped(&fixture.shard, "fetch_sections_total 2");
+        assert_scraped(
+            &fixture.shard,
+            &format!("fetch_bytes_total {}", headers.len() * record.len()),
+        );
+    }
+
+    #[compio::test]
+    async fn given_a_read_that_never_answers_when_the_reply_deadline_passes_should_reply_with_the_sections_that_arrived()
+     {
+        let fixture = Fixture::new(2);
+        let record = stored_batch_record(None);
+        let wire = fetch_of(vec![
+            fixture.target(0, WirePollingStrategy::offset(0)),
+            fixture.target(1, WirePollingStrategy::offset(0)),
+        ]);
+        let started = fixture.bus.monotonic_micros();
+        let fetch = fixture.fetch(&wire);
+        let mut fetch = pin!(fetch);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        fixture.answer_wait(vec![
+            ready(0, u64::from(STORED_BATCH_MESSAGES)),
+            ready(1, u64::from(STORED_BATCH_MESSAGES)),
+        ]);
+        // The next timer of the fetch is its reply deadline, which runs while
+        // the reads do.
+        let (deadline, held) = oneshot::channel();
+        *fixture.bus.held_timer.borrow_mut() = Some(held);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        let elapsed = fixture.bus.monotonic_micros() - started;
+
+        let read_budget = REQUEST_TIMEOUT_US - 2 * FETCH_REPLY_MARGIN_US;
+        let mut reads = fixture.reads();
+        assert_eq!(reads.len(), 1, "the first ready target is read");
+        for read in reads.values() {
+            let budget = read.request.options.request_timeout.as_micros();
+            assert!(
+                (read_budget - elapsed..=read_budget).contains(&budget),
+                "a read must commit two margins before the request timeout, not after {budget} us"
+            );
+            assert_eq!(
+                read.request.options.max_wait.as_micros(),
+                0,
+                "a read never parks"
+            );
+        }
+        let reply_budget = REQUEST_TIMEOUT_US - FETCH_REPLY_MARGIN_US;
+        let reply_timer = fixture
+            .bus
+            .sleeps
+            .borrow()
+            .last()
+            .copied()
+            .expect("the reply deadline is armed");
+        assert!(
+            (Duration::from_micros(reply_budget - elapsed)..=Duration::from_micros(reply_budget))
+                .contains(&reply_timer),
+            "the reply leaves one margin before the request timeout, not after {reply_timer:?}"
+        );
+
+        reads
+            .remove(&0)
+            .expect("the ready target is read")
+            .answer(served(fixture.context(0), whole(&record)));
+        assert!(
+            futures::poll!(fetch.as_mut()).is_pending(),
+            "the fetch waits for its other read until the reply deadline"
+        );
+        reads.extend(fixture.reads());
+        assert_eq!(reads.len(), 1, "the remaining target is now in flight");
+        deadline.send(()).expect("the reply deadline is held");
+        let Poll::Ready(reply) = futures::poll!(fetch.as_mut()) else {
+            panic!("the reply deadline ends the fetch");
+        };
+        let reply = reply.expect("the fetch succeeds").into_contiguous();
+        let headers: Vec<_> = sections(reply.as_slice())
+            .iter()
+            .map(|section| section.header)
+            .collect();
+        assert_eq!(
+            headers,
+            [
+                fixture.served_header(0, record.len()),
+                error_header(1, &IggyError::ShardCommunicationError),
+            ],
+            "the read that answered before the deadline is served, the other one is dropped"
+        );
+        drop(reads);
+    }
+
+    #[compio::test]
+    async fn given_committed_reads_when_replies_arrive_after_read_deadline_should_preserve_them_beside_refused_reads()
+     {
+        const STARTED: u64 = 1_000_000;
+        const PARTITIONS: u32 = 3;
+        let fixture = Fixture::new(PARTITIONS);
+        fixture.bus.monotonic.set(Some(STARTED));
+        let record = stored_batch_record(None);
+        let wire = FetchMessagesRequest {
+            auto_commit: true,
+            ..fetch_of(
+                (0..PARTITIONS)
+                    .map(|partition| fixture.target(partition, WirePollingStrategy::offset(0)))
+                    .collect(),
+            )
+        };
+        let fetch = fixture.fetch(&wire);
+        let mut fetch = pin!(fetch);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        fixture.answer_wait(
+            (0..PARTITIONS)
+                .map(|partition| ready(partition, u64::from(STORED_BATCH_MESSAGES)))
+                .collect(),
+        );
+        let (_deadline, held) = oneshot::channel();
+        *fixture.bus.held_timer.borrow_mut() = Some(held);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        let mut reads = fixture.reads();
+        assert_eq!(reads.len(), 1);
+        let read_deadline = STARTED + REQUEST_TIMEOUT_US - 2 * FETCH_REPLY_MARGIN_US;
+        for read in reads.values() {
+            assert!(read.request.args.auto_commit);
+            assert_eq!(
+                read.request.options.request_timeout.as_micros(),
+                read_deadline - STARTED,
+            );
+        }
+        reads
+            .remove(&0)
+            .unwrap()
+            .answer(served(fixture.context(0), whole(&record)));
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+
+        reads.extend(fixture.reads());
+        assert_eq!(reads.len(), 2);
+
+        // A commit before the owner's deadline still has one reply margin
+        // to cross the shard channel. The connection must keep that data.
+        fixture
+            .bus
+            .monotonic
+            .set(Some(read_deadline + FETCH_REPLY_MARGIN_US / 2));
+        reads
+            .remove(&1)
+            .unwrap()
+            .answer(served(fixture.context(1), whole(&record)));
+        reads
+            .remove(&2)
+            .unwrap()
+            .answer(PartitionReadReply::Rejected(
+                IggyError::TransientNotAccepted,
+            ));
+        let Poll::Ready(reply) = futures::poll!(fetch.as_mut()) else {
+            panic!("all owner replies arrived before the reply deadline");
+        };
+        let reply = reply.unwrap().into_contiguous();
+        let sections = sections(reply.as_slice());
+        assert_eq!(sections.len(), 3);
+        for (partition, section) in sections[..2].iter().enumerate() {
+            assert_eq!(
+                section.header,
+                fixture.served_header(u32::try_from(partition).unwrap(), record.len())
+            );
+            assert_eq!(section.records, record.as_slice());
+        }
+        assert_eq!(
+            sections[2].header.status,
+            IggyError::TransientNotAccepted.as_code()
+        );
+        assert_eq!(sections[2].records, b"");
+    }
+
+    #[compio::test]
+    async fn given_an_encrypted_partition_when_serving_should_answer_plaintext_and_an_error_section_for_a_bad_record()
+     {
+        const KEY: [u8; 32] = [7; 32];
+        let encryptor = Arc::new(EncryptorKind::Aes256Gcm(
+            Aes256GcmEncryptor::new(&KEY).expect("a valid key"),
+        ));
+        let fixture = Fixture::new(2);
+        let encrypted = whole(&stored_batch_record(Some(&encryptor)));
+        let plaintext = build_polled_messages_body(
+            0,
+            CURRENT_OFFSET,
+            fixture.context(0),
+            encrypted.clone(),
+            Some(&encryptor),
+        )
+        .expect("the record decrypts");
+        let plaintext = &plaintext[POLL_RESPONSE_HEADER_SIZE..];
+        let wire = fetch_of(vec![
+            fixture.target(0, WirePollingStrategy::offset(0)),
+            fixture.target(1, WirePollingStrategy::offset(0)),
+        ]);
+        let fetch = fixture.fetch(&wire);
+        let mut fetch = pin!(fetch);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        fixture.answer_wait(vec![
+            ready(0, u64::from(STORED_BATCH_MESSAGES)),
+            ready(1, u64::from(STORED_BATCH_MESSAGES)),
+        ]);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+
+        let mut reads = fixture.reads();
+        let answer = |read: OwnerRead, partition_id: usize, fragments: PollFragments| {
+            let partition = u32::try_from(partition_id).expect("the partition id fits u32");
+            read.answer(PartitionReadReply::Poll {
+                context: fixture.context(partition),
+                fragments,
+                current_offset: CURRENT_OFFSET,
+                encryptor: Some(Arc::clone(&encryptor)),
+                next_offset: Some(u64::from(STORED_BATCH_MESSAGES)),
+            });
+        };
+        answer(reads.remove(&0).unwrap(), 0, encrypted);
+        assert!(futures::poll!(fetch.as_mut()).is_pending());
+        reads.extend(fixture.reads());
+        // A record stored without the key of the partition.
+        answer(
+            reads.remove(&1).unwrap(),
+            1,
+            whole(&stored_batch_record(None)),
+        );
+        let Poll::Ready(reply) = futures::poll!(fetch.as_mut()) else {
+            panic!("the fetch answers once its reads answered");
+        };
+        let reply = reply.expect("a record that does not decrypt is a section");
+        assert_eq!(
+            reply.fragments().len(),
+            2,
+            "the decrypted records are one copied frame after the head"
+        );
+        let reply = reply.into_contiguous();
+        let sections = sections(reply.as_slice());
+        let headers: Vec<_> = sections.iter().map(|section| section.header).collect();
+        assert_eq!(
+            headers,
+            [
+                fixture.served_header(0, plaintext.len()),
+                error_header(1, &IggyError::CannotDecryptData),
+            ]
+        );
+        assert_eq!(
+            sections[0].records, plaintext,
+            "a fetch serves the records that a poll serves"
+        );
+    }
+
+    /// One shard that owns `partitions` partitions of one topic, with a bound
+    /// root session. The frames that the shard sends to the owner of a
+    /// partition land in `owner_inbox`, where the test answers as the owner.
+    struct Fixture {
+        bus: SpyBus,
+        shard: Rc<TestShard>,
+        sessions: Rc<RefCell<SessionManager>>,
+        attachment: SessionAttachment,
+        namespaces: Vec<IggyNamespace>,
+        owner_inbox: Receiver<ShardFrame>,
+        _owner_replies: Receiver<ShardFrame>,
+        _own_inbox: (Receiver<ShardFrame>, Receiver<ShardFrame>),
+    }
+
+    impl Fixture {
+        fn new(partitions: u32) -> Self {
+            let bus = SpyBus::default();
+            let mut shard = test_shard(&bus, 0, 1, FIRST_BOOT);
+            let (own, own_inbox, own_replies) = shard_channel(0, INBOX_CAPACITY, 1);
+            let (sender, owner_inbox, owner_replies) = shard_channel(OWNER, INBOX_CAPACITY, 1);
+            shard.attach_senders(vec![own, sender]);
+            let shard = Rc::new(shard);
+            let namespaces = route_partitions_to(&shard, partitions, OWNER);
+            shard.plane.metadata().applied_frontier().advance(COMMIT);
+            let attachment = attach_root_session(&shard, CLIENT, SESSION);
+            Self {
+                bus,
+                shard,
+                sessions: Rc::new(RefCell::new(SessionManager::new())),
+                attachment,
+                namespaces,
+                owner_inbox,
+                _owner_replies: owner_replies,
+                _own_inbox: (own_inbox, own_replies),
+            }
+        }
+
+        /// A target of `partition_id` that names its incarnation.
+        fn target(&self, partition_id: u32, strategy: WirePollingStrategy) -> FetchMessagesTarget {
+            FetchMessagesTarget {
+                partition_id,
+                strategy,
+                max_bytes: TARGET_MAX_BYTES,
+                incarnation: self.context(partition_id).incarnation,
+                owner_generation: 0,
+            }
+        }
+
+        /// The context of `partition_id` at the applied commit.
+        fn context(&self, partition_id: u32) -> PartitionContext {
+            let namespace = self.namespaces[partition_id as usize];
+            PartitionContext {
+                incarnation: self
+                    .shard
+                    .plane
+                    .metadata()
+                    .mux_stm
+                    .streams()
+                    .created_revision_for_namespace(namespace)
+                    .expect("the partition exists"),
+                owner_generation: 0,
+                metadata_op: COMMIT,
+            }
+        }
+
+        /// The table entry of a section that [`served`] a record of
+        /// `records_len` bytes.
+        fn served_header(
+            &self,
+            partition_id: u32,
+            records_len: usize,
+        ) -> FetchMessagesSectionHeader {
+            FetchMessagesSectionHeader {
+                partition_id,
+                status: STATUS_OK,
+                current_offset: CURRENT_OFFSET,
+                messages_count: STORED_BATCH_MESSAGES,
+                context: self.context(partition_id),
+                next_offset: u64::from(STORED_BATCH_MESSAGES),
+                records_len: u32::try_from(records_len).expect("a record fits u32"),
+            }
+        }
+
+        async fn fetch(&self, wire: &FetchMessagesRequest) -> Result<BusMessage, IggyError> {
+            self.fetch_holding(wire).await.map(|(reply, _)| reply)
+        }
+
+        /// [`Self::fetch`] with the session room that its reply holds.
+        async fn fetch_holding(
+            &self,
+            wire: &FetchMessagesRequest,
+        ) -> Result<(BusMessage, Vec<FetchReadGuard>), IggyError> {
+            let request = fetch_request(&wire.to_bytes());
+            fetch_messages(
+                &self.shard,
+                &self.sessions,
+                &request,
+                (wire, None),
+                (CLIENT, &self.attachment, DEFAULT_ROOT_USER_ID),
+            )
+            .await
+        }
+
+        /// Serves `body` as a fetch of the root session on the connection
+        /// [`TRANSPORT`]. The reply lands in the client replies of the bus.
+        async fn serve(&self, body: &[u8]) {
+            self.serve_from(ClientOrigin::capture(&self.bus, TRANSPORT), body)
+                .await;
+        }
+
+        /// [`Self::serve`] for a frame that arrived at `origin`.
+        async fn serve_from(&self, origin: ClientOrigin, body: &[u8]) {
+            let config = Arc::new(ServerConfig::default());
+            handle_non_replicated_request(
+                &self.shard,
+                &self.sessions,
+                &config,
+                origin,
+                fetch_request(body),
+                ConnectionContext {
+                    bound: Some((CLIENT, SESSION)),
+                    attachment: Some(self.attachment.clone()),
+                    user_id: Some(DEFAULT_ROOT_USER_ID),
+                    address: None,
+                    metadata_watermark: 0,
+                },
+            )
+            .await;
+        }
+
+        /// The frames that the shard sent to the owner since the last call.
+        fn owner_frames(&self) -> Vec<LifecycleFrame> {
+            iter::from_fn(|| self.owner_inbox.try_recv().ok())
+                .map(|frame| {
+                    let ShardFrame::Lifecycle(frame) = frame else {
+                        panic!("a fetch sends its owner lifecycle frames only");
+                    };
+                    frame
+                })
+                .collect()
+        }
+
+        /// Answers the stored-offset read of each `Next` target with the
+        /// entry of `answers` for its partition.
+        fn answer_stored_offsets(&self, mut answers: BTreeMap<usize, PartitionReadReply>) {
+            let mut frames = self.owner_frames();
+            assert_eq!(
+                frames.len(),
+                1,
+                "a fetch reads the stored offsets of one owner in one frame"
+            );
+            let Some(LifecycleFrame::PartitionRead {
+                read: PartitionRead::ConsumerOffsetsOnPrimary { reads },
+                reply,
+                ..
+            }) = frames.pop()
+            else {
+                panic!("a fetch reads the stored offsets of its next targets first");
+            };
+            let offsets = reads
+                .into_iter()
+                .map(|read| {
+                    let partition_id = read.namespace.partition_id();
+                    assert_eq!(
+                        read.consumer,
+                        Some(offset_key(partition_id)),
+                        "the read names the offset key of the target"
+                    );
+                    answers
+                        .remove(&partition_id)
+                        .expect("only a next target reads its stored offset")
+                })
+                .collect();
+            reply
+                .try_send(PartitionReadReply::ConsumerOffsets(offsets))
+                .expect("the fetch awaits the stored offsets");
+            assert!(
+                answers.is_empty(),
+                "each next target reads its stored offset"
+            );
+        }
+
+        /// The stored-offset read and the reads that the shard sent to the
+        /// owner at once, by kind.
+        fn offsets_and_reads(&self) -> (StoredOffsets, BTreeMap<usize, OwnerRead>) {
+            let mut offsets = None;
+            let mut reads = BTreeMap::new();
+            for frame in self.owner_frames() {
+                match frame {
+                    LifecycleFrame::PartitionRead {
+                        read: PartitionRead::ConsumerOffsetsOnPrimary { reads },
+                        reply,
+                        ..
+                    } => offsets = Some(StoredOffsets { reads, reply }),
+                    LifecycleFrame::PartitionRead {
+                        namespace,
+                        read: PartitionRead::DeferredPoll(request),
+                        reply,
+                    } => {
+                        reads.insert(namespace.partition_id(), OwnerRead { request, reply });
+                    }
+                    _ => panic!("a fetch reads stored offsets and messages here"),
+                }
+            }
+            (
+                offsets.expect("a fetch of next targets reads their stored offsets"),
+                reads,
+            )
+        }
+
+        /// Answers the readiness wait of the fetch with `ready`, and returns
+        /// the wait.
+        fn answer_wait(&self, ready: Vec<ReadyTarget>) -> ReadinessWait {
+            self.answer_wait_with(PartitionReadReply::Ready {
+                shard: OWNER,
+                parked: None,
+                partitions: ready,
+            })
+        }
+
+        fn answer_wait_with(&self, answer: PartitionReadReply) -> ReadinessWait {
+            let mut frames = self.owner_frames();
+            assert_eq!(
+                frames.len(),
+                1,
+                "the fetch waits for its targets in one frame"
+            );
+            let Some(LifecycleFrame::ReadinessWait { wait, reply, .. }) = frames.pop() else {
+                panic!("the fetch must wait for its targets");
+            };
+            reply.try_send(answer).expect("the fetch awaits its wait");
+            *wait
+        }
+
+        async fn refuse_reads(
+            &self,
+            mut fetch: Pin<&mut impl Future>,
+            count: usize,
+        ) -> BTreeMap<usize, OwnerRead> {
+            let mut reads = BTreeMap::new();
+            while reads.len() < count {
+                let current = self.reads();
+                assert!(!current.is_empty(), "a refused read starts the next one");
+                for read in current.values() {
+                    read.reply
+                        .try_send(PartitionReadReply::Rejected(IggyError::NotPrimary))
+                        .unwrap();
+                }
+                reads.extend(current);
+                if reads.len() < count {
+                    assert!(futures::poll!(fetch.as_mut()).is_pending());
+                }
+            }
+            assert_eq!(reads.len(), count);
+            reads
+        }
+
+        /// The reads that the shard sent to the owner, by partition id.
+        fn reads(&self) -> BTreeMap<usize, OwnerRead> {
+            self.owner_frames()
+                .into_iter()
+                .map(|frame| match frame {
+                    LifecycleFrame::PartitionRead {
+                        namespace,
+                        read: PartitionRead::DeferredPoll(request),
+                        reply,
+                    } => (namespace.partition_id(), OwnerRead { request, reply }),
+                    _ => panic!("after its wait a fetch sends reads only"),
+                })
+                .collect()
+        }
+    }
+
+    /// One read of a fetch, as its owner receives it.
+    struct OwnerRead {
+        request: Box<DeferredPollRequest>,
+        reply: Sender<PartitionReadReply>,
+    }
+
+    impl OwnerRead {
+        /// The count, the start offset and the byte cap of the read.
+        fn allotment(&self) -> (u32, u64, u32) {
+            (
+                self.request.args.count,
+                self.request.args.value,
+                self.request.options.max_bytes,
+            )
+        }
+
+        fn answer(self, reply: PartitionReadReply) {
+            self.reply
+                .try_send(reply)
+                .expect("the fetch awaits its read");
+        }
+    }
+
+    /// The stored-offset read of a fetch, as its owner receives it.
+    struct StoredOffsets {
+        reads: Vec<ConsumerOffsetRead>,
+        reply: Sender<PartitionReadReply>,
+    }
+
+    impl StoredOffsets {
+        /// Answers each read with the entry of `answers` for its partition.
+        fn answer(self, mut answers: BTreeMap<usize, PartitionReadReply>) {
+            let offsets = self
+                .reads
+                .iter()
+                .map(|read| {
+                    answers
+                        .remove(&read.namespace.partition_id())
+                        .expect("each next target reads its stored offset")
+                })
+                .collect();
+            self.reply
+                .try_send(PartitionReadReply::ConsumerOffsets(offsets))
+                .expect("the fetch awaits the stored offsets");
+            assert!(
+                answers.is_empty(),
+                "only next targets read their stored offset"
+            );
+        }
+    }
+
+    /// `body` as a fetch request of the root session.
+    fn fetch_request(body: &[u8]) -> Message<RoutedRequestHeader> {
+        request_message(Operation::NonReplicated, CLIENT, SESSION, REQUEST, body).transmute_header(
+            |old, header: &mut RoutedRequestHeader| {
+                *header = old;
+                let code = FETCH_MESSAGES_CODE.to_le_bytes();
+                header.reserved[..code.len()].copy_from_slice(&code);
+            },
+        )
+    }
+
+    /// A fetch of `targets` by the plain consumer [`CONSUMER_ID`].
+    fn fetch_of(targets: Vec<FetchMessagesTarget>) -> FetchMessagesRequest {
+        FetchMessagesRequest {
+            consumer: WireConsumer::consumer(WireIdentifier::numeric(CONSUMER_ID)),
+            stream_id: WireIdentifier::numeric(0),
+            topic_id: WireIdentifier::numeric(0),
+            wait_us: WAIT_US,
+            request_timeout_us: REQUEST_TIMEOUT_US,
+            min_count: MIN_COUNT,
+            count: COUNT,
+            max_bytes: MAX_BYTES,
+            auto_commit: false,
+            targets,
+        }
+    }
+
+    /// The offset key of [`CONSUMER_ID`] on `partition_id`.
+    fn offset_key(partition_id: usize) -> PollingConsumer {
+        let consumer = usize::try_from(CONSUMER_ID).expect("the consumer id fits usize");
+        PollingConsumer::Consumer(consumer, partition_id)
+    }
+
+    /// A ready target that holds `available` messages, without known bytes.
+    const fn ready(partition_id: u32, available: u64) -> ReadyTarget {
+        ReadyTarget {
+            partition_id,
+            available,
+            known_bytes: None,
+        }
+    }
+
+    /// The owner reply of a read that served `fragments`, which hold
+    /// [`stored_batch_record`].
+    fn served(context: PartitionContext, fragments: PollFragments) -> PartitionReadReply {
+        PartitionReadReply::Poll {
+            context,
+            fragments,
+            current_offset: CURRENT_OFFSET,
+            encryptor: None,
+            next_offset: Some(u64::from(STORED_BATCH_MESSAGES)),
+        }
+    }
+
+    fn whole(record: &Frozen<MESSAGE_ALIGN>) -> PollFragments {
+        PollFragments::from_iter([Fragment::whole(record.clone())])
+    }
+
+    /// The sections of the fetch reply `frame`.
+    fn sections(frame: &[u8]) -> Vec<FetchMessagesSection<'_>> {
+        FetchMessagesResponse::decode(&frame[size_of::<ReplyHeader>()..])
+            .expect("the reply decodes")
+            .sections
+    }
+
+    /// The table entry of a section whose target failed with `error`.
+    fn error_header(partition_id: u32, error: &IggyError) -> FetchMessagesSectionHeader {
+        FetchMessagesSectionHeader {
+            partition_id,
+            status: error.as_code(),
+            current_offset: 0,
+            messages_count: 0,
+            context: PartitionContext::default(),
+            next_offset: 0,
+            records_len: 0,
+        }
+    }
+
+    fn reply_status(frame: &[u8]) -> u32 {
+        const STATUS: usize = std::mem::offset_of!(ReplyHeader, status);
+        u32::from_le_bytes(
+            frame[STATUS..STATUS + size_of::<u32>()]
+                .try_into()
+                .expect("the reply holds a status"),
+        )
+    }
+
+    /// Asserts that a scrape of the metrics of `shard` holds the line `series`.
+    fn assert_scraped(shard: &TestShard, series: &str) {
+        let mut registry = Registry::default();
+        shard.metrics().register(&mut registry);
+        let mut scrape = String::new();
+        encode(&mut scrape, &registry).expect("the scrape encodes");
+        assert!(
+            scrape.lines().any(|line| line == series),
+            "{series} is not in the scrape:\n{scrape}"
+        );
+    }
+}
diff --git a/core/server/src/dispatch/mod.rs b/core/server/src/dispatch/mod.rs
index 0048610..3fc9c4e 100644
--- a/core/server/src/dispatch/mod.rs
+++ b/core/server/src/dispatch/mod.rs
@@ -34,6 +34,7 @@
 
 mod authz;
 mod failure;
+mod fetch;
 pub mod host;
 pub mod login_error;
 pub mod metadata_changes;
@@ -51,7 +52,7 @@
     send_unbound_deny_reply, send_undeclared_operation_deny,
 };
 use crate::dispatch::partition::{
-    dispatch_partition_request, handle_delete_segments_request, progress_key,
+    dispatch_partition_request, handle_delete_segments_request, progress_keys,
 };
 use crate::dispatch::reads::handle_non_replicated_request;
 use crate::dispatch::session_ops::{handle_login_register_request, handle_logout_request};
@@ -66,13 +67,16 @@
 use futures::future::{Either, select};
 use iggy_binary_protocol::PrepareHeader;
 use iggy_binary_protocol::codes::{
-    BIND_SESSION_CODE, CANCEL_DEFERRED_REQUEST_CODE, LOGIN_USER_CODE,
-    LOGIN_WITH_PERSONAL_ACCESS_TOKEN_CODE, PING_CODE,
+    BIND_SESSION_CODE, CANCEL_DEFERRED_REQUEST_CODE, FETCH_MESSAGES_CODE, LOGIN_REGISTER_CODE,
+    LOGIN_USER_CODE, LOGIN_WITH_PERSONAL_ACCESS_TOKEN_CODE, LOGOUT_USER_CODE, PING_CODE,
+    POLL_MESSAGES_DEFERRED_CODE,
 };
+use iggy_binary_protocol::requests::messages::{FetchMessagesRequest, PollMessagesRequest};
 use iggy_binary_protocol::requests::system::SessionIdentity;
 use iggy_binary_protocol::requests::users::login_register::BindSecret;
 use iggy_binary_protocol::{
     EvictionReason, GenericHeader, HEADER_SIZE, Operation, RequestHeader, RoutedRequestHeader,
+    WireDecode,
 };
 use iggy_common::{IggyError, UserStatus};
 use journal::superblock::SuperblockStore;
@@ -126,7 +130,7 @@
     origin: ClientOrigin,
     message: Message<GenericHeader>,
     // A queued metadata barrier can change the namespace resolved by this frame.
-    progress: Option<(u64, Option<ConsumerProgress>)>,
+    progress: Option<(u64, Option<ProgressKeys>)>,
 }
 
 impl QueuedClientRequest {
@@ -142,19 +146,15 @@
         self.message.as_slice().len() as u64
     }
 
-    fn progress_key(
+    fn progress_keys(
         &mut self,
         metadata_frontier: u64,
-        resolve: impl FnOnce(&Message<GenericHeader>) -> Option<ConsumerProgress>,
-    ) -> Option<ConsumerProgress> {
-        if let Some((frontier, key)) = self.progress
-            && frontier == metadata_frontier
-        {
-            return key;
+        resolve: impl FnOnce(&Message<GenericHeader>) -> Option<ProgressKeys>,
+    ) -> Option<&ProgressKeys> {
+        if !matches!(self.progress, Some((frontier, _)) if frontier == metadata_frontier) {
+            self.progress = Some((metadata_frontier, resolve(&self.message)));
         }
-        let key = resolve(&self.message);
-        self.progress = Some((metadata_frontier, key));
-        key
+        self.progress.as_ref().and_then(|(_, keys)| keys.as_ref())
     }
 }
 
@@ -182,6 +182,28 @@
 type ActiveClientRequests = Rc<RefCell<AHashMap<u128, Rc<ClientDrain>>>>;
 type ConsumerProgress = (u64, u8, usize);
 
+/// The progress lanes of one frame: one, or one per partition of a fetch.
+/// [`ClientDrain`] admits a frame only when it can take all of its lanes at
+/// once, so a frame never holds part of them and needs no lane order.
+#[derive(Debug, Clone, PartialEq, Eq)]
+enum ProgressKeys {
+    One(ConsumerProgress),
+    Many(Vec<ConsumerProgress>),
+}
+
+impl ProgressKeys {
+    fn as_slice(&self) -> &[ConsumerProgress] {
+        match self {
+            Self::One(key) => std::slice::from_ref(key),
+            Self::Many(keys) => keys,
+        }
+    }
+}
+
+/// No command has this code, so the request counter books a frame without a
+/// declared command as unknown.
+const UNDECLARED_COMMAND_CODE: u32 = u32::MAX;
+
 // BindSession authenticates with the registered secret, never with the
 // public client id and epoch alone. It retains the original retry identity;
 // unbound replicated frames still fail with Eviction(NoSession).
@@ -203,6 +225,11 @@
     S: 'static,
     SB: SuperblockStore + 'static,
 {
+    if let Some(shard) = upgrade_shard_handle(shard_handle) {
+        shard
+            .metrics()
+            .record_client_request(client_request_code(&message));
+    }
     // Captured on arrival: every reply to this frame, however late, goes to the
     // connection that sent it.
     let origin = ClientOrigin::capture(bus, client_id);
@@ -219,6 +246,7 @@
     {
         // Cancellation bypasses admission limits. A poll still queued behind
         // another operation must be removed before it can be admitted.
+        let mut cancelled = Vec::new();
         let mut queues = queues.borrow_mut();
         if let Some(queue) = queues.get_mut(&client_id) {
             for index in (0..queue.requests.len()).rev() {
@@ -232,14 +260,22 @@
                             && header.session == cancel.header().session
                             && header.request == cancel.header().request
                     });
-                if matches {
-                    queue.remove(index);
+                if matches
+                    && let Some(removed) = queue.remove(index)
+                    && commits_on_read(&removed.message)
+                {
+                    cancelled.push(removed);
                 }
             }
             if queue.requests.is_empty() {
                 queues.remove(&client_id);
             }
         }
+        drop(queues);
+        // A committing request answers its cancel once, so the client knows it stored nothing.
+        for removed in cancelled {
+            deny_cancelled_client_request(shard_handle, removed.origin, removed.message);
+        }
         if let Some(drain) = active.borrow().get(&client_id) {
             drain.wake();
         }
@@ -290,6 +326,24 @@
     });
 }
 
+/// The command code of a client frame: the code in the header of a
+/// non-replicated request, else the code of its operation.
+fn client_request_code(message: &Message<GenericHeader>) -> u32 {
+    let Ok(request) = message.try_as_typed::<RequestHeader>() else {
+        return UNDECLARED_COMMAND_CODE;
+    };
+    let header = request.header();
+    match header.operation {
+        Operation::NonReplicated => u32::from_le_bytes(header.reserved),
+        // Both logins travel as `Register`, which no command code names.
+        Operation::Register => LOGIN_REGISTER_CODE,
+        Operation::Logout => LOGOUT_USER_CODE,
+        operation => operation
+            .to_command_code()
+            .unwrap_or(UNDECLARED_COMMAND_CODE),
+    }
+}
+
 /// Answer a request that arrived with this client's queue already at
 /// [`MAX_QUEUED_CLIENT_REQUESTS`] or its byte budget with the retryable
 /// transient denial.
@@ -346,6 +400,54 @@
     });
 }
 
+/// Whether a deferred request stores the offset of what it reads.
+fn commits_on_read(message: &Message<GenericHeader>) -> bool {
+    let Ok(request) = message.try_as_typed::<RequestHeader>() else {
+        return false;
+    };
+    match u32::from_le_bytes(request.header().reserved) {
+        POLL_MESSAGES_DEFERRED_CODE => {
+            PollMessagesRequest::peek_progress(request.body()).is_ok_and(|(_, commits)| commits)
+        }
+        FETCH_MESSAGES_CODE => {
+            FetchMessagesRequest::decode_from(request.body()).is_ok_and(|fetch| fetch.auto_commit)
+        }
+        _ => false,
+    }
+}
+
+/// Answers a committing request that a cancel removed from the queue before
+/// it ran with `TransientNotAccepted`: it stored nothing.
+fn deny_cancelled_client_request<B, MJ, S, SB>(
+    shard_handle: &ShellShardHandle<B, MJ, S, SB>,
+    origin: ClientOrigin,
+    message: Message<GenericHeader>,
+) where
+    B: ShellBus,
+    MJ: JournalHandle + 'static,
+    MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+    S: 'static,
+    SB: SuperblockStore + 'static,
+{
+    let Some(shard) = upgrade_shard_handle(shard_handle) else {
+        return;
+    };
+    let bus = shard.bus.clone();
+    bus.spawn(async move {
+        let Some(request) = decode_or_deny_client_request(&shard, origin, message).await else {
+            return;
+        };
+        let request = request.into_routed();
+        send_deny_reply(
+            &shard,
+            origin,
+            request.header(),
+            IggyError::TransientNotAccepted.as_code(),
+        )
+        .await;
+    });
+}
+
 /// Holds a client's one drain entry for as long as its drain task lives.
 ///
 /// Removing it from the task's own `Drop` covers every exit, the panic compio
@@ -457,13 +559,15 @@
         }
     }
 
-    fn admit_progress(self: &Rc<Self>, progress: ConsumerProgress) -> InFlight {
-        self.progress.borrow_mut().insert(progress);
+    fn admit_progress(self: &Rc<Self>, keys: ProgressKeys) -> InFlight {
+        self.progress
+            .borrow_mut()
+            .extend(keys.as_slice().iter().copied());
         self.in_flight.set(self.in_flight.get() + 1);
         InFlight {
             drain: Rc::clone(self),
             metadata_write: false,
-            progress: Some(progress),
+            progress: Some(keys),
         }
     }
 
@@ -494,7 +598,7 @@
 pub struct InFlight {
     drain: Rc<ClientDrain>,
     metadata_write: bool,
-    progress: Option<ConsumerProgress>,
+    progress: Option<ProgressKeys>,
 }
 
 impl Drop for InFlight {
@@ -503,8 +607,11 @@
         if self.metadata_write {
             self.drain.metadata_write.set(false);
         }
-        if let Some(progress) = self.progress {
-            self.drain.progress.borrow_mut().remove(&progress);
+        if let Some(keys) = &self.progress {
+            let mut progress = self.drain.progress.borrow_mut();
+            for key in keys.as_slice() {
+                progress.remove(key);
+            }
         }
         self.drain.wake();
     }
@@ -545,17 +652,18 @@
                 return false;
             }
             queued
-                .progress_key(metadata_frontier, |message| progress_key(&shard, message))
-                .is_none_or(|key| {
+                .progress_keys(metadata_frontier, |message| progress_keys(&shard, message))
+                .is_none_or(|keys| {
+                    let held = drain.progress.borrow();
                     drain.in_flight.get() < drain.capacity
-                        && !drain.progress.borrow().contains(&key)
+                        && !keys.as_slice().iter().any(|key| held.contains(key))
                 })
         });
         if let Some(queued) = next {
             let progress = queued
                 .progress
-                .and_then(|(_, key)| key)
-                .map(|key| drain.admit_progress(key));
+                .and_then(|(_, keys)| keys)
+                .map(|keys| drain.admit_progress(keys));
             handle_client_request(
                 &shard,
                 &sessions,
@@ -1273,28 +1381,32 @@
     use super::*;
     use crate::cluster_meta::ClusterRoster;
     use crate::dispatch::host::ServerHost;
+    use crate::dispatch::partition::progress_key;
     use crate::dispatch::test_support::{
         FIRST_BOOT, SpyBus, TestMux, TestShard, UNDECLARED_OPERATION, create_one_partition,
-        create_reader_request, prepare_message, recreate_topic_in, register_reply, request_message,
-        test_shard, test_shard_with_frontier,
+        create_partitions_in, create_reader_request, prepare_message, recreate_topic_in,
+        register_reply, request_message, test_shard, test_shard_with_frontier,
     };
     use bytes::Bytes;
     use consensus::Sequencer;
     use consensus::client_table::bind_verifier;
     use iggy_binary_protocol::Command;
     use iggy_binary_protocol::codes::{
-        GET_CLIENT_CODE, GET_CLIENTS_CODE, GET_CLUSTER_METADATA_CODE, GET_CONSUMER_OFFSET_CODE,
-        GET_ME_CODE, GET_METADATA_CHANGES_CODE, GET_PARTITION_ROUTE_CODE, GET_STATS_CODE,
-        POLL_MESSAGES_CODE, POLL_MESSAGES_DEFERRED_CODE,
+        FETCH_MESSAGES_CODE, GET_CLIENT_CODE, GET_CLIENTS_CODE, GET_CLUSTER_METADATA_CODE,
+        GET_CONSUMER_OFFSET_CODE, GET_ME_CODE, GET_METADATA_CHANGES_CODE, GET_PARTITION_ROUTE_CODE,
+        GET_STATS_CODE, POLL_MESSAGES_CODE, POLL_MESSAGES_DEFERRED_CODE,
+        STORE_CONSUMER_OFFSET_CODE,
     };
+    use iggy_binary_protocol::dispatch::lookup_command;
     use iggy_binary_protocol::primitives::metadata_changes::WireMetadataChangeStatus;
     use iggy_binary_protocol::primitives::polling_strategy::WirePollingStrategy;
     use iggy_binary_protocol::requests::consumer_offsets::{
         GetConsumerOffsetRequest, StoreConsumerOffsetRequest,
     };
     use iggy_binary_protocol::requests::messages::{
-        DeferredPollMessagesRequest, GetPartitionRouteRequest, MIN_DEFERRED_POLL_MAX_BYTES,
-        MIN_DEFERRED_POLL_REPLY_MARGIN_US, PollMessagesRequest,
+        DeferredPollMessagesRequest, FetchMessagesRequest, FetchMessagesTarget,
+        GetPartitionRouteRequest, MIN_DEFERRED_POLL_MAX_BYTES, MIN_DEFERRED_POLL_REPLY_MARGIN_US,
+        PollMessagesRequest,
     };
     use iggy_binary_protocol::requests::personal_access_tokens::CreatePersonalAccessTokenRequest as WireCreatePersonalAccessTokenRequest;
     use iggy_binary_protocol::requests::streams::{
@@ -1327,6 +1439,8 @@
     use metadata::stm::user::{CreatePersonalAccessTokenRequest, PAT_TOKEN_HASH_BYTES};
     use metadata::{IggyMetadata, MetadataSubmitError};
     use partitions::{IggyPartitions, PartitionPathLayout, PartitionsConfig};
+    use prometheus_client::encoding::text::encode;
+    use prometheus_client::registry::Registry;
     use server_common::MESSAGE_ALIGN;
     use server_common::sharding::{PartitionLocation, ShardId};
     use shard::metrics::ShardMetrics;
@@ -1340,6 +1454,9 @@
     use std::sync::atomic::AtomicBool;
     use std::time::Duration;
 
+    /// Partitions of the topic that the lane tests of a fetch create.
+    const FETCH_PARTITIONS: u32 = 3;
+
     #[compio::test]
     #[allow(clippy::too_many_lines)]
     async fn given_a_verified_login_when_register_commits_after_user_deletion_should_refuse_id_reuse()
@@ -2095,7 +2212,7 @@
 
         let queues = ClientRequestQueues::default();
         let drain = Rc::new(ClientDrain::new(&server_config));
-        let dispatched = handle_client_request(
+        let mut dispatched = Box::pin(handle_client_request(
             &shard,
             &sessions,
             &server_config,
@@ -2104,8 +2221,7 @@
             ClientOrigin::capture(&shard.bus, READER_TRANSPORT),
             read.into_generic(),
             None,
-        );
-        futures::pin_mut!(dispatched);
+        ));
         assert!(futures::poll!(dispatched.as_mut()).is_pending());
         let Ok(ShardFrame::Lifecycle(frame)) = shard_inbox.try_recv() else {
             panic!("code {code} must wait on a shard");
@@ -2309,6 +2425,7 @@
                     fragments: Vec::new().into(),
                     current_offset: STORED_OFFSET,
                     encryptor: None,
+                    next_offset: None,
                 })
                 .unwrap(),
             LifecycleFrame::PartitionRead {
@@ -3638,6 +3755,7 @@
     /// does. A poll admitted after its own cancellation would park and commit
     /// with nothing left to abort it, so only that cancellation may keep it
     /// from its owner.
+    #[allow(clippy::too_many_lines)]
     async fn assert_cancel_of_queued_deferred_poll(cancel_names_the_poll: bool) {
         const POLL_REQUEST: u64 = 3;
         let cancelled = if cancel_names_the_poll {
@@ -3739,7 +3857,15 @@
             .map(|(_, frame)| reply_request(frame))
             .collect();
         replies.sort_unstable();
-        assert_eq!(replies, [1, 2], "a cancellation gets no reply of its own");
+        let expected: &[u64] = if cancel_names_the_poll {
+            &[1, 2, POLL_REQUEST]
+        } else {
+            &[1, 2]
+        };
+        assert_eq!(
+            replies, expected,
+            "a cancelled committing poll answers once, and the cancellation gets no reply of its own"
+        );
     }
 
     /// A deferred poll of partition 0 by the root session, sent as `request`.
@@ -3804,7 +3930,7 @@
         .into_generic();
         let key = progress_key(&shard, &store).expect("store must take a consumer progress lane");
         assert_eq!(key.0, namespace.inner());
-        let held = drain.admit_progress(key);
+        let held = drain.admit_progress(ProgressKeys::One(key));
         let poll = PollMessagesRequest {
             consumer: WireConsumer::consumer(WireIdentifier::numeric(CONSUMER_ID)),
             stream_id: WireIdentifier::numeric(0),
@@ -3879,6 +4005,333 @@
         );
     }
 
+    /// A fetch of `targets` of the first topic by the root session, sent as
+    /// `request`.
+    fn root_fetch(
+        request: u64,
+        auto_commit: bool,
+        targets: &[(u32, WirePollingStrategy)],
+    ) -> Message<GenericHeader> {
+        let max_bytes = u32::try_from(MIN_DEFERRED_POLL_MAX_BYTES).unwrap();
+        let fetch = FetchMessagesRequest {
+            consumer: WireConsumer::consumer(WireIdentifier::numeric(1)),
+            stream_id: WireIdentifier::numeric(0),
+            topic_id: WireIdentifier::numeric(0),
+            wait_us: 0,
+            request_timeout_us: MIN_DEFERRED_POLL_REPLY_MARGIN_US,
+            min_count: 1,
+            count: 1,
+            max_bytes,
+            auto_commit,
+            targets: targets
+                .iter()
+                .map(|(partition_id, strategy)| FetchMessagesTarget {
+                    partition_id: *partition_id,
+                    strategy: strategy.clone(),
+                    max_bytes,
+                    incarnation: 0,
+                    owner_generation: 0,
+                })
+                .collect(),
+        }
+        .to_bytes();
+        let mut message = wire_request(
+            Operation::NonReplicated,
+            ROOT_CLIENT,
+            ROOT_SESSION,
+            request,
+            &fetch,
+        );
+        bytemuck::checked::from_bytes_mut::<RequestHeader>(
+            &mut message.as_mut_slice()[..size_of::<RequestHeader>()],
+        )
+        .reserved[..4]
+            .copy_from_slice(&FETCH_MESSAGES_CODE.to_le_bytes());
+        message.into_generic()
+    }
+
+    /// A store of offset 0 of `partition_id` of the first topic by the root
+    /// session, sent as `request`.
+    fn root_store(request: u64, partition_id: u32) -> Message<GenericHeader> {
+        let store = StoreConsumerOffsetRequest {
+            consumer: WireConsumer::consumer(WireIdentifier::numeric(1)),
+            stream_id: WireIdentifier::numeric(0),
+            topic_id: WireIdentifier::numeric(0),
+            partition_id: Some(partition_id),
+            offset: 0,
+            ack: AckLevel::Quorum,
+        }
+        .to_bytes();
+        wire_request(
+            Operation::StoreConsumerOffset,
+            ROOT_CLIENT,
+            ROOT_SESSION,
+            request,
+            &store,
+        )
+        .into_generic()
+    }
+
+    /// The queue of the root transport, holding `messages` in order.
+    fn root_queue<const N: usize>(
+        bus: &SpyBus,
+        messages: [Message<GenericHeader>; N],
+    ) -> ClientRequestQueues {
+        let queue = messages
+            .into_iter()
+            .map(|message| {
+                QueuedClientRequest::new(ClientOrigin::capture(bus, ROOT_TRANSPORT), message)
+            })
+            .collect();
+        Rc::new(RefCell::new(AHashMap::from_iter([(ROOT_TRANSPORT, queue)])))
+    }
+
+    /// The request ids that wait in the queue of the root transport.
+    fn queued_requests(queues: &ClientRequestQueues) -> Vec<u64> {
+        queues
+            .borrow()
+            .get(&ROOT_TRANSPORT)
+            .map_or_else(Vec::new, |queue| {
+                queue
+                    .requests
+                    .iter()
+                    .filter_map(|queued| {
+                        queued
+                            .message
+                            .try_as_typed::<RequestHeader>()
+                            .ok()
+                            .map(|request| request.header().request)
+                    })
+                    .collect()
+            })
+    }
+
+    /// A fetch that commits, or that starts at a stored offset, takes the lane
+    /// that a poll of each of its partitions takes. A fetch of offsets without
+    /// a commit takes none.
+    #[compio::test]
+    async fn given_a_fetch_when_resolving_progress_should_take_the_lane_of_each_partition_it_can_commit()
+     {
+        let (_bus, shard, _host) = root_host(ServerConfig::default());
+        create_partitions_in(&shard.plane.metadata().mux_stm, FETCH_PARTITIONS);
+        let offset = || WirePollingStrategy::offset(0);
+        let next = WirePollingStrategy::next;
+        let partitions = [2, 0, 1];
+        let poll_lanes = ProgressKeys::Many(
+            partitions
+                .iter()
+                .map(|&partition_id| {
+                    let poll = PollMessagesRequest {
+                        consumer: WireConsumer::consumer(WireIdentifier::numeric(1)),
+                        stream_id: WireIdentifier::numeric(0),
+                        topic_id: WireIdentifier::numeric(0),
+                        partition_id: Some(partition_id),
+                        strategy: next(),
+                        count: 1,
+                        auto_commit: false,
+                    }
+                    .to_bytes();
+                    let mut message = wire_request(
+                        Operation::NonReplicated,
+                        ROOT_CLIENT,
+                        ROOT_SESSION,
+                        1,
+                        &poll,
+                    );
+                    bytemuck::checked::from_bytes_mut::<RequestHeader>(
+                        &mut message.as_mut_slice()[..size_of::<RequestHeader>()],
+                    )
+                    .reserved[..4]
+                        .copy_from_slice(&POLL_MESSAGES_CODE.to_le_bytes());
+                    progress_key(&shard, &message.into_generic())
+                        .expect("a poll of the next offset takes its lane")
+                })
+                .collect(),
+        );
+        for (strategies, auto_commit, takes_lanes) in [
+            ([offset(), next(), offset()], false, true),
+            ([offset(), offset(), offset()], true, true),
+            ([offset(), offset(), offset()], false, false),
+        ] {
+            let targets: Vec<_> = partitions.into_iter().zip(strategies).collect();
+            assert_eq!(
+                progress_keys(&shard, &root_fetch(1, auto_commit, &targets)),
+                takes_lanes.then(|| poll_lanes.clone()),
+                "{targets:?} with auto-commit {auto_commit}"
+            );
+        }
+        assert_eq!(
+            progress_keys(
+                &shard,
+                &root_fetch(1, true, &[(0, offset()), (u32::MAX, offset())])
+            ),
+            None,
+            "a partition id that no namespace can hold takes no lane"
+        );
+    }
+
+    /// The fetch is first in the queue, so only the lane can let the store
+    /// behind it go first.
+    #[compio::test]
+    async fn given_a_held_store_when_a_committing_fetch_queues_should_wait_while_other_partitions_pass()
+     {
+        const FETCH_REQUEST: u64 = 2;
+        const OTHER_STORE_REQUEST: u64 = 3;
+        let offset = || WirePollingStrategy::offset(0);
+        for store_held in [true, false] {
+            let (bus, shard, host) = root_host(ServerConfig::default());
+            create_partitions_in(&shard.plane.metadata().mux_stm, FETCH_PARTITIONS);
+            bind_root_session(&shard, host.sessions()).await;
+            let config = Arc::new(ServerConfig::default());
+            let drain = Rc::new(ClientDrain::new(&config));
+            let held = store_held.then(|| {
+                drain.admit_progress(
+                    progress_keys(&shard, &root_store(1, 1))
+                        .expect("a store takes a consumer progress lane"),
+                )
+            });
+            let queues = root_queue(
+                &bus,
+                [
+                    root_fetch(FETCH_REQUEST, true, &[(0, offset()), (1, offset())]),
+                    root_store(OTHER_STORE_REQUEST, 2),
+                ],
+            );
+            let running = drain_client_requests(
+                Rc::clone(&shard),
+                Rc::clone(host.sessions()),
+                config,
+                Rc::clone(&queues),
+                &drain,
+                ROOT_TRANSPORT,
+            );
+            futures::pin_mut!(running);
+            let _ = futures::poll!(running.as_mut());
+            let queued = queued_requests(&queues);
+            if store_held {
+                assert_eq!(
+                    queued,
+                    [FETCH_REQUEST],
+                    "a store of one of its partitions holds the fetch, and a store of \
+                     another partition passes it"
+                );
+            } else {
+                assert!(
+                    !queued.contains(&FETCH_REQUEST),
+                    "a fetch with free lanes goes first"
+                );
+            }
+            drop(held);
+        }
+    }
+
+    #[compio::test]
+    async fn given_a_running_fetch_when_stores_queue_should_hold_only_the_stores_of_its_partitions()
+    {
+        const HELD_STORE_REQUEST: u64 = 2;
+        const OTHER_STORE_REQUEST: u64 = 3;
+        let offset = || WirePollingStrategy::offset(0);
+        let (bus, shard, host) = root_host(ServerConfig::default());
+        create_partitions_in(&shard.plane.metadata().mux_stm, FETCH_PARTITIONS);
+        bind_root_session(&shard, host.sessions()).await;
+        let config = Arc::new(ServerConfig::default());
+        let drain = Rc::new(ClientDrain::new(&config));
+        let lanes = progress_keys(
+            &shard,
+            &root_fetch(1, true, &[(0, offset()), (1, offset())]),
+        )
+        .expect("an auto-committing fetch takes the lanes of its partitions");
+        let taken = lanes.as_slice().to_vec();
+        let fetch = drain.admit_progress(lanes);
+        let queues = root_queue(
+            &bus,
+            [
+                root_store(HELD_STORE_REQUEST, 1),
+                root_store(OTHER_STORE_REQUEST, 2),
+            ],
+        );
+        let running = drain_client_requests(
+            Rc::clone(&shard),
+            Rc::clone(host.sessions()),
+            config,
+            Rc::clone(&queues),
+            &drain,
+            ROOT_TRANSPORT,
+        );
+        futures::pin_mut!(running);
+        let _ = futures::poll!(running.as_mut());
+        assert_eq!(queued_requests(&queues), [HELD_STORE_REQUEST]);
+
+        drop(fetch);
+        assert!(
+            taken
+                .iter()
+                .all(|lane| !drain.progress.borrow().contains(lane)),
+            "the end of the fetch frees every lane that it took"
+        );
+    }
+
+    /// A non-replicated frame counts under the code in its header, a
+    /// replicated one under its operation, a login or a logout under its
+    /// command, and a frame that declares no command as unknown. A cancel
+    /// counts too, although the queue answers it before admission.
+    #[compio::test]
+    async fn given_client_frames_when_enqueued_should_count_each_under_its_command() {
+        const UNKNOWN_COMMAND: &str = "unknown";
+        let bus = SpyBus::default();
+        let shard = Rc::new(test_shard(&bus, 0, 1, FIRST_BOOT));
+        commit_root_session(&shard);
+        let shard_handle = Rc::new(RefCell::new(Some(Rc::downgrade(&shard))));
+        let sessions = Rc::new(RefCell::new(SessionManager::new()));
+        bind_root_session(&shard, &sessions).await;
+        let server_config = Arc::new(ServerConfig::default());
+        let queues: ClientRequestQueues = Rc::default();
+        let active: ActiveClientRequests = Rc::default();
+        let enqueue = |message| {
+            enqueue_client_request(
+                &bus,
+                &shard_handle,
+                &sessions,
+                &server_config,
+                &queues,
+                &active,
+                ROOT_TRANSPORT,
+                message,
+            );
+        };
+        let mut undeclared = root_read(PING_CODE, 5).into_generic();
+        undeclared.as_mut_slice()[offset_of!(RequestHeader, operation)] = UNDECLARED_OPERATION;
+
+        enqueue(root_read(PING_CODE, 1).into_generic());
+        enqueue(root_read(PING_CODE, 2).into_generic());
+        enqueue(root_read(CANCEL_DEFERRED_REQUEST_CODE, 3).into_generic());
+        enqueue(root_store(4, 0));
+        enqueue(undeclared);
+        enqueue(wire_request(Operation::Register, ROOT_CLIENT, 0, 0, &[]).into_generic());
+        enqueue(wire_request(Operation::Logout, ROOT_CLIENT, ROOT_SESSION, 6, &[]).into_generic());
+        settle().await;
+
+        let mut registry = Registry::default();
+        shard.metrics().register(&mut registry);
+        let mut scrape = String::new();
+        encode(&mut scrape, &registry).expect("the scrape encodes");
+        let command = |code| lookup_command(code).expect("a declared command").name;
+        for (command, count) in [
+            (command(PING_CODE), 2),
+            (command(CANCEL_DEFERRED_REQUEST_CODE), 1),
+            (command(STORE_CONSUMER_OFFSET_CODE), 1),
+            (command(LOGIN_REGISTER_CODE), 1),
+            (command(LOGOUT_USER_CODE), 1),
+            (UNKNOWN_COMMAND, 1),
+        ] {
+            let series = format!("client_requests_total{{command=\"{command}\"}} {count}");
+            assert!(
+                scrape.lines().any(|line| line == series),
+                "{series} is not in the scrape:\n{scrape}"
+            );
+        }
+    }
+
     #[compio::test]
     async fn given_queued_progress_when_metadata_changes_should_resolve_once_per_frontier() {
         const CREATED_FRONTIER: u64 = ROOT_SESSION + 1;
@@ -3910,10 +4363,10 @@
         let resolutions = Cell::new(0);
         let resolve = |message: &Message<GenericHeader>| {
             resolutions.set(resolutions.get() + 1);
-            progress_key(&shard, message)
+            progress_keys(&shard, message)
         };
         for _ in 0..3 {
-            assert_eq!(queued.progress_key(ROOT_SESSION, resolve), None);
+            assert_eq!(queued.progress_keys(ROOT_SESSION, resolve), None);
         }
         assert_eq!(
             resolutions.get(),
@@ -3928,8 +4381,12 @@
             .advance(CREATED_FRONTIER);
         for _ in 0..3 {
             assert_eq!(
-                queued.progress_key(shard.plane.metadata().applied_frontier().get(), resolve),
-                Some((namespace.inner(), iggy_binary_protocol::KIND_CONSUMER, 1))
+                queued.progress_keys(shard.plane.metadata().applied_frontier().get(), resolve),
+                Some(&ProgressKeys::One((
+                    namespace.inner(),
+                    iggy_binary_protocol::KIND_CONSUMER,
+                    1
+                )))
             );
         }
         assert_eq!(
diff --git a/core/server/src/dispatch/partition.rs b/core/server/src/dispatch/partition.rs
index de2d5e6..2b1171e 100644
--- a/core/server/src/dispatch/partition.rs
+++ b/core/server/src/dispatch/partition.rs
@@ -33,7 +33,7 @@
     send_non_replicated_deny, send_result_rejection,
 };
 use crate::dispatch::submit::submit_client_request_on_owner;
-use crate::dispatch::{ConsumerProgress, InFlight};
+use crate::dispatch::{ConsumerProgress, InFlight, ProgressKeys};
 use crate::namespace::{resolve_partition_namespace, resolve_partition_request_namespace};
 use crate::reply_frame::{
     build_consumer_offset_body, build_polled_messages_reply, current_metadata_commit,
@@ -45,7 +45,7 @@
 use consensus::client_table::SessionAttachment;
 use iggy_binary_protocol::PrepareHeader;
 use iggy_binary_protocol::codes::{
-    GET_CONSUMER_OFFSET_CODE, POLL_MESSAGES_CODE, POLL_MESSAGES_DEFERRED_CODE,
+    FETCH_MESSAGES_CODE, GET_CONSUMER_OFFSET_CODE, POLL_MESSAGES_CODE, POLL_MESSAGES_DEFERRED_CODE,
 };
 use iggy_binary_protocol::primitives::consumer::WireConsumer;
 use iggy_binary_protocol::primitives::partition_history::PartitionContext;
@@ -54,7 +54,7 @@
     DeleteConsumerOffsetRequest, GetConsumerOffsetRequest, StoreConsumerOffsetRequest,
 };
 use iggy_binary_protocol::requests::messages::{
-    DeferredPollMessagesRequest, PollMessagesRequest, WaitForMessagesRequest,
+    DeferredPollMessagesRequest, FetchMessagesRequest, PollMessagesRequest,
 };
 use iggy_binary_protocol::requests::segments::DeleteSegmentsRequest;
 use iggy_binary_protocol::responses::messages::poll_messages::PollMessagesResponseHeader;
@@ -62,9 +62,7 @@
     GenericHeader, KIND_CONSUMER, KIND_CONSUMER_GROUP, KIND_EXTERNAL_GROUP, Operation,
     RequestHeader, RoutedRequestHeader, WireDecode, WireEncode, WireIdentifier,
 };
-use iggy_common::{
-    ConsumerKind, IggyError, PollingKind, PollingStrategy, RESYNC_REQUIRED_PARTITION_SENTINEL,
-};
+use iggy_common::{ConsumerKind, IggyError, PollingStrategy, RESYNC_REQUIRED_PARTITION_SENTINEL};
 use journal::superblock::SuperblockStore;
 use journal::{Journal, JournalHandle};
 use message_bus::BusMessage;
@@ -80,7 +78,7 @@
 use shard::shards_table::ShardsTable;
 use shard::{
     ConsumerAttachment, DeferredPollContext, DeferredPollRequest, PartitionRead,
-    PartitionReadReply, PollQuotaIdentity, ReadinessTarget,
+    PartitionReadReply, PollQuotaIdentity,
 };
 use std::rc::Rc;
 use tracing::{debug, warn};
@@ -483,17 +481,85 @@
         _ => return None,
     };
     let (namespace, _, target) = resolve_consumer_offset_request(shard, &wire).ok()?;
-    let (kind, consumer_id) = match target {
-        ConsumerOffsetTarget::Consumer(PollingConsumer::Consumer(id, _)) => (KIND_CONSUMER, id),
-        ConsumerOffsetTarget::Consumer(PollingConsumer::ConsumerGroup(id, _)) => {
-            (KIND_CONSUMER_GROUP, id)
-        }
-        ConsumerOffsetTarget::ExternalGroup(group_id) => (KIND_EXTERNAL_GROUP, group_id as usize),
-        ConsumerOffsetTarget::MissingGroup => return None,
-    };
+    let (kind, consumer_id) = progress_consumer(&target)?;
     Some((namespace.inner(), kind, consumer_id))
 }
 
+/// The progress lanes of a frame for the drain: the one of [`progress_key`],
+/// or every partition of a fetch that commits or resolves a stored offset.
+pub(super) fn progress_keys<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
+    message: &Message<GenericHeader>,
+) -> Option<ProgressKeys>
+where
+    B: ShellBus,
+    MJ: JournalHandle + 'static,
+    MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+    S: 'static,
+    SB: SuperblockStore + 'static,
+{
+    let request = message.try_as_typed::<RequestHeader>().ok()?;
+    if request.header().operation != Operation::NonReplicated
+        || request.header().reserved[..4] != FETCH_MESSAGES_CODE.to_le_bytes()
+    {
+        return progress_key(shard, message).map(ProgressKeys::One);
+    }
+    let fetch = FetchMessagesRequest::decode_from(request.body()).ok()?;
+    if !fetch.auto_commit
+        && fetch
+            .targets
+            .iter()
+            .all(|target| target.strategy.kind != WirePollingStrategy::next().kind)
+    {
+        return None;
+    }
+    // The targets share the stream, the topic and the consumer, so one
+    // resolution names them all.
+    let (namespace, _, target) = resolve_consumer_offset_request(
+        shard,
+        &GetConsumerOffsetRequest {
+            consumer: fetch.consumer,
+            stream_id: fetch.stream_id,
+            topic_id: fetch.topic_id,
+            partition_id: fetch.targets.first().map(|target| target.partition_id),
+        },
+    )
+    .ok()?;
+    let (kind, consumer_id) = progress_consumer(&target)?;
+    // A wire partition id can be out of range, where `IggyNamespace::new`
+    // panics. Such a fetch fails in its handler before any read, so it needs
+    // no lane.
+    let keys = fetch
+        .targets
+        .iter()
+        .map(|target| {
+            IggyNamespace::try_new(
+                namespace.stream_id(),
+                namespace.topic_id(),
+                target.partition_id as usize,
+            )
+            .map(|partition| (partition.inner(), kind, consumer_id))
+        })
+        .collect::<Option<Vec<_>>>()?;
+    Some(ProgressKeys::Many(keys))
+}
+
+/// The consumer kind and id that key the progress lane of `target`.
+const fn progress_consumer(target: &ConsumerOffsetTarget) -> Option<(u8, usize)> {
+    match target {
+        ConsumerOffsetTarget::Consumer(PollingConsumer::Consumer(id, _)) => {
+            Some((KIND_CONSUMER, *id))
+        }
+        ConsumerOffsetTarget::Consumer(PollingConsumer::ConsumerGroup(id, _)) => {
+            Some((KIND_CONSUMER_GROUP, *id))
+        }
+        ConsumerOffsetTarget::ExternalGroup(group_id) => {
+            Some((KIND_EXTERNAL_GROUP, *group_id as usize))
+        }
+        ConsumerOffsetTarget::MissingGroup => None,
+    }
+}
+
 pub fn capture_offset_attachment(
     streams: &metadata::stm::stream::Streams,
     namespace: IggyNamespace,
@@ -740,75 +806,6 @@
     Ok((namespace, partition_id, request))
 }
 
-/// Resolve and authorize every partition of a readiness wait with the fence
-/// and metadata identity of a poll. The owner checks them again at each
-/// evaluation. Returns the caller that every target shares, and the targets.
-pub(super) fn resolve_readiness_wait<B, MJ, S, SB>(
-    shard: &Rc<ShellShard<B, MJ, S, SB>>,
-    wire: WaitForMessagesRequest,
-    (consumer_client_id, session, user_id): (u128, SessionAttachment, u32),
-) -> Result<(DeferredPollContext, Vec<ReadinessTarget>), IggyError>
-where
-    B: ShellBus,
-    MJ: JournalHandle + 'static,
-    MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
-    S: 'static,
-    SB: SuperblockStore + 'static,
-{
-    if let Some(status) = authorize_partition_read(
-        shard,
-        &wire.stream_id,
-        &wire.topic_id,
-        Some(user_id),
-        |permissioner, user_id, stream_id, topic_id| {
-            permissioner.poll_messages(user_id, stream_id, topic_id)
-        },
-    ) {
-        return Err(IggyError::from_code(status));
-    }
-    let mut poll = PollMessagesRequest {
-        consumer: wire.consumer,
-        stream_id: wire.stream_id,
-        topic_id: wire.topic_id,
-        partition_id: None,
-        strategy: WirePollingStrategy::next(),
-        count: 1,
-        auto_commit: false,
-    };
-    let targets = wire
-        .partitions
-        .into_iter()
-        .map(|target| {
-            poll.partition_id = Some(target.partition_id);
-            poll.strategy = target.strategy;
-            let (namespace, partition_id, consumer, args) =
-                resolve_poll_request(shard, &poll, consumer_client_id)?;
-            if !matches!(args.kind, PollingKind::Offset | PollingKind::Next) {
-                return Err(IggyError::InvalidCommand);
-            }
-            Ok(ReadinessTarget {
-                namespace,
-                partition_id,
-                consumer,
-                strategy: PollingStrategy {
-                    kind: args.kind,
-                    value: args.value,
-                    context: None,
-                },
-                metadata: consumer_poll_metadata(shard, namespace, consumer, consumer_client_id)?,
-            })
-        })
-        .collect::<Result<_, _>>()?;
-    let caller = DeferredPollContext {
-        session: Some(session),
-        user_id,
-        user_created_at: None,
-        quota: PollQuotaIdentity::Session(consumer_client_id),
-        primary: true,
-    };
-    Ok((caller, targets))
-}
-
 fn consumer_poll_metadata<B, MJ, S, SB>(
     shard: &Rc<ShellShard<B, MJ, S, SB>>,
     namespace: IggyNamespace,
@@ -865,6 +862,7 @@
             fragments,
             current_offset,
             encryptor,
+            ..
         }) => build_polled_messages_reply(
             request.header(),
             current_metadata_commit(shard),
@@ -951,6 +949,7 @@
             fragments,
             current_offset,
             encryptor,
+            ..
         }) => build_polled_messages_reply(
             request.header(),
             current_metadata_commit(shard),
@@ -1449,7 +1448,7 @@
     Ok((namespace, partition_id, target))
 }
 
-fn polling_consumer_from_wire(
+pub(super) fn polling_consumer_from_wire(
     consumer: &WireConsumer,
     partition_id: u32,
 ) -> Result<PollingConsumer, IggyError> {
@@ -1717,9 +1716,9 @@
 mod tests {
     use super::*;
     use crate::dispatch::test_support::{
-        SpyBus, TestMux, TestShard, create_consumer_group, create_one_partition,
-        create_reader_request, prepare_message, register_reply, request_message,
-        stored_batch_record, test_shard, test_shard_with_frontier,
+        SpyBus, TestMux, TestShard, attach_root_session, create_consumer_group,
+        create_one_partition, create_reader_request, prepare_message, request_message,
+        route_one_partition, stored_batch_record, test_shard, test_shard_with_frontier,
     };
     use crate::dispatch::tests::non_replicated_request;
     use crate::dispatch::{
@@ -3072,6 +3071,7 @@
                         fragments: PollFragments::from_iter([Fragment::whole(record)]),
                         current_offset: 1,
                         encryptor: Some(Arc::clone(&encryptor)),
+                        next_offset: None,
                     })
                     .expect("the caller still awaits the reply");
             }
@@ -3995,16 +3995,6 @@
         }
     }
 
-    /// [`create_one_partition`] with the partition routed to shard 0, so
-    /// authorization and resolution let a read reach the owner inbox.
-    fn route_one_partition(shard: &TestShard) -> IggyNamespace {
-        let namespace = create_one_partition(shard);
-        shard
-            .shards_table()
-            .insert(namespace, PartitionLocation::new(ShardId::new(0), 0));
-        namespace
-    }
-
     /// `request` as a client that has seen the partition create sends it: it
     /// names the incarnation of `namespace`.
     fn with_partition_incarnation(
@@ -4025,23 +4015,6 @@
         })
     }
 
-    /// Registers `client_id` as the root user and attaches `session`, the
-    /// state of a bound TCP connection.
-    fn attach_root_session(shard: &TestShard, client_id: u128, session: u64) -> SessionAttachment {
-        let mut table = shard.plane.metadata().client_table.borrow_mut();
-        table
-            .commit_register(
-                client_id,
-                DEFAULT_ROOT_USER_ID,
-                [0x5a; 32],
-                register_reply(client_id, session),
-            )
-            .unwrap();
-        table
-            .attach_session(client_id, session, DEFAULT_ROOT_USER_ID)
-            .unwrap()
-    }
-
     /// A partition write whose routable wait exhausts (namespace committed,
     /// but no reconciler ever seeds this shard's routing row -- the state a
     /// teardown/rematerialise churn leaves behind) must answer a nonzero
diff --git a/core/server/src/dispatch/reads.rs b/core/server/src/dispatch/reads.rs
index 4f9173c..5835838 100644
--- a/core/server/src/dispatch/reads.rs
+++ b/core/server/src/dispatch/reads.rs
@@ -35,18 +35,18 @@
     ClientOrigin, FrameChannel, send_host_frame, send_non_replicated_bytes,
     send_non_replicated_deny,
 };
+use crate::dispatch::fetch::fetch_messages;
 use crate::dispatch::metadata_changes::metadata_changes_reply;
 use crate::dispatch::partition::{
     handle_get_consumer_offset, handle_poll_messages, read_deferred_poll, resolve_deferred_poll,
-    resolve_readiness_wait,
 };
 use crate::namespace::resolve_partition_namespace;
-use crate::reply_frame::{build_empty_reply, build_reply_from_bytes, current_metadata_commit};
+use crate::reply_frame::{build_empty_reply, current_metadata_commit};
 use crate::responses::{
     build_get_me_response, build_get_personal_access_tokens_response,
     build_non_replicated_response, connected_client_to_response,
 };
-use crate::session_manager::{ConnectionContext, SessionManager};
+use crate::session_manager::{ConnectionContext, FetchReadGuard, SessionManager};
 use crate::shell::{ShellBus, ShellShard};
 use crate::snapshot;
 use crate::wire::request_body;
@@ -57,16 +57,17 @@
 use futures::future::{Abortable, Either, select};
 use iggy_binary_protocol::PrepareHeader;
 use iggy_binary_protocol::codes::{
-    BIND_SESSION_CODE, CANCEL_DEFERRED_REQUEST_CODE, DESCRIBE_OPTIONS_CODE, GET_CLIENT_CODE,
-    GET_CLIENTS_CODE, GET_CLUSTER_METADATA_CODE, GET_CONSUMER_OFFSET_CODE, GET_ME_CODE,
-    GET_METADATA_CHANGES_CODE, GET_PARTITION_ROUTE_CODE, GET_PERSONAL_ACCESS_TOKENS_CODE,
-    GET_SNAPSHOT_FILE_CODE, GET_STATS_CODE, PING_CODE, POLL_MESSAGES_CODE,
-    POLL_MESSAGES_DEFERRED_CODE, SYNC_CONSUMER_GROUP_CODE, WAIT_FOR_MESSAGES_CODE,
+    BIND_SESSION_CODE, CANCEL_DEFERRED_REQUEST_CODE, DESCRIBE_OPTIONS_CODE, FETCH_MESSAGES_CODE,
+    GET_CLIENT_CODE, GET_CLIENTS_CODE, GET_CLUSTER_METADATA_CODE, GET_CONSUMER_OFFSET_CODE,
+    GET_ME_CODE, GET_METADATA_CHANGES_CODE, GET_PARTITION_ROUTE_CODE,
+    GET_PERSONAL_ACCESS_TOKENS_CODE, GET_SNAPSHOT_FILE_CODE, GET_STATS_CODE, PING_CODE,
+    POLL_MESSAGES_CODE, POLL_MESSAGES_DEFERRED_CODE, SYNC_CONSUMER_GROUP_CODE,
 };
 use iggy_binary_protocol::dispatch::lookup_command;
 use iggy_binary_protocol::requests::consumer_groups::SyncConsumerGroupRequest;
 use iggy_binary_protocol::requests::messages::{
-    DeferredPollMessagesRequest, GetPartitionRouteRequest, WaitForMessagesRequest,
+    DeferredPollMessagesRequest, FetchMessagesRequest, GetPartitionRouteRequest,
+    MIN_DEFERRED_POLL_REPLY_MARGIN_US,
 };
 use iggy_binary_protocol::requests::system::BindSessionRequest;
 use iggy_binary_protocol::requests::system::get_client::GetClientRequest;
@@ -75,7 +76,7 @@
 use iggy_binary_protocol::responses::clients::get_client::ClientDetailsResponse;
 use iggy_binary_protocol::responses::clients::get_clients::GetClientsResponse;
 use iggy_binary_protocol::responses::consumer_groups::SyncConsumerGroupResponse;
-use iggy_binary_protocol::responses::messages::{PartitionRouteResponse, WaitForMessagesResponse};
+use iggy_binary_protocol::responses::messages::PartitionRouteResponse;
 use iggy_binary_protocol::responses::system::get_snapshot::GetSnapshotResponse;
 use iggy_binary_protocol::responses::users::LoginRegisterResponse;
 use iggy_binary_protocol::{HEADER_SIZE, RoutedRequestHeader, WireDecode, WireEncode};
@@ -88,7 +89,7 @@
 use metadata::impls::metadata::StreamsFrontend;
 use metadata::permissioner::Permissioner;
 use server_common::Message;
-use shard::{PartitionRead, PartitionReadReply};
+use shard::{CommitGate, PartitionRead, PartitionReadReply};
 use std::cell::{Cell, RefCell};
 use std::future::Future;
 use std::net::IpAddr;
@@ -198,8 +199,8 @@
 /// - `GetClusterMetadata` answers from the configured roster plus the
 ///   consensus view, and sits on the SDK's leader-discovery path, where the
 ///   wait would be real.
-/// - `PollMessages`, `PollMessagesDeferred`, `WaitForMessages` and
-///   `GetConsumerOffset` are partition-plane reads: their answer comes from a
+/// - `PollMessages`, `PollMessagesDeferred`,
+///   `FetchMessages` and `GetConsumerOffset` are partition-plane reads: their answer comes from a
 ///   partition group's own log, not the metadata STM, and holding them would
 ///   put metadata lag on the data path.
 /// - `CancelDeferredRequest` reads nothing and gets no reply.
@@ -219,7 +220,7 @@
             | GET_CLUSTER_METADATA_CODE
             | POLL_MESSAGES_CODE
             | POLL_MESSAGES_DEFERRED_CODE
-            | WAIT_FOR_MESSAGES_CODE
+            | FETCH_MESSAGES_CODE
             | CANCEL_DEFERRED_REQUEST_CODE
             | GET_CONSUMER_OFFSET_CODE
             | GET_SNAPSHOT_FILE_CODE
@@ -587,7 +588,7 @@
                 }
             }
         }
-        POLL_MESSAGES_CODE | POLL_MESSAGES_DEFERRED_CODE | WAIT_FOR_MESSAGES_CODE => {
+        POLL_MESSAGES_CODE | POLL_MESSAGES_DEFERRED_CODE | FETCH_MESSAGES_CODE => {
             let consumer_client = bound
                 .zip(attachment)
                 .map(|((client_id, _), attachment)| (client_id, attachment))
@@ -656,7 +657,16 @@
     }
 }
 
-#[allow(clippy::future_not_send, clippy::too_many_arguments)]
+enum DeferredRequest {
+    Poll(DeferredPollMessagesRequest),
+    Fetch(FetchMessagesRequest),
+}
+
+#[allow(
+    clippy::future_not_send,
+    clippy::too_many_arguments,
+    clippy::too_many_lines
+)]
 async fn handle_deferred_request<B, MJ, S, SB>(
     shard: &Rc<ShellShard<B, MJ, S, SB>>,
     sessions: &Rc<RefCell<SessionManager>>,
@@ -673,24 +683,52 @@
     S: 'static,
     SB: SuperblockStore + 'static,
 {
-    let decoded = if code == POLL_MESSAGES_DEFERRED_CODE {
-        DeferredPollMessagesRequest::decode_from(request_body(request)).map(Either::Left)
-    } else {
-        WaitForMessagesRequest::decode_from(request_body(request)).map(Either::Right)
+    let body = request_body(request);
+    let decoded = match code {
+        POLL_MESSAGES_DEFERRED_CODE => {
+            DeferredPollMessagesRequest::decode_from(body).map(DeferredRequest::Poll)
+        }
+        _ => FetchMessagesRequest::decode_from(body).map(DeferredRequest::Fetch),
     };
-    let Ok(wire) = decoded else {
+    let Ok(mut wire) = decoded else {
         send_non_replicated_deny(shard, request, origin, IggyError::InvalidCommand.as_code()).await;
         return;
     };
-    let timeout_us = match &wire {
-        Either::Left(poll) => poll.request_timeout_us,
-        Either::Right(wait) => wait.request_timeout_us,
+    let (wait_us, timeout_us) = match &mut wire {
+        DeferredRequest::Poll(poll) => (&mut poll.wait_us, &mut poll.request_timeout_us),
+        DeferredRequest::Fetch(fetch) => (&mut fetch.wait_us, &mut fetch.request_timeout_us),
     };
+    let elapsed_us = shard
+        .bus
+        .monotonic_micros()
+        .saturating_sub(origin.arrived_us());
+    // A frame that waited in the drain gets only the time its client still waits for it, so a
+    // request that its client gave up on stores nothing.
+    let Some((remaining_wait_us, remaining_timeout_us)) =
+        remaining_timers(*wait_us, *timeout_us, elapsed_us)
+    else {
+        send_non_replicated_deny(
+            shard,
+            request,
+            origin,
+            IggyError::TransientNotAccepted.as_code(),
+        )
+        .await;
+        return;
+    };
+    *wait_us = remaining_wait_us;
+    *timeout_us = remaining_timeout_us;
+    let timeout_us = remaining_timeout_us;
+    let committing = match &wire {
+        DeferredRequest::Poll(poll) => poll.poll.auto_commit,
+        DeferredRequest::Fetch(fetch) => fetch.auto_commit,
+    };
+    let commit_gate = committing.then(CommitGate::default);
     serve_deferred_request(
         shard,
         sessions,
         (origin, request),
-        Duration::from_micros(timeout_us),
+        (Duration::from_micros(timeout_us), commit_gate.clone()),
         async {
             let user_id = user_id.ok_or(IggyError::Unauthenticated)?;
             if !attachment.is_valid() {
@@ -706,52 +744,32 @@
                 return Err(IggyError::TransientNotAccepted);
             }
             match wire {
-                Either::Left(poll) => {
-                    let resolved = resolve_deferred_poll(
+                DeferredRequest::Poll(poll) => {
+                    let (namespace, partition_id, mut resolved) = resolve_deferred_poll(
                         shard,
                         &poll,
                         request.header(),
                         (consumer_client_id, attachment.clone(), user_id),
                     )?;
-                    read_deferred_poll(shard, request, resolved, attachment).await
-                }
-                Either::Right(wait) => {
-                    let duration = Duration::from_micros(shard.deferred_wait_us(wait.wait_us));
-                    let (caller, targets) = resolve_readiness_wait(
+                    resolved.commit_gate = commit_gate;
+                    read_deferred_poll(
                         shard,
-                        wait,
-                        (consumer_client_id, attachment.clone(), user_id),
-                    )?;
-                    let contexts: Vec<_> = targets
-                        .iter()
-                        .map(|target| {
-                            (
-                                target.namespace,
-                                target.metadata.context(current_metadata_commit(shard)),
-                            )
-                        })
-                        .collect();
-                    let partitions = shard.wait_for_messages(caller, targets, duration).await?;
-                    for (namespace, context) in contexts {
-                        authorize_served_partition_read(
-                            shard,
-                            Some(attachment),
-                            namespace,
-                            context.incarnation,
-                            Permissioner::poll_messages,
-                        )?;
-                    }
-                    let header = request.header();
-                    Ok(build_reply_from_bytes(
-                        header,
-                        header.client,
-                        header.session,
-                        current_metadata_commit(shard),
-                        &WaitForMessagesResponse { partitions }.to_bytes(),
+                        request,
+                        (namespace, partition_id, resolved),
+                        attachment,
                     )
-                    .into_generic()
-                    .into_frozen()
-                    .into())
+                    .await
+                    .map(|reply| (reply, Vec::new()))
+                }
+                DeferredRequest::Fetch(fetch) => {
+                    Box::pin(fetch_messages(
+                        shard,
+                        sessions,
+                        request,
+                        (&fetch, commit_gate),
+                        (consumer_client_id, attachment, user_id),
+                    ))
+                    .await
                 }
             }
         },
@@ -764,8 +782,8 @@
     shard: &Rc<ShellShard<B, MJ, S, SB>>,
     sessions: &Rc<RefCell<SessionManager>>,
     (origin, request): (ClientOrigin, &Message<RoutedRequestHeader>),
-    watchdog: Duration,
-    reply: impl Future<Output = Result<BusMessage, IggyError>>,
+    (watchdog, commit_gate): (Duration, Option<CommitGate>),
+    reply: impl Future<Output = Result<(BusMessage, Vec<FetchReadGuard>), IggyError>>,
 ) where
     B: ShellBus,
     MJ: JournalHandle + 'static,
@@ -776,7 +794,7 @@
     let (guard, cancellation) = match SessionManager::begin_deferred_request(
         sessions,
         origin.id,
-        request.header().request,
+        (request.header().request, commit_gate.clone()),
         watchdog,
         shard.bus.monotonic_now(),
     ) {
@@ -790,7 +808,8 @@
     let outcome = {
         let operation = Abortable::new(
             async {
-                let mut reply = reply.await?;
+                // The session room of the reads stays held until the reply is written.
+                let (mut reply, _held_reads) = reply.await?;
                 let written = reply.track_write();
                 // A watchdog expiry while the send waits for room drops the reply
                 // unsent, so only a returned send counts as queued.
@@ -806,6 +825,13 @@
         let timeout = pin!(shard.bus.sleep(watchdog));
         match select(operation, timeout).await {
             Either::Left((Ok(result), _)) => Some(result),
+            // A committing request that its client cancelled before any read
+            // committed answers once, so the client knows nothing was stored.
+            Either::Left((Err(_), _))
+                if commit_gate.as_ref().is_some_and(CommitGate::is_cancelled) =>
+            {
+                Some(Err(IggyError::TransientNotAccepted))
+            }
             Either::Left((Err(_), _)) => None,
             Either::Right(_) => Some(Err(IggyError::ShardCommunicationError)),
         }
@@ -822,6 +848,21 @@
     }
 }
 
+/// The wait and the request timeout of a deferred request whose frame arrived
+/// `elapsed_us` ago, or `None` once its timeout passed. The wait keeps the reply
+/// margin before the shortened timeout.
+const fn remaining_timers(wait_us: u64, timeout_us: u64, elapsed_us: u64) -> Option<(u64, u64)> {
+    match timeout_us.checked_sub(elapsed_us) {
+        Some(remaining) if remaining > 0 => {
+            let wait_us = wait_us.saturating_sub(elapsed_us);
+            let room = remaining.saturating_sub(MIN_DEFERRED_POLL_REPLY_MARGIN_US);
+            let wait = if wait_us < room { wait_us } else { room };
+            Some((wait, remaining))
+        }
+        _ => None,
+    }
+}
+
 #[allow(clippy::future_not_send)]
 async fn bind_session<B, MJ, S, SB>(
     shard: &Rc<ShellShard<B, MJ, S, SB>>,
@@ -1227,15 +1268,15 @@
 #[cfg(test)]
 mod tests {
     use super::{
-        FrontierUnreached, FrontierWait, hold_for_frontier, read_frontier_budget,
-        read_needs_metadata_frontier,
+        FrontierUnreached, FrontierWait, MIN_DEFERRED_POLL_REPLY_MARGIN_US, hold_for_frontier,
+        read_frontier_budget, read_needs_metadata_frontier, remaining_timers,
     };
     use configs::server::ServerConfig;
     use iggy_binary_protocol::codes::{
-        CANCEL_DEFERRED_REQUEST_CODE, DESCRIBE_OPTIONS_CODE, GET_CLUSTER_METADATA_CODE,
-        GET_CONSUMER_OFFSET_CODE, GET_ME_CODE, GET_SNAPSHOT_FILE_CODE, GET_STREAM_CODE, PING_CODE,
-        POLL_MESSAGES_CODE, POLL_MESSAGES_DEFERRED_CODE, SYNC_CONSUMER_GROUP_CODE,
-        WAIT_FOR_MESSAGES_CODE,
+        CANCEL_DEFERRED_REQUEST_CODE, DESCRIBE_OPTIONS_CODE, FETCH_MESSAGES_CODE,
+        GET_CLUSTER_METADATA_CODE, GET_CONSUMER_OFFSET_CODE, GET_ME_CODE, GET_SNAPSHOT_FILE_CODE,
+        GET_STREAM_CODE, PING_CODE, POLL_MESSAGES_CODE, POLL_MESSAGES_DEFERRED_CODE,
+        SYNC_CONSUMER_GROUP_CODE,
     };
     use iggy_common::IggyDuration;
     use metadata::AppliedFrontier;
@@ -1356,7 +1397,7 @@
             GET_CLUSTER_METADATA_CODE,
             POLL_MESSAGES_CODE,
             POLL_MESSAGES_DEFERRED_CODE,
-            WAIT_FOR_MESSAGES_CODE,
+            FETCH_MESSAGES_CODE,
             CANCEL_DEFERRED_REQUEST_CODE,
             GET_CONSUMER_OFFSET_CODE,
             GET_SNAPSHOT_FILE_CODE,
@@ -1372,4 +1413,43 @@
             "an unknown code has no answer to hold, so it must not park"
         );
     }
+
+    #[test]
+    fn given_a_queued_deferred_frame_when_timeout_has_room_should_subtract_queue_time_from_wait() {
+        const SECOND: u64 = 1_000_000;
+        assert_eq!(
+            remaining_timers(5 * SECOND, 10 * SECOND, 2 * SECOND),
+            Some((3 * SECOND, 8 * SECOND))
+        );
+        assert_eq!(
+            remaining_timers(5 * SECOND, 10 * SECOND, 6 * SECOND),
+            Some((0, 4 * SECOND))
+        );
+    }
+
+    #[test]
+    fn given_a_queued_deferred_frame_when_timed_should_keep_only_what_its_client_still_waits() {
+        const SECOND: u64 = 1_000_000;
+        let margin = MIN_DEFERRED_POLL_REPLY_MARGIN_US;
+        assert_eq!(
+            remaining_timers(5 * SECOND, 5 * SECOND + margin, 0),
+            Some((5 * SECOND, 5 * SECOND + margin)),
+            "a frame served on arrival keeps its timers"
+        );
+        assert_eq!(
+            remaining_timers(5 * SECOND, 5 * SECOND + margin, 2 * SECOND),
+            Some((3 * SECOND, 3 * SECOND + margin)),
+            "the wait keeps the reply margin before the shortened timeout"
+        );
+        assert_eq!(
+            remaining_timers(0, 10 * SECOND, 4 * SECOND),
+            Some((0, 6 * SECOND)),
+            "a zero wait stays zero"
+        );
+        assert_eq!(
+            remaining_timers(5 * SECOND, 5 * SECOND + margin, 5 * SECOND + margin),
+            None,
+            "a frame that outlived its timeout is not served"
+        );
+    }
 }
diff --git a/core/server/src/dispatch/test_support.rs b/core/server/src/dispatch/test_support.rs
index 4904e09..e0d7bc6 100644
--- a/core/server/src/dispatch/test_support.rs
+++ b/core/server/src/dispatch/test_support.rs
@@ -20,6 +20,7 @@
 //! builders below assemble minimal shards and wire frames.
 
 use bytes::Bytes;
+use consensus::client_table::SessionAttachment;
 use consensus::{LocalPipeline, MetadataHandle, VsrConsensus};
 use futures::channel::oneshot;
 use iggy_binary_protocol::primitives::partition_assignment::CreatedPartitionAssignment;
@@ -33,6 +34,7 @@
     Command, Operation, PrepareHeader, ReplyHeader, RoutedRequestHeader, WireEncode,
     WireGlobalPermissions, WireIdentifier, WireName, WireOptions, WirePermissions,
 };
+use iggy_common::defaults::DEFAULT_ROOT_USER_ID;
 use iggy_common::{EncryptorKind, ServerKeyContext, UserStatus, variadic};
 use journal::prepare_journal::PrepareJournal;
 use message_bus::client_listener::RequestHandler;
@@ -57,15 +59,15 @@
     COMMAND_HEADER_SIZE, IggyMessage, IggyMessageHeader, IggyMessages, PREPARE_SPLIT_POINT,
     SendMessagesOwned, encrypt_batch_request,
 };
-use server_common::sharding::{IggyNamespace, ShardId};
+use server_common::sharding::{IggyNamespace, PartitionLocation, ShardId};
 use server_common::{MESSAGE_ALIGN, Message};
-use shard::shards_table::PapayaShardsTable;
+use shard::shards_table::{PapayaShardsTable, ShardsTable};
 use shard::{IggyShard, PartitionConsensusConfig, ReplicaTopology, ShardIdentity};
 use std::cell::{Cell, RefCell};
 use std::future::Future;
 use std::mem::size_of;
 use std::rc::Rc;
-use std::sync::Arc;
+use std::sync::{Arc, OnceLock};
 use std::time::Instant;
 
 pub type TestMux = MuxStateMachine<variadic!(Users, Streams)>;
@@ -104,6 +106,8 @@
     pub sleeps: Rc<RefCell<Vec<std::time::Duration>>>,
     /// Pins [`MessageBus::realtime_micros`]. `None` reads the system clock.
     pub realtime: Rc<Cell<Option<u64>>>,
+    /// Pins the monotonic clock when a test also controls the bus timers.
+    pub monotonic: Rc<Cell<Option<u64>>>,
     /// One installation per client connection, as the production registry
     /// keeps them. [`ConnectionInstaller::client_instance`] opens one on
     /// first sight, so every client is connected until
@@ -184,6 +188,13 @@
             .get()
             .unwrap_or_else(|| iggy_common::IggyTimestamp::now().as_micros())
     }
+    fn monotonic_micros(&self) -> u64 {
+        static ORIGIN: OnceLock<Instant> = OnceLock::new();
+        self.monotonic.get().unwrap_or_else(|| {
+            u64::try_from(ORIGIN.get_or_init(Instant::now).elapsed().as_micros())
+                .unwrap_or(u64::MAX)
+        })
+    }
     fn set_connection_lost_fn(&self, _f: ConnectionLostFn) {}
     fn set_replica_forward_fn(&self, _f: ReplicaForwardFn) {}
 }
@@ -505,6 +516,10 @@
 /// The client that issues the setup operations below.
 const VSR_CLIENT_ID: u128 = 1;
 
+/// The consensus group of partition 0. Each later partition takes the next
+/// group.
+const FIRST_PARTITION_GROUP: u64 = 1;
+
 /// Creates one stream with one single-partition topic, and returns the
 /// namespace of its partition. No route names the partition yet.
 pub fn create_one_partition(shard: &TestShard) -> IggyNamespace {
@@ -513,6 +528,12 @@
 
 /// [`create_one_partition`] on the metadata state machine of any shard.
 pub fn create_one_partition_in(mux_stm: &TestMux) -> IggyNamespace {
+    create_partitions_in(mux_stm, 1)[0]
+}
+
+/// Creates one stream with one topic of `count` partitions, and returns the
+/// namespaces of its partitions in partition order. No route names them yet.
+pub fn create_partitions_in(mux_stm: &TestMux, count: u32) -> Vec<IggyNamespace> {
     mux_stm.users().ensure_root_user("iggy", "hash");
     mux_stm
         .update(prepare_message(
@@ -534,23 +555,73 @@
             &CreateTopicWithAssignmentsRequest {
                 request: CreateTopicRequest {
                     stream_id: WireIdentifier::numeric(0),
-                    partitions_count: 1,
+                    partitions_count: count,
                     name: WireName::new("topic").unwrap(),
                     options: WireOptions::empty(),
                 },
                 derived_options: WireOptions::empty(),
-                partitions: vec![CreatedPartitionAssignment {
-                    initial_view: 0,
-                    partition_id: 0,
-                    consensus_group_id: 1,
-                }],
+                partitions: (0..count)
+                    .map(|partition_id| CreatedPartitionAssignment {
+                        initial_view: 0,
+                        partition_id,
+                        consensus_group_id: FIRST_PARTITION_GROUP + u64::from(partition_id),
+                    })
+                    .collect(),
             }
             .to_bytes(),
         ))
         .unwrap();
-    mux_stm
-        .streams()
-        .namespace_from_partition(&WireIdentifier::numeric(0), &WireIdentifier::numeric(0), 0)
+    (0..count)
+        .map(|partition_id| {
+            mux_stm
+                .streams()
+                .namespace_from_partition(
+                    &WireIdentifier::numeric(0),
+                    &WireIdentifier::numeric(0),
+                    partition_id,
+                )
+                .unwrap()
+        })
+        .collect()
+}
+
+/// [`create_one_partition`] with the partition routed to shard 0, so
+/// authorization and resolution let a read reach the owner inbox.
+pub fn route_one_partition(shard: &TestShard) -> IggyNamespace {
+    route_partitions(shard, 1)[0]
+}
+
+/// [`create_partitions_in`] on `shard`, with every partition routed to
+/// shard 0.
+pub fn route_partitions(shard: &TestShard, count: u32) -> Vec<IggyNamespace> {
+    route_partitions_to(shard, count, 0)
+}
+
+/// [`route_partitions`] to the shard `owner`.
+pub fn route_partitions_to(shard: &TestShard, count: u32, owner: u16) -> Vec<IggyNamespace> {
+    let namespaces = create_partitions_in(&shard.plane.metadata().mux_stm, count);
+    for namespace in &namespaces {
+        shard
+            .shards_table()
+            .insert(*namespace, PartitionLocation::new(ShardId::new(owner), 0));
+    }
+    namespaces
+}
+
+/// Registers `client_id` as the root user and attaches `session`, the
+/// state of a bound TCP connection.
+pub fn attach_root_session(shard: &TestShard, client_id: u128, session: u64) -> SessionAttachment {
+    let mut table = shard.plane.metadata().client_table.borrow_mut();
+    table
+        .commit_register(
+            client_id,
+            DEFAULT_ROOT_USER_ID,
+            [0x5a; 32],
+            register_reply(client_id, session),
+        )
+        .unwrap();
+    table
+        .attach_session(client_id, session, DEFAULT_ROOT_USER_ID)
         .unwrap()
 }
 
diff --git a/core/server/src/http.rs b/core/server/src/http.rs
index bee3fab..2685b1b 100644
--- a/core/server/src/http.rs
+++ b/core/server/src/http.rs
@@ -75,8 +75,8 @@
     get_clients, get_cluster_metadata, get_consumer_offset, get_metadata_changes, get_pats,
     get_snapshot, get_stats, get_stream, get_streams, get_topic, get_topics, get_user, get_users,
     login_user, login_with_personal_access_token, logout_user, ping, poll_messages,
-    poll_messages_deferred, refresh_token, send_messages, store_consumer_offset,
-    update_permissions, update_stream, update_topic, update_user,
+    poll_messages_committing, poll_messages_deferred, refresh_token, send_messages,
+    store_consumer_offset, update_permissions, update_stream, update_topic, update_user,
 };
 use crate::http::jwt::JwtManager;
 use crate::http::session::RegistrationBarrier;
@@ -483,7 +483,7 @@
         )
         .route(
             "/streams/{stream_id}/topics/{topic_id}/messages/deferred",
-            get(poll_messages_deferred),
+            get(poll_messages_deferred).post(poll_messages_committing),
         )
         .route(
             "/streams/{stream_id}/topics/{topic_id}/consumer-offsets",
diff --git a/core/server/src/http/error.rs b/core/server/src/http/error.rs
index 6441f10..48e9f87 100644
--- a/core/server/src/http/error.rs
+++ b/core/server/src/http/error.rs
@@ -507,6 +507,10 @@
     /// authorization denial) graded through the legacy `IggyError -> status`
     /// map so SDK error bodies stay byte-identical.
     Rejected(IggyError),
+    /// A GET poll with `auto_commit`, rendered as `InvalidCommand` with the
+    /// member named in `field` and the route to use in `reason`, because REST
+    /// has no version handshake to tell an older client why it is refused.
+    CommittingGet,
     /// Requested entity is absent -> 404 with the legacy not-found body.
     NotFound,
     /// A linearizable read reached a follower and the primary's HTTP address was
@@ -542,6 +546,15 @@
     fn into_response(self) -> Response {
         match self {
             Self::Rejected(error) => CustomError::from(error).into_response(),
+            Self::CommittingGet => (
+                StatusCode::BAD_REQUEST,
+                Json(ErrorResponse {
+                    reason: "A poll with auto_commit stores its offset, so send it as POST /streams/{stream_id}/topics/{topic_id}/messages/deferred".to_owned(),
+                    field: Some("auto_commit".to_owned()),
+                    ..ErrorResponse::from_error(&IggyError::InvalidCommand)
+                }),
+            )
+                .into_response(),
             // Reuse the legacy 404 body so a missing stream renders exactly as
             // the legacy server's `CustomError::ResourceNotFound` does.
             Self::NotFound => CustomError::ResourceNotFound.into_response(),
diff --git a/core/server/src/http/forward.rs b/core/server/src/http/forward.rs
index 00fc229..21387d9 100644
--- a/core/server/src/http/forward.rs
+++ b/core/server/src/http/forward.rs
@@ -992,8 +992,8 @@
         Ok(bearer) => bearer,
         Err(error) => return CustomError::from(error).into_response(),
     };
-    let deferred =
-        request.method() == Method::GET && request.uri().path().ends_with("/messages/deferred");
+    let deferred = (request.method() == Method::GET || request.method() == Method::POST)
+        && request.uri().path().ends_with("/messages/deferred");
     let request_budget = if deferred {
         let query = match axum::extract::Query::<DeferredPollQuery>::try_from_uri(request.uri()) {
             Ok(query) => query.0,
@@ -2432,6 +2432,7 @@
                 fragments: Vec::new().into(),
                 current_offset: 0,
                 encryptor: None,
+                next_offset: None,
             })
             .expect("the poll waits for its reply");
         assert_eq!(
diff --git a/core/server/src/http/handlers.rs b/core/server/src/http/handlers.rs
index 2d64aa8..1e379d0 100644
--- a/core/server/src/http/handlers.rs
+++ b/core/server/src/http/handlers.rs
@@ -104,9 +104,9 @@
     ClientInfo, ClientInfoDetails, ClusterMetadata, Consumer, ConsumerGroup, ConsumerGroupDetails,
     ConsumerOffsetInfo, DEFAULT_MAX_TOPIC_SIZE, Identifier, IdentityInfo, IggyError, IggyTimestamp,
     MetadataChanges, MetadataChangesQuery, OptionSpec, OptionStorage, OptionsScope, Partitioning,
-    PersonalAccessTokenInfo, PollMessages, PolledMessages, RawPersonalAccessToken, SendMessages,
-    SendMessagesConfirmations, Stats, Stream, StreamDetails, TokenInfo, Topic, TopicDetails,
-    UserInfo, UserInfoDetails, Validatable, descriptors_for, erase_buffer,
+    PersonalAccessTokenInfo, PollMessages, PolledMessages, PollingStrategy, RawPersonalAccessToken,
+    SendMessages, SendMessagesConfirmations, Stats, Stream, StreamDetails, TokenInfo, Topic,
+    TopicDetails, UserInfo, UserInfoDetails, Validatable, descriptors_for, erase_buffer,
 };
 use metadata::impls::metadata::StreamsFrontend;
 use metadata::permissioner::Permissioner;
@@ -1190,7 +1190,8 @@
 /// `GET /streams/{stream_id}/topics/{topic_id}/messages/deferred`: the poll of
 /// [`poll_messages`] with [`DeferredPollQuery`] limits. The primary holds it until
 /// `min_count` messages are ready, the reply reaches `max_bytes`, or `wait_us`
-/// expires. Invalid limits are refused before the poll is resolved.
+/// expires. Invalid limits are refused before the poll is resolved. A poll that
+/// stores its offset goes through [`poll_messages_committing`].
 pub(in crate::http) async fn poll_messages_deferred(
     State(state): State<HttpState>,
     identity: Identity,
@@ -1200,6 +1201,32 @@
     Query(consistency): Query<ConsistencyQuery>,
     Extension(request): Extension<Arc<PartitionRequest>>,
 ) -> Result<Response, ReadError> {
+    refuse_committing_get(&query)?;
+    poll_messages_committing(
+        State(state),
+        identity,
+        Path((stream_id, topic_id)),
+        Query(query),
+        Query(wait),
+        Query(consistency),
+        Extension(request),
+    )
+    .await
+}
+
+/// `POST /streams/{stream_id}/topics/{topic_id}/messages/deferred`: the poll of
+/// [`poll_messages_deferred`], also with `auto_commit`. A poll that stores its
+/// offset changes state, so it must not ride on a GET that clients, proxies and
+/// load balancers may resend (RFC 9110, section 9.2.1).
+pub(in crate::http) async fn poll_messages_committing(
+    State(state): State<HttpState>,
+    identity: Identity,
+    Path((stream_id, topic_id)): Path<(String, String)>,
+    Query(query): Query<PollMessages>,
+    Query(wait): Query<DeferredPollQuery>,
+    Query(consistency): Query<ConsistencyQuery>,
+    Extension(request): Extension<Arc<PartitionRequest>>,
+) -> Result<Response, ReadError> {
     let options = iggy_common::DeferredPollOptions::from(wait);
     options.validate(query.count).map_err(ReadError::Rejected)?;
     poll_messages_impl(
@@ -1240,6 +1267,7 @@
     Query(consistency): Query<ConsistencyQuery>,
     Extension(request): Extension<Arc<PartitionRequest>>,
 ) -> Result<Response, ReadError> {
+    refuse_committing_get(&query)?;
     poll_messages_impl(
         state,
         identity,
@@ -1253,6 +1281,15 @@
     .await
 }
 
+/// A GET poll that would store its offset is refused: a resent GET would skip
+/// the batch that the first one stored.
+const fn refuse_committing_get(query: &PollMessages) -> Result<(), ReadError> {
+    if query.auto_commit {
+        return Err(ReadError::CommittingGet);
+    }
+    Ok(())
+}
+
 #[expect(
     clippy::too_many_lines,
     reason = "Immediate and deferred polls share one authorization, fencing and relay path"
@@ -1334,7 +1371,7 @@
     let requested = request
         .route()
         .map(|route| route.context)
-        .or(query.strategy.context)
+        .or_else(|| query.strategy.context())
         .unwrap_or(local);
     let requested = PartitionContext {
         metadata_op: requested.metadata_op.max(metadata_floor),
@@ -1398,7 +1435,7 @@
         // lose a batch here, but revoked credentials must never receive it.
         authorize_read(&state, &identity, consistency, rule)?;
         check_partition_incarnation(&state, namespace, local.incarnation)?;
-        polled_messages_reply(&state, partition_id, reply)
+        polled_messages_reply(&state, partition_id, &query.strategy, reply)
     };
     SendWrapper::new(serve_partition(
         &state,
@@ -1412,10 +1449,11 @@
     .await
 }
 
-/// The HTTP answer to the owner's reply to a poll.
+/// The HTTP answer to the owner's reply to a poll from `strategy`.
 fn polled_messages_reply(
     state: &HttpInner,
     partition_id: u32,
+    strategy: &PollingStrategy,
     reply: Option<PartitionReadReply>,
 ) -> Result<Response, ReadError> {
     match reply {
@@ -1424,6 +1462,7 @@
             fragments,
             current_offset,
             encryptor,
+            ..
         }) => {
             // TCP parity (`build_polled_messages_reply`): the served context
             // carries at least the metadata this node applied, so a later poll
@@ -1443,7 +1482,7 @@
             )
             .map_err(ReadError::Rejected)?;
             Ok(
-                Json(PolledMessages::from_bytes(body).map_err(ReadError::Rejected)?)
+                Json(PolledMessages::from_bytes(body, strategy).map_err(ReadError::Rejected)?)
                     .into_response(),
             )
         }
@@ -2329,6 +2368,41 @@
     use crate::http::wire::build_request_message;
     use crate::reply_frame::build_deny_reply;
 
+    #[tokio::test]
+    async fn given_a_committing_poll_when_sent_as_get_should_refuse_it_and_name_the_post_route() {
+        // A GET poll with `auto_commit` stored its offset, and anything that
+        // resent the GET skipped the batch that the first one stored. The bare
+        // refusal then left an older client without a hint where to send it.
+        let parse = |uri: &str| {
+            Query::<PollMessages>::try_from_uri(&uri.parse().unwrap())
+                .unwrap()
+                .0
+        };
+        let committing =
+            parse("/x?consumer_id=1&partition_id=0&kind=offset&value=0&count=1&auto_commit=true");
+        let reading = parse("/x?consumer_id=1&partition_id=0&kind=offset&value=0&count=1");
+
+        let response = refuse_committing_get(&committing)
+            .expect_err("a committing GET is refused")
+            .into_response();
+
+        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
+        let body = to_bytes(response.into_body(), usize::MAX)
+            .await
+            .expect("the error body reads");
+        let error: Value = serde_json::from_slice(&body).expect("the error body is JSON");
+        assert_eq!(
+            error,
+            serde_json::json!({
+                "id": IggyError::InvalidCommand.as_code(),
+                "code": "invalid_command",
+                "reason": "A poll with auto_commit stores its offset, so send it as POST /streams/{stream_id}/topics/{topic_id}/messages/deferred",
+                "field": "auto_commit",
+            })
+        );
+        assert!(refuse_committing_get(&reading).is_ok());
+    }
+
     /// A create body can carry the stream key, so it zeroes itself on drop and
     /// leaves its options block for the handler to erase.
     #[test]
diff --git a/core/server/src/http/reads.rs b/core/server/src/http/reads.rs
index d8fdb6c..9762959 100644
--- a/core/server/src/http/reads.rs
+++ b/core/server/src/http/reads.rs
@@ -978,6 +978,7 @@
                                 fragments: Vec::new().into(),
                                 current_offset: 0,
                                 encryptor: None,
+                                next_offset: None,
                             }
                         }
                         PartitionRead::ConsumerOffsetOnPrimary { .. } => {
@@ -1203,6 +1204,7 @@
                 fragments: Vec::new().into(),
                 current_offset: 0,
                 encryptor: None,
+                next_offset: None,
             },
             PartitionRead::ConsumerOffsetOnPrimary { .. } => PartitionReadReply::ConsumerOffset {
                 stored: Some(0),
diff --git a/core/server/src/http/wire.rs b/core/server/src/http/wire.rs
index 5943aa6..f5514a6 100644
--- a/core/server/src/http/wire.rs
+++ b/core/server/src/http/wire.rs
@@ -714,7 +714,8 @@
             None,
         )
         .expect("re-encodes wire body");
-        let polled = PolledMessages::from_bytes(body).expect("decodes as the SDK does");
+        let polled = PolledMessages::from_bytes(body, &PollingStrategy::offset(41))
+            .expect("decodes as the SDK does");
 
         assert_eq!(polled.partition_id, 3);
         assert_eq!(polled.current_offset, 42);
diff --git a/core/server/src/reply_frame.rs b/core/server/src/reply_frame.rs
index e15fa93..8961f1e 100644
--- a/core/server/src/reply_frame.rs
+++ b/core/server/src/reply_frame.rs
@@ -32,6 +32,9 @@
 use iggy_binary_protocol::responses::messages::poll_messages::{
     POLL_RESPONSE_HEADER_SIZE, PollMessagesResponseHeader,
 };
+use iggy_binary_protocol::responses::messages::{
+    FetchMessagesResponse, FetchMessagesSectionHeader,
+};
 use iggy_binary_protocol::responses::personal_access_tokens::RawPersonalAccessTokenResponse;
 use iggy_binary_protocol::responses::users::LoginRegisterResponse;
 use iggy_binary_protocol::{
@@ -513,6 +516,97 @@
     fragments: PollFragments,
     encryptor: Option<&EncryptorKind>,
 ) -> Result<Bytes, IggyError> {
+    // The head is written after the walk, which is what counts the messages.
+    let records_len: usize = fragments.iter().map(Fragment::len).sum();
+    let mut body: Vec<u8> = Vec::with_capacity(POLL_RESPONSE_HEADER_SIZE + records_len);
+    body.resize(POLL_RESPONSE_HEADER_SIZE, 0);
+    let count = walk_polled_records(fragments, encryptor, |record| {
+        body.extend_from_slice(record);
+    })?;
+    body[..POLL_RESPONSE_HEADER_SIZE].copy_from_slice(
+        &PollMessagesResponseHeader {
+            partition_id,
+            current_offset,
+            messages_count: count,
+            context,
+        }
+        .to_le_bytes(),
+    );
+    Ok(Bytes::from(body))
+}
+
+/// The records of one read section of a `FetchMessages` reply, as frames that
+/// follow the reply head, and their message count. Records go out as stored,
+/// except at-rest decryption, which copies them as a poll reply does.
+pub fn fetch_section_records(
+    fragments: PollFragments,
+    encryptor: Option<&EncryptorKind>,
+) -> Result<(Vec<Frozen<MESSAGE_ALIGN>>, u32), IggyError> {
+    let Some(encryptor) = encryptor else {
+        let frames: Vec<_> = fragments.into_iter().map(Fragment::into_frozen).collect();
+        let count = polled_message_count(&frames)?;
+        return Ok((frames, count));
+    };
+    let mut records =
+        Owned::<MESSAGE_ALIGN>::with_capacity(fragments.iter().map(Fragment::len).sum());
+    let count = walk_polled_records(fragments, Some(encryptor), |record| {
+        records.extend_from_slice(record);
+    })?;
+    Ok((vec![records.into()], count))
+}
+
+/// Build the `FetchMessages` reply as a vectored frame: one buffer holding
+/// the reply header, the section count and the table, then the records of
+/// each section in table order. `sections` pairs each table entry with its
+/// records.
+pub fn build_fetch_messages_reply(
+    request_header: &RoutedRequestHeader,
+    commit: u64,
+    sections: Vec<(FetchMessagesSectionHeader, Vec<Frozen<MESSAGE_ALIGN>>)>,
+) -> Result<BusMessage, IggyError> {
+    let headers: Vec<_> = sections.iter().map(|(header, _)| *header).collect();
+    let head_len = FetchMessagesResponse::head_size(headers.len());
+    let records_len: usize = sections
+        .iter()
+        .flat_map(|(_, records)| records)
+        .map(Frozen::len)
+        .sum();
+    let header_len = std::mem::size_of::<ReplyHeader>();
+    let size = u32::try_from(header_len + head_len + records_len)
+        .map_err(|_| IggyError::InvalidCommand)?;
+    let header = reply_header(
+        request_header,
+        request_header.client,
+        request_header.session,
+        commit,
+        size,
+    );
+    let mut head = Owned::<MESSAGE_ALIGN>::zeroed(header_len + head_len);
+    let (header_bytes, body_head) = head.as_mut_slice().split_at_mut(header_len);
+    header_bytes.copy_from_slice(bytemuck::bytes_of(&header));
+    FetchMessagesResponse::write_head(&headers, body_head)
+        .map_err(|_| IggyError::InvalidCommand)?;
+    let mut frames = ResponseFragments::with_capacity(
+        1 + sections
+            .iter()
+            .map(|(_, records)| records.len())
+            .sum::<usize>(),
+    );
+    frames.push(head.into());
+    frames.extend(sections.into_iter().flat_map(|(_, records)| records));
+    // Re-checks the header and that the frames cover `size`.
+    Message::<ReplyHeader, ResponseBacking>::try_from(frames)
+        .map(Message::into_inner)
+        .map_err(|_| IggyError::InvalidCommand)
+}
+
+/// Hands each batch record that spans `fragments` to `sink`, decrypted when
+/// `encryptor` is set, and returns the messages of all batches.
+fn walk_polled_records(
+    fragments: PollFragments,
+    encryptor: Option<&EncryptorKind>,
+    mut sink: impl FnMut(&[u8]),
+) -> Result<u32, IggyError> {
     // Batches may arrive split across fragments (rewritten batch header +
     // sliced blob); concatenate into one stream before walking records.
     let mut stream: Vec<u8> = Vec::new();
@@ -521,9 +615,6 @@
         stream.extend_from_slice(frozen.as_slice());
     }
 
-    // The head is written after the walk, which is what counts the messages.
-    let mut body: Vec<u8> = Vec::with_capacity(POLL_RESPONSE_HEADER_SIZE + stream.len());
-    body.resize(POLL_RESPONSE_HEADER_SIZE, 0);
     let mut count: u32 = 0;
     let mut position = 0usize;
     while position < stream.len() {
@@ -537,27 +628,16 @@
         }
         let record = &stream[position..batch_end];
         if let Some(encryptor) = encryptor {
-            let decrypted = send_messages::decrypt_batch_record(record, encryptor)?;
-            body.extend_from_slice(&decrypted);
+            sink(&send_messages::decrypt_batch_record(record, encryptor)?);
         } else {
-            body.extend_from_slice(record);
+            sink(record);
         }
         count = count
             .checked_add(batch.message_count)
             .ok_or(IggyError::InvalidCommand)?;
         position = batch_end;
     }
-
-    body[..POLL_RESPONSE_HEADER_SIZE].copy_from_slice(
-        &PollMessagesResponseHeader {
-            partition_id,
-            current_offset,
-            messages_count: count,
-            context,
-        }
-        .to_le_bytes(),
-    );
-    Ok(Bytes::from(body))
+    Ok(count)
 }
 
 /// Build the `ConsumerOffsetResponse` reply body:
diff --git a/core/server/src/session_manager.rs b/core/server/src/session_manager.rs
index 7f4cc36..47f943e 100644
--- a/core/server/src/session_manager.rs
+++ b/core/server/src/session_manager.rs
@@ -32,7 +32,8 @@
 use iggy_binary_protocol::ConsumerSession;
 use iggy_common::IggyError;
 use message_bus::installer::conn_info::ClientTransportKind;
-use shard::ConnectedClientInfo;
+use shard::config::DeferredPollConfig;
+use shard::{CommitGate, ConnectedClientInfo};
 use std::cell::RefCell;
 use std::net::SocketAddr;
 use std::rc::Rc;
@@ -115,7 +116,7 @@
     /// session it binds.
     pub metadata_watermark: u64,
     consumer_session: Option<(u128, SessionAttachment)>,
-    deferred_requests: AHashMap<u64, (u64, Instant, AbortHandle)>,
+    deferred_requests: AHashMap<u64, DeferredRequestEntry>,
 }
 
 /// Bridges transport connections to consensus sessions.
@@ -133,6 +134,8 @@
 pub struct SessionManager {
     connections: AHashMap<u128, Connection>,
     next_deferred_request: u64,
+    /// The fetch reads that this shard runs per session and owner shard.
+    fetch_reads: AHashMap<(u128, u16), FetchReads>,
     /// This shard's copy of the configured cluster roster, served by the
     /// `GetClusterMetadata` read. Lives here because it is the
     /// per-shard context already threaded to the non-replicated read path;
@@ -146,6 +149,7 @@
         Self {
             connections: AHashMap::new(),
             next_deferred_request: 0,
+            fetch_reads: AHashMap::new(),
             cluster_roster: Rc::new(ClusterRoster::disabled()),
         }
     }
@@ -239,7 +243,7 @@
             .filter(|(_, conn)| {
                 conn.deferred_requests
                     .values()
-                    .all(|(_, deadline, _)| now >= *deadline)
+                    .all(|entry| now >= entry.deadline)
             })
             .filter(|(_, conn)| now.duration_since(conn.last_heartbeat) > max_age)
             .map(|(&id, _)| id)
@@ -269,8 +273,11 @@
     /// Remove a transport connection without ending its durable session.
     pub fn remove_connection(&mut self, connection_id: u128) -> Option<(u128, u64)> {
         if let Some(conn) = self.connections.remove(&connection_id) {
-            for (_, _, abort) in conn.deferred_requests.into_values() {
-                abort.abort();
+            for entry in conn.deferred_requests.into_values() {
+                if let Some(gate) = &entry.commit_gate {
+                    let _ = gate.cancel();
+                }
+                entry.abort.abort();
             }
             if let ConnectionState::Bound {
                 client_id, session, ..
@@ -287,7 +294,7 @@
     pub fn begin_deferred_request(
         sessions: &Rc<RefCell<Self>>,
         connection_id: u128,
-        wire_request_id: u64,
+        (wire_request_id, commit_gate): (u64, Option<CommitGate>),
         watchdog: Duration,
         now: Instant,
     ) -> Result<(DeferredRequestGuard, AbortRegistration), IggyError> {
@@ -303,9 +310,15 @@
             .connections
             .get_mut(&connection_id)
             .ok_or(IggyError::StaleClient)?;
-        connection
-            .deferred_requests
-            .insert(request_id, (wire_request_id, deadline, abort));
+        connection.deferred_requests.insert(
+            request_id,
+            DeferredRequestEntry {
+                wire_request_id,
+                deadline,
+                abort,
+                commit_gate,
+            },
+        );
         Ok((
             DeferredRequestGuard {
                 sessions: Rc::clone(sessions),
@@ -317,6 +330,8 @@
     }
 
     /// Cancels only requests admitted on this connection with the given wire id.
+    /// A committing request stops only while none of its reads committed, and
+    /// otherwise answers as usual.
     pub fn cancel_deferred_request(
         &self,
         connection_id: u128,
@@ -335,14 +350,51 @@
         {
             return false;
         }
-        for (request, _, abort) in connection.deferred_requests.values() {
-            if *request == wire_request_id {
-                abort.abort();
+        for entry in connection.deferred_requests.values() {
+            if entry.wire_request_id == wire_request_id
+                && entry.commit_gate.as_ref().is_none_or(CommitGate::cancel)
+            {
+                entry.abort.abort();
             }
         }
         true
     }
 
+    /// Reserves one fetch read of the session `client_id` on the owner shard
+    /// `owner`, which can add `cost` bytes to a reply that this shard holds
+    /// until it is written. Refused when the reads that this shard holds there
+    /// for the session would pass the per-session quota or the byte budget of
+    /// `limits`. The owner keeps its own disk read budget and also counts the
+    /// parked waits and the reads that other shards send for the session, so
+    /// it can still refuse a reserved read.
+    #[must_use]
+    pub fn reserve_fetch_read(
+        sessions: &Rc<RefCell<Self>>,
+        (client_id, owner): (u128, u16),
+        cost: usize,
+        limits: &DeferredPollConfig,
+    ) -> Option<FetchReadGuard> {
+        let key = (client_id, owner);
+        let mut manager = sessions.borrow_mut();
+        let running = manager.fetch_reads.get(&key).copied().unwrap_or_default();
+        let bytes = running.bytes.checked_add(cost)?;
+        if running.reads >= limits.max_pending_per_session || bytes > limits.max_inflight_bytes {
+            return None;
+        }
+        manager.fetch_reads.insert(
+            key,
+            FetchReads {
+                reads: running.reads + 1,
+                bytes,
+            },
+        );
+        Some(FetchReadGuard {
+            sessions: Rc::clone(sessions),
+            key,
+            cost,
+        })
+    }
+
     #[allow(clippy::too_many_arguments)]
     pub fn bind_authenticated_connection(
         &mut self,
@@ -546,7 +598,7 @@
             && connection
                 .deferred_requests
                 .get(&self.request_id)
-                .is_some_and(|(_, deadline, _)| now < *deadline)
+                .is_some_and(|entry| now < entry.deadline)
         {
             connection.last_heartbeat = now;
         }
@@ -566,6 +618,60 @@
     }
 }
 
+/// One deferred request admitted on a connection.
+#[derive(Debug, Clone)]
+struct DeferredRequestEntry {
+    wire_request_id: u64,
+    deadline: Instant,
+    abort: AbortHandle,
+    /// Set for a request that commits as it reads.
+    commit_gate: Option<CommitGate>,
+}
+
+#[derive(Debug, Default, Clone, Copy)]
+struct FetchReads {
+    reads: usize,
+    bytes: usize,
+}
+
+/// One read that [`SessionManager::reserve_fetch_read`] reserved, released on
+/// drop.
+pub struct FetchReadGuard {
+    sessions: Rc<RefCell<SessionManager>>,
+    key: (u128, u16),
+    cost: usize,
+}
+
+impl FetchReadGuard {
+    /// Charges the bytes that the read returned instead of what it could
+    /// return, for as long as its reply is held.
+    pub fn settle(&mut self, bytes: usize) {
+        let mut manager = self.sessions.borrow_mut();
+        let Some(running) = manager.fetch_reads.get_mut(&self.key) else {
+            return;
+        };
+        running.bytes = running
+            .bytes
+            .saturating_sub(self.cost)
+            .saturating_add(bytes);
+        self.cost = bytes;
+    }
+}
+
+impl Drop for FetchReadGuard {
+    fn drop(&mut self) {
+        let mut manager = self.sessions.borrow_mut();
+        let Some(running) = manager.fetch_reads.get_mut(&self.key) else {
+            return;
+        };
+        running.reads -= 1;
+        running.bytes = running.bytes.saturating_sub(self.cost);
+        if running.reads == 0 {
+            manager.fetch_reads.remove(&self.key);
+        }
+    }
+}
+
 impl Default for SessionManager {
     fn default() -> Self {
         Self::new()
@@ -954,7 +1060,7 @@
         let (_first, first) = SessionManager::begin_deferred_request(
             &sessions,
             1,
-            10,
+            (10, None),
             Duration::from_secs(60),
             Instant::now(),
         )
@@ -962,7 +1068,7 @@
         let (_other, other) = SessionManager::begin_deferred_request(
             &sessions,
             1,
-            11,
+            (11, None),
             Duration::from_secs(60),
             Instant::now(),
         )
@@ -970,7 +1076,7 @@
         let (_peer, peer) = SessionManager::begin_deferred_request(
             &sessions,
             2,
-            10,
+            (10, None),
             Duration::from_secs(60),
             Instant::now(),
         )
@@ -1009,6 +1115,86 @@
     }
 
     #[test]
+    fn given_committing_requests_when_cancelled_should_stop_only_the_one_whose_reads_did_not_commit()
+     {
+        // Every cancel aborted its request, also one whose read had already
+        // stored its offset, so the client lost that batch without a reply.
+        let sessions = Rc::new(RefCell::new(SessionManager::new()));
+        let mut table = registry(CLIENT, USER, EPOCH);
+        bind(&mut sessions.borrow_mut(), &mut table, 1);
+        let open = CommitGate::default();
+        let committed = CommitGate::default();
+        assert!(committed.enter());
+        let (_open, open_registration) = SessionManager::begin_deferred_request(
+            &sessions,
+            1,
+            (10, Some(open.clone())),
+            Duration::from_secs(60),
+            Instant::now(),
+        )
+        .unwrap();
+        let (_committed, committed_registration) = SessionManager::begin_deferred_request(
+            &sessions,
+            1,
+            (11, Some(committed.clone())),
+            Duration::from_secs(60),
+            Instant::now(),
+        )
+        .unwrap();
+        let mut open_request = Box::pin(futures::future::Abortable::new(
+            std::future::pending::<()>(),
+            open_registration,
+        ));
+        let mut committed_request = Box::pin(futures::future::Abortable::new(
+            std::future::pending::<()>(),
+            committed_registration,
+        ));
+
+        for request in [10, 11] {
+            assert!(
+                sessions
+                    .borrow()
+                    .cancel_deferred_request(1, CLIENT, EPOCH, request)
+            );
+        }
+
+        assert!(open_request.as_mut().now_or_never().unwrap().is_err());
+        assert!(open.is_cancelled());
+        assert!(committed_request.as_mut().now_or_never().is_none());
+        assert!(!committed.is_cancelled());
+    }
+
+    #[test]
+    fn given_a_parked_committing_request_when_its_connection_is_removed_should_cancel_its_gate_before_any_store()
+     {
+        // The aborted request drops its reply only once its task runs again,
+        // and an owner that finishes the read before that must find the gate
+        // closed, or it stores an offset for a client that is gone.
+        let sessions = Rc::new(RefCell::new(SessionManager::new()));
+        let mut table = registry(CLIENT, USER, EPOCH);
+        bind(&mut sessions.borrow_mut(), &mut table, 1);
+        let gate = CommitGate::default();
+        let (_request, registration) = SessionManager::begin_deferred_request(
+            &sessions,
+            1,
+            (10, Some(gate.clone())),
+            Duration::from_secs(60),
+            Instant::now(),
+        )
+        .unwrap();
+        let mut request = Box::pin(futures::future::Abortable::new(
+            std::future::pending::<()>(),
+            registration,
+        ));
+
+        sessions.borrow_mut().remove_connection(1);
+
+        assert!(gate.is_cancelled());
+        assert!(!gate.enter(), "a read that finishes later stores nothing");
+        assert!(request.as_mut().now_or_never().unwrap().is_err());
+    }
+
+    #[test]
     fn deferred_guard_is_connection_local_finite_and_refreshes_only_after_write() {
         let sessions = Rc::new(RefCell::new(SessionManager::new()));
         let old = Instant::now().checked_sub(Duration::from_secs(30)).unwrap();
@@ -1025,7 +1211,7 @@
         let (guard, _) = SessionManager::begin_deferred_request(
             &sessions,
             2,
-            10,
+            (10, None),
             Duration::from_secs(60),
             Instant::now(),
         )
@@ -1048,7 +1234,7 @@
         let (concurrent, _) = SessionManager::begin_deferred_request(
             &sessions,
             2,
-            10,
+            (10, None),
             Duration::from_secs(60),
             Instant::now(),
         )
@@ -1067,7 +1253,7 @@
         let (failed, registration) = SessionManager::begin_deferred_request(
             &sessions,
             1,
-            10,
+            (10, None),
             Duration::from_secs(60),
             Instant::now(),
         )
@@ -1091,7 +1277,7 @@
         let (guard, _) = SessionManager::begin_deferred_request(
             &sessions,
             1,
-            10,
+            (10, None),
             Duration::from_secs(1),
             Instant::now(),
         )
diff --git a/core/shard/src/lib.rs b/core/shard/src/lib.rs
index 3b67b22..586ca01 100644
--- a/core/shard/src/lib.rs
+++ b/core/shard/src/lib.rs
@@ -32,7 +32,9 @@
 pub use config::CoordinatorConfig;
 pub use host::{NoopHost, ShardHost};
 pub use poll::deferred::{
-    DeferredPollContext, DeferredPollRequest, PollQuotaIdentity, ReadinessTarget, ReadinessWait,
+    ANY_MESSAGE, CommitGate, DeferredPollContext, DeferredPollRequest, KNOWN_BYTES_SLACK,
+    OversizedTurn, PollQuotaIdentity, ReadinessTarget, ReadinessWait, ReadinessWatch, ReadyTarget,
+    ServedState, WatchedState,
 };
 pub use poll::{ConsumerAttachment, PollCompleted};
 pub use router::CONSENSUS_TICK_INTERVAL;
@@ -66,7 +68,8 @@
 use iggy_common::PartitionStats;
 use iggy_common::variadic;
 use iggy_common::{
-    ConsumerKind, EncryptorKind, IggyError, IggyExpiry, IggyTimestamp, MetadataChangesQuery,
+    ConsumerKind, EncryptorKind, IggyDuration, IggyError, IggyExpiry, IggyTimestamp,
+    MetadataChangesQuery, PollingKind,
 };
 use journal::superblock::{PingPongSuperblock, SuperblockStore};
 use journal::{Journal, JournalHandle};
@@ -445,6 +448,12 @@
         consumer: Option<PollingConsumer>,
         metadata: metadata::stm::stream::PollMetadata,
     },
+    /// [`Self::ConsumerOffsetOnPrimary`] for each of `reads`, partitions that
+    /// the receiving shard owns, in one frame. See
+    /// [`IggyShard::consumer_offsets_on_primary`].
+    ConsumerOffsetsOnPrimary {
+        reads: Vec<ConsumerOffsetRead>,
+    },
     /// An external group's stored offset. Not a `PollingConsumer`: the kind is never polled.
     ExternalGroupOffsetOnPrimary {
         group_id: u32,
@@ -484,6 +493,7 @@
             Self::DeferredPoll(_)
             | Self::ConsumerOffset { .. }
             | Self::ConsumerOffsetOnPrimary { .. }
+            | Self::ConsumerOffsetsOnPrimary { .. }
             | Self::ExternalGroupOffsetOnPrimary { .. }
             | Self::PollOnPrimary { .. }
             | Self::UnboundPollOnPrimary { .. }
@@ -496,6 +506,16 @@
     }
 }
 
+/// One partition of [`PartitionRead::ConsumerOffsetsOnPrimary`], named as
+/// [`PartitionRead::ConsumerOffsetOnPrimary`] names it.
+#[derive(Debug, Clone, Copy)]
+pub struct ConsumerOffsetRead {
+    pub namespace: IggyNamespace,
+    /// None means the named group does not exist, so no offset can exist.
+    pub consumer: Option<PollingConsumer>,
+    pub metadata: metadata::stm::stream::PollMetadata,
+}
+
 /// Reply to a [`PartitionRead`].
 #[derive(Debug)]
 pub enum PartitionReadReply {
@@ -516,11 +536,18 @@
         current_offset: u64,
         /// The owning partition's encryptor, captured with the fragments.
         encryptor: Option<Arc<EncryptorKind>>,
+        /// Where the next read starts: after the last message, or for an
+        /// empty `Offset` read at `max(start, first retained offset)`. Only
+        /// a deferred poll sets it, and not for an empty read of another kind.
+        next_offset: Option<u64>,
     },
     ConsumerOffset {
         stored: Option<u64>,
         current_offset: u64,
     },
+    /// Reply to [`PartitionRead::ConsumerOffsetsOnPrimary`]: the reply to
+    /// each of its reads, in order.
+    ConsumerOffsets(Vec<Self>),
     /// The read was refused and returns no messages. A poll that needs a new
     /// consumer offset key beyond the configured limit returns
     /// `TooManyConsumerOffsets`. A completion whose history changed, whose owner
@@ -557,19 +584,21 @@
     /// (unknown, tombstoned, or mid-reconcile). Callers surface an error
     /// instead of an empty result.
     NotFound,
-    /// Reply to a [`LifecycleFrame::ReadinessWait`] from the owner `shard`:
-    /// `partitions` hold a message at their start point. If nothing was ready
-    /// at admission, `parked` names the waiter that the origin must cancel.
+    /// Reply to a [`LifecycleFrame::ReadinessWait`] from the evaluator
+    /// `shard`: `partitions` hold a message at their start point. If the
+    /// wait did not end at admission, `parked` names the waiter that the
+    /// origin must cancel.
     Ready {
         shard: u16,
         parked: Option<u64>,
-        partitions: Vec<u32>,
+        partitions: Vec<ReadyTarget>,
     },
 }
 
-/// Cancels the waiters that a readiness wait left on owner shards. A cancel
-/// that does not fit into a full inbox is lost. The owner then drops that
-/// waiter at its deadline, at maintenance or at its next evaluation.
+/// Cancels the waiters that a readiness wait left on the shards that
+/// evaluated it. A cancel that does not fit into a full inbox is lost. The
+/// evaluator then drops that waiter at its deadline, at maintenance or at
+/// its next evaluation.
 struct ParkedReadiness<'a> {
     senders: &'a [TaggedSender],
     waiters: BTreeMap<u16, u64>,
@@ -839,14 +868,19 @@
 /// dispatch path hot and cache-tight.
 ///
 /// Lane placement: every variant rides the main inbox EXCEPT
-/// [`LifecycleFrame::ForwardClientSend`], which rides the dedicated reply
-/// lane (`reply_inbox_capacity`) because its drops are terminal while every
-/// main-lane variant's loss is recovered by some retry (VSR retransmit,
-/// reconnect sweep, periodic tick). The two lanes are independent queues:
+/// [`LifecycleFrame::ForwardClientSend`] and
+/// [`LifecycleFrame::ReadinessUpdate`], which ride the dedicated reply lane
+/// (`reply_inbox_capacity`). A reply forward rides it because its drops are
+/// terminal while every main-lane variant's loss is recovered by some retry
+/// (VSR retransmit, reconnect sweep, periodic tick). A readiness push rides
+/// it so that consensus traffic cannot delay a wake-up, and its owner keeps
+/// a refused push for the next flush. The two lanes are independent queues:
 /// there is NO relative ordering between a consensus frame and a client
 /// reply forward, which is safe because a reply forward never
 /// order-couples with consensus traffic (it requires a served request,
 /// and per-client reply order is preserved within the reply lane itself).
+/// A readiness push carries the whole state of its partitions, so its order
+/// against the main lane does not matter either.
 #[non_exhaustive]
 pub enum LifecycleFrame {
     /// Shard 0 distributes an inbound replica link fd to the shard that
@@ -995,17 +1029,42 @@
         read: PartitionRead,
         reply: Sender<PartitionReadReply>,
     },
-    /// Evaluate a readiness wait on the shard that owns its targets. The owner
-    /// answers once at admission and at most once more. See
-    /// [`IggyShard::wait_for_messages`].
+    /// Evaluate a readiness wait on the shard that owns its targets, or on
+    /// the shard of the wait for the targets that it mirrors. The evaluator
+    /// answers once at admission and at most once more. With `watch`, the
+    /// owner also starts to push the state of the targets to the watching
+    /// shard. See [`IggyShard::wait_for_messages`].
     ReadinessWait {
-        wait: ReadinessWait,
+        /// Boxed, because inline it outgrows `MAX_SHARD_FRAME_SIZE`.
+        wait: Box<ReadinessWait>,
         reply: Sender<PartitionReadReply>,
+        watch: Option<ReadinessWatch>,
     },
     /// Drop a parked readiness waiter that its origin no longer needs.
     ReadinessCancel {
         waiter: u64,
     },
+    /// The owner `from` pushes the state of partitions that the receiving
+    /// shard watches.
+    ReadinessUpdate {
+        from: u16,
+        states: Vec<WatchedState>,
+    },
+    /// The shard `from` ends its watches of partitions that the receiving
+    /// shard owns. A refused unwatch keeps its mirrors, so the next sweep or
+    /// push repeats it.
+    ReadinessUnwatch {
+        from: u16,
+        watches: Vec<(IggyNamespace, u64)>,
+    },
+    /// The shard `from` holds a parked wait on `watches` of the receiving
+    /// shard, or no longer does. Only a shard with a parked wait gets the
+    /// pushes of the commits.
+    ReadinessInterest {
+        from: u16,
+        watches: Vec<(IggyNamespace, u64)>,
+        parked: bool,
+    },
     /// Admit a partition write (`SendMessages` / consumer-offset write) on
     /// the shard owning its namespace, carrying the channel its committed
     /// reply travels back on. The partition plane cannot route a reply by
@@ -2419,42 +2478,10 @@
         } else {
             PARTITION_READ_TIMEOUT
         };
-        let target = self.shards_table.shard_for(namespace).or_else(|| {
-            matches!(read, PartitionRead::SessionRetired { .. }).then(|| {
-                crate::shards_table::calculate_shard_assignment(&namespace, self.shard_count)
-            })
-        });
-        let Some(target) = target else {
-            tracing::warn!(
-                shard = self.id,
-                namespace_raw = namespace.inner(),
-                "partition_read: namespace not routable (not materialised yet or deleted)"
-            );
-            return Some(PartitionReadReply::Rejected(
-                IggyError::TransientNotAccepted,
-            ));
+        let (target, reply_rx) = match self.send_partition_read(namespace, read) {
+            Ok(sent) => sent,
+            Err(error) => return Some(PartitionReadReply::Rejected(error)),
         };
-        let (reply_tx, reply_rx) = channel::<PartitionReadReply>(1);
-        let frame = ShardFrame::lifecycle(LifecycleFrame::PartitionRead {
-            namespace,
-            read,
-            reply: reply_tx,
-        });
-        let Some(sender) = self.senders.get(target as usize) else {
-            return Some(PartitionReadReply::Rejected(
-                IggyError::TransientNotAccepted,
-            ));
-        };
-        if let Err(error) = sender.try_send(frame) {
-            tracing::warn!(
-                shard = self.id,
-                target,
-                "partition_read: inbox rejected PartitionRead frame: {error:?}"
-            );
-            return Some(PartitionReadReply::Rejected(
-                IggyError::TransientNotAccepted,
-            ));
-        }
         match bus_timeout(&self.bus, timeout, reply_rx.recv()).await {
             Some(Ok(reply)) => Some(reply),
             Some(Err(_)) => {
@@ -2476,78 +2503,278 @@
         }
     }
 
-    /// Wait until at least one target holds a message at its start point.
+    /// [`PartitionRead::ConsumerOffsetOnPrimary`] for each of `reads`, in
+    /// order. The partitions that this shard owns are read in place, and the
+    /// others in one frame for each owner shard, all at once. `None` where an
+    /// owner dropped the reply or did not answer within `PARTITION_READ_TIMEOUT`.
+    /// A namespace that no shard owns, or an inbox that refuses the frame,
+    /// returns [`PartitionReadReply::Rejected`] with
+    /// [`IggyError::TransientNotAccepted`].
+    #[allow(clippy::future_not_send)]
+    pub async fn consumer_offsets_on_primary(
+        &self,
+        reads: Vec<ConsumerOffsetRead>,
+    ) -> Vec<Option<PartitionReadReply>>
+    where
+        M: StreamsFrontend,
+    {
+        let mut replies: Vec<Option<PartitionReadReply>> =
+            std::iter::repeat_with(|| None).take(reads.len()).collect();
+        let mut owners: BTreeMap<u16, (Vec<usize>, Vec<ConsumerOffsetRead>)> = BTreeMap::new();
+        for (index, read) in reads.into_iter().enumerate() {
+            match self.shards_table.shard_for(read.namespace) {
+                Some(owner) if owner == self.id => {
+                    replies[index] = Some(self.consumer_offset_on_primary(read));
+                }
+                Some(owner) => {
+                    let (indexes, reads) = owners.entry(owner).or_default();
+                    indexes.push(index);
+                    reads.push(read);
+                }
+                None => {
+                    replies[index] = Some(PartitionReadReply::Rejected(
+                        IggyError::TransientNotAccepted,
+                    ));
+                }
+            }
+        }
+        let answers =
+            futures::future::join_all(owners.into_values().map(|(indexes, reads)| async {
+                let Some(namespace) = reads.first().map(|read| read.namespace) else {
+                    return (indexes, None);
+                };
+                let reply = self
+                    .partition_read(namespace, PartitionRead::ConsumerOffsetsOnPrimary { reads })
+                    .await;
+                (indexes, reply)
+            }))
+            .await;
+        for (indexes, reply) in answers {
+            match reply {
+                Some(PartitionReadReply::ConsumerOffsets(offsets))
+                    if offsets.len() == indexes.len() =>
+                {
+                    for (index, offset) in indexes.into_iter().zip(offsets) {
+                        replies[index] = Some(offset);
+                    }
+                }
+                Some(PartitionReadReply::Rejected(error)) => {
+                    for index in indexes {
+                        replies[index] = Some(PartitionReadReply::Rejected(error.clone()));
+                    }
+                }
+                _ => {}
+            }
+        }
+        replies
+    }
+
+    /// One zero-wait read of a fetch on the owner of `namespace`. The owner
+    /// tests `request_deadline` before it commits, so a late read commits
+    /// nothing. This read has no timer: its caller drops the future at its
+    /// reply deadline, a margin after `request_deadline`, so that a read that
+    /// committed reaches it.
     ///
-    /// Sends one [`LifecycleFrame::ReadinessWait`] to each owner shard. Every
-    /// owner answers once at admission, so the result covers all targets that
-    /// are ready at the start, also for a zero wait. If none is ready, the
-    /// first later readiness ends the wait. An empty list means that the wait
-    /// expired.
+    /// `None` means that no time was left, or that the owner dropped the
+    /// reply. A namespace that no shard owns, or an inbox that refuses the
+    /// frame, returns [`PartitionReadReply::Rejected`] with
+    /// [`IggyError::TransientNotAccepted`].
+    #[allow(clippy::future_not_send)]
+    pub async fn deferred_read(
+        &self,
+        namespace: IggyNamespace,
+        mut request: DeferredPollRequest,
+        request_deadline: u64,
+    ) -> Option<PartitionReadReply> {
+        let now = self.bus.monotonic_micros();
+        let remaining = request_deadline
+            .checked_sub(now)
+            .filter(|remaining| *remaining > 0)?;
+        request.options.max_wait = IggyDuration::new(Duration::ZERO);
+        request.options.request_timeout = IggyDuration::from(remaining);
+        request.deadline = now;
+        request.request_deadline = request_deadline;
+        match self.send_partition_read(namespace, PartitionRead::DeferredPoll(Box::new(request))) {
+            Ok((_, reply)) => reply.recv().await.ok(),
+            Err(error) => Some(PartitionReadReply::Rejected(error)),
+        }
+    }
+
+    /// Sends `read` to the shard that owns `namespace`, and returns that shard
+    /// and the receiver of its reply. A namespace that no shard owns, or an
+    /// inbox that refuses the frame, gives `TransientNotAccepted`.
+    fn send_partition_read(
+        &self,
+        namespace: IggyNamespace,
+        read: PartitionRead,
+    ) -> Result<(u16, Receiver<PartitionReadReply>), IggyError> {
+        let target = self.shards_table.shard_for(namespace).or_else(|| {
+            matches!(read, PartitionRead::SessionRetired { .. }).then(|| {
+                crate::shards_table::calculate_shard_assignment(&namespace, self.shard_count)
+            })
+        });
+        let Some(target) = target else {
+            tracing::warn!(
+                shard = self.id,
+                namespace_raw = namespace.inner(),
+                "partition_read: namespace not routable (not materialised yet or deleted)"
+            );
+            return Err(IggyError::TransientNotAccepted);
+        };
+        let (reply_tx, reply_rx) = channel::<PartitionReadReply>(1);
+        let frame = ShardFrame::lifecycle(LifecycleFrame::PartitionRead {
+            namespace,
+            read,
+            reply: reply_tx,
+        });
+        let Some(sender) = self.senders.get(target as usize) else {
+            return Err(IggyError::TransientNotAccepted);
+        };
+        if let Err(error) = sender.try_send(frame) {
+            tracing::warn!(
+                shard = self.id,
+                target,
+                "partition_read: inbox rejected PartitionRead frame: {error:?}"
+            );
+            return Err(IggyError::TransientNotAccepted);
+        }
+        Ok((target, reply_rx))
+    }
+
+    /// Wait until the targets hold `min_count` messages.
+    ///
+    /// This shard evaluates the targets that it owns or mirrors in place, and
+    /// it sends one [`LifecycleFrame::ReadinessWait`] to the owner of each
+    /// other target.
+    /// Every evaluator answers once at admission, so the result covers all
+    /// targets that are ready at the start, also for a zero wait. Only this
+    /// shard sees a sum, so an owner answers on its first ready target and
+    /// ends the wait. The wait also ends when the sum of the targets on this
+    /// shard reaches `min_count`, or at the deadline. At the end, this shard
+    /// adds its own targets that hold messages, also below `min_count`. An
+    /// empty list means that the wait expired without messages. An owner
+    /// that gets an `Offset` target starts to push its state here, so that
+    /// the next waits on it stay on this shard.
     ///
     /// # Errors
-    /// Returns the first owner rejection. A target that no shard owns, or an
-    /// owner inbox that refuses the frame, returns `TransientNotAccepted`. An
-    /// owner that does not answer its admission within `PARTITION_READ_TIMEOUT`
+    /// Returns the first rejection. A target that no shard owns, or an inbox
+    /// that refuses the frame, returns `TransientNotAccepted`. An evaluator
+    /// that does not answer its admission within `PARTITION_READ_TIMEOUT`
     /// returns `ShardCommunicationError`, unless another target is ready.
-    #[allow(clippy::future_not_send)]
+    #[allow(clippy::future_not_send, clippy::too_many_lines)]
     pub async fn wait_for_messages(
         &self,
         context: DeferredPollContext,
         targets: Vec<ReadinessTarget>,
         wait: std::time::Duration,
-    ) -> Result<Vec<u32>, IggyError> {
+        min_count: u32,
+    ) -> Result<Vec<ReadyTarget>, IggyError>
+    where
+        M: StreamsFrontend,
+    {
+        let now = self.bus.monotonic_micros();
         let deadline = u64::try_from(wait.as_micros())
             .ok()
-            .and_then(|wait| self.bus.monotonic_micros().checked_add(wait))
+            .and_then(|wait| now.checked_add(wait))
             .ok_or(IggyError::InvalidCommand)?;
-        let mut owners: BTreeMap<u16, Vec<ReadinessTarget>> = BTreeMap::new();
+        // A zero wait asks the owners, so that its answer covers every commit
+        // that its client saw acknowledged. A `Next` start needs the stored
+        // offset, which only the owner holds.
+        let mirrors = !wait.is_zero();
+        let mut evaluators: BTreeMap<u16, (Vec<ReadinessTarget>, Vec<IggyNamespace>)> =
+            BTreeMap::new();
         for target in targets {
-            let shard = self
+            let owner = self
                 .shards_table
                 .shard_for(target.namespace)
                 .ok_or(IggyError::TransientNotAccepted)?;
-            owners.entry(shard).or_default().push(target);
+            let mirrorable =
+                mirrors && owner != self.id && target.strategy.kind == PollingKind::Offset;
+            let shard = if mirrorable && self.mirror_serves(owner, &target, context.primary, now) {
+                self.id
+            } else {
+                owner
+            };
+            let (targets, watched) = evaluators.entry(shard).or_default();
+            if mirrorable && shard == owner {
+                watched.push(target.namespace);
+            }
+            targets.push(target);
         }
-        // An owner answers at admission and at most once more.
-        let (reply, replies) = channel::<PartitionReadReply>(2 * owners.len());
+        // An evaluator answers at admission and at most once more.
+        let (reply, replies) = channel::<PartitionReadReply>(2 * evaluators.len());
         let mut parked = ParkedReadiness {
             senders: &self.senders,
             waiters: BTreeMap::new(),
         };
         let mut admitting = BTreeSet::new();
-        for (shard, targets) in owners {
-            let frame = ShardFrame::lifecycle(LifecycleFrame::ReadinessWait {
-                wait: ReadinessWait {
-                    context: context.clone(),
-                    targets,
-                    deadline,
+        for (shard, (targets, watched)) in evaluators {
+            let watch = (!watched.is_empty()).then(|| self.next_readiness_watch());
+            let wait = ReadinessWait {
+                context: context.clone(),
+                targets,
+                deadline,
+                min_count: if shard == self.id {
+                    min_count
+                } else {
+                    ANY_MESSAGE
                 },
-                reply: reply.clone(),
-            });
-            self.senders
-                .get(usize::from(shard))
-                .ok_or(IggyError::TransientNotAccepted)?
-                .try_send(frame)
-                .map_err(|_| IggyError::TransientNotAccepted)?;
+            };
+            if shard == self.id {
+                // The targets of this shard need no trip through its own inbox.
+                self.admit_readiness_wait(wait, &reply, watch);
+            } else {
+                let frame = ShardFrame::lifecycle(LifecycleFrame::ReadinessWait {
+                    wait: Box::new(wait),
+                    reply: reply.clone(),
+                    watch,
+                });
+                self.senders
+                    .get(usize::from(shard))
+                    .ok_or(IggyError::TransientNotAccepted)?
+                    .try_send(frame)
+                    .map_err(|_| IggyError::TransientNotAccepted)?;
+            }
+            if let Some(watch) = watch {
+                self.mirror_partitions(shard, watch.seq, &watched, now);
+            }
             admitting.insert(shard);
         }
         drop(reply);
         let mut ready = Vec::new();
+        // After the end, the answers that already came still count.
+        let mut ended = false;
         loop {
-            let mut budget = std::time::Duration::from_micros(
-                deadline.saturating_sub(self.bus.monotonic_micros()),
-            );
-            // Every admission answer counts, even after a short wait expires.
-            if !admitting.is_empty() {
-                budget = budget.max(PARTITION_READ_TIMEOUT);
-            }
-            let reply = bus_timeout(&self.bus, budget, replies.recv()).await;
+            let reply = if ended {
+                match replies.try_recv() {
+                    Ok(reply) => reply,
+                    Err(_) => break,
+                }
+            } else {
+                let mut budget = std::time::Duration::from_micros(
+                    deadline.saturating_sub(self.bus.monotonic_micros()),
+                );
+                // Every admission answer counts, even after a short wait expires.
+                if !admitting.is_empty() {
+                    budget = budget.max(PARTITION_READ_TIMEOUT);
+                }
+                match bus_timeout(&self.bus, budget, replies.recv()).await {
+                    Some(Ok(reply)) => reply,
+                    // An evaluator without data drops its expired waiter
+                    // without an answer.
+                    Some(Err(_)) | None if admitting.is_empty() || !ready.is_empty() => {
+                        ended = true;
+                        continue;
+                    }
+                    Some(Err(_)) | None => return Err(IggyError::ShardCommunicationError),
+                }
+            };
             match reply {
-                Some(Ok(PartitionReadReply::Ready {
+                PartitionReadReply::Ready {
                     shard,
                     parked: waiter,
                     partitions,
-                })) => {
+                } => {
                     if admitting.remove(&shard) {
                         if let Some(waiter) = waiter {
                             parked.waiters.insert(shard, waiter);
@@ -2556,18 +2783,16 @@
                         parked.waiters.remove(&shard);
                     }
                     ready.extend(partitions);
-                    if admitting.is_empty() && !ready.is_empty() {
-                        return Ok(ready);
-                    }
+                    ended |= admitting.is_empty() && !ready.is_empty();
                 }
-                Some(Ok(PartitionReadReply::Rejected(error))) => return Err(error),
-                // Owners drop an expired waiter without an answer.
-                Some(Err(_)) | None if admitting.is_empty() || !ready.is_empty() => {
-                    return Ok(ready);
-                }
-                Some(_) | None => return Err(IggyError::ShardCommunicationError),
+                PartitionReadReply::Rejected(error) => return Err(error),
+                _ => return Err(IggyError::ShardCommunicationError),
             }
         }
+        if let Some(waiter) = parked.waiters.remove(&self.id) {
+            ready.extend(self.take_readiness_wait(waiter)?);
+        }
+        Ok(ready)
     }
 
     /// Admit a partition write on the shard owning `namespace`. Routes through
diff --git a/core/shard/src/metrics.rs b/core/shard/src/metrics.rs
index e56c538..6a25f91 100644
--- a/core/shard/src/metrics.rs
+++ b/core/shard/src/metrics.rs
@@ -40,12 +40,14 @@
 use prometheus_client::metrics::counter::Counter;
 use prometheus_client::metrics::family::Family;
 use prometheus_client::metrics::gauge::Gauge;
+use prometheus_client::metrics::histogram::{Histogram, exponential_buckets};
 use prometheus_client::registry::Registry;
 use std::fmt::Write as _;
 use std::hash::{Hash, Hasher};
 use std::sync::{Arc, OnceLock};
+use std::time::Duration;
 
-use iggy_binary_protocol::Command;
+use iggy_binary_protocol::{COMMAND_TABLE, Command, command_index};
 use iggy_common::ConsumerKind;
 use message_bus::{ReplicaLinkMetrics, ReplicaReadMetrics};
 
@@ -253,6 +255,8 @@
     pub expired: Counter,
     pub filled: Counter,
     pub late: Counter,
+    pub readiness_pushes: Counter,
+    pub readiness_watches: Gauge,
 }
 
 impl DeferredPollMetrics {
@@ -274,7 +278,7 @@
         );
         registry.register(
             "deferred_poll_failed",
-            "admitted deferred polls rejected by a read error, fence, read budget refusal or request deadline",
+            "admitted deferred polls rejected by a read error, fence, full completion lane or request deadline",
             self.failed.clone(),
         );
         registry.register(
@@ -297,6 +301,16 @@
             "deferred read completions discarded after expiry or cancellation",
             self.late.clone(),
         );
+        registry.register(
+            "deferred_poll_readiness_pushes",
+            "partition states pushed to the shards that watch them for readiness waits",
+            self.readiness_pushes.clone(),
+        );
+        registry.register(
+            "deferred_poll_readiness_watches",
+            "partitions of this shard that other shards watch for readiness waits",
+            self.readiness_watches.clone(),
+        );
     }
 }
 
@@ -370,6 +384,9 @@
     consumer_offset_denied_counters: [Counter; ConsumerKind::COUNT],
     partition_consumer_offsets_stranded: Family<ConsumerOffsetKindLabel, Gauge>,
     consumer_offset_stranded_gauges: [Gauge; ConsumerKind::COUNT],
+    client_requests: ClientRequestMetrics,
+    fetches: FetchMetrics,
+    readiness_push_to_fire: Histogram,
 }
 
 impl ShardMetrics {
@@ -457,9 +474,27 @@
             consumer_offset_denied_counters,
             partition_consumer_offsets_stranded,
             consumer_offset_stranded_gauges,
+            client_requests: ClientRequestMetrics::new(),
+            fetches: FetchMetrics::new(),
+            readiness_push_to_fire: latency_histogram(),
         }
     }
 
+    /// Counts a client request by its command `code`. A code that the
+    /// command table does not hold counts as `unknown`.
+    pub fn record_client_request(&self, code: u32) {
+        self.client_requests.record(code);
+    }
+
+    /// Counts one answered fetch (109).
+    pub fn record_fetch(&self, fetch: &FetchRecord) {
+        self.fetches.record(fetch);
+    }
+
+    pub(crate) fn record_readiness_push_to_fire(&self, elapsed: Duration) {
+        self.readiness_push_to_fire.observe(elapsed.as_secs_f64());
+    }
+
     pub fn record_persistence(&self, metrics: &partitions::PersistenceMetrics) {
         self.partition_wal_disk_bytes
             .set(i64::try_from(metrics.disk_bytes).unwrap_or(i64::MAX));
@@ -1233,6 +1268,179 @@
              against the limit",
             self.partition_consumer_offsets_stranded.clone(),
         );
+        registry.register(
+            "client_requests",
+            "client requests by command",
+            self.client_requests.total.clone(),
+        );
+        self.fetches.register(registry);
+        registry.register(
+            "readiness_push_to_fire_seconds",
+            "time from applying the pushed partition state that made a parked readiness wait \
+             ready to the end of that wait",
+            self.readiness_push_to_fire.clone(),
+        );
+    }
+
+    /// The text scrape of this handle alone, for the assertions on series
+    /// without a value accessor, such as histogram counts.
+    #[cfg(test)]
+    pub(crate) fn scrape(&self) -> String {
+        let mut registry = Registry::default();
+        self.register(&mut registry);
+        let mut buffer = String::new();
+        prometheus_client::encoding::text::encode(&mut buffer, &registry)
+            .expect("scrape encoding succeeds");
+        buffer
+    }
+}
+
+/// What one fetch (109) served, for [`ShardMetrics::record_fetch`].
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
+pub struct FetchRecord {
+    pub sections: u64,
+    /// The record bytes of the sections. A fetch without any is empty.
+    pub bytes: u64,
+    /// Ready targets that the fetch left for a later one, because its byte
+    /// budget or its read cap ran out.
+    pub budget_waits: u64,
+    /// Reads that their owner refused for its deferred poll quota.
+    pub quota_refusals: u64,
+    /// From the end of the readiness wait to the reply.
+    pub fire_to_reply: Duration,
+}
+
+/// Fetch (109) accounting on the shard of the connection.
+#[derive(Clone)]
+struct FetchMetrics {
+    fetches: Counter,
+    sections: Counter,
+    bytes: Counter,
+    empty: Counter,
+    budget_waits: Counter,
+    quota_refusals: Counter,
+    fire_to_reply: Histogram,
+}
+
+impl FetchMetrics {
+    fn new() -> Self {
+        Self {
+            fetches: Counter::default(),
+            sections: Counter::default(),
+            bytes: Counter::default(),
+            empty: Counter::default(),
+            budget_waits: Counter::default(),
+            quota_refusals: Counter::default(),
+            fire_to_reply: latency_histogram(),
+        }
+    }
+
+    fn record(&self, fetch: &FetchRecord) {
+        self.fetches.inc();
+        self.sections.inc_by(fetch.sections);
+        self.bytes.inc_by(fetch.bytes);
+        if fetch.bytes == 0 {
+            self.empty.inc();
+        }
+        self.budget_waits.inc_by(fetch.budget_waits);
+        self.quota_refusals.inc_by(fetch.quota_refusals);
+        self.fire_to_reply
+            .observe(fetch.fire_to_reply.as_secs_f64());
+    }
+
+    fn register(&self, registry: &mut Registry) {
+        registry.register("fetches", "answered fetch requests", self.fetches.clone());
+        registry.register(
+            "fetch_sections",
+            "partition sections of fetch replies",
+            self.sections.clone(),
+        );
+        registry.register(
+            "fetch_bytes",
+            "record bytes of fetch replies",
+            self.bytes.clone(),
+        );
+        registry.register(
+            "fetch_empty",
+            "fetch replies without records",
+            self.empty.clone(),
+        );
+        registry.register(
+            "fetch_budget_waits",
+            "ready fetch targets left for a later fetch by the byte budget or the read cap",
+            self.budget_waits.clone(),
+        );
+        registry.register(
+            "fetch_quota_refusals",
+            "fetch reads refused by the deferred poll quota of their owner",
+            self.quota_refusals.clone(),
+        );
+        registry.register(
+            "fetch_fire_to_reply_seconds",
+            "time from the end of the readiness wait of a fetch to its reply",
+            self.fire_to_reply.clone(),
+        );
+    }
+}
+
+/// Latency buckets from 10 us, doubling up to about 5 s.
+const LATENCY_BUCKET_START_SECONDS: f64 = 0.000_01;
+const LATENCY_BUCKET_FACTOR: f64 = 2.0;
+const LATENCY_BUCKET_COUNT: u16 = 20;
+
+fn latency_histogram() -> Histogram {
+    Histogram::new(exponential_buckets(
+        LATENCY_BUCKET_START_SECONDS,
+        LATENCY_BUCKET_FACTOR,
+        LATENCY_BUCKET_COUNT,
+    ))
+}
+
+/// The `command` of a request whose code the command table does not hold.
+const UNKNOWN_COMMAND: &str = "unknown";
+const COMMAND_COUNT: usize = COMMAND_TABLE.len();
+
+/// Label for `client_requests_total`: the command name of the table.
+#[derive(Clone, Hash, Eq, PartialEq, EncodeLabelSet, Debug)]
+struct ClientCommandLabel {
+    command: &'static str,
+}
+
+/// Client requests by command. As for frame drops, a command enters the
+/// scrape with its first request, and its later requests are an array
+/// index and an increment.
+#[derive(Clone)]
+struct ClientRequestMetrics {
+    total: Family<ClientCommandLabel, Counter>,
+    cached: Arc<[OnceLock<Counter>; COMMAND_COUNT]>,
+}
+
+impl ClientRequestMetrics {
+    fn new() -> Self {
+        Self {
+            total: Family::default(),
+            cached: Arc::new(std::array::from_fn(|_| OnceLock::new())),
+        }
+    }
+
+    fn record(&self, code: u32) {
+        let cached = command_index(code)
+            .and_then(|index| Some((self.cached.get(index)?, COMMAND_TABLE.get(index)?.name)));
+        match cached {
+            Some((counter, command)) => counter
+                .get_or_init(|| {
+                    self.total
+                        .get_or_create(&ClientCommandLabel { command })
+                        .clone()
+                })
+                .inc(),
+            None => self
+                .total
+                .get_or_create(&ClientCommandLabel {
+                    command: UNKNOWN_COMMAND,
+                })
+                .inc(),
+        };
     }
 }
 
@@ -1268,6 +1476,7 @@
 #[cfg(test)]
 mod tests {
     use super::*;
+    use iggy_binary_protocol::codes::FETCH_MESSAGES_CODE;
 
     #[test]
     fn frame_drop_counter_increments_per_label_set() {
@@ -1460,6 +1669,58 @@
     }
 
     #[test]
+    fn client_requests_count_by_command_name_and_unknown_codes_together() {
+        let metrics = ShardMetrics::for_shard();
+        let unknown_code = u32::MAX;
+        metrics.record_client_request(FETCH_MESSAGES_CODE);
+        metrics.record_client_request(FETCH_MESSAGES_CODE);
+        metrics.record_client_request(unknown_code);
+        let scrape = metrics.scrape();
+        let mut series: Vec<_> = scrape
+            .lines()
+            .filter(|line| line.starts_with("client_requests_total"))
+            .collect();
+        // A family encodes its series in hash order.
+        series.sort_unstable();
+        assert_eq!(
+            series,
+            [
+                "client_requests_total{command=\"message.fetch\"} 2",
+                "client_requests_total{command=\"unknown\"} 1",
+            ],
+            "a command without requests stays out of the scrape",
+        );
+    }
+
+    #[test]
+    fn fetch_records_add_up_and_count_empty_replies() {
+        let metrics = ShardMetrics::for_shard();
+        metrics.record_fetch(&FetchRecord {
+            sections: 2,
+            bytes: 300,
+            budget_waits: 1,
+            quota_refusals: 1,
+            fire_to_reply: Duration::from_micros(50),
+        });
+        metrics.record_fetch(&FetchRecord::default());
+        let scrape = metrics.scrape();
+        for series in [
+            "fetches_total 2",
+            "fetch_sections_total 2",
+            "fetch_bytes_total 300",
+            "fetch_empty_total 1",
+            "fetch_budget_waits_total 1",
+            "fetch_quota_refusals_total 1",
+            "fetch_fire_to_reply_seconds_count 2",
+        ] {
+            assert!(
+                scrape.lines().any(|line| line == series),
+                "{series} is not in the scrape:\n{scrape}"
+            );
+        }
+    }
+
+    #[test]
     fn unknown_label_set_falls_back_to_family() {
         // A label outside the const tables must still record via the slow
         // path so we never silently drop accounting. A scrape that filters
diff --git a/core/shard/src/poll.rs b/core/shard/src/poll.rs
index 1cdf2b5..3dd6f94 100644
--- a/core/shard/src/poll.rs
+++ b/core/shard/src/poll.rs
@@ -30,7 +30,7 @@
 //! before any replication wait.
 
 use crate::shards_table::ShardsTable;
-use crate::{IggyShard, PartitionRead, PartitionReadReply, Sender};
+use crate::{ConsumerOffsetRead, IggyShard, PartitionRead, PartitionReadReply, Sender};
 use consensus::client_table::SessionAttachment;
 use consensus::{Consensus, MetadataHandle, PartitionsHandle, is_partition_receipt_operation};
 use iggy_binary_protocol::primitives::partition_history::ConsumerGroupOwner;
@@ -170,6 +170,14 @@
             self.admit_deferred_poll(namespace, *request, &reply);
             return;
         }
+        if let PartitionRead::ConsumerOffsetsOnPrimary { reads } = read {
+            let offsets = reads
+                .into_iter()
+                .map(|read| self.consumer_offset_on_primary(read))
+                .collect();
+            let _ = reply.try_send(PartitionReadReply::ConsumerOffsets(offsets));
+            return;
+        }
         let partitions = self.plane.partitions();
         if matches!(read, PartitionRead::SessionRetired { .. }) {
             let failed_revision = partitions.failed_revision(&namespace).or_else(|| {
@@ -185,101 +193,7 @@
                 return;
             }
         }
-        let rejection = partitions
-            .with_partition(&namespace, |partition| {
-                if read.requires_primary() {
-                    let consensus = partition.consensus();
-                    if consensus.is_normal_backup() {
-                        return Some(IggyError::NotPrimary);
-                    }
-                    if !consensus.is_primary()
-                        || !consensus.is_normal()
-                        || consensus.is_transferring()
-                    {
-                        return Some(IggyError::TransientNotAccepted);
-                    }
-                }
-                if partition.requires_state_transfer() || partition.read_history_is_changing() {
-                    return Some(IggyError::TransientNotAccepted);
-                }
-                // The merged log of a new view can hold a message append or an
-                // offset store that the old view acknowledged. The new primary
-                // applies it only after the view starts, so until then a poll
-                // can miss acknowledged messages and a stored offset can be
-                // older. The head is current only on the primary that started
-                // the view.
-                if read.reads_messages_or_offset()
-                    && partition.consensus().is_primary()
-                    && !partition.consensus().applied_view_start()
-                {
-                    return Some(IggyError::TransientNotAccepted);
-                }
-                if let PartitionRead::ConsumerOffsetOnPrimary { metadata, .. }
-                | PartitionRead::ExternalGroupOffsetOnPrimary { metadata, .. } = &read
-                {
-                    if !metadata
-                        .is_valid_for_offset(self.plane.metadata().mux_stm.streams(), namespace)
-                        || !metadata.matches_partition(self.shards_table.epoch_for(namespace))
-                    {
-                        return Some(IggyError::TransientNotAccepted);
-                    }
-                    if partition.history_deleted() {
-                        return Some(IggyError::HistoryUnavailable);
-                    }
-                }
-                if let PartitionRead::PollOnPrimary {
-                    consumer,
-                    attachment,
-                    ..
-                } = &read
-                {
-                    let consensus = partition.consensus();
-                    if !consensus.is_primary()
-                        || !consensus.is_normal()
-                        || consensus.is_transferring()
-                        || !attachment
-                            .metadata
-                            .matches_partition(self.shards_table.epoch_for(namespace))
-                    {
-                        return Some(IggyError::TransientNotAccepted);
-                    }
-                    // This node's metadata can lag a delete fence the partition
-                    // already applied, so it still authorizes the old incarnation.
-                    if partition.history_deleted() {
-                        return Some(IggyError::HistoryUnavailable);
-                    }
-                    // The plan captures this owner in the same turn, and
-                    // completion refuses an owner that changed since.
-                    return match *consumer {
-                        PollingConsumer::ConsumerGroup(group_id, _) => group_owner_rejection(
-                            namespace,
-                            group_id as u64,
-                            partition.consumer_group_owner(group_id as u64),
-                            &attachment.metadata,
-                        ),
-                        PollingConsumer::Consumer(..) => None,
-                    };
-                }
-                if let PartitionRead::Poll {
-                    metadata: Some(metadata),
-                    ..
-                }
-                | PartitionRead::UnboundPollOnPrimary { metadata, .. } = &read
-                    && (!metadata.is_valid(self.plane.metadata().mux_stm.streams(), namespace)
-                        || !metadata.matches_partition(self.shards_table.epoch_for(namespace)))
-                {
-                    return Some(IggyError::TransientNotAccepted);
-                }
-                (matches!(
-                    read,
-                    PartitionRead::Poll { .. } | PartitionRead::UnboundPollOnPrimary { .. }
-                ) && partition.history_deleted())
-                .then_some(IggyError::HistoryUnavailable)
-            })
-            .unwrap_or_else(|| {
-                read.requires_primary()
-                    .then_some(IggyError::TransientNotAccepted)
-            });
+        let rejection = self.partition_read_rejection(namespace, &read);
         if let Some(error) = rejection {
             let _ = reply.try_send(PartitionReadReply::Rejected(error));
             return;
@@ -330,6 +244,10 @@
                 })
                 .unwrap_or(PartitionReadReply::NotFound),
             PartitionRead::DeferredPoll(_) => unreachable!("deferred admission handled above"),
+            // Answered above, entry by entry.
+            PartitionRead::ConsumerOffsetsOnPrimary { .. } => {
+                PartitionReadReply::Rejected(IggyError::InvalidCommand)
+            }
             PartitionRead::Primary => partitions
                 .with_partition(&namespace, |partition| {
                     let consensus = partition.consensus();
@@ -454,6 +372,143 @@
         let _ = reply.try_send(result);
     }
 
+    /// The condition of this shard that refuses `read` of `namespace` before
+    /// it runs, if any.
+    #[allow(clippy::too_many_lines)]
+    fn partition_read_rejection(
+        &self,
+        namespace: IggyNamespace,
+        read: &PartitionRead,
+    ) -> Option<IggyError> {
+        let partitions = self.plane.partitions();
+        partitions
+            .with_partition(&namespace, |partition| {
+                if read.requires_primary() {
+                    let consensus = partition.consensus();
+                    if consensus.is_normal_backup() {
+                        return Some(IggyError::NotPrimary);
+                    }
+                    if !consensus.is_primary()
+                        || !consensus.is_normal()
+                        || consensus.is_transferring()
+                    {
+                        return Some(IggyError::TransientNotAccepted);
+                    }
+                }
+                if partition.requires_state_transfer() || partition.read_history_is_changing() {
+                    return Some(IggyError::TransientNotAccepted);
+                }
+                // The merged log of a new view can hold a message append or an
+                // offset store that the old view acknowledged. The new primary
+                // applies it only after the view starts, so until then a poll
+                // can miss acknowledged messages and a stored offset can be
+                // older. The head is current only on the primary that started
+                // the view.
+                if read.reads_messages_or_offset()
+                    && partition.consensus().is_primary()
+                    && !partition.consensus().applied_view_start()
+                {
+                    return Some(IggyError::TransientNotAccepted);
+                }
+                if let PartitionRead::ConsumerOffsetOnPrimary { metadata, .. }
+                | PartitionRead::ExternalGroupOffsetOnPrimary { metadata, .. } = &read
+                {
+                    if !metadata
+                        .is_valid_for_offset(self.plane.metadata().mux_stm.streams(), namespace)
+                        || !metadata.matches_partition(self.shards_table.epoch_for(namespace))
+                    {
+                        return Some(IggyError::TransientNotAccepted);
+                    }
+                    if partition.history_deleted() {
+                        return Some(IggyError::HistoryUnavailable);
+                    }
+                }
+                if let PartitionRead::PollOnPrimary {
+                    consumer,
+                    attachment,
+                    ..
+                } = &read
+                {
+                    let consensus = partition.consensus();
+                    if !consensus.is_primary()
+                        || !consensus.is_normal()
+                        || consensus.is_transferring()
+                        || !attachment
+                            .metadata
+                            .matches_partition(self.shards_table.epoch_for(namespace))
+                    {
+                        return Some(IggyError::TransientNotAccepted);
+                    }
+                    // This node's metadata can lag a delete fence the partition
+                    // already applied, so it still authorizes the old incarnation.
+                    if partition.history_deleted() {
+                        return Some(IggyError::HistoryUnavailable);
+                    }
+                    // The plan captures this owner in the same turn, and
+                    // completion refuses an owner that changed since.
+                    return match *consumer {
+                        PollingConsumer::ConsumerGroup(group_id, _) => group_owner_rejection(
+                            namespace,
+                            group_id as u64,
+                            partition.consumer_group_owner(group_id as u64),
+                            &attachment.metadata,
+                        ),
+                        PollingConsumer::Consumer(..) => None,
+                    };
+                }
+                if let PartitionRead::Poll {
+                    metadata: Some(metadata),
+                    ..
+                }
+                | PartitionRead::UnboundPollOnPrimary { metadata, .. } = &read
+                    && (!metadata.is_valid(self.plane.metadata().mux_stm.streams(), namespace)
+                        || !metadata.matches_partition(self.shards_table.epoch_for(namespace)))
+                {
+                    return Some(IggyError::TransientNotAccepted);
+                }
+                (matches!(
+                    read,
+                    PartitionRead::Poll { .. } | PartitionRead::UnboundPollOnPrimary { .. }
+                ) && partition.history_deleted())
+                .then_some(IggyError::HistoryUnavailable)
+            })
+            .unwrap_or_else(|| {
+                read.requires_primary()
+                    .then_some(IggyError::TransientNotAccepted)
+            })
+    }
+
+    /// A [`PartitionRead::ConsumerOffsetOnPrimary`] of a partition that this
+    /// shard owns, run in place.
+    pub(crate) fn consumer_offset_on_primary(
+        &self,
+        ConsumerOffsetRead {
+            namespace,
+            consumer,
+            metadata,
+        }: ConsumerOffsetRead,
+    ) -> PartitionReadReply {
+        let read = PartitionRead::ConsumerOffsetOnPrimary { consumer, metadata };
+        if let Some(error) = self.partition_read_rejection(namespace, &read) {
+            return PartitionReadReply::Rejected(error);
+        }
+        let Some(consumer) = consumer else {
+            return PartitionReadReply::ConsumerOffset {
+                stored: None,
+                current_offset: 0,
+            };
+        };
+        self.plane
+            .partitions()
+            .consumer_offset_read(&namespace, consumer)
+            .map_or(PartitionReadReply::NotFound, |(stored, current_offset)| {
+                PartitionReadReply::ConsumerOffset {
+                    stored,
+                    current_offset,
+                }
+            })
+    }
+
     /// Discard a read if its reply receiver is already disconnected at the check.
     /// Otherwise accept it on the owner's pump and attempt the reply before
     /// replication, which may suspend. Another shard can disconnect after the
@@ -532,7 +587,8 @@
                 return;
             }
         }
-        self.accept_poll_result(namespace, result, reply).await;
+        self.accept_poll_result(namespace, result, reply, None)
+            .await;
     }
 
     #[allow(clippy::future_not_send)]
@@ -541,6 +597,7 @@
         namespace: IggyNamespace,
         result: PollReadResult,
         reply: Sender<PartitionReadReply>,
+        next_offset: Option<u64>,
     ) {
         let partitions = self.plane.partitions();
         let consumer_kind = result.consumer_kind();
@@ -560,6 +617,7 @@
                     fragments,
                     current_offset,
                     encryptor,
+                    next_offset,
                 });
                 if let Some(replication) = replication {
                     partitions
@@ -580,7 +638,9 @@
 /// Serve a group read only to the partition's installed owner. The serving
 /// node's metadata can lag an install the partition already applied, for
 /// example on a new primary after a view change, and the partition commits a
-/// read's progress under the installed owner.
+/// read's progress under the installed owner. `TransientNotAccepted` means
+/// only that the partition has not applied the install yet, and a readiness
+/// mirror reads it as not ready.
 fn group_owner_rejection(
     namespace: IggyNamespace,
     group_id: u64,
diff --git a/core/shard/src/poll/deferred.rs b/core/shard/src/poll/deferred.rs
index 5cc54b8..c2b1adf 100644
--- a/core/shard/src/poll/deferred.rs
+++ b/core/shard/src/poll/deferred.rs
@@ -20,17 +20,30 @@
 //! carry only a request identity and a reservation that survives cancellation.
 //! Readiness waits share the registry, because it owns the one poll notifier
 //! of each partition.
+//!
+//! A readiness wait runs on the shard of its connection. That shard keeps a
+//! mirror of each partition of another shard that its waits name, and the
+//! owner pushes the partition state after every change while that shard
+//! holds a parked wait on it. A wait that parks on a mirror tells the owner,
+//! and the first push that finds no parked wait ends the pushes. So a wait in
+//! steady state sends no frame to the owners, and its cancel stays local. The
+//! first wait on a partition goes to the owner, which answers it and starts
+//! the watch.
 
 use std::cell::RefCell;
+use std::collections::btree_map::Entry;
 use std::collections::{BTreeMap, BTreeSet, HashSet, VecDeque};
 use std::ops::Bound::{Excluded, Unbounded};
 use std::rc::Rc;
 use std::sync::Arc;
-use std::sync::atomic::{AtomicUsize, Ordering};
+use std::sync::atomic::{AtomicU8, AtomicU64, AtomicUsize, Ordering};
 use std::task::{Context, Poll, Waker};
+use std::time::Duration;
 
 use consensus::client_table::SessionAttachment;
 use consensus::{Consensus, MetadataHandle, PartitionsHandle};
+use crossfire::TrySendError;
+use iggy_binary_protocol::primitives::partition_history::ConsumerGroupOwner;
 use iggy_binary_protocol::responses::messages::poll_messages::POLL_RESPONSE_HEADER_SIZE;
 use iggy_common::{DeferredPollOptions, IggyError, PollingKind, PollingStrategy};
 use journal::superblock::SuperblockStore;
@@ -38,7 +51,8 @@
 use metadata::impls::metadata::StreamsFrontend;
 use metadata::stm::stream::PollMetadata;
 use partitions::{
-    DISK_POLL_CHUNK_MAX, Partition, PollReadResult, PollVisibility, PollingArgs, PollingConsumer,
+    DISK_POLL_CHUNK_MAX, IggyPartition, Partition, PollReadResult, PollVisibility, PollingArgs,
+    PollingConsumer,
 };
 use server_common::poll::PollHistoryId;
 use server_common::sharding::IggyNamespace;
@@ -46,7 +60,7 @@
 use crate::config::DeferredPollConfig;
 use crate::metrics::DeferredPollMetrics;
 use crate::shards_table::ShardsTable;
-use crate::{IggyShard, PartitionReadReply, Sender};
+use crate::{IggyShard, LifecycleFrame, PartitionReadReply, Sender, ShardFrame, TaggedSender};
 use prometheus_client::metrics::gauge::Gauge;
 
 const SERVICE_BUDGET: usize = 8;
@@ -58,6 +72,25 @@
 #[allow(clippy::cast_possible_truncation)]
 const READ_PART_MIN_BYTES: usize = DISK_POLL_CHUNK_MAX as usize;
 const MAINTENANCE_BUDGET: usize = 16;
+/// A mirror that no wait named for this long ends its watch at the next push
+/// or sweep. The waits of a consumer that keeps polling name their
+/// partitions more often.
+const MIRROR_IDLE_US: u64 = 1_000_000;
+
+/// The `min_count` of a wait that ends at its first message. An owner
+/// evaluator sees only its own targets, so it gets this, and the shard of
+/// the wait decides on the sum.
+pub const ANY_MESSAGE: u32 = 1;
+
+/// What the `max_bytes` of a read counts besides the batches of
+/// [`ReadyTarget::known_bytes`]: the poll response header.
+#[allow(clippy::cast_possible_truncation)]
+pub const KNOWN_BYTES_SLACK: u32 = POLL_RESPONSE_HEADER_SIZE as u32;
+
+/// The history, the visibility and the view of a partition that a readiness
+/// evaluation reads, and whether the partition serves the target now. Only a
+/// mirror that lags the install of a group owner does not.
+type Reading = (PollHistoryId, PollVisibility, u32, bool);
 
 /// Auxiliary connections are charged to their parent session. HTTP requests
 /// without a binary session are charged to their authenticated user.
@@ -80,10 +113,10 @@
 
 /// A deferred poll as the origin resolved it.
 ///
-/// The origin shard sets both deadlines in `partition_read` from the bus
-/// clock, which every shard of the process shares, before it routes the
-/// request to the owner. A request that skips `partition_read` keeps the
-/// zero deadlines of `new` and expires at once.
+/// The origin shard sets both deadlines in `partition_read` or
+/// `deferred_read` from the bus clock, which every shard of the process
+/// shares, before it routes the request to the owner. A request that skips
+/// both keeps the zero deadlines of `new` and expires at once.
 #[derive(Debug)]
 pub struct DeferredPollRequest {
     pub consumer: PollingConsumer,
@@ -91,6 +124,17 @@
     pub metadata: PollMetadata,
     pub context: DeferredPollContext,
     pub options: DeferredPollOptions,
+    /// The gate of a committing request that its client can cancel.
+    pub commit_gate: Option<CommitGate>,
+    /// Shared by the reads of one fetch, so at most one of them returns a
+    /// first message larger than its `max_bytes`. A poll alone has none and
+    /// always may.
+    pub oversized_turn: Option<OversizedTurn>,
+    /// From when, on the bus clock, a client store of the consumer offset
+    /// fences out a committing `Next` read, also a store of the value it
+    /// started from. `None` means from its admission. A fetch that resolved
+    /// its starts before it waited sets that time.
+    pub stores_fenced_from: Option<u64>,
     pub(crate) deadline: u64,
     pub(crate) request_deadline: u64,
 }
@@ -110,12 +154,91 @@
             metadata,
             context,
             options,
+            commit_gate: None,
+            oversized_turn: None,
+            stores_fenced_from: None,
             deadline: 0,
             request_deadline: 0,
         }
     }
 }
 
+/// The one turn of a fetch to return a first message larger than the byte
+/// limit of its read, which bounds the reply by one such message.
+#[derive(Debug, Clone, Default)]
+pub struct OversizedTurn {
+    state: Arc<AtomicU8>,
+    /// A follower shares the turn of the first read and never takes it.
+    follower: bool,
+}
+
+impl OversizedTurn {
+    /// The same turn for a read after the first, which serves only a first
+    /// message within its byte limit, so all reads of a fetch can start at
+    /// once while only the first may return a larger one.
+    #[must_use]
+    pub fn follower(&self) -> Self {
+        Self {
+            state: Arc::clone(&self.state),
+            follower: true,
+        }
+    }
+
+    /// Takes the turn, unless another read took it or this read follows.
+    #[must_use]
+    pub fn take(&self) -> bool {
+        !self.follower
+            && self
+                .state
+                .compare_exchange(0, 2, Ordering::SeqCst, Ordering::SeqCst)
+                .is_ok()
+    }
+
+    fn accept_normal(&self) -> bool {
+        matches!(
+            self.state
+                .compare_exchange(0, 1, Ordering::SeqCst, Ordering::SeqCst),
+            Ok(_) | Err(1)
+        )
+    }
+}
+
+/// The cancel gate of one committing deferred request.
+///
+/// The origin and the owners of its reads share it. A cancel wins only while
+/// no read passed the gate, and a read commits only while no cancel won, so a
+/// cancelled request stored nothing and a request whose cancel lost answers as
+/// usual.
+#[derive(Debug, Clone, Default)]
+pub struct CommitGate(Arc<AtomicU64>);
+
+const GATE_CANCELLED: u64 = 1 << 63;
+
+impl CommitGate {
+    /// Lets one read commit, unless a cancel won.
+    #[must_use]
+    pub fn enter(&self) -> bool {
+        self.0
+            .try_update(Ordering::SeqCst, Ordering::SeqCst, |state| {
+                (state & GATE_CANCELLED == 0).then_some(state + 1)
+            })
+            .is_ok()
+    }
+
+    /// Cancels the request, unless a read already passed the gate.
+    #[must_use]
+    pub fn cancel(&self) -> bool {
+        self.0
+            .compare_exchange(0, GATE_CANCELLED, Ordering::SeqCst, Ordering::SeqCst)
+            .is_ok()
+    }
+
+    #[must_use]
+    pub fn is_cancelled(&self) -> bool {
+        self.0.load(Ordering::SeqCst) & GATE_CANCELLED != 0
+    }
+}
+
 /// One partition of a readiness wait, resolved and authorized by the origin.
 #[derive(Debug)]
 pub struct ReadinessTarget {
@@ -127,21 +250,203 @@
     pub metadata: PollMetadata,
 }
 
-/// The targets of one readiness wait that a single owner shard holds. They
-/// name one stream and topic, so one check of `context` covers all of them.
+/// The targets of one readiness wait that a single shard evaluates.
+///
+/// The evaluator is their owner, or the shard of the wait for the targets
+/// that it mirrors. They name one stream and topic, so one check of
+/// `context` covers all of them.
 #[derive(Debug)]
 pub struct ReadinessWait {
     pub context: DeferredPollContext,
     pub targets: Vec<ReadinessTarget>,
     pub deadline: u64,
+    /// The evaluator answers once its targets hold this many messages, or
+    /// at the deadline with the targets that hold any.
+    pub min_count: u32,
 }
 
-/// A readiness waiter answers once. Its targets are sorted by namespace.
-/// `admitted` holds the history and view of each target at admission, and a
-/// change that an evaluation finds rejects the waiter.
+/// A target that holds messages at its start point.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct ReadyTarget {
+    pub partition_id: u32,
+    /// The offsets from the start through the committed offset. A start
+    /// before the offsets that retention kept counts removed ones too, so
+    /// this bounds the messages from above.
+    pub available: u64,
+    /// The bytes with batch framing that a read from the start returns, if
+    /// the start was the next offset to commit when the wait parked. A read
+    /// whose `max_bytes` and read budget cover them plus
+    /// [`KNOWN_BYTES_SLACK`] returns them all.
+    pub known_bytes: Option<u64>,
+}
+
+/// A shard that watches partitions of another shard.
+///
+/// Pushes and unwatches carry the sequence of the watch, so a frame from
+/// before a later watch of the same partition cannot change that watch.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct ReadinessWatch {
+    pub shard: u16,
+    pub seq: u64,
+}
+
+/// The state of a watched partition that its owner pushes. `None` means
+/// that the owner does not serve the partition.
+#[derive(Debug)]
+pub struct WatchedState {
+    pub namespace: IggyNamespace,
+    pub seq: u64,
+    pub state: Option<ServedState>,
+}
+
+/// What a readiness evaluation reads from a partition.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ServedState {
+    history: PollHistoryId,
+    visibility: PollVisibility,
+    view: u32,
+    status: PartitionStatus,
+    /// The installed owner of each group that a watching wait names.
+    owners: Vec<(u64, Option<ConsumerGroupOwner>)>,
+}
+
+impl ServedState {
+    fn check(&self, primary: bool, target: &ReadinessTarget) -> Result<bool, IggyError> {
+        self.status.check(
+            target.namespace,
+            primary,
+            &target.metadata,
+            target.consumer,
+            |group| {
+                self.owners
+                    .iter()
+                    .find(|(id, _)| *id == group)
+                    .and_then(|(_, owner)| *owner)
+            },
+        )
+    }
+}
+
+/// The conditions on a partition that the checks of a read use.
+#[allow(clippy::struct_excessive_bools)]
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+struct PartitionStatus {
+    deleted: bool,
+    backup: bool,
+    /// A fault, a read history that changes, a state transfer, or a status
+    /// other than normal.
+    unstable: bool,
+    view_started: bool,
+    primary: bool,
+    /// The incarnation that the owner of the partition serves.
+    incarnation: Option<u64>,
+}
+
+impl PartitionStatus {
+    fn of<B: MessageBus, SB: SuperblockStore>(
+        partition: &IggyPartition<B, SB>,
+        incarnation: Option<u64>,
+    ) -> Self {
+        let consensus = partition.consensus();
+        Self {
+            deleted: partition.history_deleted(),
+            backup: consensus.is_normal_backup(),
+            unstable: partition.fatal().is_some()
+                || partition.read_history_is_changing()
+                || partition.requires_state_transfer()
+                || !consensus.is_normal()
+                || consensus.is_transferring(),
+            view_started: consensus.applied_view_start(),
+            primary: consensus.is_primary(),
+            incarnation,
+        }
+    }
+
+    /// `owner` looks up the installed owner of a group. `Ok(false)` means
+    /// that the partition has not applied the install of the group owner
+    /// that `metadata` names yet.
+    fn check(
+        self,
+        namespace: IggyNamespace,
+        primary: bool,
+        metadata: &PollMetadata,
+        consumer: PollingConsumer,
+        owner: impl FnOnce(u64) -> Option<ConsumerGroupOwner>,
+    ) -> Result<bool, IggyError> {
+        if self.deleted {
+            return Err(IggyError::HistoryUnavailable);
+        }
+        if primary && self.backup {
+            return Err(IggyError::NotPrimary);
+        }
+        let installed = match consumer {
+            PollingConsumer::ConsumerGroup(group, _) => {
+                match super::group_owner_rejection(
+                    namespace,
+                    group as u64,
+                    owner(group as u64),
+                    metadata,
+                ) {
+                    None => true,
+                    Some(IggyError::TransientNotAccepted) => false,
+                    Some(error) => return Err(error),
+                }
+            }
+            PollingConsumer::Consumer(..) => true,
+        };
+        if self.unstable
+            || (primary && (!self.view_started || !self.primary))
+            || !metadata.matches_partition(self.incarnation)
+        {
+            return Err(IggyError::TransientNotAccepted);
+        }
+        Ok(installed)
+    }
+}
+
+/// A target as one readiness evaluation finds it.
+#[derive(Debug, Clone, Copy)]
+struct Readiness {
+    history: PollHistoryId,
+    view: u32,
+    /// The offsets from the start through the committed offset.
+    available: u64,
+    committed_bytes: u64,
+    /// The start is the next offset to commit, so the bytes that later
+    /// commits add are those that a read from the start returns.
+    at_end: bool,
+}
+
+/// A target of a waiter at admission and at its last evaluation.
+#[derive(Debug, Clone, Copy)]
+struct TargetReadiness {
+    admitted: Readiness,
+    last: Readiness,
+}
+
+impl TargetReadiness {
+    fn ready(&self, partition_id: u32) -> Option<ReadyTarget> {
+        (self.last.available > 0).then(|| ReadyTarget {
+            partition_id,
+            available: self.last.available,
+            known_bytes: self.admitted.at_end.then(|| {
+                self.last
+                    .committed_bytes
+                    .saturating_sub(self.admitted.committed_bytes)
+            }),
+        })
+    }
+}
+
+/// A readiness waiter answers once. Its targets are sorted by namespace, and
+/// `targets` holds the readiness of each. A history or a view that an
+/// evaluation finds changed since the admission rejects the waiter.
 struct ReadinessWaiter {
     wait: ReadinessWait,
-    admitted: Vec<(PollHistoryId, u32)>,
+    targets: Vec<TargetReadiness>,
+    /// The sum of the available offsets of the targets. It saturates only
+    /// above any `min_count`.
+    available: u64,
     /// Namespaces that changed since the last probe. A commit makes only its
     /// own target ready, so a probe evaluates these targets.
     dirty: Vec<IggyNamespace>,
@@ -152,12 +457,30 @@
     reply: Sender<PartitionReadReply>,
 }
 
+impl ReadinessWaiter {
+    fn fires(&self) -> bool {
+        self.available >= u64::from(self.wait.min_count.max(ANY_MESSAGE))
+    }
+
+    fn ready(&self) -> Vec<ReadyTarget> {
+        self.wait
+            .targets
+            .iter()
+            .zip(&self.targets)
+            .filter_map(|(target, readiness)| readiness.ready(target.partition_id))
+            .collect()
+    }
+}
+
 struct Waiter {
     namespace: IggyNamespace,
     request: DeferredPollRequest,
     /// Admission turned this `Next` poll into an `Offset` poll that starts
     /// after the stored offset.
     next: bool,
+    /// A client store of the consumer offset at or after this time fences
+    /// the poll if it commits.
+    fenced_from: u64,
     reply: Sender<PartitionReadReply>,
     history: PollHistoryId,
     view: u32,
@@ -242,15 +565,48 @@
         .saturating_sub(POLL_RESPONSE_HEADER_SIZE)
 }
 
+/// The part of a read reservation that its disk selection may hold. Index
+/// loading stays under one part (`SEALED_INDEX_RESIDENT_MAX_BYTES`) and the
+/// snapshot pins journal buffers that exist anyway, so each keeps one minimum
+/// part and the selection gets the rest, at least its third.
+fn walk_bytes(reservation: usize) -> usize {
+    reservation
+        .saturating_sub((READ_BUDGET_PARTS - 1) * READ_PART_MIN_BYTES)
+        .max(reservation / READ_BUDGET_PARTS)
+}
+
+/// Up to `MAINTENANCE_BUDGET` keys of `map` after `cursor`. After the last
+/// key, the sweep starts again at the first.
+fn keys_after<K: Ord + Copy, V>(map: &BTreeMap<K, V>, cursor: Option<K>) -> Vec<K> {
+    let after = cursor.map_or(Unbounded, Excluded);
+    let keys: Vec<_> = map
+        .range((after, Unbounded))
+        .map(|(key, _)| *key)
+        .take(MAINTENANCE_BUDGET)
+        .collect();
+    if keys.is_empty() {
+        return map.keys().copied().take(MAINTENANCE_BUDGET).collect();
+    }
+    keys
+}
+
 /// Only a watched partition has a notifier, so only its commits mark it.
 #[derive(Default)]
 struct Signals {
     queued: HashSet<IggyNamespace>,
     dirty: VecDeque<IggyNamespace>,
+    /// Marked partitions whose status changed, so every parked read of them
+    /// checks it, also a poll that a commit alone would leave parked.
+    changed: HashSet<IggyNamespace>,
     waker: Option<Waker>,
 }
 
 impl Signals {
+    fn mark_changed(&mut self, namespace: IggyNamespace) {
+        self.changed.insert(namespace);
+        self.mark(namespace);
+    }
+
     fn mark(&mut self, namespace: IggyNamespace) {
         if self.queued.insert(namespace) {
             self.dirty.push_back(namespace);
@@ -261,6 +617,49 @@
     }
 }
 
+/// A partition of this shard that other shards watch.
+struct Watch {
+    watchers: Vec<ReadinessWatch>,
+    /// The watching shards that hold a parked wait on the partition. Only
+    /// they get the pushes of its commits.
+    parked: BTreeSet<u16>,
+    /// The groups whose installed owner each push carries.
+    groups: BTreeSet<u64>,
+    notifier: Rc<dyn Fn()>,
+    /// The state of the last push, so that a sweep pushes only a change.
+    last: Option<ServedState>,
+}
+
+/// The copy of a partition of another shard that local waits evaluate.
+struct Mirror {
+    owner: u16,
+    seq: u64,
+    /// `None` until the owner pushes the state of a partition that it serves.
+    state: Option<ServedState>,
+    /// When a wait last named the partition.
+    used: u64,
+    /// When this shard applied the last push.
+    pushed: u64,
+    /// The owner took the news that this shard holds a parked wait on the
+    /// partition, so its commits come as pushes.
+    interest: bool,
+}
+
+impl Mirror {
+    /// Whether no wait named the partition for `MIRROR_IDLE_US`. A parked
+    /// wait also needs the mirror, and the caller checks that.
+    const fn unused(&self, now: u64) -> bool {
+        now.saturating_sub(self.used) >= MIRROR_IDLE_US
+    }
+}
+
+#[derive(Default)]
+struct Mirrors {
+    next_seq: u64,
+    entries: BTreeMap<IggyNamespace, Mirror>,
+    cursor: Option<IggyNamespace>,
+}
+
 #[derive(Default)]
 struct State {
     next_id: u64,
@@ -274,6 +673,12 @@
     /// Parked waiters whose read the budget refused, by `(refused_at, id)`.
     read_queue: BTreeSet<(u64, u64)>,
     maintenance_cursor: u64,
+    watches: BTreeMap<IggyNamespace, Watch>,
+    /// The watched partitions to push at the next flush, by watching shard.
+    /// A flush reads the current state, so the latest state wins.
+    pushes: BTreeMap<u16, BTreeSet<IggyNamespace>>,
+    watch_cursor: Option<IggyNamespace>,
+    status_cursor: Option<IggyNamespace>,
     closed: bool,
 }
 
@@ -312,6 +717,28 @@
         Ok(id)
     }
 
+    /// Removes the watchers of `namespace` that `stop` selects. Returns
+    /// whether the partition lost its last watcher.
+    fn unwatch(
+        &mut self,
+        namespace: IggyNamespace,
+        stop: impl Fn(&ReadinessWatch) -> bool,
+    ) -> bool {
+        let Some(watch) = self.watches.get_mut(&namespace) else {
+            return false;
+        };
+        watch.watchers.retain(|watcher| !stop(watcher));
+        let watchers = &watch.watchers;
+        watch
+            .parked
+            .retain(|shard| watchers.iter().any(|watcher| watcher.shard == *shard));
+        if !watch.watchers.is_empty() {
+            return false;
+        }
+        self.watches.remove(&namespace);
+        true
+    }
+
     fn ids_after(&self, cursor: u64) -> Vec<u64> {
         let after = (Excluded(cursor), Unbounded);
         let mut ids: Vec<_> = self
@@ -337,7 +764,11 @@
     config: RefCell<DeferredPollConfig>,
     state: RefCell<State>,
     signals: Rc<RefCell<Signals>>,
+    mirrors: RefCell<Mirrors>,
     inflight_bytes: Arc<AtomicUsize>,
+    /// The status and view of each partition with parked reads, from when
+    /// they parked or from the last tick that checked it.
+    statuses: RefCell<BTreeMap<IggyNamespace, (PartitionStatus, u32)>>,
 }
 
 impl DeferredPolls {
@@ -347,7 +778,9 @@
             config: RefCell::new(DeferredPollConfig::default()),
             state: RefCell::new(State::default()),
             signals: Rc::new(RefCell::new(Signals::default())),
+            mirrors: RefCell::new(Mirrors::default()),
             inflight_bytes: Arc::new(AtomicUsize::new(0)),
+            statuses: RefCell::new(BTreeMap::new()),
         }
     }
 
@@ -357,6 +790,16 @@
         wait_us.min(self.config.borrow().max_wait_us)
     }
 
+    /// The notifier that a change of the partition at `namespace` calls.
+    fn notifier(&self, namespace: IggyNamespace) -> Rc<dyn Fn()> {
+        let signals = Rc::downgrade(&self.signals);
+        Rc::new(move || {
+            if let Some(signals) = signals.upgrade() {
+                signals.borrow_mut().mark(namespace);
+            }
+        })
+    }
+
     pub(crate) fn earliest_deadline(&self) -> Option<u64> {
         self.state
             .borrow()
@@ -414,13 +857,49 @@
         }
     }
 
-    pub(crate) const fn pump_guard(&self) -> PumpGuard<'_> {
-        PumpGuard(self)
+    pub(crate) const fn pump_guard<'a>(
+        &'a self,
+        shard: u16,
+        senders: &'a [TaggedSender],
+    ) -> PumpGuard<'a> {
+        PumpGuard {
+            polls: self,
+            shard,
+            senders,
+        }
     }
 
-    pub(crate) fn close(&self) {
+    /// Rejects the parked waiters, and tells every watching shard that
+    /// `shard` no longer serves the partitions that it watches.
+    pub(crate) fn close(&self, shard: u16, senders: &[TaggedSender]) {
         let mut state = self.state.borrow_mut();
         state.closed = true;
+        let mut unavailable: BTreeMap<u16, Vec<WatchedState>> = BTreeMap::new();
+        for (namespace, watch) in std::mem::take(&mut state.watches) {
+            for watcher in watch.watchers {
+                unavailable
+                    .entry(watcher.shard)
+                    .or_default()
+                    .push(WatchedState {
+                        namespace,
+                        seq: watcher.seq,
+                        state: None,
+                    });
+            }
+        }
+        for (watcher, states) in unavailable {
+            if let Some(sender) = senders.get(usize::from(watcher)) {
+                let _ = sender.reply_sender().try_send(ShardFrame::lifecycle(
+                    LifecycleFrame::ReadinessUpdate {
+                        from: shard,
+                        states,
+                    },
+                ));
+            }
+        }
+        state.pushes.clear();
+        self.mirrors.borrow_mut().entries.clear();
+        self.metrics.readiness_watches.set(0);
         for reply in state
             .waiters
             .values()
@@ -441,14 +920,15 @@
         state.read_queue.clear();
         self.metrics.pending.set(0);
         *self.signals.borrow_mut() = Signals::default();
+        self.statuses.borrow_mut().clear();
     }
 
-    /// The budget that a disk read of `options` reserves. Its snapshot, disk
-    /// selection and index loading can coexist, so it reserves one part as
-    /// large as its reply for each, at least [`READ_PART_MIN_BYTES`], up to
-    /// `max_read_bytes` in total.
-    fn read_bytes(&self, options: &DeferredPollOptions) -> usize {
-        (options.max_bytes as usize)
+    /// The budget that a disk read with `max_bytes` reserves. Its snapshot,
+    /// disk selection and index loading can coexist, so it reserves one part
+    /// as large as its reply for each, at least [`READ_PART_MIN_BYTES`], up
+    /// to `max_read_bytes` in total. [`walk_bytes`] splits it.
+    fn read_bytes(&self, max_bytes: u32) -> usize {
+        (max_bytes as usize)
             .max(READ_PART_MIN_BYTES)
             .saturating_mul(READ_BUDGET_PARTS)
             .min(self.config.borrow().max_read_bytes)
@@ -469,10 +949,9 @@
     /// loop drops it instead of stalling the queue.
     fn head_read_fits(&self, state: &State) -> bool {
         state.read_queue.first().is_some_and(|(_, id)| {
-            state
-                .waiters
-                .get(id)
-                .is_none_or(|waiter| self.has_read_room(self.read_bytes(&waiter.request.options)))
+            state.waiters.get(id).is_none_or(|waiter| {
+                self.has_read_room(self.read_bytes(waiter.request.options.max_bytes))
+            })
         })
     }
 
@@ -534,7 +1013,8 @@
             .filter(|next| *next <= config.max_inflight_bytes)
     }
 
-    /// Returns the namespaces that no waiter watches any more.
+    /// Returns the namespaces that no waiter and no watching shard needs any
+    /// more.
     fn remove(
         &self,
         id: u64,
@@ -546,9 +1026,15 @@
         for deadline in deadlines {
             state.deadlines.remove(&(*deadline, id));
         }
-        state.queued.remove(&id);
-        state.ready.retain(|queued| *queued != id);
-        state.read_queue.retain(|(_, queued)| *queued != id);
+        // The pump takes a waiter off `ready` before it finishes it, so only a
+        // waiter that a wake queued again needs the scan.
+        if state.queued.remove(&id) {
+            state.ready.retain(|queued| *queued != id);
+        }
+        debug_assert!(
+            state.read_queue.iter().all(|(_, queued)| *queued != id),
+            "a probe takes the read place of a waiter before it removes the waiter"
+        );
         self.metrics.pending.dec();
         if let Some(count) = state.sessions.get_mut(&quota) {
             *count -= 1;
@@ -564,18 +1050,25 @@
             });
             if empty {
                 state.partitions.remove(&namespace);
-                unwatched.push(namespace);
+                self.statuses.borrow_mut().remove(&namespace);
+                if !state.watches.contains_key(&namespace) {
+                    unwatched.push(namespace);
+                }
             }
         }
         unwatched
     }
 }
 
-pub struct PumpGuard<'a>(&'a DeferredPolls);
+pub struct PumpGuard<'a> {
+    polls: &'a DeferredPolls,
+    shard: u16,
+    senders: &'a [TaggedSender],
+}
 
 impl Drop for PumpGuard<'_> {
     fn drop(&mut self) {
-        self.0.close();
+        self.polls.close(self.shard, self.senders);
     }
 }
 
@@ -609,6 +1102,12 @@
         self.deferred_polls.wait_us(wait_us)
     }
 
+    /// The deferred poll limits, which every shard shares.
+    #[must_use]
+    pub fn deferred_poll_config(&self) -> DeferredPollConfig {
+        *self.deferred_polls.config.borrow()
+    }
+
     fn validate_deferred_context(
         &self,
         namespace: IggyNamespace,
@@ -670,39 +1169,22 @@
         if !metadata.is_valid(self.plane.metadata().mux_stm.streams(), namespace) {
             return Err(IggyError::TransientNotAccepted);
         }
+        let incarnation = self.shards_table.epoch_for(namespace);
         self.plane
             .partitions()
             .with_partition(&namespace, |partition| {
-                let consensus = partition.consensus();
-                if partition.history_deleted() {
-                    return Err(IggyError::HistoryUnavailable);
-                }
-                if primary && consensus.is_normal_backup() {
-                    return Err(IggyError::NotPrimary);
-                }
-                if let PollingConsumer::ConsumerGroup(group, _) = consumer
-                    && let Some(error) = super::group_owner_rejection(
-                        namespace,
-                        group as u64,
-                        partition.consumer_group_owner(group as u64),
-                        metadata,
-                    )
-                {
-                    return Err(error);
-                }
-                if partition.fatal().is_some()
-                    || partition.read_history_is_changing()
-                    || (primary && !consensus.applied_view_start())
-                    || partition.requires_state_transfer()
-                    || !consensus.is_normal()
-                    || consensus.is_transferring()
-                    || (primary && !consensus.is_primary())
-                    || !metadata.matches_partition(self.shards_table.epoch_for(namespace))
-                {
+                let installed = PartitionStatus::of(partition, incarnation).check(
+                    namespace,
+                    primary,
+                    metadata,
+                    consumer,
+                    |group| partition.consumer_group_owner(group),
+                )?;
+                if !installed {
                     return Err(IggyError::TransientNotAccepted);
                 }
                 let (history, visibility) = partition.poll_visibility();
-                Ok((history, visibility, consensus.view()))
+                Ok((history, visibility, partition.consensus().view()))
             })
             .ok_or(IggyError::TransientNotAccepted)?
     }
@@ -710,7 +1192,9 @@
     /// The visibility of the partition, while its history and view are those
     /// of the admission. A `Next` poll also needs the stored offset of its
     /// admission: another session can store an offset while it waits, and a
-    /// commit of this poll would then pass over a rewind.
+    /// commit of this poll would then pass over a rewind. A committing one
+    /// also needs no client store since then, as a rewind can store the
+    /// value it started from.
     fn validate_waiter(&self, waiter: &Waiter) -> Result<PollVisibility, IggyError> {
         let (history, visibility, view) =
             self.validate_deferred_context(waiter.namespace, &waiter.request)?;
@@ -719,12 +1203,33 @@
             || (waiter.next
                 && self.next_offset(waiter.namespace, waiter.request.consumer)?
                     != waiter.request.args.value)
+            || (waiter.next
+                && waiter.request.args.auto_commit
+                && self.offset_stored_since(
+                    waiter.namespace,
+                    waiter.request.consumer,
+                    waiter.fenced_from,
+                )?)
         {
             return Err(IggyError::TransientNotAccepted);
         }
         Ok(visibility)
     }
 
+    fn offset_stored_since(
+        &self,
+        namespace: IggyNamespace,
+        consumer: PollingConsumer,
+        since: u64,
+    ) -> Result<bool, IggyError> {
+        self.plane
+            .partitions()
+            .with_partition(&namespace, |partition| {
+                partition.consumer_offset_stored_since(consumer, since)
+            })
+            .ok_or(IggyError::TransientNotAccepted)
+    }
+
     /// Where a `Next` poll of `consumer` starts: after its stored offset.
     fn next_offset(
         &self,
@@ -762,6 +1267,9 @@
             let mut state = self.deferred_polls.state.borrow_mut();
             let id = state.admit(request.context.quota, &config)?;
             let next = request.args.kind == PollingKind::Next;
+            let fenced_from = request
+                .stores_fenced_from
+                .unwrap_or_else(|| self.bus.monotonic_micros());
             if next {
                 request.args.kind = PollingKind::Offset;
                 request.args.value = self.next_offset(namespace, request.consumer)?;
@@ -778,6 +1286,7 @@
                     namespace,
                     request,
                     next,
+                    fenced_from,
                     reply: reply.clone(),
                     history,
                     view,
@@ -797,6 +1306,56 @@
         }
     }
 
+    /// One consensus tick of the deferred polls. A partition that loses its
+    /// primary, changes view or starts a state transfer marks nothing, so the
+    /// tick compares the status of `MAINTENANCE_BUDGET` partitions with parked
+    /// reads, from a cursor, to what it saw when they parked or at their last
+    /// check, and wakes the reads of a changed one at once. Each such change
+    /// is found within one pass over them. The watch sweep pushes the changes
+    /// of a watched partition.
+    pub(crate) fn tick_deferred_polls(&self) {
+        let namespaces = {
+            let mut state = self.deferred_polls.state.borrow_mut();
+            let namespaces = keys_after(&state.partitions, state.status_cursor);
+            if let Some(last) = namespaces.last() {
+                state.status_cursor = Some(*last);
+            }
+            namespaces
+        };
+        for namespace in namespaces {
+            let Some(current) = self.partition_status(namespace) else {
+                continue;
+            };
+            if self
+                .deferred_polls
+                .statuses
+                .borrow_mut()
+                .insert(namespace, current)
+                .is_some_and(|previous| previous != current)
+            {
+                self.deferred_polls
+                    .signals
+                    .borrow_mut()
+                    .mark_changed(namespace);
+            }
+        }
+        self.deferred_polls.maintenance();
+    }
+
+    /// The status and view of the partition at `namespace`, `None` if this
+    /// shard has no such partition.
+    fn partition_status(&self, namespace: IggyNamespace) -> Option<(PartitionStatus, u32)> {
+        let incarnation = self.shards_table.epoch_for(namespace);
+        self.plane
+            .partitions()
+            .with_partition(&namespace, |partition| {
+                (
+                    PartitionStatus::of(partition, incarnation),
+                    partition.consensus().view(),
+                )
+            })
+    }
+
     #[allow(clippy::future_not_send)]
     pub(crate) async fn service_deferred_polls(&self) {
         for _ in 0..SERVICE_BUDGET {
@@ -814,13 +1373,14 @@
                     // first, ahead of the other wakes.
                     Some(id)
                 } else {
-                    let namespace = {
+                    let (namespace, changed) = {
                         let mut signals = self.deferred_polls.signals.borrow_mut();
                         let namespace = signals.dirty.pop_front();
-                        if let Some(namespace) = namespace {
+                        let changed = namespace.is_some_and(|namespace| {
                             signals.queued.remove(&namespace);
-                        }
-                        namespace
+                            signals.changed.remove(&namespace)
+                        });
+                        (namespace, changed)
                     };
                     if let Some(namespace) = namespace {
                         // A commit queues a poll only if its history or view
@@ -843,7 +1403,8 @@
                             .flat_map(|ids| ids.iter().copied())
                             .collect();
                         for id in ids {
-                            if let Some((history, visibility, view)) = current
+                            if !changed
+                                && let Some((history, visibility, view)) = current
                                 && let Some(waiter) = state.waiters.get_mut(&id)
                                 && waiter.history == history
                                 && waiter.view == view
@@ -858,10 +1419,22 @@
                             }
                             state.enqueue(id);
                         }
+                        let state = &mut *state;
+                        if let Some(watch) = state.watches.get(&namespace) {
+                            for shard in &watch.parked {
+                                state.pushes.entry(*shard).or_default().insert(namespace);
+                            }
+                        }
                     }
                     let next = state.ready.pop_front();
-                    if let Some(id) = next {
-                        state.queued.remove(&id);
+                    match next {
+                        Some(id) => {
+                            state.queued.remove(&id);
+                        }
+                        // A change that woke no waiter, such as one that only
+                        // other shards watch, does not end the turn.
+                        None if namespace.is_some() => continue,
+                        None => {}
                     }
                     next
                 }
@@ -925,7 +1498,9 @@
             refused_at.unwrap_or_else(|| self.bus.monotonic_micros()),
             id,
         );
-        let read_bytes = self.deferred_polls.read_bytes(&waiter.request.options);
+        let read_bytes = self
+            .deferred_polls
+            .read_bytes(waiter.request.options.max_bytes);
         if refused_at.is_some() && !self.deferred_polls.read_turn(place, read_bytes) {
             self.deferred_polls.queue_read(id, waiter, place);
             return;
@@ -948,8 +1523,7 @@
             self.deferred_polls.queue_read(id, waiter, place);
             return;
         };
-        // Snapshot, disk selection and index loading can coexist.
-        let max_bytes = reservation.bytes / READ_BUDGET_PARTS;
+        let max_bytes = walk_bytes(reservation.bytes);
         let max_selected_bytes = reply_byte_limit(
             &waiter.request.options,
             self.deferred_polls.config.borrow().max_read_bytes,
@@ -990,11 +1564,7 @@
 
     #[allow(clippy::future_not_send)]
     async fn accept_deferred_result(&self, id: u64, mut waiter: Waiter, result: PollReadResult) {
-        if waiter.reply.is_disconnected() {
-            self.metrics.deferred_polls.cancelled.inc();
-            self.remove_deferred_poll(id, &waiter);
-            return;
-        }
+        // The disconnect check sits right before the store, in `finish_deferred_poll`.
         if self.bus.monotonic_micros() >= waiter.request.request_deadline {
             self.reject_deferred_poll(id, &waiter, IggyError::TransientNotCommitted);
             return;
@@ -1014,14 +1584,32 @@
             }
         };
         let max_read_bytes = self.deferred_polls.config.borrow().max_read_bytes;
-        let (mut result, byte_limited) =
-            match result.limit_bytes(waiter.request.options.max_bytes as usize, max_read_bytes) {
-                Ok(result) => result,
-                Err(error) => {
-                    self.reject_deferred_poll(id, &waiter, error);
-                    return;
-                }
+        let (mut result, byte_limited) = match result.limit_bytes(
+            waiter.request.options.max_bytes as usize,
+            max_read_bytes,
+            || true,
+        ) {
+            Ok(result) => result,
+            Err(error) => {
+                self.reject_deferred_poll(id, &waiter, error);
+                return;
+            }
+        };
+        if result.message_count() > 0
+            && let Some(turn) = &waiter.request.oversized_turn
+        {
+            let oversized =
+                result.encoded_bytes() > reply_byte_limit(&waiter.request.options, max_read_bytes);
+            let accepted = if oversized {
+                turn.take()
+            } else {
+                turn.accept_normal()
             };
+            if !accepted {
+                self.reject_deferred_poll(id, &waiter, IggyError::DeferredPollCapacityExceeded);
+                return;
+            }
+        }
         if result.message_count() >= waiter.request.options.min_count
             || byte_limited
             || result.truncated()
@@ -1073,14 +1661,19 @@
         *state.sessions.entry(quota).or_default() += 1;
         state.deadlines.insert((deadline, id));
         for namespace in namespaces {
-            state.partitions.entry(namespace).or_default().insert(id);
-            let signals = Rc::downgrade(&self.deferred_polls.signals);
+            let ids = state.partitions.entry(namespace).or_default();
+            // A change before the tick reaches the partition still counts.
+            if ids.is_empty()
+                && let Some(current) = self.partition_status(namespace)
+            {
+                self.deferred_polls
+                    .statuses
+                    .borrow_mut()
+                    .insert(namespace, current);
+            }
+            ids.insert(id);
             if let Some(partition) = self.plane.partitions().get_mut_by_ns(&namespace) {
-                partition.set_poll_notifier(Some(Rc::new(move || {
-                    if let Some(signals) = signals.upgrade() {
-                        signals.borrow_mut().mark(namespace);
-                    }
-                })));
+                partition.set_poll_notifier(Some(self.deferred_polls.notifier(namespace)));
             }
         }
     }
@@ -1114,24 +1707,73 @@
             self.metrics.deferred_polls.cancelled.inc();
             return;
         }
+        // A read that stores nothing needs no gate. One that would commit after
+        // a cancel won stores nothing either.
+        if result.message_count() > 0
+            && let Some(gate) = &waiter.request.commit_gate
+            && !gate.enter()
+        {
+            self.metrics.deferred_polls.cancelled.inc();
+            let _ = waiter.reply.try_send(PartitionReadReply::Rejected(
+                IggyError::TransientNotAccepted,
+            ));
+            return;
+        }
         let before_deadline = self.bus.monotonic_micros() < waiter.request.deadline;
         if before_deadline {
             self.metrics.deferred_polls.filled.inc();
         } else {
             self.metrics.deferred_polls.expired.inc();
         }
-        self.accept_poll_result(waiter.namespace, result, waiter.reply)
+        let next_offset = self.next_read_offset(waiter.namespace, &waiter.request.args, &result);
+        self.accept_poll_result(waiter.namespace, result, waiter.reply, next_offset)
             .await;
     }
 
+    /// Where the read after `result` starts: after its last message. An empty
+    /// `Offset` read moves to the first offset that retention kept, so that a
+    /// start in deleted segments moves on. `None` for an empty read of another
+    /// kind, whose request names no start.
+    fn next_read_offset(
+        &self,
+        namespace: IggyNamespace,
+        args: &PollingArgs,
+        result: &PollReadResult,
+    ) -> Option<u64> {
+        if let Some(last) = result.last_matching_offset() {
+            return Some(last.saturating_add(1));
+        }
+        if args.kind != PollingKind::Offset {
+            return None;
+        }
+        let first = self
+            .plane
+            .partitions()
+            .with_partition(&namespace, |partition| {
+                partition
+                    .log
+                    .segments()
+                    .first()
+                    .map(|segment| segment.start_offset)
+            })
+            .flatten();
+        Some(first.map_or(args.value, |first| first.max(args.value)))
+    }
+
     /// Answers at once with the ready targets. If none is ready and the wait
     /// did not expire, parks one waiter for all targets and answers with its
-    /// id, so that the origin can cancel it.
+    /// id, so that the origin can cancel it. With `watch`, this shard also
+    /// starts to push the state of the `Offset` targets to the watching
+    /// shard, which then evaluates its waits on them itself.
     pub(crate) fn admit_readiness_wait(
         &self,
         wait: ReadinessWait,
         reply: &Sender<PartitionReadReply>,
+        watch: Option<ReadinessWatch>,
     ) {
+        if let Some(watch) = watch {
+            self.watch_partitions(watch, &wait.targets);
+        }
         if reply.is_disconnected() {
             return;
         }
@@ -1171,76 +1813,128 @@
         self.validate_caller(first, &wait.context)?;
         // A probe finds the target of a changed namespace by binary search.
         wait.targets.sort_unstable_by_key(|target| target.namespace);
-        let mut admitted = Vec::with_capacity(wait.targets.len());
-        let mut partitions = Vec::new();
+        let mut targets = Vec::with_capacity(wait.targets.len());
+        let mut available = 0_u64;
         for target in &wait.targets {
-            let (history, view, ready) = self.evaluate_readiness(wait.context.primary, target)?;
-            admitted.push((history, view));
-            if ready {
-                partitions.push(target.partition_id);
-            }
+            let readiness = self.evaluate_readiness(wait.context.primary, target)?;
+            available = available.saturating_add(readiness.available);
+            targets.push(TargetReadiness {
+                admitted: readiness,
+                last: readiness,
+            });
         }
-        let expired = self.bus.monotonic_micros() >= wait.deadline;
-        if !partitions.is_empty() || expired {
-            if partitions.is_empty() {
-                self.metrics.deferred_polls.expired.inc();
-            } else {
+        let waiter = ReadinessWaiter {
+            wait,
+            targets,
+            available,
+            dirty: Vec::new(),
+            swept: 0,
+            sweep: 0,
+            reply: reply.clone(),
+        };
+        let fires = waiter.fires();
+        if fires || self.bus.monotonic_micros() >= waiter.wait.deadline {
+            if fires {
                 self.metrics.deferred_polls.filled.inc();
+            } else {
+                self.metrics.deferred_polls.expired.inc();
             }
             return Ok(PartitionReadReply::Ready {
                 shard: self.id,
                 parked: None,
-                partitions,
+                partitions: waiter.ready(),
             });
         }
         let config = *self.deferred_polls.config.borrow();
         let mut state = self.deferred_polls.state.borrow_mut();
-        let quota = wait.context.quota;
+        let quota = waiter.wait.context.quota;
         let id = state.admit(quota, &config)?;
         self.register_waiter(
             &mut state,
             id,
-            (quota, wait.deadline),
-            wait.targets.iter().map(|target| target.namespace),
+            (quota, waiter.wait.deadline),
+            waiter.wait.targets.iter().map(|target| target.namespace),
         );
-        state.readiness.insert(
-            id,
-            ReadinessWaiter {
-                wait,
-                admitted,
-                dirty: Vec::new(),
-                swept: 0,
-                sweep: 0,
-                reply: reply.clone(),
-            },
-        );
+        let namespaces = waiter
+            .wait
+            .targets
+            .iter()
+            .map(|target| target.namespace)
+            .collect();
+        state.readiness.insert(id, waiter);
+        drop(state);
+        self.tell_mirror_owners(namespaces, true);
         Ok(PartitionReadReply::Ready {
             shard: self.id,
             parked: Some(id),
-            partitions,
+            partitions: Vec::new(),
         })
     }
 
-    /// Returns the history and view of the target, and whether a poll with its
-    /// strategy returns a message now. This is the empty-plan test of
-    /// `build_poll_plan`. The caller of the wait is checked before.
+    /// The target as a poll with its strategy finds it now. Its available
+    /// offsets follow the empty-plan test of `build_poll_plan`. The caller of
+    /// the wait is checked before.
     fn evaluate_readiness(
         &self,
         primary: bool,
         target: &ReadinessTarget,
-    ) -> Result<(PollHistoryId, u32, bool), IggyError> {
-        let (history, visibility, view) =
-            self.validate_partition(target.namespace, primary, &target.metadata, target.consumer)?;
+    ) -> Result<Readiness, IggyError> {
+        let (history, visibility, view, serves) =
+            self.evaluate_mirror(primary, target).unwrap_or_else(|| {
+                self.validate_partition(
+                    target.namespace,
+                    primary,
+                    &target.metadata,
+                    target.consumer,
+                )
+                .map(|(history, visibility, view)| (history, visibility, view, true))
+            })?;
         let start = match target.strategy.kind {
             PollingKind::Offset => target.strategy.value,
             PollingKind::Next => self.next_offset(target.namespace, target.consumer)?,
             _ => return Err(IggyError::InvalidCommand),
         };
-        Ok((
+        let available = match visibility.offset {
+            Some(committed) if serves && start <= committed => {
+                (committed - start).saturating_add(1)
+            }
+            _ => 0,
+        };
+        Ok(Readiness {
             history,
             view,
-            visibility.offset.is_some_and(|commit| start <= commit),
-        ))
+            available,
+            committed_bytes: visibility.committed_bytes,
+            at_end: visibility
+                .offset
+                .and_then(|committed| committed.checked_add(1))
+                == Some(start),
+        })
+    }
+
+    /// The reading of a target that this shard mirrors, `None` if it has no
+    /// mirror of the target.
+    fn evaluate_mirror(
+        &self,
+        primary: bool,
+        target: &ReadinessTarget,
+    ) -> Option<Result<Reading, IggyError>> {
+        let mirrors = self.deferred_polls.mirrors.borrow();
+        let mirror = mirrors.entries.get(&target.namespace)?;
+        if !target
+            .metadata
+            .is_valid(self.plane.metadata().mux_stm.streams(), target.namespace)
+        {
+            return Some(Err(IggyError::TransientNotAccepted));
+        }
+        let Some(state) = &mirror.state else {
+            return Some(Err(IggyError::TransientNotAccepted));
+        };
+        Some(
+            state
+                .check(primary, target)
+                .map(|serves| (state.history, state.visibility, state.view, serves)),
+        )
     }
 
     fn probe_readiness(&self, id: u64, mut waiter: ReadinessWaiter) {
@@ -1249,18 +1943,72 @@
             self.remove_readiness_wait(id, &waiter);
             return;
         }
-        // The origin answers an expired wait itself.
+        let now = self.bus.monotonic_micros();
+        let answer = match self.evaluate_readiness_waiter(&mut waiter) {
+            Ok(()) if waiter.fires() => {
+                self.metrics.deferred_polls.filled.inc();
+                self.record_push_to_fire(&waiter, now);
+                Some(PartitionReadReply::Ready {
+                    shard: self.id,
+                    parked: None,
+                    partitions: waiter.ready(),
+                })
+            }
+            // An expired wait reads what it has. The origin answers one
+            // without data itself.
+            Ok(()) if now >= waiter.wait.deadline => {
+                self.metrics.deferred_polls.expired.inc();
+                let partitions = waiter.ready();
+                (!partitions.is_empty()).then(|| PartitionReadReply::Ready {
+                    shard: self.id,
+                    parked: None,
+                    partitions,
+                })
+            }
+            Ok(()) => {
+                self.deferred_polls
+                    .state
+                    .borrow_mut()
+                    .readiness
+                    .insert(id, waiter);
+                return;
+            }
+            Err(error) => {
+                self.metrics.deferred_polls.failed.inc();
+                Some(PartitionReadReply::Rejected(error))
+            }
+        };
+        self.remove_readiness_wait(id, &waiter);
+        if let Some(answer) = answer {
+            let _ = waiter.reply.try_send(answer);
+        }
+    }
+
+    /// Ends the waiter `id` that this shard parked for the targets that it
+    /// evaluates of a wait that ends here, and returns those that hold
+    /// messages, also below `min_count`.
+    pub(crate) fn take_readiness_wait(&self, id: u64) -> Result<Vec<ReadyTarget>, IggyError> {
+        let Some(mut waiter) = self.deferred_polls.state.borrow_mut().readiness.remove(&id) else {
+            return Ok(Vec::new());
+        };
+        let evaluated = self.evaluate_readiness_waiter(&mut waiter);
         if self.bus.monotonic_micros() >= waiter.wait.deadline {
             self.metrics.deferred_polls.expired.inc();
-            self.remove_readiness_wait(id, &waiter);
-            return;
+        } else {
+            self.metrics.deferred_polls.cancelled.inc();
         }
+        self.remove_readiness_wait(id, &waiter);
+        evaluated.map(|()| waiter.ready())
+    }
+
+    /// Evaluates the targets of `waiter` that can have changed since its last
+    /// evaluation. A commit marks only its own namespace. This evaluates the
+    /// targets of marked namespaces, also of those that the pump has not
+    /// taken yet, and the targets that maintenance assigned. Maintenance
+    /// reaches every other target in turn. A lookup per target keeps the cost
+    /// of the marks that the shard still holds within the wait size.
+    fn evaluate_readiness_waiter(&self, waiter: &mut ReadinessWaiter) -> Result<(), IggyError> {
         let targets = &waiter.wait.targets;
-        // A commit marks only its own namespace. The probe evaluates the
-        // targets of marked namespaces, also of those that the pump has not
-        // taken yet, and the targets that maintenance assigned. Maintenance
-        // reaches every other target in turn. A lookup per target keeps the
-        // cost of the marks that the shard still holds within the wait size.
         let mut evaluated: Vec<usize> = {
             let signals = self.deferred_polls.signals.borrow();
             waiter
@@ -1283,57 +2031,55 @@
         };
         evaluated.sort_unstable();
         evaluated.dedup();
+        waiter.dirty.clear();
+        waiter.swept += waiter.sweep;
+        waiter.sweep = 0;
+        if waiter.swept >= targets.len() {
+            waiter.swept = 0;
+        }
         let context = &waiter.wait.context;
-        let partitions = targets
-            .first()
-            .map_or(Ok(()), |first| {
-                self.validate_caller(first.namespace, context)
-            })
-            .and_then(|()| {
-                evaluated
-                    .iter()
-                    .filter_map(|index| targets.get(*index).zip(waiter.admitted.get(*index)))
-                    .filter_map(|(target, admitted)| {
-                        match self.evaluate_readiness(context.primary, target) {
-                            Ok((history, view, ready)) if (history, view) == *admitted => {
-                                ready.then_some(Ok(target.partition_id))
-                            }
-                            Ok(_) => Some(Err(IggyError::TransientNotAccepted)),
-                            Err(error) => Some(Err(error)),
-                        }
-                    })
-                    .collect::<Result<Vec<_>, _>>()
-            });
-        let answer = match partitions {
-            Ok(partitions) if partitions.is_empty() => {
-                waiter.dirty.clear();
-                waiter.swept += waiter.sweep;
-                waiter.sweep = 0;
-                if waiter.swept >= waiter.wait.targets.len() {
-                    waiter.swept = 0;
-                }
-                self.deferred_polls
-                    .state
-                    .borrow_mut()
-                    .readiness
-                    .insert(id, waiter);
-                return;
+        if let Some(first) = targets.first() {
+            self.validate_caller(first.namespace, context)?;
+        }
+        for index in evaluated {
+            let (Some(target), Some(readiness)) =
+                (targets.get(index), waiter.targets.get_mut(index))
+            else {
+                continue;
+            };
+            let last = self.evaluate_readiness(context.primary, target)?;
+            if (last.history, last.view) != (readiness.admitted.history, readiness.admitted.view) {
+                return Err(IggyError::TransientNotAccepted);
             }
-            Ok(partitions) => {
-                self.metrics.deferred_polls.filled.inc();
-                PartitionReadReply::Ready {
-                    shard: self.id,
-                    parked: None,
-                    partitions,
-                }
-            }
-            Err(error) => {
-                self.metrics.deferred_polls.failed.inc();
-                PartitionReadReply::Rejected(error)
-            }
+            waiter.available = waiter
+                .available
+                .saturating_sub(readiness.last.available)
+                .saturating_add(last.available);
+            readiness.last = last;
+        }
+        Ok(())
+    }
+
+    /// Observes the time from the last push that this shard applied for a
+    /// ready target to the fire of `waiter`. Targets of this shard have no
+    /// push.
+    fn record_push_to_fire(&self, waiter: &ReadinessWaiter, now: u64) {
+        let pushed = {
+            let mirrors = self.deferred_polls.mirrors.borrow();
+            waiter
+                .wait
+                .targets
+                .iter()
+                .zip(&waiter.targets)
+                .filter(|(_, readiness)| readiness.last.available > 0)
+                .filter_map(|(target, _)| mirrors.entries.get(&target.namespace))
+                .map(|mirror| mirror.pushed)
+                .max()
         };
-        self.remove_readiness_wait(id, &waiter);
-        let _ = waiter.reply.try_send(answer);
+        if let Some(pushed) = pushed {
+            self.metrics
+                .record_readiness_push_to_fire(Duration::from_micros(now.saturating_sub(pushed)));
+        }
     }
 
     fn remove_readiness_wait(&self, id: u64, waiter: &ReadinessWaiter) {
@@ -1344,6 +2090,435 @@
             waiter.wait.targets.iter().map(|target| target.namespace),
         ));
     }
+
+    /// Starts or renews the watch of `watch.shard` on the `Offset` targets,
+    /// and queues their state for it.
+    fn watch_partitions(&self, watch: ReadinessWatch, targets: &[ReadinessTarget]) {
+        let mut state = self.deferred_polls.state.borrow_mut();
+        if state.closed {
+            return;
+        }
+        let state = &mut *state;
+        for target in targets
+            .iter()
+            .filter(|target| target.strategy.kind == PollingKind::Offset)
+        {
+            let namespace = target.namespace;
+            let entry = match state.watches.entry(namespace) {
+                Entry::Occupied(entry) => entry.into_mut(),
+                Entry::Vacant(entry) => {
+                    let notifier = self.deferred_polls.notifier(namespace);
+                    if let Some(partition) = self.plane.partitions().get_mut_by_ns(&namespace) {
+                        partition.set_poll_notifier(Some(Rc::clone(&notifier)));
+                    }
+                    self.metrics.deferred_polls.readiness_watches.inc();
+                    entry.insert(Watch {
+                        watchers: Vec::new(),
+                        parked: BTreeSet::new(),
+                        groups: BTreeSet::new(),
+                        notifier,
+                        last: None,
+                    })
+                }
+            };
+            match entry
+                .watchers
+                .iter_mut()
+                .find(|watcher| watcher.shard == watch.shard)
+            {
+                Some(watcher) => watcher.seq = watch.seq,
+                None => entry.watchers.push(watch),
+            }
+            if let PollingConsumer::ConsumerGroup(group, _) = target.consumer {
+                entry.groups.insert(group as u64);
+            }
+            state
+                .pushes
+                .entry(watch.shard)
+                .or_default()
+                .insert(namespace);
+        }
+    }
+
+    /// The state of a watched partition, `None` if this shard does not serve
+    /// it.
+    fn served_state(
+        &self,
+        namespace: IggyNamespace,
+        groups: &BTreeSet<u64>,
+    ) -> Option<ServedState> {
+        let incarnation = self.shards_table.epoch_for(namespace);
+        self.plane
+            .partitions()
+            .with_partition(&namespace, |partition| {
+                let (history, visibility) = partition.poll_visibility();
+                ServedState {
+                    history,
+                    visibility,
+                    view: partition.consensus().view(),
+                    status: PartitionStatus::of(partition, incarnation),
+                    owners: groups
+                        .iter()
+                        .map(|group| (*group, partition.consumer_group_owner(*group)))
+                        .collect(),
+                }
+            })
+    }
+
+    /// Sends the queued pushes, one frame per watching shard. They ride the
+    /// reply lane, so that consensus traffic cannot hold them back. A lane
+    /// that holds half its capacity keeps them for the next flush, which reads
+    /// the state again, so pushes never fill the lane that client replies
+    /// and transient denies need.
+    pub(crate) fn flush_readiness_pushes(&self) {
+        let mut idle = Vec::new();
+        {
+            let mut state = self.deferred_polls.state.borrow_mut();
+            let state = &mut *state;
+            let mut disconnected = Vec::new();
+            state.pushes.retain(|watcher, namespaces| {
+                let Some(sender) = self.senders.get(usize::from(*watcher)) else {
+                    return false;
+                };
+                let lane = sender.reply_sender();
+                let room = lane
+                    .capacity()
+                    .map_or(usize::MAX, |capacity| (capacity / 2).max(1));
+                if lane.len() >= room {
+                    return true;
+                }
+                let states: Vec<_> = namespaces
+                    .iter()
+                    .filter_map(|namespace| {
+                        let watch = state.watches.get_mut(namespace)?;
+                        let seq = watch
+                            .watchers
+                            .iter()
+                            .find(|candidate| candidate.shard == *watcher)?
+                            .seq;
+                        let served = self.served_state(*namespace, &watch.groups);
+                        watch.last.clone_from(&served);
+                        Some(WatchedState {
+                            namespace: *namespace,
+                            seq,
+                            state: served,
+                        })
+                    })
+                    .collect();
+                if states.is_empty() {
+                    return false;
+                }
+                let pushed = states.len() as u64;
+                let update = LifecycleFrame::ReadinessUpdate {
+                    from: self.id,
+                    states,
+                };
+                match lane.try_send(ShardFrame::lifecycle(update)) {
+                    Ok(()) => {
+                        self.metrics.deferred_polls.readiness_pushes.inc_by(pushed);
+                        false
+                    }
+                    Err(TrySendError::Full(_)) => true,
+                    Err(TrySendError::Disconnected(_)) => {
+                        disconnected.push(*watcher);
+                        false
+                    }
+                }
+            });
+            for watcher in disconnected {
+                let namespaces: Vec<_> = state.watches.keys().copied().collect();
+                for namespace in namespaces {
+                    if state.unwatch(namespace, |candidate| candidate.shard == watcher) {
+                        self.metrics.deferred_polls.readiness_watches.dec();
+                        if !state.partitions.contains_key(&namespace) {
+                            idle.push(namespace);
+                        }
+                    }
+                }
+            }
+        }
+        self.unwatch_partitions(idle);
+    }
+
+    /// A tick sweeps `MAINTENANCE_BUDGET` watched partitions and as many
+    /// mirrors. It installs the notifier of a watched partition again,
+    /// because a rebuilt partition starts without one, and it queues for the
+    /// shards with a parked wait the changes that no notifier reports, such
+    /// as a view change or a group owner install. It ends the watch of a
+    /// mirror that no wait needs, also for a partition without commits, which
+    /// pushes nothing.
+    pub(crate) fn sweep_readiness_watches(&self) {
+        let now = self.bus.monotonic_micros();
+        let mut unused: BTreeMap<u16, Vec<(IggyNamespace, u64)>> = BTreeMap::new();
+        let mut interested = Vec::new();
+        {
+            let mut state = self.deferred_polls.state.borrow_mut();
+            let state = &mut *state;
+            for namespace in keys_after(&state.watches, state.watch_cursor) {
+                state.watch_cursor = Some(namespace);
+                let Some(watch) = state.watches.get(&namespace) else {
+                    continue;
+                };
+                if let Some(partition) = self.plane.partitions().get_mut_by_ns(&namespace) {
+                    partition.set_poll_notifier(Some(Rc::clone(&watch.notifier)));
+                }
+                if !watch.parked.is_empty()
+                    && self.served_state(namespace, &watch.groups) != watch.last
+                {
+                    for shard in &watch.parked {
+                        state.pushes.entry(*shard).or_default().insert(namespace);
+                    }
+                }
+            }
+            let mut mirrors = self.deferred_polls.mirrors.borrow_mut();
+            for namespace in keys_after(&mirrors.entries, mirrors.cursor) {
+                mirrors.cursor = Some(namespace);
+                let Some(mirror) = mirrors.entries.get(&namespace) else {
+                    continue;
+                };
+                let parked = state.partitions.contains_key(&namespace);
+                if mirror.unused(now) && !parked {
+                    unused
+                        .entry(mirror.owner)
+                        .or_default()
+                        .push((namespace, mirror.seq));
+                } else if parked && !mirror.interest {
+                    // An owner whose inbox refused the news hears it again.
+                    interested.push(namespace);
+                }
+            }
+        }
+        for (owner, watches) in unused {
+            self.unwatch_mirrors(owner, watches);
+        }
+        self.tell_mirror_owners(interested, true);
+    }
+
+    /// Ends the watches of `from` whose sequence matches.
+    pub(crate) fn unwatch_readiness(&self, from: u16, watches: Vec<(IggyNamespace, u64)>) {
+        let mut idle = Vec::new();
+        {
+            let mut state = self.deferred_polls.state.borrow_mut();
+            for (namespace, seq) in watches {
+                if state.unwatch(namespace, |watcher| {
+                    watcher.shard == from && watcher.seq == seq
+                }) {
+                    self.metrics.deferred_polls.readiness_watches.dec();
+                    if !state.partitions.contains_key(&namespace) {
+                        idle.push(namespace);
+                    }
+                }
+            }
+        }
+        self.unwatch_partitions(idle);
+    }
+
+    /// Starts or ends the pushes of commits to `from` for those of `watches`
+    /// whose sequence matches. A start pushes the state at once, because the
+    /// mirror that the parked wait read can be behind.
+    pub(crate) fn set_readiness_interest(
+        &self,
+        from: u16,
+        watches: Vec<(IggyNamespace, u64)>,
+        parked: bool,
+    ) {
+        let mut state = self.deferred_polls.state.borrow_mut();
+        let state = &mut *state;
+        for (namespace, seq) in watches {
+            let Some(watch) = state.watches.get_mut(&namespace) else {
+                continue;
+            };
+            if !watch
+                .watchers
+                .iter()
+                .any(|watcher| watcher.shard == from && watcher.seq == seq)
+            {
+                continue;
+            }
+            if parked {
+                watch.parked.insert(from);
+                state.pushes.entry(from).or_default().insert(namespace);
+            } else {
+                watch.parked.remove(&from);
+            }
+        }
+    }
+
+    /// Stores pushed states in their mirrors and marks their partitions, so
+    /// that the service loop probes the local waiters on them. A push for a
+    /// mirror that no wait needs ends its watch, and so does a push for a
+    /// partition without a mirror. A push that finds no parked wait ends the
+    /// pushes of commits.
+    pub(crate) fn apply_readiness_update(&self, from: u16, states: Vec<WatchedState>) {
+        let now = self.bus.monotonic_micros();
+        let mut unused = Vec::new();
+        let mut idle = Vec::new();
+        {
+            let state = self.deferred_polls.state.borrow();
+            let mut mirrors = self.deferred_polls.mirrors.borrow_mut();
+            let mut signals = self.deferred_polls.signals.borrow_mut();
+            for update in states {
+                let Some(mirror) = mirrors.entries.get_mut(&update.namespace) else {
+                    unused.push((update.namespace, update.seq));
+                    continue;
+                };
+                // A push from before the current watch.
+                if mirror.owner != from || mirror.seq != update.seq {
+                    continue;
+                }
+                // The state goes in also if the unwatch is refused, because
+                // the next wait can still read the mirror.
+                mirror.state = update.state;
+                mirror.pushed = now;
+                let parked = state.partitions.contains_key(&update.namespace);
+                if mirror.unused(now) && !parked {
+                    unused.push((update.namespace, update.seq));
+                    continue;
+                }
+                if !parked && mirror.interest {
+                    idle.push(update.namespace);
+                }
+                signals.mark(update.namespace);
+            }
+        }
+        self.unwatch_mirrors(from, unused);
+        self.tell_mirror_owners(idle, false);
+    }
+
+    /// Tells the owners of the mirrors of `namespaces` whose interest is not
+    /// `parked` yet that this shard now holds a parked wait on them, or no
+    /// longer does. A mirror changes its interest only if the inbox of its
+    /// owner takes the frame, so that the next sweep or push repeats a
+    /// refused one.
+    fn tell_mirror_owners(&self, namespaces: Vec<IggyNamespace>, parked: bool) {
+        if namespaces.is_empty() {
+            return;
+        }
+        let mut mirrors = self.deferred_polls.mirrors.borrow_mut();
+        let mut owners: BTreeMap<u16, Vec<(IggyNamespace, u64)>> = BTreeMap::new();
+        for namespace in namespaces {
+            if let Some(mirror) = mirrors.entries.get(&namespace)
+                && mirror.interest != parked
+            {
+                owners
+                    .entry(mirror.owner)
+                    .or_default()
+                    .push((namespace, mirror.seq));
+            }
+        }
+        for (owner, watches) in owners {
+            let Some(sender) = self.senders.get(usize::from(owner)) else {
+                continue;
+            };
+            let namespaces: Vec<_> = watches.iter().map(|(namespace, _)| *namespace).collect();
+            let interest = LifecycleFrame::ReadinessInterest {
+                from: self.id,
+                watches,
+                parked,
+            };
+            if sender.try_send(ShardFrame::lifecycle(interest)).is_ok() {
+                for namespace in namespaces {
+                    if let Some(mirror) = mirrors.entries.get_mut(&namespace) {
+                        mirror.interest = parked;
+                    }
+                }
+            }
+        }
+    }
+
+    /// Ends the watches of this shard on `watches` of `owner`. Their mirrors
+    /// go only if the inbox of `owner` takes the unwatch, so that the next
+    /// sweep or push repeats a refused one.
+    fn unwatch_mirrors(&self, owner: u16, watches: Vec<(IggyNamespace, u64)>) {
+        if watches.is_empty() {
+            return;
+        }
+        let Some(sender) = self.senders.get(usize::from(owner)) else {
+            return;
+        };
+        let namespaces: Vec<_> = watches.iter().map(|(namespace, _)| *namespace).collect();
+        let unwatch = LifecycleFrame::ReadinessUnwatch {
+            from: self.id,
+            watches,
+        };
+        if sender.try_send(ShardFrame::lifecycle(unwatch)).is_ok() {
+            let mut mirrors = self.deferred_polls.mirrors.borrow_mut();
+            for namespace in namespaces {
+                mirrors.entries.remove(&namespace);
+            }
+        }
+    }
+}
+
+/// The routing of a readiness wait on the shard of its connection.
+impl<B, MJ, S, M, T, SB> IggyShard<B, MJ, S, M, T, SB>
+where
+    B: MessageBus + 'static,
+    T: ShardsTable,
+    SB: SuperblockStore,
+{
+    /// The watch that the next frame to an owner asks for.
+    pub(crate) fn next_readiness_watch(&self) -> ReadinessWatch {
+        let mut mirrors = self.deferred_polls.mirrors.borrow_mut();
+        mirrors.next_seq = mirrors.next_seq.wrapping_add(1);
+        ReadinessWatch {
+            shard: self.id,
+            seq: mirrors.next_seq,
+        }
+    }
+
+    /// Whether this shard evaluates `target` against its mirror. A mirror
+    /// that finds an error in the partition, or that lags the install of a
+    /// group owner, sends the target to the owner, so that no wait gets a
+    /// stale error from a mirror.
+    pub(crate) fn mirror_serves(
+        &self,
+        owner: u16,
+        target: &ReadinessTarget,
+        primary: bool,
+        now: u64,
+    ) -> bool {
+        let mut mirrors = self.deferred_polls.mirrors.borrow_mut();
+        let Some(mirror) = mirrors.entries.get_mut(&target.namespace) else {
+            return false;
+        };
+        mirror.used = now;
+        mirror.owner == owner
+            && mirror
+                .state
+                .as_ref()
+                .is_some_and(|state| matches!(state.check(primary, target), Ok(true)))
+    }
+
+    /// Mirrors `namespaces` under `seq`, after `owner` took the frame that
+    /// watches them. A mirror keeps its state until the owner pushes for the
+    /// new sequence.
+    pub(crate) fn mirror_partitions(
+        &self,
+        owner: u16,
+        seq: u64,
+        namespaces: &[IggyNamespace],
+        now: u64,
+    ) {
+        let mut mirrors = self.deferred_polls.mirrors.borrow_mut();
+        for namespace in namespaces {
+            let mirror = mirrors.entries.entry(*namespace).or_insert(Mirror {
+                owner,
+                seq,
+                state: None,
+                used: now,
+                pushed: now,
+                interest: false,
+            });
+            // A new owner holds no news of this shard's parked waits.
+            if mirror.owner != owner {
+                mirror.interest = false;
+            }
+            mirror.owner = owner;
+            mirror.seq = seq;
+            mirror.used = now;
+        }
+    }
 }
 
 #[cfg(test)]
diff --git a/core/shard/src/poll/deferred_tests.rs b/core/shard/src/poll/deferred_tests.rs
index 3a66f1a..d22e27c 100644
--- a/core/shard/src/poll/deferred_tests.rs
+++ b/core/shard/src/poll/deferred_tests.rs
@@ -22,7 +22,7 @@
 use super::*;
 use consensus::{ClientTable, PartitionsHandle, build_reply_message_with};
 use iggy_binary_protocol::requests::partitions::InstallConsumerGroupOwnerRequest;
-use iggy_binary_protocol::{Operation, PrepareHeader};
+use iggy_binary_protocol::{AckLevel, Operation, PrepareHeader};
 use iggy_common::{
     ConsumerGroupId, ConsumerKind, ConsumerOffset, DEFAULT_POLL_MAX_BYTES, IggyByteSize,
     IggyTimestamp,
@@ -31,19 +31,20 @@
 use metadata::stm::consumer_group::{ConsumerGroup, ConsumerGroupMember};
 use metadata::stm::stream::{Partition as MetadataPartition, Stream, StreamsInner, Topic};
 use metadata::stm::user::Users;
-use partitions::IggyPartitions;
+use partitions::{Fragment, IggyPartitions};
 use server_common::send_messages::decode_batch_slice;
 use server_common::sharding::{PartitionLocation, ShardId};
 
 use crate::poll::completion::PollCompletionLane;
 use crate::poll::test_support::{
     PollTestMetadata, commit_messages, install_group_owner, partition_with_messages,
+    store_consumer_offset,
 };
 use crate::poll::timeout_tests::{PollTestBus, evict_messages_for_disk_dispatch};
 use crate::shards_table::PapayaShardsTable;
 use crate::{
-    LifecycleFrame, PARTITION_READ_TIMEOUT, PartitionConsensusConfig, Receiver, ReplicaTopology,
-    ShardFrame, ShardIdentity, channel, shard_channel,
+    ConsumerOffsetRead, LifecycleFrame, PARTITION_READ_TIMEOUT, PartitionConsensusConfig,
+    PartitionRead, Receiver, ReplicaTopology, ShardFrame, ShardIdentity, channel, shard_channel,
 };
 
 const DEADLINE: u64 = 100;
@@ -66,7 +67,11 @@
 
 /// One owner shard with a partition for each entry of `partitions`.
 async fn owner_of(partitions: &[&[&str]]) -> TestShard {
-    let bus = PollTestBus::default();
+    shard_of(0, PollTestBus::default(), partitions).await
+}
+
+/// Shard `shard` that owns a partition for each entry of `partitions`.
+async fn shard_of(shard: u16, bus: PollTestBus, partitions: &[&[&str]]) -> TestShard {
     let mut plane = None;
     let mut topic = Topic::default();
     let routes = PapayaShardsTable::new();
@@ -74,7 +79,7 @@
         let namespace = partition_namespace(id);
         let (partition, config) = partition_with_messages(&bus, namespace, payloads).await;
         plane
-            .get_or_insert_with(|| IggyPartitions::new(ShardId::new(0), config))
+            .get_or_insert_with(|| IggyPartitions::new(ShardId::new(shard), config))
             .insert(namespace, partition);
         topic.partitions.push(MetadataPartition::new(
             id,
@@ -83,7 +88,7 @@
             1,
             0,
         ));
-        routes.insert(namespace, PartitionLocation::new(ShardId::new(0), 1));
+        routes.insert(namespace, PartitionLocation::new(ShardId::new(shard), 1));
     }
     let partitions_plane = plane.expect("fixture requires a partition");
     let mut inner = StreamsInner::default();
@@ -123,7 +128,7 @@
         None,
     );
     TestShard::without_inbox(
-        ShardIdentity::new(0, "deferred-test".to_string()),
+        ShardIdentity::new(shard, "deferred-test".to_string()),
         bus.clone(),
         metadata,
         partitions_plane,
@@ -213,7 +218,7 @@
 fn read_bytes(owner: &TestShard) -> usize {
     owner
         .deferred_polls
-        .read_bytes(&request(owner, 1, false, false).options)
+        .read_bytes(request(owner, 1, false, false).options.max_bytes)
 }
 
 /// Fills the read budget with reservations the size of a fixture read, so
@@ -253,6 +258,154 @@
 }
 
 #[compio::test]
+async fn given_a_normal_fetch_section_when_an_oversized_section_finishes_should_leave_it_uncommitted()
+ {
+    for auto_commit in [false, true] {
+        let normal = owner(&["small"]).await;
+        let oversized_payload = "x".repeat(4096);
+        let oversized = owner(&[&oversized_payload]).await;
+        let turn = OversizedTurn::default();
+        let mut first = request(&normal, 1, false, auto_commit);
+        first.options.max_bytes = 1024;
+        first.oversized_turn = Some(turn.clone());
+        assert_eq!(offsets(&submit(&normal, first).await), [0]);
+
+        let mut second = request(&oversized, 1, false, auto_commit);
+        second.options.max_bytes = 1024;
+        second.oversized_turn = Some(turn);
+        assert!(matches!(
+            submit(&oversized, second).await.try_recv().unwrap(),
+            PartitionReadReply::Rejected(IggyError::DeferredPollCapacityExceeded)
+        ));
+        assert_eq!(
+            stored_offset(&oversized, PollingConsumer::Consumer(0, 0)),
+            None
+        );
+    }
+}
+
+#[compio::test]
+async fn given_an_oversized_fetch_section_when_a_normal_section_finishes_should_leave_it_uncommitted()
+ {
+    for auto_commit in [false, true] {
+        let oversized_payload = "x".repeat(4096);
+        let oversized = owner(&[&oversized_payload]).await;
+        let normal = owner(&["small"]).await;
+        let turn = OversizedTurn::default();
+        let mut first = request(&oversized, 1, false, auto_commit);
+        first.options.max_bytes = 1024;
+        first.oversized_turn = Some(turn.clone());
+        assert_eq!(offsets(&submit(&oversized, first).await), [0]);
+
+        let mut second = request(&normal, 1, false, auto_commit);
+        second.options.max_bytes = 1024;
+        second.oversized_turn = Some(turn);
+        assert!(matches!(
+            submit(&normal, second).await.try_recv().unwrap(),
+            PartitionReadReply::Rejected(IggyError::DeferredPollCapacityExceeded)
+        ));
+        assert_eq!(
+            stored_offset(&normal, PollingConsumer::Consumer(0, 0)),
+            None
+        );
+    }
+}
+
+#[compio::test]
+async fn given_a_follower_section_above_its_bytes_when_the_turn_is_open_should_leave_it_uncommitted_for_the_first()
+ {
+    // Every read of a fetch could take the turn, so the reads after the first
+    // had to wait for it, or a later one could take the record above its
+    // bytes that the first target holds.
+    for auto_commit in [false, true] {
+        let oversized_payload = "x".repeat(4096);
+        let follower_owner = owner(&[&oversized_payload]).await;
+        let first_owner = owner(&[&oversized_payload]).await;
+        let turn = OversizedTurn::default();
+        let mut follower = request(&follower_owner, 1, false, auto_commit);
+        follower.options.max_bytes = 1024;
+        follower.oversized_turn = Some(turn.follower());
+        assert!(matches!(
+            submit(&follower_owner, follower).await.try_recv().unwrap(),
+            PartitionReadReply::Rejected(IggyError::DeferredPollCapacityExceeded)
+        ));
+        assert_eq!(
+            stored_offset(&follower_owner, PollingConsumer::Consumer(0, 0)),
+            None
+        );
+
+        let mut first = request(&first_owner, 1, false, auto_commit);
+        first.options.max_bytes = 1024;
+        first.oversized_turn = Some(turn);
+        assert_eq!(offsets(&submit(&first_owner, first).await), [0]);
+    }
+}
+
+#[compio::test]
+async fn given_normal_fetch_sections_when_sharing_the_byte_limit_should_serve_each_share() {
+    let owner = owner(&["small"]).await;
+    let turn = OversizedTurn::default();
+    for _ in 0..3 {
+        let mut read = request(&owner, 1, false, false);
+        read.options.max_bytes = 1024;
+        read.oversized_turn = Some(turn.clone());
+        assert_eq!(offsets(&submit(&owner, read).await), [0]);
+    }
+}
+
+#[compio::test]
+async fn given_an_empty_fetch_section_when_an_oversized_section_follows_should_preserve_the_exception()
+ {
+    let payload = "x".repeat(4096);
+    let owner = owner(&[&payload]).await;
+    let turn = OversizedTurn::default();
+    let mut empty = request(&owner, 1, false, false);
+    empty.args = PollingArgs::new(PollingStrategy::offset(1), 1, false);
+    empty.deadline = 0;
+    empty.oversized_turn = Some(turn.clone());
+    assert_eq!(offsets(&submit(&owner, empty).await), Vec::<u64>::new());
+
+    let mut read = request(&owner, 1, false, true);
+    read.options.max_bytes = 1024;
+    read.oversized_turn = Some(turn.clone());
+    assert_eq!(offsets(&submit(&owner, read).await), [0]);
+    assert_eq!(
+        stored_offset(&owner, PollingConsumer::Consumer(0, 0)),
+        Some(0)
+    );
+
+    let mut another = request(&owner, 1, false, false);
+    another.args = PollingArgs::new(PollingStrategy::offset(0), 1, false);
+    another.options.max_bytes = 1024;
+    another.oversized_turn = Some(turn);
+    assert!(matches!(
+        submit(&owner, another).await.try_recv().unwrap(),
+        PartitionReadReply::Rejected(IggyError::DeferredPollCapacityExceeded)
+    ));
+}
+
+#[compio::test]
+async fn given_a_first_record_above_the_server_read_cap_when_polling_should_return_only_that_record()
+ {
+    for fetch in [false, true] {
+        let payload = "x".repeat(4096);
+        let owner = owner(&[&payload, "next"]).await;
+        owner.set_deferred_poll_config(DeferredPollConfig {
+            max_read_bytes: 1024,
+            ..Default::default()
+        });
+        let mut read = request(&owner, 2, false, true);
+        read.options.max_bytes = 8192;
+        read.oversized_turn = fetch.then(OversizedTurn::default);
+        assert_eq!(offsets(&submit(&owner, read).await), [0]);
+        assert_eq!(
+            stored_offset(&owner, PollingConsumer::Consumer(0, 0)),
+            Some(0)
+        );
+    }
+}
+
+#[compio::test]
 async fn resident_poll_completes_while_disk_reads_hold_the_whole_read_budget() {
     let owner = owner(&["first", "second"]).await;
     let held = hold_read_budget(&owner);
@@ -407,6 +560,23 @@
     }
 }
 
+#[test]
+fn disk_walk_holds_the_reservation_but_one_minimum_part_each_for_index_and_snapshot() {
+    // A walk held a third of the reservation, so a 16 MiB reply could never
+    // carry more than about 5 MiB from disk.
+    let max_read_bytes = DeferredPollConfig::default().max_read_bytes;
+    for (reservation, walk) in [
+        (max_read_bytes, max_read_bytes - 2 * READ_PART_MIN_BYTES),
+        (3 * READ_PART_MIN_BYTES, READ_PART_MIN_BYTES),
+        (
+            2 * READ_PART_MIN_BYTES,
+            2 * READ_PART_MIN_BYTES / READ_BUDGET_PARTS,
+        ),
+    ] {
+        assert_eq!(walk_bytes(reservation), walk, "reservation {reservation}");
+    }
+}
+
 #[compio::test]
 async fn disk_read_reserves_at_most_max_read_bytes() {
     let mut owner = owner(&["first"]).await;
@@ -522,17 +692,22 @@
 }
 
 async fn commit(owner: &TestShard, payloads: &[&str], request: u64) {
+    commit_to(owner, 0, payloads, request).await;
+}
+
+async fn commit_to(owner: &TestShard, partition: usize, payloads: &[&str], request: u64) {
+    let namespace = partition_namespace(partition);
     let partitions = owner.plane.partitions();
-    let mut partition = partitions.remove(&namespace()).unwrap();
+    let mut stored = partitions.remove(&namespace).unwrap();
     commit_messages(
-        &mut partition,
+        &mut stored,
         partitions.config(),
-        namespace(),
+        namespace,
         payloads,
         (request, METADATA_OP),
     )
     .await;
-    partitions.insert(namespace(), partition);
+    partitions.insert(namespace, stored);
 }
 
 /// Commits `payloads` and, like a persisted topic on one replica, can drop
@@ -749,6 +924,36 @@
 }
 
 #[compio::test]
+async fn a_committing_poll_whose_cancel_won_stores_nothing_and_a_commit_makes_the_cancel_lose() {
+    // A cancel proved nothing: a read that the owner served had stored its
+    // offset, the aborted request got no reply, and its batch was lost.
+    let owner = owner(&["first"]).await;
+    let cancelled = CommitGate::default();
+    assert!(cancelled.cancel());
+    let mut poll = request(&owner, 1, false, true);
+    poll.commit_gate = Some(cancelled);
+
+    let replies = submit(&owner, poll).await;
+
+    assert!(matches!(
+        replies.try_recv(),
+        Ok(PartitionReadReply::Rejected(
+            IggyError::TransientNotAccepted
+        ))
+    ));
+    assert_eq!(stored_offset(&owner, PollingConsumer::Consumer(0, 0)), None);
+    let open = CommitGate::default();
+    let mut poll = request(&owner, 1, false, true);
+    poll.commit_gate = Some(open.clone());
+    assert_eq!(offsets(&submit(&owner, poll).await), [0]);
+    assert_eq!(
+        stored_offset(&owner, PollingConsumer::Consumer(0, 0)),
+        Some(0)
+    );
+    assert!(!open.cancel(), "a cancel after a commit loses");
+}
+
+#[compio::test]
 async fn readiness_expiry_returns_committed_data_even_after_the_wait() {
     for arrival in [DEADLINE - 1, DEADLINE, DEADLINE + 1] {
         let owner = owner(&["first"]).await;
@@ -950,15 +1155,56 @@
 }
 
 #[compio::test]
-async fn offset_that_another_session_stores_again_lets_parked_next_poll_finish() {
+async fn offset_that_another_session_stores_again_rejects_parked_next_poll() {
+    // A close rewind stores the offset that the parked read was admitted
+    // against, and the read must not commit past the messages it rewinds.
+    for group in [false, true] {
+        let owner = owner(&["a", "b", "c", "d"]).await;
+        let poll = request(&owner, 3, group, true);
+        let consumer = poll.consumer;
+        store_offset_explicitly(&owner, (consumer, 1, AckLevel::Quorum), 1).await;
+        owner.bus.now.set(1);
+        let replies = submit(&owner, poll).await;
+        pending(&replies);
+
+        store_offset_explicitly(&owner, (consumer, 1, AckLevel::Quorum), 2).await;
+        commit(&owner, &["e"], 2).await;
+        owner.service_deferred_polls().await;
+
+        let reply = replies.try_recv();
+        assert!(
+            matches!(
+                reply,
+                Ok(PartitionReadReply::Rejected(
+                    IggyError::TransientNotAccepted
+                ))
+            ),
+            "a store of the admitted offset fences the read, group {group}: {reply:?}"
+        );
+        assert_eq!(
+            stored_offset(&owner, consumer),
+            Some(1),
+            "the refused poll commits nothing"
+        );
+        owner.bus.now.set(2);
+        assert_eq!(
+            offsets(&submit(&owner, request(&owner, 3, group, true)).await),
+            [2, 3, 4],
+            "the next poll reads what the fenced one held"
+        );
+    }
+}
+
+#[compio::test]
+async fn offset_stored_before_admission_lets_parked_next_poll_finish() {
     let owner = owner(&["a", "b", "c", "d"]).await;
     let poll = request(&owner, 3, false, true);
     let consumer = poll.consumer;
-    store_offset(&owner, consumer, 1);
+    store_offset_explicitly(&owner, (consumer, 1, AckLevel::Quorum), 1).await;
+    owner.bus.now.set(1);
     let replies = submit(&owner, poll).await;
     pending(&replies);
 
-    store_offset(&owner, consumer, 1);
     commit(&owner, &["e"], 2).await;
     owner.service_deferred_polls().await;
 
@@ -967,6 +1213,49 @@
 }
 
 #[compio::test]
+async fn store_after_a_fetch_resolved_its_start_rejects_its_later_read() {
+    let owner = owner(&["a", "b", "c", "d"]).await;
+    let consumer = request(&owner, 2, false, true).consumer;
+    store_offset_explicitly(&owner, (consumer, 1, AckLevel::Quorum), 1).await;
+    owner.bus.now.set(1);
+    // The fetch resolved its start here and waited, then a rewind stored the
+    // same offset before the fetch sent its read.
+    store_offset_explicitly(&owner, (consumer, 1, AckLevel::Quorum), 2).await;
+    owner.bus.now.set(2);
+    let mut read = request(&owner, 2, false, true);
+    read.stores_fenced_from = Some(1);
+
+    let replies = submit(&owner, read).await;
+
+    assert!(matches!(
+        replies.try_recv(),
+        Ok(PartitionReadReply::Rejected(
+            IggyError::TransientNotAccepted
+        ))
+    ));
+    assert_eq!(stored_offset(&owner, consumer), Some(1));
+}
+
+/// Stores an offset as a client does, through the commit of the partition.
+async fn store_offset_explicitly(
+    owner: &TestShard,
+    store: (PollingConsumer, u64, AckLevel),
+    request: u64,
+) {
+    let partitions = owner.plane.partitions();
+    let mut partition = partitions.remove(&namespace()).unwrap();
+    store_consumer_offset(
+        &mut partition,
+        partitions.config(),
+        namespace(),
+        store,
+        (request, METADATA_OP),
+    )
+    .await;
+    partitions.insert(namespace(), partition);
+}
+
+#[compio::test]
 async fn full_shard_quota_rejects_another_session_without_poll_progress() {
     let owner = owner(&["first"]).await;
     owner.set_deferred_poll_config(DeferredPollConfig {
@@ -1164,8 +1453,10 @@
             context: readiness_caller(),
             targets,
             deadline: DEADLINE,
+            min_count: ANY_MESSAGE,
         },
         &reply,
+        None,
     );
     replies
 }
@@ -1180,7 +1471,11 @@
     else {
         panic!("expected a readiness answer from shard 0");
     };
-    (parked, partitions)
+    (parked, ids(&partitions))
+}
+
+fn ids(ready: &[ReadyTarget]) -> Vec<u32> {
+    ready.iter().map(|target| target.partition_id).collect()
 }
 
 fn set_committed(owner: &TestShard, partition: usize, committed: bool) {
@@ -1393,6 +1688,77 @@
 }
 
 #[compio::test]
+async fn status_change_ends_a_parked_readiness_wait_within_one_pass_of_the_ticks() {
+    // A partition that stopped serving reads marks nothing, so a large wait
+    // learned it only when the sweep of its targets reached it.
+    let (owner, replies) = parked_wait_on(4 * MAINTENANCE_BUDGET).await;
+    begin_state_transfer(&owner, 4 * MAINTENANCE_BUDGET - 1);
+    // A pass over the parked partitions takes four ticks.
+    for _ in 0..4 {
+        owner.tick_deferred_polls();
+    }
+    owner.service_deferred_polls().await;
+    assert!(matches!(
+        replies.try_recv(),
+        Ok(PartitionReadReply::Rejected(
+            IggyError::TransientNotAccepted
+        ))
+    ));
+    assert_released(&owner);
+}
+
+#[compio::test]
+async fn tick_checks_the_status_of_one_budget_of_parked_partitions() {
+    // A tick read the status of every partition with parked reads.
+    const PARTITIONS: usize = 3 * MAINTENANCE_BUDGET;
+    let (owner, _replies) = parked_wait_on(PARTITIONS).await;
+    owner.tick_deferred_polls();
+    for partition in 0..PARTITIONS {
+        begin_state_transfer(&owner, partition);
+    }
+    let changed = |owner: &TestShard| owner.deferred_polls.signals.borrow().changed.len();
+
+    owner.tick_deferred_polls();
+    assert_eq!(changed(&owner), MAINTENANCE_BUDGET);
+    for _ in 0..2 {
+        owner.tick_deferred_polls();
+    }
+    assert_eq!(
+        changed(&owner),
+        PARTITIONS,
+        "one pass over the parked partitions finds every change"
+    );
+}
+
+#[compio::test]
+async fn status_change_ends_a_parked_poll_on_the_next_tick() {
+    let owner = owner(&["first"]).await;
+    // The sweep reaches the last poll only on the third tick.
+    let mut parked = Vec::new();
+    for _ in 0..2 * MAINTENANCE_BUDGET {
+        let other = PollingConsumer::Consumer(parked.len() + 2, 0);
+        let mut request = request(&owner, 2, false, false);
+        request.consumer = other;
+        parked.push(submit(&owner, request).await);
+    }
+    let replies = submit(&owner, request(&owner, 2, false, false)).await;
+    owner.tick_deferred_polls();
+    owner.service_deferred_polls().await;
+    begin_state_transfer(&owner, 0);
+    owner.tick_deferred_polls();
+    // The pump turns until the woken polls are served, a budget per turn.
+    for _ in 0..=parked.len().div_ceil(SERVICE_BUDGET) {
+        owner.service_deferred_polls().await;
+    }
+    assert!(matches!(
+        replies.try_recv(),
+        Ok(PartitionReadReply::Rejected(
+            IggyError::TransientNotAccepted
+        ))
+    ));
+}
+
+#[compio::test]
 async fn stale_session_rejects_parked_readiness_on_a_commit() {
     let owner = owner(&["first"]).await;
     let mut table = ClientTable::new(1);
@@ -1420,8 +1786,10 @@
             },
             targets: vec![readiness_target(&owner, 0, PollingStrategy::offset(1))],
             deadline: DEADLINE,
+            min_count: ANY_MESSAGE,
         },
         &reply,
+        None,
     );
     assert!(answer(&replies).0.is_some());
     drop(table);
@@ -1487,7 +1855,8 @@
 }
 
 #[compio::test]
-async fn fan_out_merges_admissions_and_cancels_what_stays_parked() {
+async fn fan_out_evaluates_its_own_targets_in_place_merges_admissions_and_cancels_what_stays_parked()
+ {
     for (later, duration) in [
         (false, PARTITION_READ_TIMEOUT),
         // A zero wait still collects every admission answer.
@@ -1509,21 +1878,30 @@
             readiness_target(&owner, 1, PollingStrategy::offset(0)),
         ];
         let owners = async {
-            let Ok(ShardFrame::Lifecycle(LifecycleFrame::ReadinessWait { wait, reply })) =
-                local_inbox.recv().await
-            else {
-                panic!("shard 0 holds partition 0");
-            };
-            owner.admit_readiness_wait(wait, &reply);
-            let Ok(ShardFrame::Lifecycle(LifecycleFrame::ReadinessWait { wait, reply })) =
+            // The wait evaluated partition 0 in place, without a frame to its own inbox.
+            assert!(local_inbox.try_recv().is_err(), "shard 0 holds partition 0");
+            let Ok(ShardFrame::Lifecycle(LifecycleFrame::ReadinessWait { wait, reply, watch })) =
                 remote_inbox.recv().await
             else {
                 panic!("shard 1 holds partition 1");
             };
             assert_eq!(wait.targets.len(), 1);
-            for answer in [Some((Some(7), Vec::new())), later.then(|| (None, vec![1]))]
-                .into_iter()
-                .flatten()
+            assert_eq!(
+                watch.is_some(),
+                !duration.is_zero(),
+                "only a wait that can park starts a watch"
+            );
+            let ready = ReadyTarget {
+                partition_id: 1,
+                available: 1,
+                known_bytes: None,
+            };
+            for answer in [
+                Some((Some(7), Vec::new())),
+                later.then(|| (None, vec![ready])),
+            ]
+            .into_iter()
+            .flatten()
             {
                 reply
                     .try_send(PartitionReadReply::Ready {
@@ -1535,23 +1913,21 @@
             }
         };
         let (ready, ()) = futures::join!(
-            owner.wait_for_messages(readiness_caller(), targets, duration),
+            owner.wait_for_messages(readiness_caller(), targets, duration, ANY_MESSAGE),
             owners
         );
         if later {
-            assert_eq!(ready.unwrap(), [1]);
-            let Ok(ShardFrame::Lifecycle(LifecycleFrame::ReadinessCancel { waiter })) =
-                local_inbox.try_recv()
-            else {
-                panic!("the local waiter stays parked and is cancelled");
-            };
-            owner.cancel_readiness_wait(waiter);
+            assert_eq!(ids(&ready.unwrap()), [1]);
+            assert!(
+                local_inbox.try_recv().is_err(),
+                "the local waiter ends in place, without a cancel"
+            );
             assert!(
                 remote_inbox.try_recv().is_err(),
                 "the remote waiter answered"
             );
         } else {
-            assert_eq!(ready.unwrap(), [0]);
+            assert_eq!(ids(&ready.unwrap()), [0]);
             assert!(local_inbox.try_recv().is_err(), "nothing parked locally");
             assert!(matches!(
                 remote_inbox.try_recv(),
@@ -1563,3 +1939,1051 @@
         assert_released(&owner);
     }
 }
+
+#[compio::test]
+async fn given_stored_offsets_of_this_shard_and_another_when_read_should_read_in_place_and_send_one_frame()
+ {
+    // Each stored offset was a frame of its own, also on this shard, so a
+    // fetch of many partitions paid one inbox trip for each of them.
+    const STORED: u64 = 7;
+    let mut owner = owner_of(&[&["first"], &["first"], &["first"]]).await;
+    let (local, local_inbox, _) = shard_channel(0, 4, 1);
+    let (remote, remote_inbox, _) = shard_channel(1, 4, 1);
+    owner.attach_senders(vec![local, remote]);
+    for partition in [1, 2] {
+        owner.shards_table.insert(
+            partition_namespace(partition),
+            PartitionLocation::new(ShardId::new(1), 1),
+        );
+    }
+    store_offset(&owner, PollingConsumer::Consumer(0, 0), STORED);
+    let reads = (0..3)
+        .map(|partition| {
+            let target = readiness_target(&owner, partition, PollingStrategy::next());
+            ConsumerOffsetRead {
+                namespace: target.namespace,
+                consumer: Some(target.consumer),
+                metadata: target.metadata,
+            }
+        })
+        .collect();
+    let remote_owner = async {
+        let Ok(ShardFrame::Lifecycle(LifecycleFrame::PartitionRead {
+            namespace,
+            read,
+            reply,
+        })) = remote_inbox.recv().await
+        else {
+            panic!("the other owner gets one frame");
+        };
+        let PartitionRead::ConsumerOffsetsOnPrimary { reads } = &read else {
+            panic!("the frame reads the stored offsets of its partitions");
+        };
+        assert_eq!(
+            reads.iter().map(|read| read.namespace).collect::<Vec<_>>(),
+            [partition_namespace(1), partition_namespace(2)]
+        );
+        owner.on_partition_read(namespace, read, reply).await;
+    };
+
+    let (offsets, ()) = futures::join!(owner.consumer_offsets_on_primary(reads), remote_owner);
+
+    let offsets: Vec<_> = offsets
+        .into_iter()
+        .map(|offset| match offset {
+            Some(PartitionReadReply::ConsumerOffset {
+                stored,
+                current_offset,
+            }) => (stored, current_offset),
+            other => panic!("each partition answers its stored offset, not {other:?}"),
+        })
+        .collect();
+    assert_eq!(offsets, [(Some(STORED), 0), (None, 0), (None, 0)]);
+    assert!(
+        local_inbox.try_recv().is_err(),
+        "the partition of this shard is read in place"
+    );
+    assert!(remote_inbox.try_recv().is_err(), "one frame for one owner");
+}
+
+const WATCHER: u16 = 0;
+const OWNER: u16 = 1;
+/// Room for the frames of one test step.
+const INBOX: usize = 4;
+
+struct Inbox {
+    main: Receiver<ShardFrame>,
+    replies: Receiver<ShardFrame>,
+}
+
+/// Shard `WATCHER` serves the waits, and shard `OWNER` holds a partition for
+/// each entry of `partitions`. Both read one clock.
+async fn watched_pair(partitions: &[&[&str]]) -> (TestShard, TestShard, [Inbox; 2]) {
+    let (watcher, mut owners, inboxes) = watched_by(1, partitions).await;
+    let Ok(inboxes) = <[Inbox; 2]>::try_from(inboxes) else {
+        panic!("one watcher and one owner");
+    };
+    (watcher, owners.remove(0), inboxes)
+}
+
+/// Shard `WATCHER` serves the waits, and each of the `owners` shards after
+/// it holds a partition for each entry of `partitions`. Partition `p` is the
+/// one of shard `OWNER + p % owners`. All read one clock, and the inbox of
+/// a shard is at its id.
+async fn watched_by(
+    owners: u16,
+    partitions: &[&[&str]],
+) -> (TestShard, Vec<TestShard>, Vec<Inbox>) {
+    let bus = PollTestBus::default();
+    // Boxed: one shard held across the build of another outgrows the
+    // `large_futures` cap.
+    let mut shards = vec![Box::pin(shard_of(WATCHER, bus.clone(), partitions)).await];
+    for owner in OWNER..OWNER + owners {
+        shards.push(Box::pin(shard_of(owner, bus.clone(), partitions)).await);
+    }
+    for partition in 0..partitions.len() {
+        let namespace = partition_namespace(partition);
+        assert!(shards[0].plane.partitions().remove(&namespace).is_some());
+        let owner = OWNER + u16::try_from(partition).unwrap() % owners;
+        shards[0]
+            .shards_table
+            .insert(namespace, PartitionLocation::new(ShardId::new(owner), 1));
+    }
+    let (senders, inboxes): (Vec<_>, Vec<_>) = (WATCHER..OWNER + owners)
+        .map(|shard| {
+            let (sender, main, replies) = shard_channel(shard, INBOX, INBOX);
+            (sender, Inbox { main, replies })
+        })
+        .unzip();
+    for shard in &mut shards {
+        shard.attach_senders(senders.clone());
+    }
+    let watcher = shards.remove(0);
+    (watcher, shards, inboxes)
+}
+
+/// Runs the frames of `inbox` as the pump of `shard` does, then its service
+/// loop and its flush.
+async fn pump(shard: &TestShard, inbox: &Inbox) {
+    while let Ok(frame) = inbox.replies.try_recv().or_else(|_| inbox.main.try_recv()) {
+        let ShardFrame::Lifecycle(frame) = frame else {
+            panic!("a readiness test sends lifecycle frames only");
+        };
+        match frame {
+            LifecycleFrame::ReadinessWait { wait, reply, watch } => {
+                shard.admit_readiness_wait(*wait, &reply, watch);
+            }
+            LifecycleFrame::ReadinessCancel { waiter } => shard.cancel_readiness_wait(waiter),
+            LifecycleFrame::ReadinessUpdate { from, states } => {
+                shard.apply_readiness_update(from, states);
+            }
+            LifecycleFrame::ReadinessUnwatch { from, watches } => {
+                shard.unwatch_readiness(from, watches);
+            }
+            LifecycleFrame::ReadinessInterest {
+                from,
+                watches,
+                parked,
+            } => shard.set_readiness_interest(from, watches, parked),
+            LifecycleFrame::PartitionRead {
+                namespace,
+                read,
+                reply,
+            } => shard.on_partition_read(namespace, read, reply).await,
+            _ => panic!("a readiness test sends readiness frames and reads only"),
+        }
+    }
+    shard.service_deferred_polls().await;
+    shard.flush_readiness_pushes();
+}
+
+/// A first wait for `partition` goes to the owner. The owner answers it and
+/// pushes the state, which the watcher then mirrors.
+async fn mirror(watcher: &TestShard, owner: &TestShard, inboxes: &[Inbox], partition: usize) {
+    let target = readiness_target(watcher, partition, PollingStrategy::offset(0));
+    mirror_on(watcher, owner, inboxes, target).await;
+}
+
+/// [`mirror`] through the ready `target`, so that the pushes carry the
+/// installed owner of its group too.
+async fn mirror_on(
+    watcher: &TestShard,
+    owner: &TestShard,
+    inboxes: &[Inbox],
+    target: ReadinessTarget,
+) {
+    let namespace = target.namespace;
+    let partition = target.partition_id;
+    let (ready, ()) = futures::join!(
+        watcher.wait_for_messages(
+            readiness_caller(),
+            vec![target],
+            PARTITION_READ_TIMEOUT,
+            ANY_MESSAGE
+        ),
+        async {
+            let Ok(ShardFrame::Lifecycle(LifecycleFrame::ReadinessWait {
+                wait,
+                reply,
+                watch: Some(watch),
+            })) = inboxes[usize::from(owner.id)].main.try_recv()
+            else {
+                panic!("the first wait goes to the owner and starts a watch");
+            };
+            owner.admit_readiness_wait(*wait, &reply, Some(watch));
+            owner.flush_readiness_pushes();
+        }
+    );
+    assert_eq!(ids(&ready.unwrap()), [partition]);
+    pump(watcher, &inboxes[usize::from(WATCHER)]).await;
+    assert!(
+        watcher.deferred_polls.mirrors.borrow().entries[&namespace]
+            .state
+            .is_some()
+    );
+}
+
+/// Parks a wait for offset 1 of partition 0 on the mirror of the watcher,
+/// makes `change` on the owner, and passes on what the owner pushes.
+async fn wait_through(
+    watcher: &TestShard,
+    owner: &TestShard,
+    inboxes: &[Inbox; 2],
+    change: impl AsyncFnOnce(),
+) -> Result<Vec<ReadyTarget>, IggyError> {
+    let target = readiness_target(watcher, 0, PollingStrategy::offset(1));
+    wait_through_on(watcher, owner, inboxes, target, change).await
+}
+
+/// [`wait_through`] for `target`.
+async fn wait_through_on(
+    watcher: &TestShard,
+    owner: &TestShard,
+    inboxes: &[Inbox; 2],
+    target: ReadinessTarget,
+    change: impl AsyncFnOnce(),
+) -> Result<Vec<ReadyTarget>, IggyError> {
+    let (ready, ()) = futures::join!(
+        watcher.wait_for_messages(
+            readiness_caller(),
+            vec![target],
+            PARTITION_READ_TIMEOUT,
+            ANY_MESSAGE
+        ),
+        async {
+            pump(watcher, &inboxes[0]).await;
+            assert_eq!(
+                watcher.deferred_polls.state.borrow().readiness.len(),
+                1,
+                "the wait parks on the watcher"
+            );
+            change().await;
+            pump(owner, &inboxes[1]).await;
+            pump(watcher, &inboxes[0]).await;
+            assert!(
+                watcher.deferred_polls.state.borrow().readiness.is_empty(),
+                "the push ends the wait"
+            );
+        }
+    );
+    ready
+}
+
+#[compio::test]
+async fn wait_on_a_mirror_sends_no_frame_to_the_owner() {
+    let (watcher, owner, inboxes) = watched_pair(&[&["first"]]).await;
+    mirror(&watcher, &owner, &inboxes, 0).await;
+    let ready = wait_through(&watcher, &owner, &inboxes, async || {
+        commit(&owner, &["second"], 2).await;
+    })
+    .await;
+    assert_eq!(ids(&ready.unwrap()), [0]);
+    assert!(
+        inboxes[1].main.try_recv().is_err(),
+        "the wait and its end stay on the watcher"
+    );
+    assert!(
+        inboxes[0].main.try_recv().is_err(),
+        "an answered waiter needs no cancel"
+    );
+    assert_eq!(owner.metrics.deferred_polls.readiness_pushes.get(), 2);
+    assert_eq!(owner.metrics.deferred_polls.readiness_watches.get(), 1);
+    assert_released(&watcher);
+    assert_released(&owner);
+}
+
+#[compio::test]
+async fn zero_wait_or_next_target_asks_the_owner_without_a_watch() {
+    let (watcher, owner, inboxes) = watched_pair(&[&["first"]]).await;
+    mirror(&watcher, &owner, &inboxes, 0).await;
+    for (strategy, duration) in [
+        (PollingStrategy::offset(0), Duration::ZERO),
+        (PollingStrategy::next(), PARTITION_READ_TIMEOUT),
+    ] {
+        let target = readiness_target(&watcher, 0, strategy);
+        let (ready, ()) = futures::join!(
+            watcher.wait_for_messages(readiness_caller(), vec![target], duration, ANY_MESSAGE),
+            async {
+                let Ok(ShardFrame::Lifecycle(LifecycleFrame::ReadinessWait {
+                    wait,
+                    reply,
+                    watch: None,
+                })) = inboxes[1].main.try_recv()
+                else {
+                    panic!("the owner evaluates the target without a watch");
+                };
+                owner.admit_readiness_wait(*wait, &reply, None);
+            }
+        );
+        assert_eq!(ids(&ready.unwrap()), [0]);
+    }
+}
+
+#[compio::test]
+async fn mirror_that_refuses_a_target_sends_it_to_the_owner() {
+    let (watcher, owner, inboxes) = watched_pair(&[&["first"]]).await;
+    mirror(&watcher, &owner, &inboxes, 0).await;
+    begin_state_transfer(&owner, 0);
+    // As a push to a shard with a parked wait carries the state transfer.
+    change_mirror(&watcher, |state| state.status.unstable = true);
+    pump(&watcher, &inboxes[0]).await;
+    let target = readiness_target(&watcher, 0, PollingStrategy::offset(1));
+    let (ready, ()) = futures::join!(
+        watcher.wait_for_messages(
+            readiness_caller(),
+            vec![target],
+            PARTITION_READ_TIMEOUT,
+            ANY_MESSAGE
+        ),
+        async {
+            let Ok(ShardFrame::Lifecycle(LifecycleFrame::ReadinessWait {
+                wait,
+                reply,
+                watch: Some(watch),
+            })) = inboxes[1].main.try_recv()
+            else {
+                panic!("the owner answers the error, and the watch goes on");
+            };
+            owner.admit_readiness_wait(*wait, &reply, Some(watch));
+        }
+    );
+    assert!(matches!(ready, Err(IggyError::TransientNotAccepted)));
+}
+
+#[compio::test]
+async fn frames_from_before_a_renewed_watch_change_nothing() {
+    let (watcher, owner, inboxes) = watched_pair(&[&["first"]]).await;
+    mirror(&watcher, &owner, &inboxes, 0).await;
+    let stale = watcher.deferred_polls.mirrors.borrow().entries[&namespace()].seq;
+    let renewed = watcher.next_readiness_watch();
+    watcher.mirror_partitions(OWNER, renewed.seq, &[namespace()], 0);
+    owner.watch_partitions(
+        renewed,
+        &[readiness_target(&owner, 0, PollingStrategy::offset(0))],
+    );
+    watcher.apply_readiness_update(
+        OWNER,
+        vec![WatchedState {
+            namespace: namespace(),
+            seq: stale,
+            state: None,
+        }],
+    );
+    assert!(
+        watcher.deferred_polls.mirrors.borrow().entries[&namespace()]
+            .state
+            .is_some(),
+        "a stale push keeps the mirror"
+    );
+    assert!(inboxes[1].main.try_recv().is_err(), "and unwatches nothing");
+    owner.unwatch_readiness(WATCHER, vec![(namespace(), stale)]);
+    assert_eq!(
+        owner.deferred_polls.state.borrow().watches[&namespace()].watchers,
+        [renewed],
+        "a stale unwatch keeps the renewed watch"
+    );
+}
+
+#[compio::test]
+async fn unused_mirror_ends_its_watch() {
+    for commits in [true, false] {
+        let (watcher, owner, inboxes) = watched_pair(&[&["first"]]).await;
+        mirror(&watcher, &owner, &inboxes, 0).await;
+        watcher.bus.now.set(MIRROR_IDLE_US);
+        if commits {
+            // Without a parked wait a commit pushes nothing.
+            commit(&owner, &["second"], 2).await;
+            pump(&owner, &inboxes[1]).await;
+            assert!(inboxes[0].replies.try_recv().is_err());
+        }
+        watcher.sweep_readiness_watches();
+        assert!(watcher.deferred_polls.mirrors.borrow().entries.is_empty());
+        pump(&owner, &inboxes[1]).await;
+        assert!(owner.deferred_polls.state.borrow().watches.is_empty());
+        assert_eq!(owner.metrics.deferred_polls.readiness_watches.get(), 0);
+        set_committed(&owner, 0, true);
+        assert!(
+            owner.deferred_polls.signals.borrow().dirty.is_empty(),
+            "the notifier goes with the last watch"
+        );
+    }
+}
+
+#[compio::test]
+async fn finished_poll_keeps_the_notifier_of_a_watched_partition() {
+    let (watcher, owner, inboxes) = watched_pair(&[&["first"]]).await;
+    mirror(&watcher, &owner, &inboxes, 0).await;
+    let replies = submit(&owner, request(&owner, 2, false, false)).await;
+    commit(&owner, &["second"], 2).await;
+    pump(&owner, &inboxes[1]).await;
+    assert_eq!(offsets(&replies), [0, 1]);
+    commit(&owner, &["third"], 3).await;
+    assert!(
+        owner
+            .deferred_polls
+            .signals
+            .borrow()
+            .queued
+            .contains(&namespace()),
+        "the commit still reaches the watch"
+    );
+}
+
+#[compio::test]
+async fn owner_close_rejects_a_wait_parked_on_its_mirror() {
+    let (watcher, owner, inboxes) = watched_pair(&[&["first"]]).await;
+    mirror(&watcher, &owner, &inboxes, 0).await;
+    let ready = wait_through(&watcher, &owner, &inboxes, async || {
+        owner.deferred_polls.close(OWNER, &owner.senders);
+    })
+    .await;
+    assert!(matches!(ready, Err(IggyError::TransientNotAccepted)));
+    assert_released(&watcher);
+}
+
+#[compio::test]
+async fn replacement_history_in_a_push_rejects_a_parked_wait() {
+    let (watcher, owner, inboxes) = watched_pair(&[&["old"]]).await;
+    mirror(&watcher, &owner, &inboxes, 0).await;
+    let ready = wait_through(&watcher, &owner, &inboxes, async || {
+        let (replacement, _) =
+            partition_with_messages(&owner.bus, namespace(), &["new", "newer"]).await;
+        owner.plane.partitions().insert(namespace(), replacement);
+        // The rebuilt partition has no notifier, so the sweep finds it.
+        owner.sweep_readiness_watches();
+    })
+    .await;
+    assert!(matches!(ready, Err(IggyError::TransientNotAccepted)));
+    set_committed(&owner, 0, true);
+    assert!(
+        owner
+            .deferred_polls
+            .signals
+            .borrow()
+            .queued
+            .contains(&namespace()),
+        "the sweep installs the notifier again"
+    );
+    assert_released(&watcher);
+}
+
+#[compio::test]
+async fn refused_push_goes_at_the_next_flush() {
+    let (watcher, owner, inboxes) = watched_pair(&[&["first"]]).await;
+    mirror(&watcher, &owner, &inboxes, 0).await;
+    hold_interest(&watcher, &owner);
+    let lane = owner.senders[usize::from(WATCHER)].reply_sender();
+    while lane
+        .try_send(ShardFrame::lifecycle(LifecycleFrame::ReadinessUpdate {
+            from: OWNER,
+            states: Vec::new(),
+        }))
+        .is_ok()
+    {}
+    commit(&owner, &["second"], 2).await;
+    pump(&owner, &inboxes[1]).await;
+    assert!(
+        !owner.deferred_polls.state.borrow().pushes.is_empty(),
+        "a full lane keeps the push"
+    );
+    while inboxes[0].replies.try_recv().is_ok() {}
+    owner.flush_readiness_pushes();
+    let Ok(ShardFrame::Lifecycle(LifecycleFrame::ReadinessUpdate { states, .. })) =
+        inboxes[0].replies.try_recv()
+    else {
+        panic!("the next flush sends the push");
+    };
+    assert!(matches!(
+        &states[..],
+        [WatchedState { state: Some(state), .. }] if state.visibility.offset == Some(1)
+    ));
+    assert!(owner.deferred_polls.state.borrow().pushes.is_empty());
+}
+
+#[compio::test]
+async fn commits_push_nothing_to_a_watcher_without_a_parked_wait() {
+    let (watcher, owner, inboxes) = watched_pair(&[&["first"]]).await;
+    mirror(&watcher, &owner, &inboxes, 0).await;
+    let pushes = owner.metrics.deferred_polls.readiness_pushes.get();
+    // A consumer behind the end names the mirror in every wait and never parks.
+    for request in 2..5 {
+        let target = readiness_target(&watcher, 0, PollingStrategy::offset(0));
+        let (ready, ()) = futures::join!(
+            watcher.wait_for_messages(
+                readiness_caller(),
+                vec![target],
+                PARTITION_READ_TIMEOUT,
+                ANY_MESSAGE
+            ),
+            pump(&watcher, &inboxes[0])
+        );
+        assert_eq!(ids(&ready.unwrap()), [0]);
+        commit(&owner, &["more"], request).await;
+        pump(&owner, &inboxes[1]).await;
+    }
+    assert!(inboxes[0].replies.try_recv().is_err(), "no push goes");
+    assert!(inboxes[1].main.try_recv().is_err(), "and no frame comes");
+    assert_eq!(owner.metrics.deferred_polls.readiness_pushes.get(), pushes);
+}
+
+#[compio::test]
+async fn parked_waits_get_the_pushes_until_a_push_finds_none_parked() {
+    let (watcher, owner, inboxes) = watched_pair(&[&["first"]]).await;
+    mirror(&watcher, &owner, &inboxes, 0).await;
+    let pushes = owner.metrics.deferred_polls.readiness_pushes.get();
+    for (start, request) in [(1, 2), (2, 3)] {
+        let target = readiness_target(&watcher, 0, PollingStrategy::offset(start));
+        let (ready, ()) = futures::join!(
+            watcher.wait_for_messages(
+                readiness_caller(),
+                vec![target],
+                PARTITION_READ_TIMEOUT,
+                ANY_MESSAGE
+            ),
+            async {
+                pump(&watcher, &inboxes[0]).await;
+                assert_eq!(parked_waits(&watcher), 1);
+                let told = inboxes[1].main.try_recv();
+                if start == 1 {
+                    assert!(
+                        matches!(
+                            &told,
+                            Ok(ShardFrame::Lifecycle(LifecycleFrame::ReadinessInterest {
+                                from: WATCHER,
+                                parked: true,
+                                ..
+                            }))
+                        ),
+                        "the first park tells the owner"
+                    );
+                    let Ok(ShardFrame::Lifecycle(LifecycleFrame::ReadinessInterest {
+                        from,
+                        watches,
+                        parked,
+                    })) = told
+                    else {
+                        unreachable!();
+                    };
+                    owner.set_readiness_interest(from, watches, parked);
+                } else {
+                    assert!(
+                        told.is_err(),
+                        "a park before the next commit keeps the pushes on"
+                    );
+                }
+                commit(&owner, &["more"], request).await;
+                pump(&owner, &inboxes[1]).await;
+                pump(&watcher, &inboxes[0]).await;
+            }
+        );
+        assert_eq!(ids(&ready.unwrap()), [0]);
+    }
+    commit(&owner, &["unread"], 4).await;
+    pump(&owner, &inboxes[1]).await;
+    pump(&watcher, &inboxes[0]).await;
+    assert!(
+        matches!(
+            inboxes[1].main.try_recv(),
+            Ok(ShardFrame::Lifecycle(LifecycleFrame::ReadinessInterest {
+                from: WATCHER,
+                parked: false,
+                ..
+            }))
+        ),
+        "a push that finds no parked wait ends the pushes"
+    );
+    owner.set_readiness_interest(
+        WATCHER,
+        vec![(
+            namespace(),
+            watcher.deferred_polls.mirrors.borrow().entries[&namespace()].seq,
+        )],
+        false,
+    );
+    commit(&owner, &["unread"], 5).await;
+    pump(&owner, &inboxes[1]).await;
+    assert!(inboxes[0].replies.try_recv().is_err());
+    assert_eq!(
+        owner.metrics.deferred_polls.readiness_pushes.get(),
+        pushes + 3,
+        "one push per commit with a parked wait, and one that found none"
+    );
+}
+
+#[compio::test]
+async fn pushes_leave_half_the_reply_lane_to_client_replies() {
+    let (watcher, owner, inboxes) = watched_pair(&[&["first"]]).await;
+    mirror(&watcher, &owner, &inboxes, 0).await;
+    hold_interest(&watcher, &owner);
+    let lane = owner.senders[usize::from(WATCHER)].reply_sender();
+    let reply = || {
+        ShardFrame::lifecycle(LifecycleFrame::ReadinessUpdate {
+            from: OWNER,
+            states: Vec::new(),
+        })
+    };
+    for _ in 0..INBOX / 2 {
+        assert!(lane.try_send(reply()).is_ok());
+    }
+    commit(&owner, &["second"], 2).await;
+    pump(&owner, &inboxes[1]).await;
+    assert!(
+        !owner.deferred_polls.state.borrow().pushes.is_empty(),
+        "a lane that holds half its capacity keeps the push"
+    );
+    for _ in 0..INBOX / 2 {
+        assert!(
+            lane.try_send(reply()).is_ok(),
+            "the other half takes client replies"
+        );
+    }
+}
+
+#[compio::test]
+async fn mirror_of_a_partition_that_moved_asks_its_new_owner_for_pushes() {
+    let (watcher, owner, inboxes) = watched_pair(&[&["first"]]).await;
+    mirror(&watcher, &owner, &inboxes, 0).await;
+    hold_interest(&watcher, &owner);
+    let renewed = watcher.next_readiness_watch();
+    watcher.mirror_partitions(OWNER + 1, renewed.seq, &[namespace()], 0);
+    assert!(
+        !watcher.deferred_polls.mirrors.borrow().entries[&namespace()].interest,
+        "the next park tells the new owner"
+    );
+}
+
+/// Holds the interest of the watcher in partition 0 on both sides, as a
+/// parked wait does.
+fn hold_interest(watcher: &TestShard, owner: &TestShard) {
+    let seq = {
+        let mut mirrors = watcher.deferred_polls.mirrors.borrow_mut();
+        let mirror = mirrors
+            .entries
+            .get_mut(&namespace())
+            .expect("the watcher mirrors partition 0");
+        mirror.interest = true;
+        mirror.seq
+    };
+    owner.set_readiness_interest(WATCHER, vec![(namespace(), seq)], true);
+}
+
+/// The messages that a fetch read asks for, more than any test commits.
+const FETCH_COUNT: u32 = 16;
+
+/// A target of the group of the fixture, whose owner the partitions install.
+fn group_target(shard: &TestShard, partition: usize, strategy: PollingStrategy) -> ReadinessTarget {
+    let namespace = partition_namespace(partition);
+    ReadinessTarget {
+        consumer: PollingConsumer::ConsumerGroup(0, partition),
+        metadata: shard
+            .plane
+            .metadata()
+            .mux_stm
+            .streams()
+            .poll_metadata(namespace, Some(0), CLIENT)
+            .unwrap(),
+        ..readiness_target(shard, partition, strategy)
+    }
+}
+
+/// A read of a fetch from `start` of `partition`.
+fn fetch_read(
+    shard: &TestShard,
+    partition: usize,
+    start: u64,
+    max_bytes: u32,
+    auto_commit: bool,
+) -> DeferredPollRequest {
+    let target = readiness_target(shard, partition, PollingStrategy::offset(start));
+    DeferredPollRequest::new(
+        target.consumer,
+        PollingArgs::new(target.strategy, FETCH_COUNT, auto_commit),
+        target.metadata,
+        readiness_caller(),
+        DeferredPollOptions {
+            max_bytes,
+            ..Default::default()
+        },
+    )
+}
+
+/// Changes the mirror of partition 0 as a push would, and marks it.
+fn change_mirror(watcher: &TestShard, change: impl FnOnce(&mut ServedState)) {
+    change(
+        watcher
+            .deferred_polls
+            .mirrors
+            .borrow_mut()
+            .entries
+            .get_mut(&namespace())
+            .and_then(|mirror| mirror.state.as_mut())
+            .expect("the watcher mirrors partition 0"),
+    );
+    watcher
+        .deferred_polls
+        .signals
+        .borrow_mut()
+        .mark(namespace());
+}
+
+/// Lets the other futures of a `join!` run once.
+async fn yield_now() {
+    let mut yielded = false;
+    std::future::poll_fn(|context| {
+        if yielded {
+            return Poll::Ready(());
+        }
+        yielded = true;
+        context.waker().wake_by_ref();
+        Poll::Pending
+    })
+    .await;
+}
+
+fn parked_waits(shard: &TestShard) -> usize {
+    shard.deferred_polls.state.borrow().readiness.len()
+}
+
+#[compio::test]
+async fn wait_fires_at_the_min_count_sum_of_targets_on_two_owners() {
+    let (watcher, owners, inboxes) = watched_by(2, &[&["first"], &["first"]]).await;
+    for (partition, owner) in owners.iter().enumerate() {
+        mirror(&watcher, owner, &inboxes, partition).await;
+    }
+    let targets = (0..owners.len())
+        .map(|partition| readiness_target(&watcher, partition, PollingStrategy::offset(1)))
+        .collect();
+    let (ready, ()) = futures::join!(
+        watcher.wait_for_messages(readiness_caller(), targets, PARTITION_READ_TIMEOUT, 2),
+        async {
+            pump(&watcher, &inboxes[usize::from(WATCHER)]).await;
+            for (partition, owner) in owners.iter().enumerate() {
+                assert_eq!(parked_waits(&watcher), 1, "the sum is below min_count");
+                commit_to(owner, partition, &["second"], 2).await;
+                pump(owner, &inboxes[usize::from(owner.id)]).await;
+                pump(&watcher, &inboxes[usize::from(WATCHER)]).await;
+            }
+        }
+    );
+    let ready = ready.unwrap();
+    assert_eq!(ids(&ready), [0, 1]);
+    assert!(ready.iter().all(|target| target.available == 1));
+    assert_released(&watcher);
+}
+
+#[compio::test]
+async fn expired_wait_returns_the_targets_below_min_count_or_nothing() {
+    for commits in [true, false] {
+        let (watcher, owner, inboxes) = watched_pair(&[&["first"], &["first"]]).await;
+        mirror(&watcher, &owner, &inboxes, 0).await;
+        mirror(&watcher, &owner, &inboxes, 1).await;
+        let targets = (0..2)
+            .map(|partition| readiness_target(&watcher, partition, PollingStrategy::offset(1)))
+            .collect();
+        let (ready, ()) = futures::join!(
+            watcher.wait_for_messages(readiness_caller(), targets, PARTITION_READ_TIMEOUT, 3),
+            async {
+                pump(&watcher, &inboxes[0]).await;
+                // The wait takes its admission answer before the clock moves.
+                yield_now().await;
+                if commits {
+                    commit(&owner, &["second"], 2).await;
+                    pump(&owner, &inboxes[1]).await;
+                    pump(&watcher, &inboxes[0]).await;
+                    assert_eq!(parked_waits(&watcher), 1, "one message is below min_count");
+                }
+                watcher
+                    .bus
+                    .now
+                    .set(u64::try_from(PARTITION_READ_TIMEOUT.as_micros()).unwrap());
+                pump(&watcher, &inboxes[0]).await;
+            }
+        );
+        let ready = ready.unwrap();
+        if commits {
+            assert_eq!(ids(&ready), [0]);
+            assert_eq!(ready[0].available, 1);
+        } else {
+            assert_eq!(ready, []);
+        }
+        assert_eq!(watcher.metrics.deferred_polls.expired.get(), 1);
+        assert_released(&watcher);
+    }
+}
+
+#[compio::test]
+async fn known_bytes_are_what_a_read_from_the_start_returns() {
+    let (watcher, owner, inboxes) = watched_pair(&[&["first"], &["first"]]).await;
+    mirror(&watcher, &owner, &inboxes, 0).await;
+    mirror(&watcher, &owner, &inboxes, 1).await;
+    // Partition 0 waits at its end, and partition 1 behind its last offset.
+    let targets = vec![
+        readiness_target(&watcher, 0, PollingStrategy::offset(1)),
+        readiness_target(&watcher, 1, PollingStrategy::offset(0)),
+    ];
+    let (ready, ()) = futures::join!(
+        watcher.wait_for_messages(readiness_caller(), targets, PARTITION_READ_TIMEOUT, 4),
+        async {
+            pump(&watcher, &inboxes[0]).await;
+            commit(&owner, &["second", "third"], 2).await;
+            commit_to(&owner, 1, &["second"], 2).await;
+            pump(&owner, &inboxes[1]).await;
+            pump(&watcher, &inboxes[0]).await;
+        }
+    );
+    let ready = ready.unwrap();
+    let [at_end, behind] = &ready[..] else {
+        panic!("both targets hold messages");
+    };
+    assert_eq!((at_end.partition_id, at_end.available), (0, 2));
+    assert_eq!(
+        (behind.partition_id, behind.available, behind.known_bytes),
+        (1, 2, None)
+    );
+    let known = at_end
+        .known_bytes
+        .expect("the start was the next offset to commit");
+    let room = u32::try_from(known).unwrap() + KNOWN_BYTES_SLACK;
+    // The slack is exact: one byte less cuts the batch after its first message.
+    for (max_bytes, next) in [(room, 3), (room - 1, 2)] {
+        let read = fetch_read(&watcher, 0, 1, max_bytes, false);
+        let (reply, ()) = futures::join!(
+            watcher.deferred_read(namespace(), read, REQUEST_DEADLINE),
+            pump(&owner, &inboxes[1])
+        );
+        let Some(PartitionReadReply::Poll {
+            fragments,
+            next_offset,
+            ..
+        }) = reply
+        else {
+            panic!("the read returns messages");
+        };
+        let bytes = fragments.iter().map(Fragment::len).sum::<usize>() as u64;
+        assert_eq!(
+            bytes == known,
+            next == 3,
+            "{bytes} bytes against {known} known"
+        );
+        assert_eq!(next_offset, Some(next));
+    }
+}
+
+#[compio::test]
+async fn one_commit_gives_one_push_one_fire_and_no_cancel() {
+    let (watcher, owner, inboxes) = watched_pair(&[&["first"], &["first"]]).await;
+    mirror(&watcher, &owner, &inboxes, 0).await;
+    mirror(&watcher, &owner, &inboxes, 1).await;
+    let pushes = owner.metrics.deferred_polls.readiness_pushes.get();
+    let targets = (0..2)
+        .map(|partition| readiness_target(&watcher, partition, PollingStrategy::offset(1)))
+        .collect();
+    let (ready, ()) = futures::join!(
+        watcher.wait_for_messages(readiness_caller(), targets, PARTITION_READ_TIMEOUT, 1),
+        async {
+            pump(&watcher, &inboxes[0]).await;
+            commit(&owner, &["second"], 2).await;
+            pump(&owner, &inboxes[1]).await;
+            pump(&watcher, &inboxes[0]).await;
+        }
+    );
+    assert_eq!(ids(&ready.unwrap()), [0]);
+    assert_eq!(
+        owner.metrics.deferred_polls.readiness_pushes.get(),
+        pushes + 2,
+        "the park and the commit push both partitions once, in one frame"
+    );
+    assert_eq!(watcher.metrics.deferred_polls.filled.get(), 1);
+    assert!(
+        watcher
+            .metrics
+            .scrape()
+            .lines()
+            .any(|line| line == "readiness_push_to_fire_seconds_count 1")
+    );
+    assert!(
+        inboxes[1].main.try_recv().is_err(),
+        "nothing but the news of the park goes to the owner"
+    );
+    assert!(inboxes[0].main.try_recv().is_err(), "no cancel");
+    assert_released(&watcher);
+}
+
+#[compio::test]
+async fn only_a_mirror_that_lags_an_owner_install_is_not_ready() {
+    for cause in ["install", "unstable", "view", "incarnation"] {
+        let (watcher, owner, inboxes) = watched_pair(&[&["first"]]).await;
+        // A first wait of the group starts pushes that carry its owner.
+        mirror_on(
+            &watcher,
+            &owner,
+            &inboxes,
+            group_target(&watcher, 0, PollingStrategy::offset(0)),
+        )
+        .await;
+        let target = group_target(&watcher, 0, PollingStrategy::offset(1));
+        let ready = wait_through_on(&watcher, &owner, &inboxes, target, async || match cause {
+            "install" => {
+                change_mirror(&watcher, |state| {
+                    state.visibility.offset = Some(1);
+                    state.owners = vec![(0, None)];
+                });
+                pump(&watcher, &inboxes[0]).await;
+                assert_eq!(parked_waits(&watcher), 1, "a lagging install waits");
+                commit(&owner, &["second"], 2).await;
+            }
+            "unstable" => {
+                begin_state_transfer(&owner, 0);
+                owner.sweep_readiness_watches();
+            }
+            // The push that the park asked for comes first, so the change stays.
+            "view" => {
+                pump(&owner, &inboxes[1]).await;
+                pump(&watcher, &inboxes[0]).await;
+                change_mirror(&watcher, |state| state.status.view_started = false);
+            }
+            _ => {
+                pump(&owner, &inboxes[1]).await;
+                pump(&watcher, &inboxes[0]).await;
+                change_mirror(&watcher, |state| state.status.incarnation = None);
+            }
+        })
+        .await;
+        if cause == "install" {
+            assert_eq!(ids(&ready.unwrap()), [0]);
+        } else {
+            assert!(
+                matches!(ready, Err(IggyError::TransientNotAccepted)),
+                "{cause} fails the wait"
+            );
+        }
+        assert_released(&watcher);
+    }
+}
+
+#[compio::test]
+async fn deferred_read_commits_only_before_its_request_deadline() {
+    for late in [false, true] {
+        let (watcher, owner, inboxes) = watched_pair(&[&["first"]]).await;
+        let read = fetch_read(&watcher, 0, 0, DEFAULT_POLL_MAX_BYTES, true);
+        let consumer = read.consumer;
+        let (reply, ()) = futures::join!(
+            watcher.deferred_read(namespace(), read, REQUEST_DEADLINE),
+            async {
+                if late {
+                    owner.bus.now.set(REQUEST_DEADLINE);
+                }
+                pump(&owner, &inboxes[1]).await;
+            }
+        );
+        if late {
+            assert!(matches!(
+                reply,
+                Some(PartitionReadReply::Rejected(
+                    IggyError::TransientNotCommitted
+                ))
+            ));
+            assert_eq!(stored_offset(&owner, consumer), None);
+        } else {
+            assert!(matches!(
+                reply,
+                Some(PartitionReadReply::Poll {
+                    next_offset: Some(1),
+                    ..
+                })
+            ));
+            assert_eq!(stored_offset(&owner, consumer), Some(0));
+        }
+    }
+    let (watcher, _owner, inboxes) = watched_pair(&[&["first"]]).await;
+    watcher.bus.now.set(REQUEST_DEADLINE);
+    let read = fetch_read(&watcher, 0, 0, DEFAULT_POLL_MAX_BYTES, true);
+    assert!(
+        watcher
+            .deferred_read(namespace(), read, REQUEST_DEADLINE)
+            .await
+            .is_none()
+    );
+    assert!(
+        inboxes[1].main.try_recv().is_err(),
+        "a read without time stays"
+    );
+}
+
+#[compio::test]
+async fn deferred_read_at_the_end_names_its_start_as_next_offset() {
+    let (watcher, owner, inboxes) = watched_pair(&[&["first"]]).await;
+    let read = fetch_read(&watcher, 0, 1, DEFAULT_POLL_MAX_BYTES, true);
+    let (reply, ()) = futures::join!(
+        watcher.deferred_read(namespace(), read, REQUEST_DEADLINE),
+        pump(&owner, &inboxes[1])
+    );
+    let Some(PartitionReadReply::Poll {
+        fragments,
+        next_offset,
+        ..
+    }) = reply
+    else {
+        panic!("a read without messages still returns a poll");
+    };
+    assert!(fragments.is_empty());
+    assert_eq!(next_offset, Some(1));
+}
+
+#[compio::test]
+async fn empty_read_before_the_retained_offsets_moves_to_the_first_one() {
+    let owner = owner(&["first", "second"]).await;
+    evict_messages_for_disk_dispatch(&owner, namespace());
+    // As after retention deleted offsets 0 and 1: the log starts at offset 2.
+    let partitions = owner.plane.partitions();
+    let mut partition = partitions.remove(&namespace()).unwrap();
+    partition.log.active_segment_mut().start_offset = 2;
+    partitions.insert(namespace(), partition);
+    let mut read = request(&owner, 1, false, false);
+    read.args = PollingArgs::new(PollingStrategy::offset(0), 1, false);
+    let consumer = read.consumer;
+    let replies = submit(&owner, read).await;
+    assert_eq!(owner.bus.spawned_tasks.borrow().len(), 1, "a disk read");
+    owner.bus.spawned_tasks.borrow_mut().clear();
+    owner.bus.now.set(DEADLINE);
+    // What a disk read of the empty active segment finds.
+    let nothing = partitions
+        .build_poll_snapshot(
+            &namespace(),
+            consumer,
+            &PollingArgs::new(PollingStrategy::offset(2), 1, false),
+        )
+        .unwrap()
+        .execute_resident();
+    owner.on_deferred_poll_completed(1, nothing).await;
+    let Ok(PartitionReadReply::Poll {
+        fragments,
+        next_offset,
+        ..
+    }) = replies.try_recv()
+    else {
+        panic!("the expired poll returns what it read");
+    };
+    assert!(fragments.is_empty());
+    assert_eq!(next_offset, Some(2));
+}
diff --git a/core/shard/src/poll/test_support.rs b/core/shard/src/poll/test_support.rs
index 38a0ec8..d5940de 100644
--- a/core/shard/src/poll/test_support.rs
+++ b/core/shard/src/poll/test_support.rs
@@ -19,16 +19,21 @@
 
 use consensus::{LocalPipeline, Sequencer, VsrConsensus, oneshot_channel};
 use futures::FutureExt;
+use iggy_binary_protocol::requests::consumer_offsets::StoreConsumerOffsetRequest;
 use iggy_binary_protocol::requests::partitions::{
     InstallConsumerGroupOwnerRequest, TransitionPartitionHistoryRequest,
 };
-use iggy_binary_protocol::{Command, Operation, RoutedRequestHeader, WireEncode};
+use iggy_binary_protocol::{
+    AckLevel, Command, Operation, RoutedRequestHeader, WireConsumer, WireEncode, WireIdentifier,
+};
 use iggy_common::{IggyByteSize, PartitionStats, variadic};
 use message_bus::MessageBus;
 use metadata::MuxStateMachine;
 use metadata::stm::stream::Streams;
 use metadata::stm::user::Users;
-use partitions::{IggyPartition, Partition, PartitionPathLayout, PartitionsConfig};
+use partitions::{
+    IggyPartition, Partition, PartitionPathLayout, PartitionsConfig, PollingConsumer,
+};
 use server_common::send_messages::{
     IggyMessage, IggyMessageHeader, IggyMessages, SendMessagesOwned,
 };
@@ -163,6 +168,84 @@
     );
 }
 
+/// The client of a [`store_consumer_offset`] of a standalone consumer, apart
+/// from the one that appends.
+const STORING_CLIENT: u128 = 2;
+
+/// Stores `offset` for `consumer` as a client store does, through the commit
+/// of the partition with `ack`. `request` numbers the store among the
+/// storing client's requests.
+#[allow(clippy::future_not_send)]
+pub(super) async fn store_consumer_offset<B: MessageBus + Clone>(
+    partition: &mut IggyPartition<B>,
+    config: &PartitionsConfig,
+    namespace: IggyNamespace,
+    (consumer, offset, ack): (PollingConsumer, u64, AckLevel),
+    (request, metadata_watermark): (u64, u64),
+) {
+    // A group offset takes a store only from its installed owner.
+    let (wire_consumer, client, owner_generation) = match consumer {
+        PollingConsumer::Consumer(id, _) => (
+            WireConsumer::consumer(WireIdentifier::numeric(u32::try_from(id).unwrap())),
+            STORING_CLIENT,
+            0,
+        ),
+        PollingConsumer::ConsumerGroup(group, _) => {
+            let owner = partition
+                .consumer_group_owner(group as u64)
+                .expect("the fixture installs the group owner");
+            (
+                WireConsumer::consumer_group(WireIdentifier::numeric(
+                    u32::try_from(group).unwrap(),
+                )),
+                owner.client_id,
+                owner.generation,
+            )
+        }
+    };
+    let body = StoreConsumerOffsetRequest {
+        consumer: wire_consumer,
+        stream_id: WireIdentifier::numeric(u32::try_from(namespace.stream_id()).unwrap()),
+        topic_id: WireIdentifier::numeric(u32::try_from(namespace.topic_id()).unwrap()),
+        partition_id: Some(u32::try_from(namespace.partition_id()).unwrap()),
+        offset,
+        ack,
+    }
+    .to_bytes();
+    let size = size_of::<RoutedRequestHeader>() + body.len();
+    let mut message = server_common::Message::<RoutedRequestHeader>::new(size);
+    message.as_mut_slice()[size_of::<RoutedRequestHeader>()..].copy_from_slice(&body);
+    let message = message.transmute_header::<RoutedRequestHeader>(|_, header| {
+        *header = RoutedRequestHeader {
+            command: Command::Request,
+            operation: Operation::StoreConsumerOffset,
+            client,
+            session: 1,
+            request,
+            size: u32::try_from(size).unwrap(),
+            group: namespace.inner(),
+            partition_incarnation: partition.created_revision(),
+            owner_generation,
+            metadata_watermark,
+            ..Default::default()
+        };
+    });
+    let (reply, stored) = oneshot_channel();
+    partition.on_request(message, Some(reply)).await;
+    if ack != AckLevel::NoAck {
+        let op = partition.consensus().sequencer().current_sequence();
+        partition.consensus().advance_commit_max(op);
+        partition.commit_journal(config).await;
+    }
+    let stored = stored.now_or_never();
+    assert!(
+        matches!(&stored, Some(Ok(reply)) if reply.header().status == 0),
+        "the fixture store {consumer:?} with {ack:?} was committed: {:?}",
+        stored.map(|reply| reply.map(|reply| reply.header().status))
+    );
+    assert_eq!(partition.get_consumer_offset(consumer), Some(offset));
+}
+
 #[allow(clippy::future_not_send)]
 pub(super) async fn install_group_owner<B: MessageBus + Clone>(
     partition: &mut IggyPartition<B>,
diff --git a/core/shard/src/router.rs b/core/shard/src/router.rs
index 9b918ae..6fb87ff 100644
--- a/core/shard/src/router.rs
+++ b/core/shard/src/router.rs
@@ -324,7 +324,7 @@
         SB: 'static,
     {
         let _completion_pump = self.poll_completions.pump_guard();
-        let _deferred_pump = self.deferred_polls.pump_guard();
+        let _deferred_pump = self.deferred_polls.pump_guard(self.id, &self.senders);
         self.plane.partitions().set_io_notifier(
             self.partition_io.notifier(),
             self.partition_io.limits.bytes_max(),
@@ -386,6 +386,9 @@
         let mut deferred_deadline = std::pin::pin!(deadline_timer(None));
         loop {
             self.service_deferred_polls().await;
+            // Here and not at the end of the turn: an idle owner would hold
+            // the pushes of this turn until its next event or tick.
+            self.flush_readiness_pushes();
             let next_deadline = self.deferred_polls.earliest_deadline();
             if next_deadline != armed_deadline {
                 armed_deadline = next_deadline;
@@ -431,7 +434,8 @@
                     // a quiet shard until the next inbound frame's tail
                     // drain; parked partition frames then never re-dispatch.
                     self.apply_reconcile_ops();
-                    self.deferred_polls.maintenance();
+                    self.tick_deferred_polls();
+                    self.sweep_readiness_watches();
                     consensus_tick.set(rearm_tick());
                     self.process_loopback(&mut loopback_buf).await;
                 }
@@ -538,7 +542,7 @@
         }
 
         self.poll_completions.close();
-        self.deferred_polls.close();
+        self.deferred_polls.close(self.id, &self.senders);
 
         // A stop can win the select immediately after a frame fenced a
         // partition, before the next tick observes it. Preserve that fault so
@@ -1035,12 +1039,25 @@
             } => {
                 self.on_partition_read(namespace, read, reply).await;
             }
-            LifecycleFrame::ReadinessWait { wait, reply } => {
-                self.admit_readiness_wait(wait, &reply);
+            LifecycleFrame::ReadinessWait { wait, reply, watch } => {
+                self.admit_readiness_wait(*wait, &reply, watch);
             }
             LifecycleFrame::ReadinessCancel { waiter } => {
                 self.cancel_readiness_wait(waiter);
             }
+            LifecycleFrame::ReadinessUpdate { from, states } => {
+                self.apply_readiness_update(from, states);
+            }
+            LifecycleFrame::ReadinessUnwatch { from, watches } => {
+                self.unwatch_readiness(from, watches);
+            }
+            LifecycleFrame::ReadinessInterest {
+                from,
+                watches,
+                parked,
+            } => {
+                self.set_readiness_interest(from, watches, parked);
+            }
             LifecycleFrame::PartitionSubmit {
                 request,
                 reply,
diff --git a/core/simulator/src/client.rs b/core/simulator/src/client.rs
index 6c12e6f..a0ce46d 100644
--- a/core/simulator/src/client.rs
+++ b/core/simulator/src/client.rs
@@ -16,7 +16,10 @@
 // under the License.
 
 use bytes::{Bytes, BytesMut};
-use iggy_binary_protocol::codes::{GET_STREAM_CODE, POLL_MESSAGES_CODE};
+use iggy_binary_protocol::codes::{
+    CANCEL_DEFERRED_REQUEST_CODE, FETCH_MESSAGES_CODE, GET_STREAM_CODE, POLL_MESSAGES_CODE,
+    POLL_MESSAGES_DEFERRED_CODE,
+};
 use iggy_binary_protocol::primitives::consumer::WireConsumer;
 use iggy_binary_protocol::primitives::partition_history::PartitionContext;
 use iggy_binary_protocol::requests::consumer_groups::{
@@ -27,7 +30,8 @@
     DeleteConsumerOffsetRequest, StoreConsumerOffsetRequest,
 };
 use iggy_binary_protocol::requests::messages::{
-    PollMessagesRequest, RawMessage, SendMessagesEncoder,
+    DeferredPollMessagesRequest, FetchMessagesRequest, FetchMessagesTarget, PollMessagesRequest,
+    RawMessage, SendMessagesEncoder,
 };
 use iggy_binary_protocol::requests::partitions::{
     CreatePartitionsRequest, DeletePartitionsRequest,
@@ -53,6 +57,7 @@
     AckLevel, ClientVersionInfo, IGGY_PROTOCOL_VERSION, Operation, RoutedRequestHeader, WireEncode,
     WireIdentifier, WireName, WireOptions, WirePartitioning, WirePermissions, WirePollingStrategy,
 };
+use iggy_common::DEFAULT_POLL_MAX_BYTES;
 use iggy_common::wire_conversions::resource_options_to_wire;
 use iggy_common::{
     HeaderKey, HeaderValue, OptionKey, OptionValue, OptionsPatch, OptionsProvenance,
@@ -859,6 +864,134 @@
         )
     }
 
+    /// Build a `POLL_MESSAGES_DEFERRED` request for an individual consumer of
+    /// `group`'s partition. Spends a request id, as every bound read does, so
+    /// the reply and a cancellation can name it.
+    ///
+    /// # Panics
+    /// Panics if the session is unbound or the request buffer is invalid.
+    #[allow(clippy::cast_possible_truncation)]
+    pub fn deferred_poll_messages(
+        &self,
+        group: IggyNamespace,
+        strategy: WirePollingStrategy,
+        (count, min_count): (u32, u32),
+        auto_commit: bool,
+        (wait_us, request_timeout_us): (u64, u64),
+    ) -> Message<RoutedRequestHeader> {
+        self.deferred_poll_messages_for_consumer(
+            group,
+            strategy,
+            (count, min_count),
+            auto_commit,
+            (wait_us, request_timeout_us),
+            self.client_id as u32,
+        )
+    }
+
+    /// Build a deferred read of another session's standalone consumer.
+    pub fn deferred_poll_messages_for_consumer(
+        &self,
+        group: IggyNamespace,
+        strategy: WirePollingStrategy,
+        (count, min_count): (u32, u32),
+        auto_commit: bool,
+        (wait_us, request_timeout_us): (u64, u64),
+        consumer_id: u32,
+    ) -> Message<RoutedRequestHeader> {
+        let (stream_id, topic_id, partition_id) = namespace_ids(group);
+        let body = DeferredPollMessagesRequest {
+            poll: PollMessagesRequest {
+                consumer: WireConsumer::consumer(WireIdentifier::Numeric(consumer_id)),
+                stream_id,
+                topic_id,
+                partition_id,
+                strategy,
+                count,
+                auto_commit,
+            },
+            wait_us,
+            min_count,
+            max_bytes: DEFAULT_POLL_MAX_BYTES,
+            request_timeout_us,
+        }
+        .to_bytes();
+        self.non_replicated_request(
+            POLL_MESSAGES_DEFERRED_CODE,
+            group.inner(),
+            &body,
+            self.next_request_id(),
+        )
+    }
+
+    /// Build a `FETCH_MESSAGES` request of the client's own consumer for
+    /// `partitions` of one stream and topic, each read from `strategy` with
+    /// the partition context the client discovered. Spends a request id, so
+    /// the reply and a cancellation can name it.
+    ///
+    /// # Panics
+    /// Panics if `partitions` is empty, the session is unbound or the request
+    /// buffer is invalid.
+    #[allow(clippy::cast_possible_truncation)]
+    pub fn fetch_messages(
+        &self,
+        partitions: &[IggyNamespace],
+        strategy: &WirePollingStrategy,
+        (count, min_count): (u32, u32),
+        auto_commit: bool,
+        (wait_us, request_timeout_us): (u64, u64),
+    ) -> Message<RoutedRequestHeader> {
+        let first = *partitions.first().expect("a fetch names a partition");
+        let (stream_id, topic_id, _) = namespace_ids(first);
+        let contexts = self.partition_contexts.borrow();
+        let targets = partitions
+            .iter()
+            .map(|namespace| {
+                let context = contexts.get(namespace).copied().unwrap_or_default();
+                FetchMessagesTarget {
+                    partition_id: namespace.partition_id() as u32,
+                    strategy: strategy.clone(),
+                    max_bytes: DEFAULT_POLL_MAX_BYTES,
+                    incarnation: context.incarnation,
+                    owner_generation: context.owner_generation,
+                }
+            })
+            .collect();
+        drop(contexts);
+        let body = FetchMessagesRequest {
+            consumer: WireConsumer::consumer(WireIdentifier::Numeric(self.client_id as u32)),
+            stream_id,
+            topic_id,
+            wait_us,
+            request_timeout_us,
+            min_count,
+            count,
+            max_bytes: DEFAULT_POLL_MAX_BYTES,
+            auto_commit,
+            targets,
+        }
+        .to_bytes();
+        self.non_replicated_request(
+            FETCH_MESSAGES_CODE,
+            first.inner(),
+            &body,
+            self.next_request_id(),
+        )
+    }
+
+    /// Build a `CANCEL_DEFERRED_REQUEST` for the deferred request this client
+    /// sent with `request`, as the SDK sends it: same connection, empty body.
+    ///
+    /// # Panics
+    /// Panics if the session is unbound or the request buffer is invalid.
+    pub fn cancel_deferred_request(
+        &self,
+        group: IggyNamespace,
+        request: u64,
+    ) -> Message<RoutedRequestHeader> {
+        self.non_replicated_request(CANCEL_DEFERRED_REQUEST_CODE, group.inner(), &[], request)
+    }
+
     /// Build a `POLL_MESSAGES` request reading `count` messages from `offset` of
     /// `group`'s partition as `consumer_kind`/`consumer_id`, without auto-commit.
     ///
diff --git a/core/simulator/src/deferred_commit_tests.rs b/core/simulator/src/deferred_commit_tests.rs
new file mode 100644
index 0000000..a9181af
--- /dev/null
+++ b/core/simulator/src/deferred_commit_tests.rs
@@ -0,0 +1,773 @@
+// 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.
+
+//! A committing `Next` read stores at most once, and only while the stored
+//! offset is still the one it was admitted against.
+//!
+//! One client reads one partition with `POLL_MESSAGES_DEFERRED` plus
+//! `auto_commit`, through the dispatch shell, so the origin and the owner run
+//! the production code. The cases compose what the shard and session unit
+//! tests prove one piece at a time: a reply lost on the wire after the owner
+//! stored, a read that commits after a later read already moved the offset, a
+//! cancellation that wins or loses against the store, a rewind racing a parked
+//! read, and a request that waited in the connection drain past its timeout.
+
+use bytes::Bytes;
+use consensus::PartitionsHandle;
+use iggy_binary_protocol::responses::messages::PollMessagesResponse;
+use iggy_binary_protocol::responses::messages::fetch_messages::{
+    FetchMessagesResponse, FetchMessagesSectionHeader,
+};
+use iggy_binary_protocol::{
+    AckLevel, Command, KIND_CONSUMER, ReplyHeader, RoutedRequestHeader, WirePollingStrategy,
+};
+use iggy_common::IggyError;
+use partitions::{Partition, PollingConsumer};
+use server_common::Message;
+use server_common::sharding::IggyNamespace;
+use shard::CONSENSUS_TICK_INTERVAL;
+
+use crate::Simulator;
+use crate::client::SimClient;
+use crate::packet::{Packet, PacketSimulatorOptions, ProcessId};
+
+const CLIENT: u128 = 1;
+/// A second session that stores for the first client's consumer.
+const OTHER_CLIENT: u128 = 2;
+const PRIMARY: u8 = 0;
+const REPLICAS: u8 = 3;
+const SEED: u64 = 0xD4C0_1117;
+const PARTITION: usize = 0;
+/// Steps a reply or a store is given before the case fails.
+const REPLY_STEPS: usize = 400;
+/// Steps the dropped reply is given to reach its link and die there: well past the
+/// mean one way delay, which is a few ticks.
+const LOST_WINDOW_STEPS: usize = 60;
+/// A parked read waits this long, longer than any case runs.
+const PARKED_WAIT_US: u64 = 5_000_000;
+const PARKED_TIMEOUT_US: u64 = 7_000_000;
+/// A read that must not wait still carries a timeout the owner can enforce.
+const QUICK_TIMEOUT_US: u64 = 2_000_000;
+/// The queued read of the drain case gives up this long after its frame arrived.
+const QUEUED_TIMEOUT_US: u64 = 1_500_000;
+
+/// (a) The lost read committed first: the owner stored, the reply died on the
+/// wire, and the client's next `Next` read starts after the stored offset, so
+/// the gap it never saw shows as a first offset above its last delivered plus
+/// one. The stored offset moved exactly once for the lost read.
+#[test]
+fn given_a_committing_read_whose_reply_is_lost_when_the_next_read_runs_should_start_after_the_single_store()
+ {
+    let (mut sim, client, _) = cluster();
+    let mut replies = Vec::new();
+    produce(&mut sim, &client, &["m0", "m1", "m2", "m3"], &mut replies);
+    assert_eq!(stored_offset(&sim), None);
+
+    drop_replies(&mut sim, true);
+    let lost = submit(
+        &mut sim,
+        &client,
+        next_read(&client, (2, 1), (0, QUICK_TIMEOUT_US)),
+    );
+    assert!(
+        (0..REPLY_STEPS).any(|_| {
+            replies.extend(sim.step());
+            stored_offset(&sim) == Some(1)
+        }),
+        "the lost read must store the offset of its last message"
+    );
+    settle(&mut sim, &mut replies);
+    drop_replies(&mut sim, false);
+    assert_eq!(stored_offset(&sim), Some(1), "one store for the lost read");
+
+    let next = submit(
+        &mut sim,
+        &client,
+        next_read(&client, (2, 1), (0, QUICK_TIMEOUT_US)),
+    );
+    let reply = await_reply(&mut sim, (CLIENT, next), &mut replies);
+    assert_eq!(reply.header().status, 0, "the next read is served");
+    assert_eq!(
+        offsets_of(&reply),
+        vec![2, 3],
+        "the next read starts after the lost read's store, so the gap 0..1 is visible"
+    );
+    assert_eq!(stored_offset(&sim), Some(3));
+    assert!(
+        replies.iter().all(|reply| reply.header().request != lost),
+        "the lost reply never reached the client"
+    );
+}
+
+/// (a) The next read committed first: the lost read was still parked at the
+/// owner when the client's next read stored, so its late commit finds the
+/// stored offset moved since its admission and is refused with 58.
+#[test]
+fn given_a_parked_committing_read_when_a_later_read_stores_first_should_refuse_its_late_commit() {
+    let (mut sim, client, other) = cluster();
+    let mut replies = Vec::new();
+    produce(&mut sim, &client, &["m0", "m1"], &mut replies);
+
+    let parked = submit(
+        &mut sim,
+        &client,
+        next_read(&client, (4, 4), (PARKED_WAIT_US, PARKED_TIMEOUT_US)),
+    );
+    settle(&mut sim, &mut replies);
+    assert!(
+        replies.is_empty(),
+        "two messages under min_count 4 park the read"
+    );
+    assert_eq!(stored_offset(&sim), None);
+
+    let next = submit(
+        &mut sim,
+        &other,
+        other.deferred_poll_messages_for_consumer(
+            namespace(),
+            WirePollingStrategy::next(),
+            (2, 1),
+            true,
+            (0, QUICK_TIMEOUT_US),
+            u32::try_from(CLIENT).expect("client id fits u32"),
+        ),
+    );
+    let reply = await_reply(&mut sim, (OTHER_CLIENT, next), &mut replies);
+    assert_eq!(reply.header().status, 0);
+    assert_eq!(offsets_of(&reply), vec![0, 1]);
+    assert_eq!(stored_offset(&sim), Some(1), "the later read stored first");
+
+    produce(&mut sim, &client, &["m2", "m3"], &mut replies);
+    let late = await_reply(&mut sim, (CLIENT, parked), &mut replies);
+    assert_eq!(
+        late.header().status,
+        IggyError::TransientNotAccepted.as_code(),
+        "the parked read's commit is fenced by the moved offset"
+    );
+    assert_eq!(
+        stored_offset(&sim),
+        Some(1),
+        "the fenced read stored nothing"
+    );
+}
+
+/// (b) The cancel wins: a parked committing read is cancelled before any read
+/// passed its gate, so it answers exactly once with 58 and the produce that
+/// would have filled it stores nothing.
+#[test]
+fn given_a_parked_committing_read_when_cancelled_before_its_store_should_answer_58_and_store_nothing()
+ {
+    let (mut sim, client, _) = cluster();
+    let mut replies = Vec::new();
+    produce(&mut sim, &client, &["m0", "m1"], &mut replies);
+
+    let parked = submit(
+        &mut sim,
+        &client,
+        next_read(&client, (4, 4), (PARKED_WAIT_US, PARKED_TIMEOUT_US)),
+    );
+    settle(&mut sim, &mut replies);
+    assert!(replies.is_empty(), "the read parks");
+
+    submit(
+        &mut sim,
+        &client,
+        client.cancel_deferred_request(namespace(), parked),
+    );
+    let cancelled = await_reply(&mut sim, (CLIENT, parked), &mut replies);
+    assert_eq!(
+        cancelled.header().status,
+        IggyError::TransientNotAccepted.as_code(),
+        "a cancelled committing read answers once with 58"
+    );
+    assert_eq!(stored_offset(&sim), None);
+
+    produce(&mut sim, &client, &["m2", "m3"], &mut replies);
+    settle(&mut sim, &mut replies);
+    assert!(
+        replies.iter().all(|reply| reply.header().request != parked),
+        "the cancelled read answers only once"
+    );
+    assert_eq!(
+        stored_offset(&sim),
+        None,
+        "the cancelled read stores nothing"
+    );
+}
+
+/// (c) The cancel loses: the owner stored before the cancellation arrived, so
+/// the read answers as usual, the store stands and no 58 follows.
+#[test]
+fn given_a_committing_read_that_stored_when_its_cancel_arrives_late_should_keep_the_store_and_answer_once()
+ {
+    let (mut sim, client, _) = cluster();
+    let mut replies = Vec::new();
+    produce(&mut sim, &client, &["m0", "m1"], &mut replies);
+
+    let parked = submit(
+        &mut sim,
+        &client,
+        next_read(&client, (4, 4), (PARKED_WAIT_US, PARKED_TIMEOUT_US)),
+    );
+    settle(&mut sim, &mut replies);
+    assert!(replies.is_empty(), "the read parks");
+
+    produce(&mut sim, &client, &["m2", "m3"], &mut replies);
+    assert!(
+        (0..REPLY_STEPS).any(|_| {
+            replies.extend(sim.step());
+            stored_offset(&sim) == Some(3)
+        }),
+        "the produce fills the read and the owner stores"
+    );
+    submit(
+        &mut sim,
+        &client,
+        client.cancel_deferred_request(namespace(), parked),
+    );
+    let reply = await_reply(&mut sim, (CLIENT, parked), &mut replies);
+    assert_eq!(
+        reply.header().status,
+        0,
+        "the cancel lost, the read answers as usual"
+    );
+    assert_eq!(offsets_of(&reply), vec![0, 1, 2, 3]);
+    settle(&mut sim, &mut replies);
+    assert!(
+        replies.iter().all(|reply| reply.header().request != parked),
+        "no second reply follows the lost cancel"
+    );
+    assert_eq!(stored_offset(&sim), Some(3), "the store stands");
+}
+
+/// (d) A close rewind against a late commit: the client delivered 0..1, lost
+/// the reply of the read that stored 3, and parked a `Next` read at 4. The
+/// rewind to the last delivered message comes from another session, because
+/// the drain of the client's own connection orders a store of a consumer
+/// behind that consumer's in-flight committing read. The produce that fills
+/// the parked read must not let it commit past the rewind.
+#[test]
+fn given_a_parked_committing_read_when_another_session_rewinds_its_offset_should_refuse_the_late_commit()
+ {
+    let (mut sim, client, other) = cluster();
+    let mut replies = Vec::new();
+    produce(&mut sim, &client, &["m0", "m1", "m2", "m3"], &mut replies);
+
+    let delivered = submit(
+        &mut sim,
+        &client,
+        next_read(&client, (2, 1), (0, QUICK_TIMEOUT_US)),
+    );
+    let reply = await_reply(&mut sim, (CLIENT, delivered), &mut replies);
+    assert_eq!(offsets_of(&reply), vec![0, 1]);
+
+    drop_replies(&mut sim, true);
+    let lost = submit(
+        &mut sim,
+        &client,
+        next_read(&client, (2, 1), (0, QUICK_TIMEOUT_US)),
+    );
+    assert!(
+        (0..REPLY_STEPS).any(|_| {
+            replies.extend(sim.step());
+            stored_offset(&sim) == Some(3)
+        }),
+        "the lost read stores 3"
+    );
+    settle(&mut sim, &mut replies);
+    drop_replies(&mut sim, false);
+
+    let parked = submit(
+        &mut sim,
+        &client,
+        next_read(&client, (2, 2), (PARKED_WAIT_US, PARKED_TIMEOUT_US)),
+    );
+    settle(&mut sim, &mut replies);
+    assert!(
+        replies.iter().all(|reply| reply.header().request != parked),
+        "nothing past 3 yet, the read parks at 4"
+    );
+
+    let rewind = other.store_consumer_offset(
+        namespace(),
+        KIND_CONSUMER,
+        u32::try_from(CLIENT).expect("client id fits u32"),
+        1,
+        AckLevel::Quorum,
+    );
+    let rewind = submit(&mut sim, &other, rewind);
+    let rewound = await_reply(&mut sim, (OTHER_CLIENT, rewind), &mut replies);
+    assert_eq!(rewound.header().status, 0, "the rewind commits");
+    assert_eq!(stored_offset(&sim), Some(1));
+
+    produce(&mut sim, &client, &["m4", "m5"], &mut replies);
+    let late = await_reply(&mut sim, (CLIENT, parked), &mut replies);
+    assert_eq!(
+        late.header().status,
+        IggyError::TransientNotAccepted.as_code(),
+        "the read admitted at 4 is fenced by the rewind to 1"
+    );
+    assert_eq!(stored_offset(&sim), Some(1), "the rewound offset stands");
+    assert!(
+        replies.iter().all(|reply| reply.header().request != lost),
+        "the lost reply never reached the client"
+    );
+}
+
+/// (e) A committing read waits in the connection drain behind an earlier
+/// committing read of the same consumer. The earlier read outlives the next
+/// request's deadline, so admission denies the queued request before it reads.
+#[test]
+fn given_a_committing_read_queued_past_its_timeout_when_the_drain_serves_it_should_deny_it_unread()
+{
+    let (mut sim, client, _) = cluster();
+    let mut replies = Vec::new();
+    produce(&mut sim, &client, &["m0", "m1", "m2", "m3"], &mut replies);
+    let first = submit(
+        &mut sim,
+        &client,
+        next_read(&client, (2, 1), (0, QUICK_TIMEOUT_US)),
+    );
+    let reply = await_reply(&mut sim, (CLIENT, first), &mut replies);
+    assert_eq!(offsets_of(&reply), vec![0, 1]);
+
+    let parked = submit(
+        &mut sim,
+        &client,
+        next_read(&client, (4, 4), (PARKED_WAIT_US, PARKED_TIMEOUT_US)),
+    );
+    settle(&mut sim, &mut replies);
+    assert!(replies.is_empty(), "the first read parks");
+    let queued = submit(
+        &mut sim,
+        &client,
+        next_read(&client, (2, 1), (0, QUEUED_TIMEOUT_US)),
+    );
+    for _ in 0..=steps_of(QUEUED_TIMEOUT_US) {
+        replies.extend(sim.step());
+    }
+    assert!(
+        replies.iter().all(|reply| reply.header().request != queued),
+        "the queued read waits behind the first read's progress lane"
+    );
+    assert_eq!(
+        stored_offset(&sim),
+        Some(1),
+        "nothing new was read while queued"
+    );
+
+    let parked_reply = await_reply(&mut sim, (CLIENT, parked), &mut replies);
+    assert_eq!(parked_reply.header().status, 0);
+    assert_eq!(offsets_of(&parked_reply), vec![2, 3]);
+
+    let denied = await_reply(&mut sim, (CLIENT, queued), &mut replies);
+    assert_eq!(
+        denied.header().status,
+        IggyError::TransientNotAccepted.as_code(),
+        "a read served after its client gave up is denied unread"
+    );
+    assert_eq!(
+        stored_offset(&sim),
+        Some(3),
+        "the denied read stored nothing"
+    );
+}
+
+/// (f) A close rewind that stores the very offset a parked read was admitted
+/// against: a half-open link keeps the parked read alive, the rewind comes
+/// from another session, and the produce that fills the read must not let it
+/// commit past messages the client never got.
+#[test]
+fn given_a_parked_committing_read_when_another_session_stores_its_admitted_offset_should_refuse_the_late_commit()
+ {
+    let (mut sim, client, other) = cluster();
+    let mut replies = Vec::new();
+    produce(&mut sim, &client, &["m0", "m1"], &mut replies);
+    let delivered = submit(
+        &mut sim,
+        &client,
+        next_read(&client, (2, 1), (0, QUICK_TIMEOUT_US)),
+    );
+    let reply = await_reply(&mut sim, (CLIENT, delivered), &mut replies);
+    assert_eq!(offsets_of(&reply), vec![0, 1]);
+    assert_eq!(stored_offset(&sim), Some(1));
+
+    let parked = submit(
+        &mut sim,
+        &client,
+        next_read(&client, (2, 2), (PARKED_WAIT_US, PARKED_TIMEOUT_US)),
+    );
+    settle(&mut sim, &mut replies);
+    assert!(
+        replies.iter().all(|reply| reply.header().request != parked),
+        "nothing past 1 yet, the read parks at 2"
+    );
+
+    let rewind = submit(&mut sim, &other, rewind_to(&other, 1));
+    let rewound = await_reply(&mut sim, (OTHER_CLIENT, rewind), &mut replies);
+    assert_eq!(rewound.header().status, 0, "the rewind commits");
+
+    produce(&mut sim, &other, &["m2", "m3"], &mut replies);
+    let late = await_reply(&mut sim, (CLIENT, parked), &mut replies);
+    assert_eq!(
+        late.header().status,
+        IggyError::TransientNotAccepted.as_code(),
+        "a store of the admitted offset fences the read"
+    );
+    assert_eq!(stored_offset(&sim), Some(1), "the rewound offset stands");
+}
+
+/// (f) for a fetch, which resolves its start and then waits with no read at
+/// the owner: the store of the same offset during that wait fences the read
+/// the fetch sends once data comes.
+#[test]
+fn given_a_parked_committing_fetch_when_another_session_stores_its_resolved_offset_should_refuse_the_late_commit()
+ {
+    let (mut sim, client, other) = cluster();
+    let mut replies = Vec::new();
+    produce(&mut sim, &client, &["m0", "m1"], &mut replies);
+    let delivered = submit(
+        &mut sim,
+        &client,
+        fetch_read(&client, (2, 1), (0, QUICK_TIMEOUT_US)),
+    );
+    let reply = await_reply(&mut sim, (CLIENT, delivered), &mut replies);
+    assert_eq!(only_section(&reply).next_offset, 2);
+    assert_eq!(stored_offset(&sim), Some(1));
+
+    let parked = submit(
+        &mut sim,
+        &client,
+        fetch_read(&client, (2, 2), (PARKED_WAIT_US, PARKED_TIMEOUT_US)),
+    );
+    settle(&mut sim, &mut replies);
+    assert!(
+        replies.iter().all(|reply| reply.header().request != parked),
+        "nothing past 1 yet, the fetch waits at 2"
+    );
+
+    let rewind = submit(&mut sim, &other, rewind_to(&other, 1));
+    let rewound = await_reply(&mut sim, (OTHER_CLIENT, rewind), &mut replies);
+    assert_eq!(rewound.header().status, 0, "the rewind commits");
+
+    produce(&mut sim, &other, &["m2", "m3"], &mut replies);
+    let late = await_reply(&mut sim, (CLIENT, parked), &mut replies);
+    let section = only_section(&late);
+    assert_eq!(
+        (section.status, section.messages_count),
+        (IggyError::TransientNotAccepted.as_code(), 0),
+        "a store of the resolved offset fences the read"
+    );
+    assert_eq!(stored_offset(&sim), Some(1), "the rewound offset stands");
+}
+
+/// (g) A committing read parks, then the connection drops before any data
+/// comes. The produce that would fill the read stores nothing.
+#[test]
+fn given_a_parked_committing_read_when_its_connection_drops_should_store_nothing() {
+    for fetch in [false, true] {
+        let (mut sim, client, other) = cluster();
+        let mut replies = Vec::new();
+        produce(&mut sim, &client, &["m0", "m1"], &mut replies);
+        let read = if fetch {
+            fetch_read(&client, (4, 4), (PARKED_WAIT_US, PARKED_TIMEOUT_US))
+        } else {
+            next_read(&client, (4, 4), (PARKED_WAIT_US, PARKED_TIMEOUT_US))
+        };
+        let parked = submit(&mut sim, &client, read);
+        settle(&mut sim, &mut replies);
+        assert!(replies.is_empty(), "the read parks, fetch {fetch}");
+
+        sim.outboxes[usize::from(PRIMARY)].notify_client_connection_lost(CLIENT);
+        sim.run_pumps();
+        produce(&mut sim, &other, &["m2", "m3"], &mut replies);
+        settle(&mut sim, &mut replies);
+
+        assert_eq!(
+            stored_offset(&sim),
+            None,
+            "a read whose connection dropped stores nothing, fetch {fetch}"
+        );
+        assert!(
+            replies
+                .iter()
+                .filter(|reply| reply.header().request == parked)
+                .all(|reply| reply.header().status == IggyError::TransientNotAccepted.as_code()),
+            "the dropped read can only answer that it stored nothing, fetch {fetch}"
+        );
+    }
+}
+
+/// (h) A committing fetch whose reply is lost after the owner stored: the
+/// next fetch starts after that one store.
+#[test]
+fn given_a_committing_fetch_whose_reply_is_lost_when_the_next_fetch_runs_should_start_after_the_single_store()
+ {
+    let (mut sim, client, _) = cluster();
+    let mut replies = Vec::new();
+    produce(&mut sim, &client, &["m0", "m1", "m2", "m3"], &mut replies);
+
+    drop_replies(&mut sim, true);
+    let lost = submit(
+        &mut sim,
+        &client,
+        fetch_read(&client, (2, 1), (0, QUICK_TIMEOUT_US)),
+    );
+    assert!(
+        (0..REPLY_STEPS).any(|_| {
+            replies.extend(sim.step());
+            stored_offset(&sim) == Some(1)
+        }),
+        "the lost fetch stores the offset of its last message"
+    );
+    settle(&mut sim, &mut replies);
+    drop_replies(&mut sim, false);
+
+    let next = submit(
+        &mut sim,
+        &client,
+        fetch_read(&client, (2, 1), (0, QUICK_TIMEOUT_US)),
+    );
+    let reply = await_reply(&mut sim, (CLIENT, next), &mut replies);
+    assert_eq!(reply.header().status, 0, "the next fetch is served");
+    let section = only_section(&reply);
+    assert_eq!(
+        (section.status, section.messages_count, section.next_offset),
+        (0, 2, 4),
+        "the next fetch reads 2 and 3, after the lost fetch's store"
+    );
+    assert_eq!(stored_offset(&sim), Some(3));
+    assert!(
+        replies.iter().all(|reply| reply.header().request != lost),
+        "the lost reply never reached the client"
+    );
+}
+
+/// (i) A fetch that parked before a cancel answers 58 and stores nothing,
+/// also once data comes.
+#[test]
+fn given_a_parked_committing_fetch_when_cancelled_should_answer_58_and_store_nothing() {
+    let (mut sim, client, other) = cluster();
+    let mut replies = Vec::new();
+    produce(&mut sim, &client, &["m0", "m1"], &mut replies);
+    let parked = submit(
+        &mut sim,
+        &client,
+        fetch_read(&client, (4, 4), (PARKED_WAIT_US, PARKED_TIMEOUT_US)),
+    );
+    settle(&mut sim, &mut replies);
+    assert!(replies.is_empty(), "the fetch parks");
+
+    submit(
+        &mut sim,
+        &client,
+        client.cancel_deferred_request(namespace(), parked),
+    );
+    let cancelled = await_reply(&mut sim, (CLIENT, parked), &mut replies);
+    assert_eq!(
+        cancelled.header().status,
+        IggyError::TransientNotAccepted.as_code(),
+        "a cancelled committing fetch answers once with 58"
+    );
+    produce(&mut sim, &other, &["m2", "m3"], &mut replies);
+    settle(&mut sim, &mut replies);
+    assert!(
+        replies.iter().all(|reply| reply.header().request != parked),
+        "the cancelled fetch answers only once"
+    );
+    assert_eq!(stored_offset(&sim), None);
+}
+
+/// Three replicas of one shard each with the dispatch shell on, one seeded
+/// partition, two clients logged in against root on the primary.
+fn cluster() -> (Simulator, SimClient, SimClient) {
+    server_common::MemoryPool::init_pool(&server_common::MemoryPoolSettings {
+        enabled: false,
+        size: iggy_common::IggyByteSize::from(0u64),
+        bucket_capacity: 1,
+    });
+    let network = PacketSimulatorOptions {
+        node_count: REPLICAS,
+        client_count: 2,
+        seed: SEED,
+        ..PacketSimulatorOptions::default()
+    };
+    let mut sim = Simulator::with_shards_shell(
+        usize::from(REPLICAS),
+        1,
+        [CLIENT, OTHER_CLIENT].into_iter(),
+        network,
+    );
+    sim.init_partition(namespace());
+    sim.seed_stream_topic_partition(namespace());
+    let client = SimClient::new(CLIENT);
+    let other = SimClient::new(OTHER_CLIENT);
+    sim.shell_login(&client);
+    sim.shell_login(&other);
+    sim.discover_partition_contexts(&client);
+    sim.discover_partition_contexts(&other);
+    (sim, client, other)
+}
+
+fn produce(
+    sim: &mut Simulator,
+    client: &SimClient,
+    payloads: &[&str],
+    replies: &mut Vec<Message<ReplyHeader>>,
+) {
+    let payloads: Vec<Bytes> = payloads
+        .iter()
+        .map(|payload| Bytes::from((*payload).to_owned()))
+        .collect();
+    let request = submit(sim, client, client.send_messages(namespace(), &payloads));
+    let reply = await_reply(sim, (client.client_id(), request), replies);
+    assert_eq!(reply.header().status, 0, "the produce commits");
+}
+
+/// A committing `Next` read of the client's own consumer.
+fn next_read(
+    client: &SimClient,
+    counts: (u32, u32),
+    timers: (u64, u64),
+) -> Message<RoutedRequestHeader> {
+    client.deferred_poll_messages(
+        namespace(),
+        WirePollingStrategy::next(),
+        counts,
+        true,
+        timers,
+    )
+}
+
+/// A committing fetch of the client's own consumer from `Next`, on the one
+/// partition of the cluster.
+fn fetch_read(
+    client: &SimClient,
+    counts: (u32, u32),
+    timers: (u64, u64),
+) -> Message<RoutedRequestHeader> {
+    client.fetch_messages(
+        &[namespace()],
+        &WirePollingStrategy::next(),
+        counts,
+        true,
+        timers,
+    )
+}
+
+/// A store of the client's consumer offset by `other`, as the close rewind of
+/// a reader on a new connection.
+fn rewind_to(other: &SimClient, offset: u64) -> Message<RoutedRequestHeader> {
+    other.store_consumer_offset(
+        namespace(),
+        KIND_CONSUMER,
+        u32::try_from(CLIENT).expect("client id fits u32"),
+        offset,
+        AckLevel::Quorum,
+    )
+}
+
+/// Sends `request` to the primary and returns its wire request id.
+fn submit(sim: &mut Simulator, client: &SimClient, request: Message<RoutedRequestHeader>) -> u64 {
+    let request_id = request.header().request;
+    sim.submit_request(client.client_id(), PRIMARY, request.into_generic());
+    request_id
+}
+
+/// The reply to `request` of `client`, from those already kept or from the
+/// steps to come. Every other reply is kept.
+fn await_reply(
+    sim: &mut Simulator,
+    (client, request): (u128, u64),
+    replies: &mut Vec<Message<ReplyHeader>>,
+) -> Message<ReplyHeader> {
+    let answers = |reply: &Message<ReplyHeader>| {
+        reply.header().client == client && reply.header().request == request
+    };
+    if let Some(index) = replies.iter().position(answers) {
+        return replies.remove(index);
+    }
+    for _ in 0..REPLY_STEPS {
+        for reply in sim.step() {
+            if answers(&reply) {
+                return reply;
+            }
+            replies.push(reply);
+        }
+    }
+    panic!("request {request} of client {client} got no reply within {REPLY_STEPS} steps");
+}
+
+/// Steps long enough for anything in flight to land, keeping the replies.
+fn settle(sim: &mut Simulator, replies: &mut Vec<Message<ReplyHeader>>) {
+    for _ in 0..LOST_WINDOW_STEPS {
+        replies.extend(sim.step());
+    }
+}
+
+/// Loses every reply on the way from the primary to the client, or stops losing them.
+fn drop_replies(sim: &mut Simulator, dropping: bool) {
+    *sim.network
+        .link_drop_packet_fn(ProcessId::Replica(PRIMARY), ProcessId::Client(CLIENT)) =
+        dropping.then_some(is_reply as fn(&Packet) -> bool);
+}
+
+fn is_reply(packet: &Packet) -> bool {
+    packet.message.header().command == Command::Reply
+}
+
+/// The offset the primary stores for the client's consumer on the partition.
+fn stored_offset(sim: &Simulator) -> Option<u64> {
+    let consumer = PollingConsumer::Consumer(
+        usize::try_from(CLIENT).expect("client id fits usize"),
+        PARTITION,
+    );
+    sim.replicas[usize::from(PRIMARY)]
+        .partition_shard(namespace())
+        .plane
+        .partitions()
+        .get_by_ns(&namespace())
+        .expect("the primary hosts the partition")
+        .get_consumer_offset(consumer)
+}
+
+/// The header of the one section of a fetch reply.
+fn only_section(reply: &Message<ReplyHeader>) -> FetchMessagesSectionHeader {
+    let response = FetchMessagesResponse::decode(reply.body()).expect("a fetch reply decodes");
+    let [section] = response.sections[..] else {
+        panic!("a fetch of one partition answers with one section");
+    };
+    section.header
+}
+
+fn offsets_of(reply: &Message<ReplyHeader>) -> Vec<u64> {
+    let response = PollMessagesResponse::decode(reply.body()).expect("a poll reply decodes");
+    response
+        .messages
+        .map(|message| message.expect("a polled message decodes").offset)
+        .collect()
+}
+
+/// Steps that cover `micros` of virtual time, one consensus tick per step.
+#[allow(clippy::cast_possible_truncation)]
+const fn steps_of(micros: u64) -> usize {
+    micros.div_ceil(CONSENSUS_TICK_INTERVAL.as_micros() as u64) as usize
+}
+
+fn namespace() -> IggyNamespace {
+    IggyNamespace::new(0, 0, PARTITION)
+}
diff --git a/core/simulator/src/lib.rs b/core/simulator/src/lib.rs
index 4d5329e..4e0c45a 100644
--- a/core/simulator/src/lib.rs
+++ b/core/simulator/src/lib.rs
@@ -2222,6 +2222,8 @@
 }
 
 #[cfg(test)]
+mod deferred_commit_tests;
+#[cfg(test)]
 mod slow_replica_tests;
 
 #[cfg(test)]
diff --git a/examples/csharp/src/NewSdk/Iggy_SDK.Examples.NewSdk.Consumer/Program.cs b/examples/csharp/src/NewSdk/Iggy_SDK.Examples.NewSdk.Consumer/Program.cs
index 58ee8ad..3e8e35b 100644
--- a/examples/csharp/src/NewSdk/Iggy_SDK.Examples.NewSdk.Consumer/Program.cs
+++ b/examples/csharp/src/NewSdk/Iggy_SDK.Examples.NewSdk.Consumer/Program.cs
@@ -18,6 +18,7 @@
 using Apache.Iggy;
 using Apache.Iggy.Configuration;
 using Apache.Iggy.Consumers;
+using Apache.Iggy.Contracts;
 using Apache.Iggy.Enums;
 using Apache.Iggy.Extensions;
 using Apache.Iggy.Factory;
@@ -41,9 +42,14 @@
 var consumer = client.CreateConsumerBuilder(Identifier.String("new-sdk-stream"), Identifier.String("new-sdk-topic"),
         Consumer.Group("new-sdk-consumer-group"))
     .WithPollingStrategy(PollingStrategy.Next())
-    .WithConsumerGroup("new-sdk-consumer-group", true, true)
-    .WithBatchSize(20)
-    .WithAutoCommitMode(AutoCommitMode.AfterReceive)
+    .WithConsumerGroup("new-sdk-consumer-group")
+    // The consumer stores the offset of each message once the loop asks for the next one, and on shutdown.
+    .WithPollOptions(new PollOptions
+    {
+        Count = 20,
+        MaxWait = TimeSpan.FromSeconds(5),
+        Commit = CommitPolicy.Auto(new AutoCommit { Every = CommitEvery.Message, After = CommitAfter.Processing })
+    })
     .WithLogger(loggerFactory)
     .SubscribeOnPollingError(e =>
     {
diff --git a/examples/go/README.md b/examples/go/README.md
index e26eb7e..8e1d797 100644
--- a/examples/go/README.md
+++ b/examples/go/README.md
@@ -42,6 +42,15 @@
 go run ./getting-started/consumer/main.go
 ```
 
+### Reader Consumer
+
+Reads the messages of the getting started producer as a member of a consumer group, through a partition reader that waits on the server for new messages and stores the offset of each message once it is processed:
+
+```bash
+go run ./getting-started/producer/main.go
+go run ./reader-consumer/main.go
+```
+
 ## TLS Examples
 
 From the repository root, start a disposable TLS server with the development certificate pair and the same credential prerequisites:
diff --git a/examples/go/getting-started/consumer/main.go b/examples/go/getting-started/consumer/main.go
index a88672b..72ddd64 100644
--- a/examples/go/getting-started/consumer/main.go
+++ b/examples/go/getting-started/consumer/main.go
@@ -88,16 +88,17 @@
 
 		streamIdentifier, _ := iggcon.NewIdentifier(StreamID)
 		topicIdentifier, _ := iggcon.NewIdentifier(TopicID)
+		options := iggcon.DefaultPollOptions()
+		options.Count = messagePerBatch
 		pollMessages, err := client.
 			PollMessages(
 				ctx,
 				streamIdentifier,
 				topicIdentifier,
+				PartitionID,
 				consumer,
 				iggcon.OffsetPollingStrategy(offset),
-				messagePerBatch,
-				false,
-				&PartitionID,
+				options,
 			)
 		if err != nil {
 			return err
diff --git a/examples/go/getting-started/consumer/main_test.go b/examples/go/getting-started/consumer/main_test.go
index f2c9550..eb7d008 100644
--- a/examples/go/getting-started/consumer/main_test.go
+++ b/examples/go/getting-started/consumer/main_test.go
@@ -68,12 +68,12 @@
 func (c *retainedMessagesClient) PollMessages(
 	_ context.Context,
 	_, _ iggcon.Identifier,
+	_ uint32,
 	_ iggcon.Consumer,
 	strategy iggcon.PollingStrategy,
-	count uint32,
-	_ bool,
-	_ *uint32,
+	options iggcon.PollOptions,
 ) (*iggcon.PolledMessage, error) {
+	count := options.Count
 	result := &iggcon.PolledMessage{}
 	for _, message := range c.messages {
 		if message.Header.Offset < strategy.Value {
diff --git a/examples/go/reader-consumer/main.go b/examples/go/reader-consumer/main.go
new file mode 100644
index 0000000..79d108b
--- /dev/null
+++ b/examples/go/reader-consumer/main.go
@@ -0,0 +1,102 @@
+// 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.
+
+// Reader-consumer reads the messages of the getting started producer as a
+// member of a consumer group. The consumer waits on the server for new
+// messages, and stores the offset of each message after its handler processed it.
+package main
+
+import (
+	"context"
+	"errors"
+	"flag"
+	"log"
+	"time"
+
+	"github.com/apache/iggy/examples/go/common"
+	"github.com/apache/iggy/foreign/go/client"
+	"github.com/apache/iggy/foreign/go/client/reader"
+	"github.com/apache/iggy/foreign/go/client/tcp"
+	iggcon "github.com/apache/iggy/foreign/go/contracts"
+)
+
+const (
+	groupName     = "sample-readers"
+	messagesLimit = 100
+)
+
+func main() {
+	address := flag.String("tcp-server-address", "127.0.0.1:8090", "the TCP address of the server")
+	flag.Parse()
+	cli, err := client.NewIggyClient(client.WithTcp(tcp.WithServerAddress(*address)))
+	if err != nil {
+		log.Fatal(err)
+	}
+	defer func() {
+		if err := cli.Close(); err != nil {
+			log.Printf("Error closing client: %v", err)
+		}
+	}()
+	ctx := context.Background()
+	if err := cli.Connect(ctx); err != nil {
+		log.Fatal(err)
+	}
+	if _, err := cli.LoginUser(ctx, common.DefaultRootUsername, common.DefaultRootPassword); err != nil {
+		log.Fatal(err)
+	}
+	if err := consume(ctx, cli); err != nil {
+		log.Fatal(err)
+	}
+}
+
+func consume(ctx context.Context, cli iggcon.Client) error {
+	stream, _ := iggcon.NewIdentifier(uint32(0))
+	topic, _ := iggcon.NewIdentifier(uint32(0))
+	group, _ := iggcon.NewIdentifier(groupName)
+	if _, err := cli.GetConsumerGroup(ctx, stream, topic, group); err != nil {
+		if _, err := cli.CreateConsumerGroup(ctx, stream, topic, groupName); err != nil {
+			return err
+		}
+	}
+	// Every 10,000 messages or 5 s, a message counts once its handler succeeded.
+	options := iggcon.WaitPollOptions(5 * time.Second)
+	options.Commit = iggcon.AutoCommitPolicy(iggcon.DefaultAutoCommit())
+	consumer, err := reader.NewIggyConsumer(ctx, cli, stream, topic, iggcon.NewGroupConsumer(group),
+		reader.WithPollOptions(options))
+	if err != nil {
+		return err
+	}
+	running, stop := context.WithCancel(ctx)
+	defer stop()
+	handled := 0
+	consumed := consumer.Consume(running, func(_ context.Context, message reader.ReceivedMessage) error {
+		log.Printf("Partition %d offset %d: %s", message.PartitionID, message.Checkpoint.Offset(), message.Message.Payload)
+		handled++
+		if handled == messagesLimit {
+			stop()
+		}
+		return nil
+	})
+	// Counts the last message, stores the counted offsets and leaves the group.
+	if err := consumer.Shutdown(ctx); err != nil {
+		return err
+	}
+	if errors.Is(consumed, context.Canceled) {
+		return nil
+	}
+	return consumed
+}
diff --git a/examples/java/README.md b/examples/java/README.md
index ba6971d..1936858 100644
--- a/examples/java/README.md
+++ b/examples/java/README.md
@@ -50,6 +50,15 @@
 ./gradlew runGettingStartedConsumer
 ```
 
+### Reader Consumer
+
+Reads the messages of the getting started producer as a member of a consumer group, through a partition reader that waits on the server for new messages and stores the offset of each message once it is processed:
+
+```bash
+./gradlew runGettingStartedProducer
+./gradlew runReaderConsumer
+```
+
 ### Message Headers
 
 Shows metadata management using custom headers:
diff --git a/examples/java/build.gradle.kts b/examples/java/build.gradle.kts
index 7522725..86cfa59 100644
--- a/examples/java/build.gradle.kts
+++ b/examples/java/build.gradle.kts
@@ -116,6 +116,11 @@
     mainClass.set("org.apache.iggy.examples.async.AsyncConsumer")
 }
 
+tasks.register<JavaExec>("runReaderConsumer") {
+    classpath = sourceSets["main"].runtimeClasspath
+    mainClass.set("org.apache.iggy.examples.reader.ReaderConsumer")
+}
+
 tasks.register<JavaExec>("runTcpTlsProducer") {
     classpath = sourceSets["main"].runtimeClasspath
     mainClass.set("org.apache.iggy.examples.tcptls.producer.TcpTlsProducer")
diff --git a/examples/java/src/main/java/org/apache/iggy/examples/reader/ReaderConsumer.java b/examples/java/src/main/java/org/apache/iggy/examples/reader/ReaderConsumer.java
new file mode 100644
index 0000000..fffece0
--- /dev/null
+++ b/examples/java/src/main/java/org/apache/iggy/examples/reader/ReaderConsumer.java
@@ -0,0 +1,89 @@
+/*
+ * 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.
+ */
+
+package org.apache.iggy.examples.reader;
+
+import org.apache.iggy.client.async.reader.IggyConsumer;
+import org.apache.iggy.client.async.reader.ReceivedMessage;
+import org.apache.iggy.client.async.tcp.AsyncIggyTcpClient;
+import org.apache.iggy.consumergroup.Consumer;
+import org.apache.iggy.identifier.ConsumerId;
+import org.apache.iggy.identifier.StreamId;
+import org.apache.iggy.identifier.TopicId;
+import org.apache.iggy.message.CommitPolicy;
+import org.apache.iggy.message.PollOptions;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+
+/**
+ * Reads the messages of the getting started producer as a member of a consumer group. The consumer waits on the server
+ * for new messages, and stores the offset of each message after its handler processed it.
+ */
+public final class ReaderConsumer {
+    private static final Logger log = LoggerFactory.getLogger(ReaderConsumer.class);
+
+    private static final StreamId STREAM_ID = StreamId.of("sample-stream");
+    private static final TopicId TOPIC_ID = TopicId.of("sample-topic");
+    private static final String GROUP_NAME = "sample-readers";
+    private static final int MESSAGES_LIMIT = 100;
+
+    private ReaderConsumer() {}
+
+    public static void main(String[] args) throws Exception {
+        AsyncIggyTcpClient client = AsyncIggyTcpClient.builder()
+                .host("localhost")
+                .port(8090)
+                .credentials("iggy", "iggy")
+                .build();
+        client.connect().join();
+        client.login().join();
+        ConsumerId group = ConsumerId.of(GROUP_NAME);
+        if (client.consumerGroups()
+                .getConsumerGroup(STREAM_ID, TOPIC_ID, group)
+                .join()
+                .isEmpty()) {
+            client.consumerGroups()
+                    .createConsumerGroup(STREAM_ID, TOPIC_ID, GROUP_NAME)
+                    .join();
+        }
+
+        // Every 10,000 messages or 5 s, a message counts once its handler succeeded.
+        PollOptions options = PollOptions.waitFor(Duration.ofSeconds(5)).withCommit(CommitPolicy.auto());
+        IggyConsumer consumer = IggyConsumer.build(client.reader(STREAM_ID, TOPIC_ID, Consumer.group(group))
+                        .options(options))
+                .join();
+        try {
+            for (int handled = 0; handled < MESSAGES_LIMIT; handled++) {
+                ReceivedMessage received = consumer.next().join();
+                log.info(
+                        "Partition {} offset {}: {}",
+                        received.partitionId(),
+                        received.checkpoint().offset(),
+                        new String(received.message().payload(), StandardCharsets.UTF_8));
+            }
+        } finally {
+            // Counts the last message, stores the counted offsets and leaves the group.
+            consumer.shutdown().join();
+            client.close().join();
+        }
+    }
+}
diff --git a/examples/node/package-lock.json b/examples/node/package-lock.json
index afd8cca..7aa2e08 100644
--- a/examples/node/package-lock.json
+++ b/examples/node/package-lock.json
@@ -23,7 +23,7 @@
     },
     "../../foreign/node": {
       "name": "apache-iggy",
-      "version": "0.10.1-edge.5",
+      "version": "0.10.1-edge.6",
       "license": "Apache-2.0",
       "dependencies": {
         "@node-rs/xxhash": "1.7.8",
diff --git a/examples/php/src/common.php b/examples/php/src/common.php
index d8c541a..8bb7bbc 100644
--- a/examples/php/src/common.php
+++ b/examples/php/src/common.php
@@ -19,9 +19,13 @@
 declare(strict_types=1);
 
 use Iggy\Client;
+use Iggy\CommitPolicy;
 use Iggy\PollingStrategy;
+use Iggy\PollOptions;
 use Iggy\SendMessage;
 
+const CONSUMER_ID = 1;
+
 function iggy_client(): Client
 {
     $configured = getenv('IGGY_CONNECTION_STRING');
@@ -64,7 +68,14 @@
 
 function print_polled_messages(Client $client, string $stream, string $topic, int $count): void
 {
-    $messages = $client->pollMessages($stream, $topic, 0, PollingStrategy::first(), $count, true);
+    $messages = $client->pollMessages(
+        $stream,
+        $topic,
+        0,
+        CONSUMER_ID,
+        PollingStrategy::first(),
+        new PollOptions(count: $count, commit: CommitPolicy::onRead()),
+    )->messages;
 
     if (count($messages) < $count) {
         throw new RuntimeException("Expected {$count} messages, received " . count($messages));
diff --git a/examples/python/basic/consumer.py b/examples/python/basic/consumer.py
index e90df3c..3b84cf5 100644
--- a/examples/python/basic/consumer.py
+++ b/examples/python/basic/consumer.py
@@ -19,7 +19,14 @@
 import asyncio
 from typing import NamedTuple
 
-from apache_iggy import Consumer, IggyClient, PollingStrategy, ReceiveMessage
+from apache_iggy import (
+    CommitPolicy,
+    Consumer,
+    IggyClient,
+    PollingStrategy,
+    PollOptions,
+    ReceiveMessage,
+)
 from loguru import logger
 
 STREAM_NAME = "sample-stream"
@@ -71,22 +78,24 @@
     while n_consumed_batches < BATCHES_LIMIT:
         try:
             logger.debug("Polling for messages...")
-            polled_messages = await client.poll_messages(
+            polled = await client.poll_messages(
                 stream=STREAM_NAME,
                 topic=TOPIC_NAME,
-                consumer=Consumer.Single(CONSUMER_NAME),
                 partition_id=PARTITION_ID,
+                consumer=Consumer.Single(CONSUMER_NAME),
                 polling_strategy=PollingStrategy.Next(),
-                count=messages_per_batch,
-                auto_commit=True,
+                options=PollOptions(
+                    count=messages_per_batch,
+                    commit=CommitPolicy.OnRead(),
+                ),
             )
-            if not polled_messages:
+            if not polled.messages:
                 logger.info("No messages found in current poll")
                 await asyncio.sleep(interval)
                 continue
 
-            offset += len(polled_messages)
-            for message in polled_messages:
+            offset += len(polled.messages)
+            for message in polled.messages:
                 handle_message(message)
             n_consumed_batches += 1
             await asyncio.sleep(interval)
diff --git a/examples/python/getting-started/consumer.py b/examples/python/getting-started/consumer.py
index 16405d9..9178d99 100755
--- a/examples/python/getting-started/consumer.py
+++ b/examples/python/getting-started/consumer.py
@@ -23,10 +23,12 @@
 
 from apache_iggy import (
     AutoLogin,
+    CommitPolicy,
     Consumer,
     HttpConfig,
     IggyClient,
     PollingStrategy,
+    PollOptions,
     QuicConfig,
     ReceiveMessage,
     TcpConfig,
@@ -185,22 +187,24 @@
     while n_consumed_batches < BATCHES_LIMIT:
         try:
             logger.debug("Polling for messages...")
-            polled_messages = await client.poll_messages(
+            polled = await client.poll_messages(
                 stream=STREAM_NAME,
                 topic=TOPIC_NAME,
-                consumer=Consumer.Single(CONSUMER_NAME),
                 partition_id=PARTITION_ID,
+                consumer=Consumer.Single(CONSUMER_NAME),
                 polling_strategy=PollingStrategy.Next(),
-                count=messages_per_batch,
-                auto_commit=True,
+                options=PollOptions(
+                    count=messages_per_batch,
+                    commit=CommitPolicy.OnRead(),
+                ),
             )
-            if not polled_messages:
+            if not polled.messages:
                 logger.info("No messages found in current poll")
                 await asyncio.sleep(interval)
                 continue
 
-            offset += len(polled_messages)
-            for message in polled_messages:
+            offset += len(polled.messages)
+            for message in polled.messages:
                 handle_message(message)
             n_consumed_batches += 1
             await asyncio.sleep(interval)
diff --git a/examples/python/high-level/consumer.py b/examples/python/high-level/consumer.py
index ee06cfc..c96203f 100644
--- a/examples/python/high-level/consumer.py
+++ b/examples/python/high-level/consumer.py
@@ -17,13 +17,17 @@
 
 import argparse
 import asyncio
+from datetime import timedelta
 from typing import NamedTuple
 
 from apache_iggy import (
     AutoCommit,
-    AutoCommitAfter,
+    CommitEvery,
+    CommitPolicy,
     IggyClient,
     PollingStrategy,
+    PollOptions,
+    ReadError,
     ReceiveMessage,
 )
 from loguru import logger
@@ -32,6 +36,7 @@
 TOPIC_NAME = "high-level-topic"
 CONSUMER_GROUP_NAME = "high-level-consumer"
 MESSAGES_TO_CONSUME = 12
+HISTORY_UNAVAILABLE = 87
 
 
 class ArgNamespace(NamedTuple):
@@ -64,8 +69,12 @@
         STREAM_NAME,
         TOPIC_NAME,
         polling_strategy=PollingStrategy.First(),
-        batch_length=100,
-        auto_commit=AutoCommit.After(AutoCommitAfter.ConsumingEachMessage()),
+        # Stores the offset of each message once its handler returns.
+        poll_options=PollOptions(
+            count=100,
+            max_wait=timedelta(seconds=1),
+            commit=CommitPolicy.Auto(AutoCommit(every=CommitEvery.Message())),
+        ),
     )
 
     shutdown_event = asyncio.Event()
@@ -83,7 +92,20 @@
         if consumed_messages == MESSAGES_TO_CONSUME:
             shutdown_event.set()
 
-    await consumer.consume_messages(handle_message, shutdown_event)
+    while True:
+        try:
+            await consumer.consume_messages(handle_message, shutdown_event)
+            break
+        except ReadError as error:
+            # A partition that another client deleted and created again reads nothing
+            # until a seek picks a start in its new history.
+            if error.partition_id is None or error.code != HISTORY_UNAVAILABLE:
+                raise
+            logger.warning(
+                "Partition {} has a new history: {}", error.partition_id, error
+            )
+            await consumer.seek(error.partition_id, PollingStrategy.Next())
+    await consumer.shutdown()
     logger.info("Consumed {} messages, exiting", consumed_messages)
 
 
diff --git a/examples/python/message-headers/common.py b/examples/python/message-headers/common.py
index 95609b9..dc8f811 100644
--- a/examples/python/message-headers/common.py
+++ b/examples/python/message-headers/common.py
@@ -25,11 +25,13 @@
 from enum import StrEnum
 
 from apache_iggy import (
+    CommitPolicy,
     Consumer,
     HeaderKey,
     HeaderValue,
     IggyClient,
     PollingStrategy,
+    PollOptions,
     ReceiveMessage,
     StreamDetails,
     TopicDetails,
@@ -188,25 +190,27 @@
     while consumed_batches < BATCHES_LIMIT:
         try:
             logger.debug("Polling for messages...")
-            polled_messages = await client.poll_messages(
+            polled = await client.poll_messages(
                 stream=STREAM_NAME,
                 topic=TOPIC_NAME,
-                consumer=Consumer.Single(consumer_name),
                 partition_id=PARTITION_ID,
+                consumer=Consumer.Single(consumer_name),
                 polling_strategy=PollingStrategy.Next(),
-                count=MESSAGES_PER_BATCH,
-                auto_commit=True,
+                options=PollOptions(
+                    count=MESSAGES_PER_BATCH,
+                    commit=CommitPolicy.OnRead(),
+                ),
             )
-            if not polled_messages:
+            if not polled.messages:
                 logger.info("No messages found in current poll")
                 await asyncio.sleep(interval)
                 continue
 
-            for message in polled_messages:
+            for message in polled.messages:
                 handle_message(message)
 
             consumed_batches += 1
-            logger.info(f"Consumed {len(polled_messages)} message(s).")
+            logger.info(f"Consumed {len(polled.messages)} message(s).")
             await asyncio.sleep(interval)
         except Exception as error:
             logger.exception(f"Exception occurred while consuming messages: {error}")
diff --git a/examples/python/uv.lock b/examples/python/uv.lock
index b247029..ec939e5 100644
--- a/examples/python/uv.lock
+++ b/examples/python/uv.lock
@@ -8,7 +8,7 @@
 
 [[package]]
 name = "apache-iggy"
-version = "0.9.1.dev5"
+version = "0.9.1.dev6"
 source = { directory = "../../foreign/python" }
 
 [package.metadata]
diff --git a/examples/rust/README.md b/examples/rust/README.md
index 370bcf3..3e8e454 100644
--- a/examples/rust/README.md
+++ b/examples/rust/README.md
@@ -162,7 +162,7 @@
 cargo run --example new-sdk-consumer
 ```
 
-Showcases the newer SDK APIs with simplified setup, automatic topic creation, consumer groups, and AutoCommit configuration.
+Showcases the newer SDK APIs with simplified setup, automatic topic creation, consumer groups, and offsets that the server stores on each poll.
 
 ### High-Volume Data Generation
 
@@ -195,7 +195,7 @@
 cargo run --example stream-consumer-config
 ```
 
-These examples document all available configuration options including partitioning strategies, retry policies, batching, AutoCommit strategies, polling strategies, and retry mechanisms.
+These examples document all available configuration options including partitioning strategies, retry policies, batching, offset commit policies, polling strategies, and retry mechanisms.
 
 ## Security Examples
 
diff --git a/examples/rust/src/getting-started/consumer/main.rs b/examples/rust/src/getting-started/consumer/main.rs
index 4362b2e..b627687 100644
--- a/examples/rust/src/getting-started/consumer/main.rs
+++ b/examples/rust/src/getting-started/consumer/main.rs
@@ -77,11 +77,13 @@
             .poll_messages(
                 &STREAM_ID.try_into()?,
                 &TOPIC_ID.try_into()?,
-                Some(PARTITION_ID),
+                PARTITION_ID,
                 &consumer,
                 &PollingStrategy::offset(offset),
-                messages_per_batch,
-                false,
+                PollOptions {
+                    count: messages_per_batch,
+                    ..PollOptions::default()
+                },
             )
             .await?;
 
diff --git a/examples/rust/src/message-headers/message-compression/consumer/main.rs b/examples/rust/src/message-headers/message-compression/consumer/main.rs
index 0795fc6..606d2f9 100644
--- a/examples/rust/src/message-headers/message-compression/consumer/main.rs
+++ b/examples/rust/src/message-headers/message-compression/consumer/main.rs
@@ -24,6 +24,7 @@
 use iggy_examples::shared::codec::{Codec, NUM_MESSAGES, STREAM_NAME, TOPIC_NAME};
 
 pub const CONSUMER_NAME: &str = "example-consumer";
+const PARTITION_ID: u32 = 0;
 
 #[tokio::main]
 async fn main() -> Result<(), IggyError> {
@@ -38,7 +39,8 @@
 
     // Configure a consumer, to read the compressed messages that were previously written to the server (using ../producer/main.rs).
     let mut consumer = client
-        .consumer(CONSUMER_NAME, STREAM_NAME, TOPIC_NAME, 0)?
+        .consumer(CONSUMER_NAME, STREAM_NAME, TOPIC_NAME)?
+        .partitions([PARTITION_ID])
         .build();
     consumer.init().await?;
 
diff --git a/examples/rust/src/multi-tenant/consumer/main.rs b/examples/rust/src/multi-tenant/consumer/main.rs
index e731f16..81aac66 100644
--- a/examples/rust/src/multi-tenant/consumer/main.rs
+++ b/examples/rust/src/multi-tenant/consumer/main.rs
@@ -295,10 +295,12 @@
         for id in 1..=consumers_count {
             let mut consumer = client
                 .consumer_group(CONSUMER_GROUP, stream, topic)?
-                .batch_length(batch_length)
                 .polling_strategy(PollingStrategy::next())
-                .auto_join_consumer_group()
-                .auto_commit(AutoCommit::When(AutoCommitWhen::PollingMessages))
+                .poll_options(PollOptions {
+                    count: batch_length,
+                    commit: CommitPolicy::OnRead,
+                    ..PollOptions::wait(IggyDuration::ONE_SECOND)
+                })
                 .build();
             consumer.init().await?;
             consumers.push(TenantConsumer::new(
diff --git a/examples/rust/src/new-sdk/consumer/main.rs b/examples/rust/src/new-sdk/consumer/main.rs
index 0d57f73..b62b473 100644
--- a/examples/rust/src/new-sdk/consumer/main.rs
+++ b/examples/rust/src/new-sdk/consumer/main.rs
@@ -47,29 +47,32 @@
 
     let name = "new-sdk-consumer";
     let mut consumer = match ConsumerKind::from_code(args.consumer_kind)? {
-        ConsumerKind::Consumer => {
-            client.consumer(name, &args.stream_id, &args.topic_id, args.partition_id)?
-        }
+        ConsumerKind::Consumer => client
+            .consumer(name, &args.stream_id, &args.topic_id)?
+            .partitions([args.partition_id]),
         ConsumerKind::ConsumerGroup => {
             client.consumer_group(name, &args.stream_id, &args.topic_id)?
         }
         ConsumerKind::ExternalGroup => return Err("an external group cannot poll".into()),
     }
-    .auto_commit(AutoCommit::When(AutoCommitWhen::PollingMessages))
     .create_consumer_group_if_not_exists()
-    .auto_join_consumer_group()
     .polling_strategy(PollingStrategy::next())
-    .batch_length(args.messages_per_batch)
+    .poll_options(PollOptions {
+        count: args.messages_per_batch,
+        commit: CommitPolicy::OnRead,
+        ..PollOptions::wait(IggyDuration::ONE_SECOND)
+    })
     .build();
 
     consumer.init().await?;
-    consume_messages(&args, &mut consumer).await?;
+    consume_messages(&args, &client, &mut consumer).await?;
 
     Ok(())
 }
 
 pub async fn consume_messages(
     args: &Args,
+    client: &IggyClient,
     consumer: &mut IggyConsumer,
 ) -> Result<(), Box<dyn Error>> {
     let mut consumed_messages = 0;
@@ -95,7 +98,7 @@
             }
         } else if let Err(error) = message {
             error!("Error while handling message: {error}");
-            continue;
+            resume_partition(client, consumer, error).await?;
         }
     }
 
@@ -106,6 +109,37 @@
     Ok(())
 }
 
+/// The consumer stops a partition after a read with an unknown outcome, a message that does not
+/// decode, or a new partition history, and retries any other error by itself. Without a seek, a
+/// run on one partition would stall.
+async fn resume_partition(
+    client: &IggyClient,
+    consumer: &mut IggyConsumer,
+    error: ReadError,
+) -> Result<(), IggyError> {
+    let Some(partition_id) = error.partition_id else {
+        return Ok(());
+    };
+    let start = match (error.error, error.skip) {
+        (
+            IggyError::FetchProgressUncertain(operation)
+            | IggyError::PartitionLaneBlocked(_, operation),
+            _,
+        ) => {
+            match client.abandon_operation(operation).await {
+                // A lost fetch stops each of its partitions, and the first abandon frees them all.
+                Ok(()) | Err(IggyError::OperationNotRetained(_)) => {}
+                Err(abandon_error) => return Err(abandon_error),
+            }
+            PollingStrategy::next()
+        }
+        (_, Some(skip)) => skip,
+        (IggyError::HistoryUnavailable, None) => PollingStrategy::next(),
+        _ => return Ok(()),
+    };
+    consumer.seek(partition_id, start).await
+}
+
 fn handle_message(message: &ReceivedMessage) -> anyhow::Result<(), Box<dyn Error>> {
     // The payload can be of any type as it is a raw byte array. In this case it's a JSON string.
     let json = std::str::from_utf8(&message.message.payload)?;
diff --git a/examples/rust/src/shared/system.rs b/examples/rust/src/shared/system.rs
index 63d0d55..82f6a4c 100644
--- a/examples/rust/src/shared/system.rs
+++ b/examples/rust/src/shared/system.rs
@@ -139,11 +139,14 @@
             .poll_messages(
                 &stream_id,
                 &topic_id,
-                Some(args.partition_id),
+                args.partition_id,
                 &consumer,
                 &PollingStrategy::next(),
-                args.messages_per_batch,
-                true,
+                PollOptions {
+                    count: args.messages_per_batch,
+                    commit: CommitPolicy::OnRead,
+                    ..PollOptions::default()
+                },
             )
             .await?;
         if polled_messages.messages.is_empty() {
diff --git a/examples/rust/src/stream-builder/stream-basic/main.rs b/examples/rust/src/stream-builder/stream-basic/main.rs
index 4c38215..bc6211e 100644
--- a/examples/rust/src/stream-builder/stream-basic/main.rs
+++ b/examples/rust/src/stream-builder/stream-basic/main.rs
@@ -29,8 +29,12 @@
 
     println!("Build iggy producer & consumer");
     // For customization, use the `new` or `from_stream_topic` constructor
-    let consumer_config = IggyConsumerConfig::default()
-        .with_auto_commit(AutoCommit::When(AutoCommitWhen::PollingMessages));
+    let consumer_config = IggyConsumerConfig::default();
+    let poll_options = PollOptions {
+        commit: CommitPolicy::OnRead,
+        ..consumer_config.poll_options()
+    };
+    let consumer_config = consumer_config.with_poll_options(poll_options);
     let stream_config = IggyStreamConfig::new(consumer_config, IggyProducerConfig::default());
     let (producer, mut consumer) = IggyStream::build(&client, &stream_config).await?;
 
diff --git a/examples/rust/src/stream-builder/stream-consumer-config/main.rs b/examples/rust/src/stream-builder/stream-consumer-config/main.rs
index d7a847e..affc5b0 100644
--- a/examples/rust/src/stream-builder/stream-consumer-config/main.rs
+++ b/examples/rust/src/stream-builder/stream-consumer-config/main.rs
@@ -31,19 +31,6 @@
         .stream_name(stream)
         // Set the topic name. The consumer binds to the topic by this name.
         .topic_name(topic)
-        // The auto-commit configuration for storing the message offset on the server.
-        // * Disabled:  The auto-commit is disabled and the offset must be stored manually by the consumer.
-        // * Interval: The auto-commit is enabled and the offset is stored on the server after a certain interval.
-        // * IntervalOrWhen: The auto-commit is enabled and the offset is stored on the server after a certain interval or depending on the mode when consuming the messages.
-        // * IntervalOrAfter: [This requires the `IggyConsumerMessageExt` trait when using `consume_messages()`.]
-        // The auto-commit is enabled and the offset is stored on the server after a certain interval or depending on the mode after consuming the messages.
-        // * When: The auto-commit is enabled and the offset is stored on the server depending on the mode when consuming the messages.
-        // * After: [This requires the `IggyConsumerMessageExt` trait when using `consume_messages()`.]
-        // The auto-commit is enabled and the offset is stored on the server depending on the mode after consuming the messages.
-        .auto_commit(AutoCommit::When(AutoCommitWhen::PollingMessages))
-        // The max number of messages to poll in a batch. The greater the batch size, the higher the throughput for bulk data.
-        // Note, there is a tradeoff between batch size and latency, so you want to benchmark your setup.
-        .batch_length(100)
         // Create the stream if it doesn't exist.
         .create_stream_if_not_exists(true)
         // Create the topic if it doesn't exist.
@@ -52,16 +39,27 @@
         .consumer_name("test_consumer".to_string())
         // The type of consumer. It can be either `Consumer` or `ConsumerGroup`. ConsumerGroup is default.
         .consumer_kind(ConsumerKind::ConsumerGroup)
-        // Topic creation count. Ordinary consumers select a partition separately with partition_id.
+        // Topic creation count. Ordinary consumers select their partitions with `partitions`.
         .partitions_count(1)
-        // A poll returns when `min_count` messages are ready, `max_bytes` is reached or `max_wait` expires.
-        .poll_options(DeferredPollOptions::default())
+        // A poll returns up to `count` messages when `min_count` messages are ready, `max_bytes` is reached or `max_wait` expires.
+        // The greater the `count`, the higher the throughput for bulk data.
+        // Note, there is a tradeoff between batch size and latency, so you want to benchmark your setup.
+        // `commit` decides who stores offsets:
+        // * `CommitPolicy::Manual`, the default: the application commits checkpoints itself.
+        // * `CommitPolicy::OnRead`: the server stores the offset of each reply as part of it.
+        // * `CommitPolicy::Auto`: the consumer stores the latest counted message at an interval or on an event.
+        //   `AutoCommit::default()` stores every 10,000 messages or 5 s, after the application processed them.
+        .poll_options(PollOptions {
+            count: 100,
+            commit: CommitPolicy::OnRead,
+            ..PollOptions::wait(IggyDuration::ONE_SECOND)
+        })
         // `PollingStrategy` specifies from where to start polling messages.
         // It has the following kinds:
         // - `Offset` - start polling from the specified offset.
         // - `Timestamp` - start at or after the broker append timestamp.
         // - `First` - start polling from the first message in the partition. This enables messages replay in order of arrival.
-        // - `Last` - read up to batch_length messages ending at the committed offset.
+        // - `Last` - read up to `count` messages ending at the committed offset.
         // - `Next` - start polling from the next message after the last polled message based on the stored consumer offset.
         .polling_strategy(PollingStrategy::last())
         // Sets the polling retry interval in case of server disconnection.
@@ -74,7 +72,7 @@
         .init_retries(5)
         .init_interval(NonZeroIggyDuration::ONE_SECOND)
         // Optionally, configure Aes256Gcm payload and user-header decryption.
-        // Replace PollingMessages auto-commit with a compatible mode first.
+        // Set `commit` to `Manual` or `Auto` first, because the consumer refuses an encryptor with `OnRead`.
         // Key must be identical to the one used by the producer; thus ensure secure key exchange i.e. K8s secret etc.
         // Note, this is independent of server side encryption meaning you can add client encryption, server encryption, or both.
         // .encryptor(std::sync::Arc::new(EncryptorKind::Aes256Gcm(Aes256GcmEncryptor::new(&[1; 32])?)))
diff --git a/examples/rust/src/stream-builder/stream-consumer/main.rs b/examples/rust/src/stream-builder/stream-consumer/main.rs
index dc09a77..5229151 100644
--- a/examples/rust/src/stream-builder/stream-consumer/main.rs
+++ b/examples/rust/src/stream-builder/stream-consumer/main.rs
@@ -26,8 +26,12 @@
     println!("Build iggy client & consumer");
     //For customization, use the `new` or `from_stream_topic` constructor
     // Stores the offset of each polled batch, so a restart continues after it.
-    let config = IggyConsumerConfig::default()
-        .with_auto_commit(AutoCommit::When(AutoCommitWhen::PollingMessages));
+    let config = IggyConsumerConfig::default();
+    let poll_options = PollOptions {
+        commit: CommitPolicy::OnRead,
+        ..config.poll_options()
+    };
+    let config = config.with_poll_options(poll_options);
     let (client, mut consumer) =
         IggyStreamConsumer::with_client_from_url(IGGY_URL, &config).await?;
 
diff --git a/examples/rust/src/tcp-tls/consumer/main.rs b/examples/rust/src/tcp-tls/consumer/main.rs
index 486cda9..5e2fefc 100644
--- a/examples/rust/src/tcp-tls/consumer/main.rs
+++ b/examples/rust/src/tcp-tls/consumer/main.rs
@@ -98,11 +98,13 @@
             .poll_messages(
                 &Identifier::named(STREAM_NAME).unwrap(),
                 &Identifier::named(TOPIC_NAME).unwrap(),
-                Some(PARTITION_ID),
+                PARTITION_ID,
                 &consumer,
                 &PollingStrategy::offset(offset),
-                messages_per_batch,
-                false,
+                PollOptions {
+                    count: messages_per_batch,
+                    ..PollOptions::default()
+                },
             )
             .await?;
 
diff --git a/foreign/cpp/BUILD.bazel b/foreign/cpp/BUILD.bazel
index ec6ce33..6175a1d 100644
--- a/foreign/cpp/BUILD.bazel
+++ b/foreign/cpp/BUILD.bazel
@@ -194,6 +194,7 @@
     testonly = True,
     srcs = [
         "tests/bdd/background_steps.cpp",
+        "tests/bdd/deferred_polling_steps.cpp",
         "tests/bdd/messaging_steps.cpp",
         "tests/bdd/metadata_changes_steps.cpp",
         "tests/bdd/raw_command_steps.cpp",
diff --git a/foreign/cpp/Cargo.toml b/foreign/cpp/Cargo.toml
index 1cf9f8c..7065ebc 100644
--- a/foreign/cpp/Cargo.toml
+++ b/foreign/cpp/Cargo.toml
@@ -17,7 +17,7 @@
 
 [package]
 name = "iggy-cpp"
-version = "0.1.7"
+version = "0.1.8"
 edition = "2024"
 license = "Apache-2.0"
 
@@ -30,6 +30,7 @@
 [dependencies]
 bytes = "1.12.1"
 cxx = "1.0.205"
+futures = "0.3.34"
 iggy = { path = "../../core/sdk" }
 iggy_binary_protocol = { path = "../../core/binary_protocol" }
 iggy_common = { path = "../../core/common" }
diff --git a/foreign/cpp/MODULE.bazel b/foreign/cpp/MODULE.bazel
index e2bd0da..49ed253 100644
--- a/foreign/cpp/MODULE.bazel
+++ b/foreign/cpp/MODULE.bazel
@@ -17,7 +17,7 @@
 
 module(
     name = "iggy_cpp",
-    version = "0.1.7",
+    version = "0.1.8",
 )
 
 bazel_dep(name = "rules_cc", version = "0.2.26")
diff --git a/foreign/cpp/README.md b/foreign/cpp/README.md
index 46e1a64..3de19cd 100644
--- a/foreign/cpp/README.md
+++ b/foreign/cpp/README.md
@@ -61,10 +61,7 @@
 
 A stream key must be 32 random bytes, never derived from a password, because reads expose its fingerprint. It travels inside the create request, so protecting it in transit with TLS is the caller's job. The server stores it wrapped and never returns it. Reads report only its source and fingerprints. Without a key, a server with encryption enabled generates one for the stream. An invalid key is refused before anything is sent.
 
-Binary mutations retain an uncertain operation until it is resolved or abandoned.
-`IggyException::OperationId()` carries an exact `std::uint64_t` for
-`operation_uncertain`, `lane_blocked`, and `operation_not_retained`.
-Keep using the same client to recover:
+Binary mutations retain an uncertain operation until it is resolved or abandoned. `IggyException::OperationId()` carries an exact `std::uint64_t` for `operation_uncertain`, `lane_blocked`, `operation_not_retained`, `partition_lane_blocked`, and `fetch_progress_uncertain`. `IggyException::BlockedPartitionId()` names the partition of a `partition_lane_blocked` error. Keep using the same client to recover:
 
 ```cpp
 try {
@@ -85,16 +82,18 @@
 `AbandonOperation` releases local state without undoing or determining the server
 result. `operation_not_retained` does not prove noncommit, so submitting the old
 payload again can duplicate work. A `lane_blocked` ID belongs to an earlier call;
-resolve it before submitting the new request again. Polls remain transient and
-are never retained or replayed.
+resolve it before submitting the new request again. Polls are never replayed.
 
-`Disconnect()` and session reset discard retained operations without settling
+A poll with `CommitPolicy::OnRead` whose reply is lost, also to a `Disconnect()` while it waits, fails with `fetch_progress_uncertain` and blocks its partition on the client: later committing polls and offset stores or deletes of that partition fail with `partition_lane_blocked` until `AbandonOperation` releases the operation that the error names. The block survives a reconnect and a new session.
+
+`Disconnect()` and session reset discard retained mutations without settling
 their outcomes. Reconnecting cannot recover those discarded operations.
 The client keeps the proved outcomes of its last `max_operations` replays, also
 across a session reset. A later `ResolveOperation` of a proved operation returns
 the same outcome without a request.
-TCP, QUIC, and WebSocket support `ResolveOperation` and `AbandonOperation`;
-HTTP reports `feature_unavailable` for both.
+TCP, QUIC, and WebSocket support `ResolveOperation` and `AbandonOperation`.
+HTTP reports `feature_unavailable` for `ResolveOperation`, and its
+`AbandonOperation` releases only the poll operations of `fetch_progress_uncertain`.
 
 The blocking wrapper populates `IggyException::Code()` and `Kind()` for Rust SDK
 errors from these methods:
@@ -105,10 +104,11 @@
 - `CreateTopic`, `UpdateTopic`, and `DeleteTopic`.
 - `CreatePartitions`, `DeletePartitions`, and `DeleteSegments`.
 - `CreateConsumerGroup`, `DeleteConsumerGroup`, `JoinConsumerGroup`, and `LeaveConsumerGroup`.
-- `StoreConsumerOffset`, `StoreConsumerPosition`, and `DeleteConsumerOffset`.
+- `StoreConsumerOffset`, `StoreConsumerPosition`, `DeleteConsumerOffset`, and `DeleteConsumerPosition`.
+- `PollMessages`, `PendingPoll::Wait`, `Reader`, and the calls of `PartitionReader`.
 - `GetMetadataChanges`.
 
-Other methods, including reads and `Connect`, `Disconnect`, and `Shutdown`, report errors through the exception message only. C++ and bridge-local argument errors also leave `Code()` and `OperationId()` empty and `Kind()` empty, including for the methods listed above. `GetMetadataChanges` is the exception: it reports every invalid query as `invalid_command`, including one the bridge refuses.
+Other methods, including `Connect`, `Disconnect`, and `Shutdown`, report errors through the exception message only. C++ and bridge-local argument errors also leave `Code()` and `OperationId()` empty and `Kind()` empty, including for the methods listed above. `GetMetadataChanges` is the exception: it reports every invalid query as `invalid_command`, including one the bridge refuses.
 
 Direct generated-bridge callers pass a local `iggy::ffi::ErrorInfo` as the last argument to login/logout, mutations, polls, raw commands, recovery calls, and the metadata change functions. It is reset at entry and populated only when the Rust SDK returns an `IggyError`. Bridge-local argument errors leave it empty, except in the metadata change functions, which record invalid input as `invalid_command`.
 
@@ -123,6 +123,42 @@
 Concurrent calls on one client are safe. Moving, assigning, or destroying the
 client while any call is in progress is undefined behavior.
 
+`IggyBlockingClient::PollMessages` reads one batch of one partition for a `PollingStrategy` and `PollOptions`, and its overload that takes a `Continuation` reads on after a batch. A default `PollOptions` reads up to 1000 messages and 1 MiB and does not wait. A continuation belongs to the partition of its batch.
+
+`PolledMessages::Checkpoint()` and `CheckpointAt(index)` name the position after a delivered message. Pass one to `StoreConsumerPosition` to store it, or to `DeleteConsumerPosition` to delete the stored offset of its partition. Only a polled batch or a read error makes a `Checkpoint` or a `Continuation`, so a caller cannot change the partition context that they keep.
+
+`PollOptions::SetCommit` says when offsets are stored: `CommitPolicy::Manual` (the default) stores nothing, `CommitPolicy::OnRead` makes the server store the offset of each read, and `CommitPolicy::Auto` makes a reader store the latest counted position as its `AutoCommit` says. `SetAutoCommit` sets `Auto` with an interval, a `CommitEvery` event and a `CommitAfter` point. A default `AutoCommit` stores every 10,000 messages or every 5 s, after processing. A one-shot poll refuses `Auto`.
+
+```cpp
+auto reader = client.Reader(iggy::Consumer::Single(iggy::Identifier::Numeric(1)), stream, topic,
+                            iggy::ReaderOptions().SetPartitions({0}).SetPollOptions(
+                                iggy::PollOptions().SetAutoCommit(iggy::AutoCommit())));
+while (auto batch = reader.Next(std::chrono::seconds(1))) {
+    for (const auto &message : batch->Messages()) {
+        process(message);
+    }
+}
+reader.Close();
+```
+
+`IggyBlockingClient::Reader` builds a `PartitionReader` with routing and prefetch:
+
+- `Next` waits up to its timeout and returns `std::nullopt` when the time runs out.
+- `Commit` stores a checkpoint that the reader delivered, and under `CommitPolicy::Auto` `Mark` counts one for the policy to store.
+- `Seek` moves a partition to a `PollingStrategy`, or to a `Continuation` of that partition.
+- A failed read throws `ReadException`, which names its partition. After a message that does not decode, its `Skip()` holds the continuation past it.
+- `Close` stops the reader, and a second `Close` does nothing. Destroying a reader without `Close` moves no offset back and does not leave the group.
+
+A consumer group reader joins its group in `Reader` and leaves it in `Close`.
+
+`IggyBlockingClient::StandaloneConsumer` and `GroupConsumer` build an `IggyConsumer`, which hands over one `ReceivedMessage` at a time through a reader, with the commit policy of the `PollOptions` of its `ConsumerOptions`. A standalone consumer reads the partitions of its options. A group consumer creates its group when it is missing, joins it, and leaves it in `Close`.
+
+`IggyConsumer::Next` returns `std::nullopt` when its timeout runs out, and under `CommitAfter::Processing` it counts the message handed over before. `Close` stores as the commit policy says.
+
+`IggyBlockingClient::StartPollMessages` runs the poll of `PollMessages` in the background and returns a `PendingPoll` at once. `Wait` returns its batch, or `std::nullopt` while the poll still runs, and `Cancel` drops the request, which cancels a parked deferred poll on the server. Destroying a `PendingPoll` cancels it. A parked poll holds its connection, so start it on a client that sends nothing else meanwhile.
+
+The generated bridge offers the same reads as `Client::poll_messages` and `Client::reader`, with `default_poll_options()` and a `ReadErrorInfo` for the errors of `next`.
+
 `StoreConsumerOffset` and `DeleteConsumerOffset` require an explicit partition on
 TCP, QUIC, and WebSocket; an absent partition returns `invalid_identifier`.
 An omitted partition, including the default on `DeleteConsumerOffset`, is for
diff --git a/foreign/cpp/build.rs b/foreign/cpp/build.rs
index 1910eaa..33b30dd 100644
--- a/foreign/cpp/build.rs
+++ b/foreign/cpp/build.rs
@@ -26,5 +26,6 @@
     println!("cargo:rerun-if-changed=src/lib.rs");
     println!("cargo:rerun-if-changed=src/messages.rs");
     println!("cargo:rerun-if-changed=src/producer.rs");
+    println!("cargo:rerun-if-changed=src/reader.rs");
     println!("cargo:rerun-if-changed=src/type_conversion.rs");
 }
diff --git a/foreign/cpp/include/iggy.hpp b/foreign/cpp/include/iggy.hpp
index bc32ca5..108cef0 100644
--- a/foreign/cpp/include/iggy.hpp
+++ b/foreign/cpp/include/iggy.hpp
@@ -117,8 +117,10 @@
  * C++ or bridge-local argument errors leave Code() empty and Kind() empty,
  * except the metadata change calls, which report invalid input with the kind
  * each one documents.
- * OperationId() is present only for operation_uncertain, lane_blocked, and
- * operation_not_retained errors and preserves the full unsigned 64-bit ID.
+ * OperationId() is present only for operation_uncertain, lane_blocked,
+ * operation_not_retained, partition_lane_blocked and fetch_progress_uncertain
+ * errors and preserves the full unsigned 64-bit ID. BlockedPartitionId() is
+ * present only for partition_lane_blocked.
  */
 class IggyException : public std::runtime_error {
   public:
@@ -131,6 +133,7 @@
         return kind_ ? *kind_ : kNoKind;
     }
     [[nodiscard]] std::optional<std::uint64_t> OperationId() const noexcept { return operation_id_; }
+    [[nodiscard]] std::optional<std::uint32_t> BlockedPartitionId() const noexcept { return blocked_partition_id_; }
 
   private:
     IggyException(const std::string &message, const ffi::ErrorInfo &error) : std::runtime_error(message) {
@@ -141,15 +144,20 @@
         if (error.has_operation_id) {
             operation_id_ = error.operation_id;
         }
+        if (error.has_blocked_partition_id) {
+            blocked_partition_id_ = error.blocked_partition_id;
+        }
     }
 
     template <typename Operation>
     friend decltype(auto) detail::RethrowAsIggyException(Operation &&operation);
+    friend class ReadException;
 
     std::optional<std::uint32_t> code_;
     // Shared, so that copying the exception cannot throw.
     std::shared_ptr<const std::string> kind_;
     std::optional<std::uint64_t> operation_id_;
+    std::optional<std::uint32_t> blocked_partition_id_;
 };
 
 namespace detail {
@@ -1126,6 +1134,8 @@
     static IggyMessagePolled FromFfi(ffi::IggyMessagePolled message);
 
     friend class IggyBlockingClient;
+    friend class PolledMessages;
+    friend class ReceivedMessage;
 
     std::uint64_t checksum_;
     absl::uint128 id_;
@@ -1444,18 +1454,19 @@
 };
 
 /**
- * @brief Partition incarnation and consumer-group owner captured by a read or
- *        a poll.
+ * @brief Partition incarnation and consumer-group owner that
+ *        Partition::Context() reports.
  *
- * Store an offset under the context that delivered it with
- * IggyBlockingClient::StoreConsumerPosition(). The store then fails instead of
- * landing on a recreated partition or under a newer consumer-group owner. The
- * server refuses a context whose incarnation is zero.
+ * A checkpoint keeps the context of its batch privately, and
+ * IggyBlockingClient::StoreConsumerPosition() stores under it. The store then
+ * fails instead of landing on a recreated partition or under a newer
+ * consumer-group owner.
  */
 class PartitionContext final {
   public:
     /**
-     * @brief Creates a context from the values a poll or a read returned.
+     * @brief Creates a context from the values that Partition::Context()
+     *        reports.
      * @param incarnation Creation revision of the partition. It changes when a
      *        deleted partition ID is created again.
      * @param owner_generation Consumer-group owner generation, or zero outside
@@ -1484,11 +1495,9 @@
     [[nodiscard]] std::uint64_t MetadataOp() const noexcept { return metadata_op_; }
 
   private:
-    [[nodiscard]] ffi::PartitionContext ToFfi() const;
     static PartitionContext FromFfi(ffi::PartitionContext context);
 
     friend class Partition;
-    friend class IggyBlockingClient;
 
     std::uint64_t incarnation_;
     std::uint64_t owner_generation_;
@@ -5676,6 +5685,635 @@
 };
 
 /**
+ * @brief When the offsets of the messages that a read returns are stored.
+ */
+enum class CommitPolicy : std::uint8_t {
+    /** Nothing is stored. The caller commits checkpoints. The default. */
+    Manual = 0,
+    /**
+     * The server stores the offset of the last message of each read as part of the read, in one round trip. While the
+     * primary stays up a message is delivered at most once. A failover before the store replicates can deliver the
+     * last batch again.
+     */
+    OnRead = 1,
+    /** A reader stores the latest counted position, as its AutoCommit says. A one-shot poll refuses it. */
+    Auto = 2,
+};
+
+/**
+ * @brief When a message counts for CommitPolicy::Auto.
+ */
+enum class CommitAfter : std::uint8_t {
+    /** When the caller asks for the next batch or closes the reader. */
+    Processing = 0,
+    /** When the batch is handed over. */
+    Delivery = 1,
+    /** Only when the caller marks its checkpoint with PartitionReader::Mark(). */
+    Marked = 2,
+};
+
+/**
+ * @brief The event on which CommitPolicy::Auto also stores.
+ */
+class CommitEvery final {
+  public:
+    enum class Kind : std::uint8_t {
+        /** Each counted batch of a reader. */
+        Message = 0,
+        /** Count() counted messages of a partition since its last store. */
+        Messages = 1,
+        /** The last message of each batch. */
+        Batch = 2,
+    };
+
+    static CommitEvery Message() { return CommitEvery(Kind::Message, 0); }
+
+    /** @param count Counted messages between two stores. Zero is refused when the options are used. */
+    static CommitEvery Messages(std::uint32_t count) { return CommitEvery(Kind::Messages, count); }
+
+    static CommitEvery Batch() { return CommitEvery(Kind::Batch, 0); }
+
+    [[nodiscard]] Kind GetKind() const noexcept { return kind_; }
+
+    /** @return The count of Kind::Messages, zero otherwise. */
+    [[nodiscard]] std::uint32_t Count() const noexcept { return count_; }
+
+  private:
+    CommitEvery(Kind kind, std::uint32_t count) : kind_(kind), count_(count) {}
+
+    Kind kind_;
+    std::uint32_t count_;
+};
+
+/**
+ * @brief When CommitPolicy::Auto stores.
+ *
+ * A default AutoCommit stores every 10,000 messages or every 5 seconds,
+ * whichever comes first, after processing. It also stores when the reader
+ * closes. It needs an interval or an event, otherwise the options are refused
+ * when they are used.
+ */
+class AutoCommit final {
+  public:
+    AutoCommit();
+
+    /** @return The interval of the stores, or `std::nullopt`. */
+    [[nodiscard]] std::optional<std::chrono::microseconds> Interval() const noexcept { return interval_; }
+
+    /** @param interval Store the latest counted position at this interval, not zero, or never on a timer. */
+    AutoCommit &SetInterval(std::optional<std::chrono::microseconds> interval) noexcept {
+        interval_ = interval;
+        return *this;
+    }
+
+    /** @return The event of the stores, or `std::nullopt`. */
+    [[nodiscard]] const std::optional<CommitEvery> &Every() const noexcept { return every_; }
+
+    /** @param every Also store on this event, or on none. */
+    AutoCommit &SetEvery(std::optional<CommitEvery> every) noexcept {
+        every_ = every;
+        return *this;
+    }
+
+    [[nodiscard]] CommitAfter After() const noexcept { return after_; }
+
+    AutoCommit &SetAfter(CommitAfter after) noexcept {
+        after_ = after;
+        return *this;
+    }
+
+  private:
+    std::optional<std::chrono::microseconds> interval_;
+    std::optional<CommitEvery> every_;
+    CommitAfter after_ = CommitAfter::Processing;
+};
+
+/**
+ * @brief The limits of one read and when its offsets are stored.
+ *
+ * A read is a poll of one partition, or a fetch that a PartitionReader sends for
+ * several partitions of one primary. A fetch applies Count(), MinCount() and
+ * MaxBytes() to all of its partitions together. A reader that commits nothing
+ * runs several fetches at once, each within these limits, for partitions with
+ * more messages waiting.
+ *
+ * A default PollOptions reads up to 1000 messages and 1 MiB, answers at once,
+ * waits for at least one message and stores nothing. A reader or a poll checks
+ * the limits when it uses them and throws IggyException for an invalid one.
+ */
+class PollOptions final {
+  public:
+    PollOptions();
+
+    [[nodiscard]] std::uint32_t Count() const noexcept { return options_.count; }
+
+    /** @param count Most messages in one read, over all partitions of a fetch. */
+    PollOptions &SetCount(std::uint32_t count) noexcept {
+        options_.count = count;
+        return *this;
+    }
+
+    [[nodiscard]] std::chrono::microseconds MaxWait() const noexcept {
+        return std::chrono::microseconds(options_.max_wait_micros);
+    }
+
+    /** @param max_wait How long the server waits for MinCount() messages, at most 600 s. Zero or less answers at once.
+     */
+    PollOptions &SetMaxWait(std::chrono::microseconds max_wait) noexcept {
+        options_.max_wait_micros = max_wait.count() > 0 ? static_cast<std::uint64_t>(max_wait.count()) : 0;
+        return *this;
+    }
+
+    [[nodiscard]] std::uint32_t MinCount() const noexcept { return options_.min_count; }
+
+    /** @param min_count Messages to wait for, from 1 to Count(), over all partitions of a fetch. */
+    PollOptions &SetMinCount(std::uint32_t min_count) noexcept {
+        options_.min_count = min_count;
+        return *this;
+    }
+
+    [[nodiscard]] std::uint32_t MaxBytes() const noexcept { return options_.max_bytes; }
+
+    /** @param max_bytes Most bytes in one read, over all partitions of a fetch. A first message larger than that comes
+     * alone. */
+    PollOptions &SetMaxBytes(std::uint32_t max_bytes) noexcept {
+        options_.max_bytes = max_bytes;
+        return *this;
+    }
+
+    [[nodiscard]] CommitPolicy Commit() const noexcept { return static_cast<CommitPolicy>(options_.commit); }
+
+    /** @param commit When offsets are stored. CommitPolicy::Auto uses the AutoCommit of SetAutoCommit(), the default
+     * one until then. */
+    PollOptions &SetCommit(CommitPolicy commit) noexcept {
+        options_.commit = static_cast<ffi::CommitPolicy>(commit);
+        return *this;
+    }
+
+    /** @return The AutoCommit that CommitPolicy::Auto uses. */
+    [[nodiscard]] AutoCommit AutoCommitSettings() const;
+
+    /** @brief Sets CommitPolicy::Auto with these settings. */
+    PollOptions &SetAutoCommit(const AutoCommit &auto_commit);
+
+  private:
+    friend class IggyBlockingClient;
+    friend class ReaderOptions;
+    friend class ConsumerOptions;
+
+    ffi::PollOptions options_;
+};
+
+/**
+ * @brief The position after one delivered message, bound to its partition and
+ *        to the partition history that delivered it.
+ *
+ * Only a polled batch makes one. A commit of it after the partition was deleted
+ * and created again, or after its consumer group owner changed, is refused
+ * instead of landing on the new partition.
+ */
+class Checkpoint final {
+  public:
+    Checkpoint(const Checkpoint &other) : inner_(other.inner_->copy()) {}
+    Checkpoint(Checkpoint &&) noexcept = default;
+    Checkpoint &operator=(const Checkpoint &other) {
+        if (this != &other) {
+            inner_ = other.inner_->copy();
+        }
+        return *this;
+    }
+    Checkpoint &operator=(Checkpoint &&) noexcept = default;
+    ~Checkpoint()                                 = default;
+
+    [[nodiscard]] std::uint32_t PartitionId() const { return inner_->partition_id(); }
+
+    [[nodiscard]] std::uint64_t Offset() const { return inner_->offset(); }
+
+  private:
+    explicit Checkpoint(rust::Box<ffi::Checkpoint> inner) : inner_(std::move(inner)) {}
+
+    friend class PolledMessages;
+    friend class PartitionReader;
+    friend class ReceivedMessage;
+    friend class IggyConsumer;
+    friend class IggyBlockingClient;
+
+    rust::Box<ffi::Checkpoint> inner_;
+};
+
+/**
+ * @brief Where the next read of one partition starts after a batch.
+ *
+ * It belongs to the partition of its batch, and a read or a seek of another
+ * partition refuses it with InvalidCommand (3).
+ */
+class Continuation final {
+  public:
+    Continuation(const Continuation &other) : inner_(other.inner_->copy()) {}
+    Continuation(Continuation &&) noexcept = default;
+    Continuation &operator=(const Continuation &other) {
+        if (this != &other) {
+            inner_ = other.inner_->copy();
+        }
+        return *this;
+    }
+    Continuation &operator=(Continuation &&) noexcept = default;
+    ~Continuation()                                   = default;
+
+    /** @return The strategy kind: offset, timestamp, first, last or next. */
+    [[nodiscard]] std::string Kind() const { return std::string(inner_->kind()); }
+
+    [[nodiscard]] std::uint64_t Value() const { return inner_->value(); }
+
+  private:
+    explicit Continuation(rust::Box<ffi::Continuation> inner) : inner_(std::move(inner)) {}
+
+    friend class PolledMessages;
+    friend class PartitionReader;
+    friend class IggyConsumer;
+    friend class ReadException;
+    friend class IggyBlockingClient;
+
+    rust::Box<ffi::Continuation> inner_;
+};
+
+/**
+ * @brief One batch of one partition, as a poll or a reader delivered it.
+ *
+ * Its checkpoints name the messages as they were delivered.
+ */
+class PolledMessages final {
+  public:
+    [[nodiscard]] std::uint32_t PartitionId() const noexcept { return partition_id_; }
+
+    [[nodiscard]] std::uint64_t CurrentOffset() const noexcept { return current_offset_; }
+
+    [[nodiscard]] const std::vector<IggyMessagePolled> &Messages() const noexcept { return messages_; }
+
+    /**
+     * @brief The position after the last message, to commit the whole batch.
+     * @throws IggyException for an empty batch.
+     */
+    [[nodiscard]] ::iggy::Checkpoint Checkpoint() const;
+
+    /**
+     * @brief The position after the message at `index`, to commit a processed part of the batch.
+     * @throws IggyException for an index out of range.
+     */
+    [[nodiscard]] ::iggy::Checkpoint CheckpointAt(std::size_t index) const;
+
+    /** @brief The position after the batch, for the next read of its partition. */
+    [[nodiscard]] ::iggy::Continuation Continuation() const;
+
+  private:
+    explicit PolledMessages(ffi::PolledMessages batch);
+
+    friend class PartitionReader;
+    friend class PendingPoll;
+    friend class IggyBlockingClient;
+
+    std::uint32_t partition_id_;
+    std::uint64_t current_offset_;
+    std::vector<IggyMessagePolled> messages_;
+    // Keeps the delivered positions. Its messages were moved into messages_.
+    ffi::PolledMessages batch_;
+};
+
+/**
+ * @brief A failed read of a PartitionReader. The reader stays usable after it
+ *        and retries by itself.
+ */
+class ReadException final : public IggyException {
+  public:
+    /** @return The partition of the error, or `std::nullopt` for an error of the whole reader. */
+    [[nodiscard]] std::optional<std::uint32_t> PartitionId() const noexcept { return partition_id_; }
+
+    /**
+     * @return For a message that does not decode, the continuation after it,
+     *         to pass to PartitionReader::Seek(), otherwise `nullptr`.
+     */
+    [[nodiscard]] const ::iggy::Continuation *Skip() const noexcept { return skip_.get(); }
+
+  private:
+    ReadException(const std::string &message, ffi::ReadErrorInfo &error);
+
+    friend class PartitionReader;
+    friend class IggyConsumer;
+
+    std::optional<std::uint32_t> partition_id_;
+    // Shared, so that copying the exception cannot throw.
+    std::shared_ptr<const ::iggy::Continuation> skip_;
+};
+
+/**
+ * @brief The settings of a PartitionReader. A setting left unset keeps its default.
+ */
+class ReaderOptions final {
+  public:
+    ReaderOptions() = default;
+
+    /** @param partitions The partitions of a standalone consumer, which needs them. A consumer group reads its
+     * assignment. */
+    ReaderOptions &SetPartitions(std::vector<std::uint32_t> partitions) {
+        partitions_ = std::move(partitions);
+        return *this;
+    }
+
+    /** @param start Where a partition without a stored offset starts. Default next. A continuation is refused. */
+    ReaderOptions &SetStart(const PollingStrategy &start) {
+        start_ = start;
+        return *this;
+    }
+
+    /** @param options The options of each request. Default: the poll defaults with a five-second wait. */
+    ReaderOptions &SetPollOptions(const PollOptions &options) {
+        poll_options_ = options;
+        return *this;
+    }
+
+    /** @param prefetch_bytes Encoded bytes of the requests in flight and the queued batches. Default 16 MiB. */
+    ReaderOptions &SetPrefetchBytes(std::uint32_t prefetch_bytes) noexcept {
+        prefetch_bytes_ = prefetch_bytes;
+        return *this;
+    }
+
+    /** @param prefetch_messages Messages in flight and queued. Default: the larger of 16,000 and the count. */
+    ReaderOptions &SetPrefetchMessages(std::uint32_t prefetch_messages) noexcept {
+        prefetch_messages_ = prefetch_messages;
+        return *this;
+    }
+
+  private:
+    [[nodiscard]] ffi::ReaderOptions ToFfi() const;
+
+    friend class IggyBlockingClient;
+
+    std::vector<std::uint32_t> partitions_;
+    std::optional<PollingStrategy> start_;
+    std::optional<PollOptions> poll_options_;
+    std::optional<std::uint32_t> prefetch_bytes_;
+    std::optional<std::uint32_t> prefetch_messages_;
+};
+
+/**
+ * @brief A poll that runs in the background, from
+ *        IggyBlockingClient::StartPollMessages().
+ *
+ * Wait for its batch in steps, or cancel it. Destroying it cancels the poll.
+ */
+class PendingPoll final {
+  public:
+    PendingPoll(PendingPoll &&) noexcept            = default;
+    PendingPoll &operator=(PendingPoll &&) noexcept = default;
+    PendingPoll(const PendingPoll &)                = delete;
+    PendingPoll &operator=(const PendingPoll &)     = delete;
+
+    /**
+     * @brief Waits up to `timeout` for the batch of the poll.
+     * @return The batch, or `std::nullopt` while the poll still runs. The
+     *         batch is empty when a deferred poll found no message in its max wait.
+     * @throws IggyException for a failed poll, and after the batch was
+     *         returned or the poll was cancelled.
+     */
+    std::optional<PolledMessages> Wait(std::chrono::microseconds timeout);
+
+    /**
+     * @brief Drops the request, also while another thread waits for it.
+     *
+     * A parked deferred poll is cancelled on the server. A poll with
+     * CommitPolicy::OnRead that the server had not answered stores nothing. It
+     * returns after the request was dropped and does nothing after the poll ended.
+     */
+    void Cancel();
+
+  private:
+    explicit PendingPoll(rust::Box<ffi::PendingPoll> poll) : poll_(std::move(poll)) {}
+
+    friend class IggyBlockingClient;
+
+    rust::Box<ffi::PendingPoll> poll_;
+};
+
+/**
+ * @brief Reads the partitions of a topic in batches, with routing, prefetch
+ *        and the position of each partition inside.
+ *
+ * IggyBlockingClient::Reader() builds one. Its calls are safe from several
+ * threads and run one at a time. Destroying it stops its work without waiting,
+ * moves no offset back and does not leave a consumer group: call Close().
+ */
+class PartitionReader final {
+  public:
+    PartitionReader(PartitionReader &&) noexcept            = default;
+    PartitionReader &operator=(PartitionReader &&) noexcept = default;
+    PartitionReader(const PartitionReader &)                = delete;
+    PartitionReader &operator=(const PartitionReader &)     = delete;
+
+    /**
+     * @brief Waits up to `timeout` for the next batch of one partition.
+     * @return The batch, or `std::nullopt` when the time ran out. A batch that
+     *         arrives later stays in the reader.
+     * @throws ReadException for a failed read, IggyException after Close().
+     */
+    std::optional<PolledMessages> Next(std::chrono::microseconds timeout);
+
+    /**
+     * @brief Stores the offset of a checkpoint that this reader delivered.
+     * @throws IggyException with CommitPolicy::OnRead, for a checkpoint this
+     *         reader did not deliver, and for a failed store.
+     */
+    void Commit(const Checkpoint &checkpoint);
+
+    /**
+     * @brief Counts a checkpoint that this reader delivered under CommitPolicy::Auto.
+     * @throws IggyException without CommitPolicy::Auto and for a checkpoint this reader did not deliver.
+     */
+    void Mark(const Checkpoint &checkpoint);
+
+    /**
+     * @brief Sets the next read position of a partition and drops its queued batches.
+     *
+     * With CommitPolicy::OnRead, a seek to an offset above 0 first stores the
+     * offset before it, so a restart resumes at the new position. Every other
+     * seek stores nothing.
+     * @throws IggyException for a partition this reader does not read, and for
+     *         a failed store, after which nothing moved.
+     */
+    void Seek(std::uint32_t partition_id, const PollingStrategy &start);
+
+    /** @brief Moves a partition to a continuation of one of its batches, or past a message with ReadException::Skip().
+     */
+    void Seek(std::uint32_t partition_id, const Continuation &continuation);
+
+    /** @return The partitions of a standalone consumer, or the current assignment of a group member. */
+    [[nodiscard]] std::vector<std::uint32_t> Partitions() const;
+
+    /**
+     * @brief Stops the reader. With CommitPolicy::Auto it stores the latest
+     *        counted positions, with CommitPolicy::OnRead it moves the offsets
+     *        back to the last delivered messages, and a group reader then
+     *        leaves its group. A second call does nothing.
+     */
+    void Close();
+
+  private:
+    explicit PartitionReader(rust::Box<ffi::PartitionReader> reader) : reader_(std::move(reader)) {}
+
+    friend class IggyBlockingClient;
+
+    rust::Box<ffi::PartitionReader> reader_;
+};
+
+/**
+ * @brief One message that an IggyConsumer handed over.
+ */
+class ReceivedMessage final {
+  public:
+    [[nodiscard]] std::uint32_t PartitionId() const noexcept { return partition_id_; }
+
+    /** @return The offset of the newest message of the partition when it was polled. */
+    [[nodiscard]] std::uint64_t CurrentOffset() const noexcept { return current_offset_; }
+
+    [[nodiscard]] const IggyMessagePolled &Message() const noexcept { return message_; }
+
+    /** @return The position after the message, to commit or mark it. */
+    [[nodiscard]] const ::iggy::Checkpoint &Checkpoint() const noexcept { return checkpoint_; }
+
+  private:
+    // Copies the checkpoint, since a Box cannot leave a rust::Vec.
+    explicit ReceivedMessage(ffi::ReceivedMessage &received);
+
+    friend class IggyConsumer;
+
+    std::uint32_t partition_id_;
+    std::uint64_t current_offset_;
+    IggyMessagePolled message_;
+    ::iggy::Checkpoint checkpoint_;
+};
+
+/**
+ * @brief The settings of an IggyConsumer. A setting left unset keeps its default.
+ */
+class ConsumerOptions final {
+  public:
+    ConsumerOptions() = default;
+
+    /** @param partitions The partitions of a standalone consumer, which needs them. A group consumer ignores them. */
+    ConsumerOptions &SetPartitions(std::vector<std::uint32_t> partitions) {
+        partitions_ = std::move(partitions);
+        return *this;
+    }
+
+    /** @param start Where a partition without a stored offset starts. Default next. */
+    ConsumerOptions &SetStart(const PollingStrategy &start) {
+        start_ = start;
+        return *this;
+    }
+
+    /** @param options The options of each request and the commit policy. Default: the poll defaults with a five-second
+     * wait. */
+    ConsumerOptions &SetPollOptions(const PollOptions &options) {
+        poll_options_ = options;
+        return *this;
+    }
+
+    /** @param prefetch_bytes Encoded bytes of the requests in flight and the queued batches. Default 16 MiB. */
+    ConsumerOptions &SetPrefetchBytes(std::uint32_t prefetch_bytes) noexcept {
+        prefetch_bytes_ = prefetch_bytes;
+        return *this;
+    }
+
+    /** @param prefetch_messages Messages in flight and queued. Default: the larger of 16,000 and the count. */
+    ConsumerOptions &SetPrefetchMessages(std::uint32_t prefetch_messages) noexcept {
+        prefetch_messages_ = prefetch_messages;
+        return *this;
+    }
+
+    /** @param allow_replay Hand over again the messages that a seek back reads twice. Default false. */
+    ConsumerOptions &SetAllowReplay(bool allow_replay) noexcept {
+        allow_replay_ = allow_replay;
+        return *this;
+    }
+
+  private:
+    [[nodiscard]] ffi::ConsumerOptions ToFfi() const;
+
+    friend class IggyBlockingClient;
+
+    std::vector<std::uint32_t> partitions_;
+    std::optional<PollingStrategy> start_;
+    std::optional<PollOptions> poll_options_;
+    std::optional<std::uint32_t> prefetch_bytes_;
+    std::optional<std::uint32_t> prefetch_messages_;
+    bool allow_replay_ = false;
+};
+
+/**
+ * @brief Hands over the messages of a topic one at a time, through one
+ *        PartitionReader, with the commit policy of its PollOptions.
+ *
+ * IggyBlockingClient::StandaloneConsumer() and GroupConsumer() build one. Its
+ * calls are safe from several threads and run one at a time. Destroying it
+ * stops its work without waiting and stores nothing: call Close().
+ */
+class IggyConsumer final {
+  public:
+    IggyConsumer(IggyConsumer &&) noexcept            = default;
+    IggyConsumer &operator=(IggyConsumer &&) noexcept = default;
+    IggyConsumer(const IggyConsumer &)                = delete;
+    IggyConsumer &operator=(const IggyConsumer &)     = delete;
+
+    /**
+     * @brief Waits up to `timeout` for the next message.
+     *
+     * Under CommitAfter::Processing it counts the message handed over before.
+     *
+     * @return The message, or `std::nullopt` when the time ran out.
+     * @throws ReadException for a failed read, IggyException after Close().
+     */
+    std::optional<ReceivedMessage> Next(std::chrono::microseconds timeout);
+
+    /**
+     * @brief Stores the offset of a checkpoint that this consumer handed over.
+     * @throws IggyException with CommitPolicy::OnRead and for a failed store.
+     */
+    void Commit(const Checkpoint &checkpoint);
+
+    /**
+     * @brief Counts a checkpoint that this consumer handed over under CommitPolicy::Auto.
+     * @throws IggyException without CommitPolicy::Auto and for a checkpoint this consumer did not hand over.
+     */
+    void Mark(const Checkpoint &checkpoint);
+
+    /**
+     * @brief Sets the next read position of a partition.
+     *
+     * With CommitPolicy::OnRead, a seek to an offset above 0 first stores the
+     * offset before it, so a restart resumes at the new position. Every other
+     * seek stores nothing.
+     * @throws IggyException for a partition this consumer does not read, and
+     *         for a failed store, after which nothing moved.
+     */
+    void Seek(std::uint32_t partition_id, const PollingStrategy &start);
+    void Seek(std::uint32_t partition_id, const Continuation &continuation);
+
+    /**
+     * @brief Stops the consumer. With CommitPolicy::Auto it stores the latest
+     *        counted positions, with CommitPolicy::OnRead it moves the offsets
+     *        back to the last handed over messages, and a group consumer then
+     *        leaves its group. A second call does nothing.
+     */
+    void Close();
+
+  private:
+    explicit IggyConsumer(rust::Box<ffi::Consumer> consumer) : consumer_(std::move(consumer)) {}
+
+    friend class IggyBlockingClient;
+
+    rust::Box<ffi::Consumer> consumer_;
+};
+
+/**
  * @brief Owning client connection to an Apache Iggy server.
  *
  * Create instances with Builder or FromConnectionString(). The client owns a
@@ -5699,7 +6337,10 @@
  * - CreatePartitions(), DeletePartitions(), and DeleteSegments().
  * - CreateConsumerGroup(), DeleteConsumerGroup(), JoinConsumerGroup(), and
  *   LeaveConsumerGroup().
- * - StoreConsumerOffset(), StoreConsumerPosition(), and DeleteConsumerOffset().
+ * - StoreConsumerOffset(), StoreConsumerPosition(), DeleteConsumerOffset(), and
+ *   DeleteConsumerPosition().
+ * - PollMessages(), Reader(), StandaloneConsumer(), GroupConsumer(),
+ *   PendingPoll::Wait(), and the calls of PartitionReader and IggyConsumer.
  * - GetMetadataChanges().
  *
  * Other methods report errors through the exception message only. C++ and
@@ -5872,8 +6513,11 @@
      * changes the client state to disconnected, but keeps the client reusable.
      * Call Connect() to establish a new connection. Configured automatic login
      * is applied when reconnecting.
-     * For binary transports, disconnect also drops all retained operations
+     * For binary transports, disconnect also drops all retained mutations
      * without settling their outcomes. They cannot be resolved after reconnecting.
+     * A poll with CommitPolicy::OnRead that waits fails with
+     * fetch_progress_uncertain, and its partition stays blocked across the
+     * reconnect until AbandonOperation().
      *
      * @note The disconnect holds until the next Connect(): requests fail and
      *       nothing reconnects the client in the background, even with
@@ -6428,30 +7072,150 @@
                              std::uint64_t offset);
 
     /**
-     * @brief Stores an offset under the partition context that delivered it.
+     * @brief Stores the offset of a checkpoint under the partition context
+     *        that delivered it.
      *
-     * Every retry keeps @p context. The store fails instead of landing on a
-     * recreated partition or under a newer consumer-group owner. Otherwise it
-     * behaves as StoreConsumerOffset() with @p partition_id set.
+     * Every retry keeps the context of @p checkpoint. The store fails instead
+     * of landing on a recreated partition or under a newer consumer-group
+     * owner. Otherwise it behaves as StoreConsumerOffset() with the partition
+     * of @p checkpoint.
      *
      * @param consumer Consumer identity that owns the offset.
      * @param stream Parent stream, addressed by numeric ID or name.
      * @param topic Parent topic, addressed by numeric ID or name.
-     * @param partition_id Partition that delivered @p offset.
-     * @param offset Message offset to store.
-     * @param context Context the poll that delivered @p offset returned.
+     * @param checkpoint Position after a delivered message, from a batch
+     *        that PollMessages() or PartitionReader::Next() returned.
      * @throws IggyException if the partition was deleted or recreated after
-     *         @p context was captured (HistoryUnavailable, 87); this client no
-     *         longer owns the partition in the consumer group
-     *         (ConsumerGroupPartitionNotOwned, 5009); or for any reason
+     *         the context of @p checkpoint was captured (HistoryUnavailable,
+     *         87), if this client no longer owns the partition in the consumer
+     *         group (ConsumerGroupPartitionNotOwned, 5009), or for any reason
      *         StoreConsumerOffset() throws.
      */
     void StoreConsumerPosition(const Consumer &consumer,
                                const Identifier &stream,
                                const Identifier &topic,
-                               std::uint32_t partition_id,
-                               std::uint64_t offset,
-                               const PartitionContext &context);
+                               const Checkpoint &checkpoint);
+
+    /**
+     * @brief Deletes the stored offset of the partition of a checkpoint under
+     *        the partition context that delivered it.
+     *
+     * The delete fails instead of removing the offset of a recreated
+     * partition or of a newer consumer-group owner. The offset of
+     * @p checkpoint is not used.
+     *
+     * @param consumer Consumer identity that owns the offset.
+     * @param stream Parent stream, addressed by numeric ID or name.
+     * @param topic Parent topic, addressed by numeric ID or name.
+     * @param checkpoint Position after a delivered message of the partition,
+     *        from a batch that PollMessages() or PartitionReader::Next()
+     *        returned.
+     * @throws IggyException if no offset is stored (ConsumerOffsetNotFound,
+     *         3021), if the partition was deleted or recreated after the
+     *         context of @p checkpoint was captured (HistoryUnavailable, 87),
+     *         or if this client no longer owns the partition in the consumer
+     *         group (ConsumerGroupPartitionNotOwned, 5009).
+     */
+    void DeleteConsumerPosition(const Consumer &consumer,
+                                const Identifier &stream,
+                                const Identifier &topic,
+                                const Checkpoint &checkpoint);
+
+    /**
+     * @brief Reads one partition once.
+     *
+     * A strategy takes the partition context that the cached route reports.
+     * After another client deleted and recreated the partition, one call can
+     * fail with HistoryUnavailable (87), or ConsumerGroupPartitionNotOwned
+     * (5009) for a consumer group. The failed route is dropped and the next
+     * call routes again.
+     *
+     * With CommitPolicy::OnRead, a lost reply fails with
+     * FetchProgressUncertain (14010) and blocks the partition on this client:
+     * later polls of it fail with PartitionLaneBlocked (14009) until
+     * AbandonOperation() of the operation that the error names.
+     *
+     * @param consumer Consumer identity of the read.
+     * @param stream Parent stream, addressed by numeric ID or name.
+     * @param topic Parent topic, addressed by numeric ID or name.
+     * @param partition_id Partition to read.
+     * @param strategy Where the read starts.
+     * @param options Limits of the read and when its offset is stored.
+     * @return The batch, empty when the partition has no message there.
+     * @throws IggyException for CommitPolicy::Auto (invalid_configuration),
+     *         invalid options, or a failed read.
+     */
+    PolledMessages PollMessages(const Consumer &consumer,
+                                const Identifier &stream,
+                                const Identifier &topic,
+                                std::uint32_t partition_id,
+                                const PollingStrategy &strategy,
+                                const PollOptions &options = PollOptions());
+
+    /**
+     * @brief Reads on from the continuation of a batch of the same partition.
+     * @throws IggyException as the strategy overload does, and with
+     *         InvalidCommand (3) for a continuation of another partition.
+     */
+    PolledMessages PollMessages(const Consumer &consumer,
+                                const Identifier &stream,
+                                const Identifier &topic,
+                                std::uint32_t partition_id,
+                                const Continuation &continuation,
+                                const PollOptions &options = PollOptions());
+
+    /**
+     * @brief Builds a reader of the partitions of a topic.
+     *
+     * A consumer group reads the assignment of its member: the reader joins
+     * the group here, joins again after a session loss, and leaves it in
+     * PartitionReader::Close().
+     *
+     * @param consumer Consumer identity of the reader.
+     * @param stream Parent stream, addressed by numeric ID or name.
+     * @param topic Parent topic, addressed by numeric ID or name.
+     * @param options Settings of the reader. A standalone consumer needs its partitions.
+     * @throws IggyException for invalid options or a failed join.
+     */
+    PartitionReader Reader(const Consumer &consumer,
+                           const Identifier &stream,
+                           const Identifier &topic,
+                           const ReaderOptions &options = ReaderOptions());
+
+    /**
+     * @brief Starts a poll in the background, as PollMessages() does, so the
+     *        caller can wait for its batch in steps or cancel it.
+     * @throws IggyException for invalid arguments. A failed poll throws from PendingPoll::Wait().
+     */
+    PendingPoll StartPollMessages(const Consumer &consumer,
+                                  const Identifier &stream,
+                                  const Identifier &topic,
+                                  std::uint32_t partition_id,
+                                  const PollingStrategy &strategy,
+                                  const PollOptions &options = PollOptions());
+
+    /**
+     * @brief Builds and initializes a standalone consumer named `name` that
+     *        reads the partitions of @p options.
+     * @throws IggyException for invalid options or a failed initialization.
+     */
+    IggyConsumer StandaloneConsumer(const std::string &name,
+                                    const Identifier &stream,
+                                    const Identifier &topic,
+                                    const ConsumerOptions &options);
+
+    /**
+     * @brief Builds and initializes a member of the consumer group `name`.
+     *
+     * It creates the group if it does not exist, joins it here, reads the
+     * assignment of its member, and leaves the group in IggyConsumer::Close().
+     *
+     * @throws IggyException for invalid options or a failed join.
+     */
+    IggyConsumer GroupConsumer(const std::string &name,
+                               const Identifier &stream,
+                               const Identifier &topic,
+                               const ConsumerOptions &options = ConsumerOptions());
 
     /**
      * @brief Retrieves the stored offset for a consumer or consumer group.
@@ -6618,7 +7382,7 @@
      * An operation_uncertain result keeps the original frame and lane for a later
      * resolve. operation_not_retained does not prove that the original command
      * failed or make resending it safe. Disconnect() and session reset drop
-     * retained operations without settling them or allowing later recovery.
+     * retained mutations without settling them or allowing later recovery.
      * The client keeps the proved outcomes of its last max_operations replays,
      * also across a session reset. A later resolve of a proved operation returns
      * the same outcome without a request.
@@ -6636,10 +7400,12 @@
     /**
      * @brief Releases a retained operation and its local lane.
      *
-     * Abandonment does not undo or determine the server result. HTTP reports
-     * feature_unavailable because it does not retain operations.
+     * Abandonment does not undo or determine the server result. HTTP retains
+     * only the poll operations of fetch_progress_uncertain, so it reports
+     * operation_not_retained for every other operation.
      *
-     * @param operation_id ID carried by operation_uncertain or lane_blocked.
+     * @param operation_id ID carried by operation_uncertain, lane_blocked,
+     *        fetch_progress_uncertain, or partition_lane_blocked.
      * @throws IggyException if the operation is not retained, recovery is
      *         unsupported, or the client handle is unavailable.
      */
diff --git a/foreign/cpp/src/client.cpp b/foreign/cpp/src/client.cpp
index a0b1605..329ad1a 100644
--- a/foreign/cpp/src/client.cpp
+++ b/foreign/cpp/src/client.cpp
@@ -455,13 +455,20 @@
 void IggyBlockingClient::StoreConsumerPosition(const Consumer &consumer,
                                                const Identifier &stream,
                                                const Identifier &topic,
-                                               const std::uint32_t partition_id,
-                                               const std::uint64_t offset,
-                                               const PartitionContext &context) {
-    RethrowAsIggyException([this, &consumer, &stream, &topic, partition_id, offset,
-                            &context](ffi::ErrorInfo &error_info) {
-        Handle()->store_consumer_position(stream.ToFfi(), topic.ToFfi(), partition_id, std::string(consumer.KindName()),
-                                          consumer.Id().ToFfi(), offset, context.ToFfi(), error_info);
+                                               const Checkpoint &checkpoint) {
+    RethrowAsIggyException([this, &consumer, &stream, &topic, &checkpoint](ffi::ErrorInfo &error_info) {
+        Handle()->store_consumer_position(stream.ToFfi(), topic.ToFfi(), std::string(consumer.KindName()),
+                                          consumer.Id().ToFfi(), *checkpoint.inner_, error_info);
+    });
+}
+
+void IggyBlockingClient::DeleteConsumerPosition(const Consumer &consumer,
+                                                const Identifier &stream,
+                                                const Identifier &topic,
+                                                const Checkpoint &checkpoint) {
+    RethrowAsIggyException([this, &consumer, &stream, &topic, &checkpoint](ffi::ErrorInfo &error_info) {
+        Handle()->delete_consumer_position(stream.ToFfi(), topic.ToFfi(), std::string(consumer.KindName()),
+                                           consumer.Id().ToFfi(), *checkpoint.inner_, error_info);
     });
 }
 
diff --git a/foreign/cpp/src/client.rs b/foreign/cpp/src/client.rs
index cfc1eae..7ff2d3d 100644
--- a/foreign/cpp/src/client.rs
+++ b/foreign/cpp/src/client.rs
@@ -16,20 +16,24 @@
 // under the License.
 
 use crate::{
-    RUNTIME, ffi,
+    RUNTIME,
+    consumer::Consumer as ConsumerHandle,
+    ffi,
+    messages::{Checkpoint, Continuation},
+    pending_poll::PendingPoll,
+    reader::PartitionReader,
     type_conversion::{ffi_options_to_raw, parse_message_expiry},
 };
 use bytes::Bytes;
 use iggy::prelude::{
     AutoLogin as RustAutoLogin, Client as IggyConnectionClient, ClusterClient, Consumer,
-    ConsumerGroupClient, ConsumerOffsetClient, ConsumerPosition, Identifier as RustIdentifier,
+    ConsumerGroupClient, ConsumerOffsetClient, Identifier as RustIdentifier,
     IggyClient as RustIggyClient, IggyClientBuilder as RustIggyClientBuilder,
-    IggyDuration as RustIggyDuration, IggyMessage, IggyTimestamp, MessageClient,
+    IggyDuration as RustIggyDuration, IggyMessage, MessageClient,
     NonZeroIggyDuration as RustNonZeroIggyDuration, OptionsScope as RustOptionsScope,
-    PartitionClient, PartitionContext, Partitioning, Permissions as RustPermissions,
-    PersonalAccessTokenClient, PollingStrategy, SegmentClient,
-    SnapshotCompression as RustSnapshotCompression, StreamClient,
-    StreamCreateOptions as RustStreamCreateOptions, StreamEncryptionKey,
+    PartitionClient, Partitioning, Permissions as RustPermissions, PersonalAccessTokenClient,
+    PollOptions, PollingStrategy, SegmentClient, SnapshotCompression as RustSnapshotCompression,
+    StreamClient, StreamCreateOptions as RustStreamCreateOptions, StreamEncryptionKey,
     StreamUpdateOptions as RustStreamUpdateOptions, SystemClient as RustSystemClient,
     SystemSnapshotType as RustSystemSnapshotType, TopicClient,
     TopicCreateOptions as RustTopicCreateOptions, TopicUpdateOptions as RustTopicUpdateOptions,
@@ -54,9 +58,9 @@
 use std::sync::Arc;
 use std::time::{Duration, Instant};
 
-/// Sentinel value passed from C++ to mean "no partition specified" — the server picks the
-/// partition based on the consumer/strategy. Cxx FFI does not support `Option<u32>`, so we
-/// reserve `u32::MAX` as the sentinel for `partition_id`.
+/// Sentinel value passed from C++ to the consumer offset calls to mean "no partition specified".
+/// Cxx FFI does not support `Option<u32>`, so we reserve `u32::MAX` as the sentinel for
+/// `partition_id`.
 const ANY_PARTITION_ID: u32 = u32::MAX;
 
 fn resolve_consumer(consumer_kind: &str, consumer_id: RustIdentifier) -> Result<Consumer, String> {
@@ -67,6 +71,33 @@
     }
 }
 
+/// The resolved parts of a poll request.
+struct PollRequest {
+    stream_id: RustIdentifier,
+    topic_id: RustIdentifier,
+    consumer: Consumer,
+    options: PollOptions,
+}
+
+impl PollRequest {
+    fn new(
+        stream_id: ffi::Identifier,
+        topic_id: ffi::Identifier,
+        consumer_kind: &str,
+        consumer_id: ffi::Identifier,
+        options: ffi::PollOptions,
+    ) -> Result<Self, String> {
+        let fail = |error: String| format!("Could not poll messages: {error}");
+        let consumer_id = RustIdentifier::try_from(consumer_id).map_err(fail)?;
+        Ok(Self {
+            stream_id: RustIdentifier::try_from(stream_id).map_err(fail)?,
+            topic_id: RustIdentifier::try_from(topic_id).map_err(fail)?,
+            consumer: resolve_consumer(consumer_kind, consumer_id).map_err(fail)?,
+            options: PollOptions::try_from(options).map_err(fail)?,
+        })
+    }
+}
+
 fn opt_partition(partition_id: u32) -> Option<u32> {
     if partition_id == ANY_PARTITION_ID {
         None
@@ -75,25 +106,6 @@
     }
 }
 
-fn polling_strategy(
-    kind: &str,
-    value: u64,
-    context: Option<PartitionContext>,
-) -> Result<PollingStrategy, String> {
-    let strategy = match kind {
-        "offset" => PollingStrategy::offset(value),
-        "timestamp" => PollingStrategy::timestamp(IggyTimestamp::from(value)),
-        "first" => PollingStrategy::first(),
-        "last" => PollingStrategy::last(),
-        "next" => PollingStrategy::next(),
-        _ => return Err(format!("invalid polling strategy: {kind}")),
-    };
-    Ok(match context {
-        Some(context) => strategy.with_context(context),
-        None => strategy,
-    })
-}
-
 /// The `id` of the JSON error body an HTTP refusal carries.
 #[derive(Deserialize)]
 struct HttpErrorBody {
@@ -101,7 +113,7 @@
 }
 
 impl ffi::ErrorInfo {
-    fn record(&mut self, error: &IggyError) {
+    pub(crate) fn record(&mut self, error: &IggyError) {
         self.code = error.as_code();
         self.kind = error.as_string().to_owned();
         // HTTP reports an option refusal only in its body, binary transports by the error itself.
@@ -115,6 +127,12 @@
         let operation = error.operation_id();
         self.has_operation_id = operation.is_some();
         self.operation_id = operation.unwrap_or_default();
+        let blocked_partition = match error {
+            IggyError::PartitionLaneBlocked(partition_id, _) => Some(*partition_id),
+            _ => None,
+        };
+        self.has_blocked_partition_id = blocked_partition.is_some();
+        self.blocked_partition_id = blocked_partition.unwrap_or_default();
     }
 }
 
@@ -488,53 +506,46 @@
         partition_id: u32,
         consumer_kind: String,
         consumer_id: ffi::Identifier,
-        polling_strategy_kind: String,
-        polling_strategy_value: u64,
-        count: u32,
-        auto_commit: bool,
+        strategy: ffi::PollingStrategy,
+        options: ffi::PollOptions,
         error_info: &mut ffi::ErrorInfo,
     ) -> Result<ffi::PolledMessages, String> {
+        *error_info = ffi::ErrorInfo::default();
+        let strategy = PollingStrategy::try_from(strategy)
+            .map_err(|error| format!("Could not poll messages: {error}"))?;
         self.poll(
             stream_id,
             topic_id,
             partition_id,
             consumer_kind,
             consumer_id,
-            polling_strategy_kind,
-            polling_strategy_value,
-            count,
-            auto_commit,
-            None,
+            &strategy,
+            options,
             error_info,
         )
     }
 
     #[allow(clippy::too_many_arguments)]
-    pub fn poll_messages_with_context(
+    pub fn poll_messages_from(
         &self,
         stream_id: ffi::Identifier,
         topic_id: ffi::Identifier,
         partition_id: u32,
         consumer_kind: String,
         consumer_id: ffi::Identifier,
-        polling_strategy_kind: String,
-        polling_strategy_value: u64,
-        count: u32,
-        auto_commit: bool,
-        context: ffi::PartitionContext,
+        continuation: &Continuation,
+        options: ffi::PollOptions,
         error_info: &mut ffi::ErrorInfo,
     ) -> Result<ffi::PolledMessages, String> {
+        *error_info = ffi::ErrorInfo::default();
         self.poll(
             stream_id,
             topic_id,
             partition_id,
             consumer_kind,
             consumer_id,
-            polling_strategy_kind,
-            polling_strategy_value,
-            count,
-            auto_commit,
-            Some(context.into()),
+            &continuation.0,
+            options,
             error_info,
         )
     }
@@ -547,36 +558,21 @@
         partition_id: u32,
         consumer_kind: String,
         consumer_id: ffi::Identifier,
-        polling_strategy_kind: String,
-        polling_strategy_value: u64,
-        count: u32,
-        auto_commit: bool,
-        context: Option<PartitionContext>,
+        strategy: &PollingStrategy,
+        options: ffi::PollOptions,
         error_info: &mut ffi::ErrorInfo,
     ) -> Result<ffi::PolledMessages, String> {
-        *error_info = ffi::ErrorInfo::default();
-        let rust_stream_id = RustIdentifier::try_from(stream_id)
-            .map_err(|error| format!("Could not poll messages: {error}"))?;
-        let rust_topic_id = RustIdentifier::try_from(topic_id)
-            .map_err(|error| format!("Could not poll messages: {error}"))?;
-        let rust_consumer_id = RustIdentifier::try_from(consumer_id)
-            .map_err(|error| format!("Could not poll messages: {error}"))?;
-        let consumer = resolve_consumer(&consumer_kind, rust_consumer_id)
-            .map_err(|error| format!("Could not poll messages: {error}"))?;
-        let strategy = polling_strategy(&polling_strategy_kind, polling_strategy_value, context)
-            .map_err(|error| format!("Could not poll messages: {error}"))?;
-
+        let request = PollRequest::new(stream_id, topic_id, &consumer_kind, consumer_id, options)?;
         RUNTIME.block_on(async {
             let polled = self
                 .inner
                 .poll_messages(
-                    &rust_stream_id,
-                    &rust_topic_id,
-                    opt_partition(partition_id),
-                    &consumer,
-                    &strategy,
-                    count,
-                    auto_commit,
+                    &request.stream_id,
+                    &request.topic_id,
+                    partition_id,
+                    &request.consumer,
+                    strategy,
+                    request.options,
                 )
                 .await
                 .inspect_err(|error| error_info.record(error))
@@ -585,6 +581,140 @@
         })
     }
 
+    #[allow(clippy::too_many_arguments)]
+    pub fn start_poll_messages(
+        &self,
+        stream_id: ffi::Identifier,
+        topic_id: ffi::Identifier,
+        partition_id: u32,
+        consumer_kind: String,
+        consumer_id: ffi::Identifier,
+        strategy: ffi::PollingStrategy,
+        options: ffi::PollOptions,
+    ) -> Result<Box<PendingPoll>, String> {
+        let strategy = PollingStrategy::try_from(strategy)
+            .map_err(|error| format!("Could not poll messages: {error}"))?;
+        let request = PollRequest::new(stream_id, topic_id, &consumer_kind, consumer_id, options)?;
+        let client = Arc::clone(&self.inner);
+        let task = RUNTIME.spawn(async move {
+            client
+                .poll_messages(
+                    &request.stream_id,
+                    &request.topic_id,
+                    partition_id,
+                    &request.consumer,
+                    &strategy,
+                    request.options,
+                )
+                .await
+        });
+        Ok(Box::new(PendingPoll::new(task)))
+    }
+
+    pub fn consumer(
+        &self,
+        name: String,
+        stream_id: ffi::Identifier,
+        topic_id: ffi::Identifier,
+        group: bool,
+        options: ffi::ConsumerOptions,
+        error_info: &mut ffi::ErrorInfo,
+    ) -> Result<Box<ConsumerHandle>, String> {
+        *error_info = ffi::ErrorInfo::default();
+        let fail = |error: String| format!("Could not build consumer '{name}': {error}");
+        let rust_stream_id = RustIdentifier::try_from(stream_id).map_err(fail)?;
+        let rust_topic_id = RustIdentifier::try_from(topic_id).map_err(fail)?;
+        // The builders take names, so the stream and topic identifiers replace them.
+        let builder = if group {
+            self.inner.consumer_group(&name, &name, &name)
+        } else {
+            self.inner.consumer(&name, &name, &name)
+        };
+        let mut builder = builder
+            .map_err(|error| fail(error.to_string()))?
+            .stream(rust_stream_id)
+            .topic(rust_topic_id);
+        if !group {
+            builder = builder.partitions(options.partitions);
+        }
+        if options.has_start {
+            builder =
+                builder.polling_strategy(PollingStrategy::try_from(options.start).map_err(fail)?);
+        }
+        if options.has_poll_options {
+            builder =
+                builder.poll_options(PollOptions::try_from(options.poll_options).map_err(fail)?);
+        }
+        if options.has_prefetch_bytes {
+            builder = builder.prefetch_bytes(options.prefetch_bytes);
+        }
+        if options.has_prefetch_messages {
+            builder = builder.prefetch_messages(options.prefetch_messages);
+        }
+        if options.allow_replay {
+            builder = builder.allow_replay();
+        }
+        let mut consumer = builder.build();
+        RUNTIME
+            .block_on(consumer.init())
+            .inspect_err(|error| error_info.record(error))
+            .map_err(|error| fail(error.to_string()))?;
+        Ok(Box::new(ConsumerHandle::new(consumer)))
+    }
+
+    pub fn reader(
+        &self,
+        stream_id: ffi::Identifier,
+        topic_id: ffi::Identifier,
+        consumer_kind: String,
+        consumer_id: ffi::Identifier,
+        options: ffi::ReaderOptions,
+        error_info: &mut ffi::ErrorInfo,
+    ) -> Result<Box<PartitionReader>, String> {
+        *error_info = ffi::ErrorInfo::default();
+        let rust_stream_id = RustIdentifier::try_from(stream_id)
+            .map_err(|error| format!("Could not build reader: {error}"))?;
+        let rust_topic_id = RustIdentifier::try_from(topic_id)
+            .map_err(|error| format!("Could not build reader: {error}"))?;
+        let rust_consumer_id = RustIdentifier::try_from(consumer_id)
+            .map_err(|error| format!("Could not build reader: {error}"))?;
+        let consumer = resolve_consumer(&consumer_kind, rust_consumer_id)
+            .map_err(|error| format!("Could not build reader: {error}"))?;
+
+        let mut builder = self
+            .inner
+            .reader(&rust_stream_id, &rust_topic_id, &consumer);
+        if !options.partitions.is_empty() {
+            builder = builder.partitions(options.partitions);
+        }
+        if options.has_start {
+            let start = PollingStrategy::try_from(options.start)
+                .map_err(|error| format!("Could not build reader: {error}"))?;
+            builder = builder.start(start);
+        }
+        if options.has_poll_options {
+            let poll_options = PollOptions::try_from(options.poll_options)
+                .map_err(|error| format!("Could not build reader: {error}"))?;
+            builder = builder.options(poll_options);
+        }
+        if options.has_prefetch_bytes {
+            builder = builder.prefetch_bytes(options.prefetch_bytes);
+        }
+        if options.has_prefetch_messages {
+            builder = builder.prefetch_messages(options.prefetch_messages);
+        }
+
+        RUNTIME
+            .block_on(builder.build())
+            .map(|reader| Box::new(PartitionReader::new(reader)))
+            .inspect_err(|error| error_info.record(error))
+            .map_err(|error| {
+                format!(
+                    "Could not build reader for stream '{rust_stream_id}', topic '{rust_topic_id}': {error}"
+                )
+            })
+    }
+
     pub fn create_topic(
         &self,
         stream_id: ffi::Identifier,
@@ -1004,16 +1134,13 @@
         })
     }
 
-    #[allow(clippy::too_many_arguments)]
     pub fn store_consumer_position(
         &self,
         stream_id: ffi::Identifier,
         topic_id: ffi::Identifier,
-        partition_id: u32,
         consumer_kind: String,
         consumer_id: ffi::Identifier,
-        offset: u64,
-        context: ffi::PartitionContext,
+        checkpoint: &Checkpoint,
         error_info: &mut ffi::ErrorInfo,
     ) -> Result<(), String> {
         *error_info = ffi::ErrorInfo::default();
@@ -1025,15 +1152,10 @@
             .map_err(|error| format!("Could not store consumer position: {error}"))?;
         let consumer = resolve_consumer(&consumer_kind, rust_consumer_id)
             .map_err(|error| format!("Could not store consumer position: {error}"))?;
-        let position = ConsumerPosition {
-            partition_id,
-            offset,
-            context: context.into(),
-        };
 
         RUNTIME.block_on(async {
             self.inner
-                .store_consumer_position(&consumer, &rust_stream_id, &rust_topic_id, position)
+                .store_consumer_position(&consumer, &rust_stream_id, &rust_topic_id, &checkpoint.0)
                 .await
                 .inspect_err(|error| error_info.record(error))
                 .map_err(|error| {
@@ -1044,6 +1166,38 @@
         })
     }
 
+    pub fn delete_consumer_position(
+        &self,
+        stream_id: ffi::Identifier,
+        topic_id: ffi::Identifier,
+        consumer_kind: String,
+        consumer_id: ffi::Identifier,
+        checkpoint: &Checkpoint,
+        error_info: &mut ffi::ErrorInfo,
+    ) -> Result<(), String> {
+        *error_info = ffi::ErrorInfo::default();
+        let rust_stream_id = RustIdentifier::try_from(stream_id)
+            .map_err(|error| format!("Could not delete consumer position: {error}"))?;
+        let rust_topic_id = RustIdentifier::try_from(topic_id)
+            .map_err(|error| format!("Could not delete consumer position: {error}"))?;
+        let rust_consumer_id = RustIdentifier::try_from(consumer_id)
+            .map_err(|error| format!("Could not delete consumer position: {error}"))?;
+        let consumer = resolve_consumer(&consumer_kind, rust_consumer_id)
+            .map_err(|error| format!("Could not delete consumer position: {error}"))?;
+
+        RUNTIME.block_on(async {
+            self.inner
+                .delete_consumer_position(&consumer, &rust_stream_id, &rust_topic_id, &checkpoint.0)
+                .await
+                .inspect_err(|error| error_info.record(error))
+                .map_err(|error| {
+                    format!(
+                        "Could not delete consumer position for stream '{rust_stream_id}', topic '{rust_topic_id}': {error}"
+                    )
+                })
+        })
+    }
+
     pub fn get_consumer_offset(
         &self,
         stream_id: ffi::Identifier,
@@ -1682,14 +1836,16 @@
 #[cfg(test)]
 mod tests {
     use super::*;
+    use crate::messages::default_poll_options;
+    use iggy::prelude::{PartitionContext, PollingKind};
     use std::thread;
 
-    const POLLING_STRATEGY_KINDS: [&str; 5] = ["offset", "timestamp", "first", "last", "next"];
     const CONTEXT: PartitionContext = PartitionContext {
         incarnation: 3,
         owner_generation: 5,
         metadata_op: 7,
     };
+    const PARTITION: u32 = 1;
 
     #[test]
     fn should_preserve_each_calls_exact_operation_identity_under_concurrent_recovery() {
@@ -1735,25 +1891,24 @@
     }
 
     #[test]
-    fn should_leave_the_context_unset_on_a_poll_without_one() {
-        for kind in POLLING_STRATEGY_KINDS {
-            assert_eq!(
-                polling_strategy(kind, 9, None).map(|strategy| strategy.context),
-                Ok(None),
-                "{kind}"
-            );
-        }
-    }
+    fn given_a_lost_committing_poll_when_recording_should_keep_its_operation_and_blocked_partition()
+    {
+        let mut info = ffi::ErrorInfo::default();
+        info.record(&IggyError::PartitionLaneBlocked(u32::MAX, u64::MAX));
+        assert_eq!(info.code, IggyError::PartitionLaneBlocked(0, 0).as_code());
+        assert_eq!(info.kind, "partition_lane_blocked");
+        assert!(info.has_operation_id);
+        assert_eq!(info.operation_id, u64::MAX);
+        assert!(info.has_blocked_partition_id);
+        assert_eq!(info.blocked_partition_id, u32::MAX);
 
-    #[test]
-    fn should_carry_the_caller_context_on_a_poll_with_one() {
-        for kind in POLLING_STRATEGY_KINDS {
-            assert_eq!(
-                polling_strategy(kind, 9, Some(CONTEXT)).map(|strategy| strategy.context),
-                Ok(Some(CONTEXT)),
-                "{kind}"
-            );
-        }
+        info.record(&IggyError::FetchProgressUncertain(u64::MAX));
+        assert_eq!(info.code, IggyError::FetchProgressUncertain(0).as_code());
+        assert_eq!(info.kind, "fetch_progress_uncertain");
+        assert!(info.has_operation_id);
+        assert_eq!(info.operation_id, u64::MAX);
+        assert!(!info.has_blocked_partition_id);
+        assert_eq!(info.blocked_partition_id, 0);
     }
 
     #[test]
@@ -1788,61 +1943,111 @@
     }
 
     #[test]
-    fn should_reject_an_unknown_polling_strategy() {
-        assert_eq!(
-            polling_strategy("latest", 0, Some(CONTEXT))
-                .err()
-                .as_deref(),
-            Some("invalid polling strategy: latest")
-        );
-    }
-
-    #[test]
     fn should_clear_the_previous_error_info_when_a_poll_rejects_its_arguments() {
         let client = Client {
             inner: Arc::new(RustIggyClient::default()),
         };
-        let invalid_identifier = || ffi::Identifier {
-            kind: String::new(),
-            length: 0,
-            value: Vec::new(),
-        };
-        for with_context in [false, true] {
+        let continuation = Continuation(PollingStrategy::continuation(
+            PARTITION,
+            PollingKind::Offset,
+            0,
+            CONTEXT,
+        ));
+        for from_continuation in [false, true] {
             let mut info = ffi::ErrorInfo::default();
             info.record(&IggyError::OperationUncertain(u64::MAX));
-            let polled = if with_context {
-                client.poll_messages_with_context(
+            let polled = if from_continuation {
+                client.poll_messages_from(
                     invalid_identifier(),
                     invalid_identifier(),
-                    ANY_PARTITION_ID,
+                    PARTITION,
                     "consumer".to_owned(),
                     invalid_identifier(),
-                    "next".to_owned(),
-                    0,
-                    1,
-                    false,
-                    ffi::PartitionContext::from(CONTEXT),
+                    &continuation,
+                    default_poll_options(),
                     &mut info,
                 )
             } else {
                 client.poll_messages(
                     invalid_identifier(),
                     invalid_identifier(),
-                    ANY_PARTITION_ID,
+                    PARTITION,
                     "consumer".to_owned(),
                     invalid_identifier(),
-                    "next".to_owned(),
-                    0,
-                    1,
-                    false,
+                    ffi::PollingStrategy {
+                        kind: "next".to_owned(),
+                        value: 0,
+                    },
+                    default_poll_options(),
                     &mut info,
                 )
             };
-            assert!(polled.is_err(), "with_context: {with_context}");
-            assert_eq!(info.code, 0, "with_context: {with_context}");
-            assert!(info.kind.is_empty(), "with_context: {with_context}");
-            assert!(!info.has_operation_id, "with_context: {with_context}");
-            assert_eq!(info.operation_id, 0, "with_context: {with_context}");
+            assert!(polled.is_err(), "{from_continuation}");
+            assert_eq!(info.code, 0, "{from_continuation}");
+            assert!(info.kind.is_empty(), "{from_continuation}");
+            assert!(!info.has_operation_id, "{from_continuation}");
+            assert_eq!(info.operation_id, 0, "{from_continuation}");
+        }
+    }
+
+    #[test]
+    fn should_refuse_invalid_reader_settings_before_any_request() {
+        let client = Client {
+            inner: Arc::new(RustIggyClient::default()),
+        };
+        let cases = [
+            (
+                "consumer_group",
+                vec![PARTITION],
+                None,
+                IggyError::InvalidCommand,
+            ),
+            ("consumer", Vec::new(), None, IggyError::InvalidCommand),
+            (
+                "consumer",
+                vec![PARTITION],
+                Some(PollOptions {
+                    count: 0,
+                    ..PollOptions::default()
+                }),
+                IggyError::InvalidMessagesCount,
+            ),
+        ];
+        for (consumer_kind, partitions, poll_options, expected) in cases {
+            let options = ffi::ReaderOptions {
+                partitions,
+                has_start: false,
+                start: ffi::PollingStrategy {
+                    kind: "next".to_owned(),
+                    value: 0,
+                },
+                has_poll_options: poll_options.is_some(),
+                poll_options: poll_options.unwrap_or_default().into(),
+                has_prefetch_bytes: false,
+                prefetch_bytes: 0,
+                has_prefetch_messages: false,
+                prefetch_messages: 0,
+            };
+            let mut info = ffi::ErrorInfo::default();
+            let reader = client.reader(
+                ffi::Identifier::from(RustIdentifier::numeric(1).unwrap()),
+                ffi::Identifier::from(RustIdentifier::numeric(1).unwrap()),
+                consumer_kind.to_owned(),
+                ffi::Identifier::from(RustIdentifier::numeric(1).unwrap()),
+                options,
+                &mut info,
+            );
+            assert!(reader.is_err(), "{expected}");
+            assert_eq!(info.code, expected.as_code(), "{expected}");
+            assert_eq!(info.kind, expected.as_string(), "{expected}");
+        }
+    }
+
+    fn invalid_identifier() -> ffi::Identifier {
+        ffi::Identifier {
+            kind: String::new(),
+            length: 0,
+            value: Vec::new(),
         }
     }
 }
diff --git a/foreign/cpp/src/consumer.rs b/foreign/cpp/src/consumer.rs
index 147374d..4383b16 100644
--- a/foreign/cpp/src/consumer.rs
+++ b/foreign/cpp/src/consumer.rs
@@ -15,9 +15,158 @@
 // specific language governing permissions and limitations
 // under the License.
 
-use iggy::prelude::IggyConsumer as RustIggyConsumer;
+use std::sync::{Mutex, MutexGuard, PoisonError};
+use std::time::Duration;
 
-#[allow(dead_code)]
+use futures::StreamExt;
+use iggy::prelude::{
+    IggyConsumer as RustIggyConsumer, PollingStrategy as RustPollingStrategy, ReceivedMessage,
+};
+
+use crate::{
+    RUNTIME, ffi,
+    messages::{Checkpoint, Continuation},
+};
+
+const CLOSED: &str = "the consumer is closed";
+
+/// The C++ handle of a consumer. `close` takes the consumer out, so every later call fails,
+/// except a second `close`, which does nothing.
+///
+/// C++ can call it from several threads, so the calls run one at a time: a call waits for a
+/// running `next` to end, at most for its timeout.
 pub struct Consumer {
-    pub inner: RustIggyConsumer,
+    inner: Mutex<Option<RustIggyConsumer>>,
+}
+
+impl Consumer {
+    pub(crate) fn new(consumer: RustIggyConsumer) -> Self {
+        Self {
+            inner: Mutex::new(Some(consumer)),
+        }
+    }
+
+    fn consumer(&self) -> MutexGuard<'_, Option<RustIggyConsumer>> {
+        // A panic in a call leaves the consumer as it was, so the next call can use it.
+        self.inner.lock().unwrap_or_else(PoisonError::into_inner)
+    }
+
+    pub fn next(
+        &self,
+        timeout_micros: u64,
+        error_info: &mut ffi::ReadErrorInfo,
+    ) -> Result<Vec<ffi::ReceivedMessage>, String> {
+        *error_info = ffi::ReadErrorInfo::default();
+        let mut consumer = self.consumer();
+        let consumer = consumer
+            .as_mut()
+            .ok_or_else(|| format!("Could not consume a message: {CLOSED}"))?;
+        // `timeout` is built inside `block_on`, see the reader. The stream is cancel safe, so a
+        // message that arrives after the timeout stays in the consumer.
+        let received = RUNTIME.block_on(async {
+            tokio::time::timeout(Duration::from_micros(timeout_micros), consumer.next()).await
+        });
+        match received {
+            Ok(Some(Ok(received))) => Ok(vec![received.into()]),
+            Ok(Some(Err(error))) => {
+                error_info.record(&error);
+                Err(format!("Could not consume a message: {error}"))
+            }
+            Ok(None) => Err(format!("Could not consume a message: {CLOSED}")),
+            Err(_) => Ok(Vec::new()),
+        }
+    }
+
+    pub fn commit(
+        &self,
+        checkpoint: &Checkpoint,
+        error_info: &mut ffi::ErrorInfo,
+    ) -> Result<(), String> {
+        *error_info = ffi::ErrorInfo::default();
+        let consumer = self.consumer();
+        let consumer = consumer
+            .as_ref()
+            .ok_or_else(|| format!("Could not commit checkpoint: {CLOSED}"))?;
+        RUNTIME
+            .block_on(consumer.commit(checkpoint.0))
+            .inspect_err(|error| error_info.record(error))
+            .map_err(|error| format!("Could not commit checkpoint: {error}"))
+    }
+
+    pub fn mark(
+        &self,
+        checkpoint: &Checkpoint,
+        error_info: &mut ffi::ErrorInfo,
+    ) -> Result<(), String> {
+        *error_info = ffi::ErrorInfo::default();
+        let consumer = self.consumer();
+        let consumer = consumer
+            .as_ref()
+            .ok_or_else(|| format!("Could not mark checkpoint: {CLOSED}"))?;
+        consumer
+            .mark(checkpoint.0)
+            .inspect_err(|error| error_info.record(error))
+            .map_err(|error| format!("Could not mark checkpoint: {error}"))
+    }
+
+    pub fn seek(
+        &self,
+        partition_id: u32,
+        start: ffi::PollingStrategy,
+        error_info: &mut ffi::ErrorInfo,
+    ) -> Result<(), String> {
+        *error_info = ffi::ErrorInfo::default();
+        let start = RustPollingStrategy::try_from(start)
+            .map_err(|error| format!("Could not seek partition {partition_id}: {error}"))?;
+        self.set_position(partition_id, start, error_info)
+    }
+
+    pub fn seek_to(
+        &self,
+        partition_id: u32,
+        continuation: &Continuation,
+        error_info: &mut ffi::ErrorInfo,
+    ) -> Result<(), String> {
+        *error_info = ffi::ErrorInfo::default();
+        self.set_position(partition_id, continuation.0, error_info)
+    }
+
+    fn set_position(
+        &self,
+        partition_id: u32,
+        start: RustPollingStrategy,
+        error_info: &mut ffi::ErrorInfo,
+    ) -> Result<(), String> {
+        let mut consumer = self.consumer();
+        let consumer = consumer
+            .as_mut()
+            .ok_or_else(|| format!("Could not seek partition {partition_id}: {CLOSED}"))?;
+        RUNTIME
+            .block_on(consumer.seek(partition_id, start))
+            .inspect_err(|error| error_info.record(error))
+            .map_err(|error| format!("Could not seek partition {partition_id}: {error}"))
+    }
+
+    pub fn close(&self, error_info: &mut ffi::ErrorInfo) -> Result<(), String> {
+        *error_info = ffi::ErrorInfo::default();
+        let Some(mut consumer) = self.consumer().take() else {
+            return Ok(());
+        };
+        RUNTIME
+            .block_on(consumer.shutdown())
+            .inspect_err(|error| error_info.record(error))
+            .map_err(|error| format!("Could not close consumer: {error}"))
+    }
+}
+
+impl From<ReceivedMessage> for ffi::ReceivedMessage {
+    fn from(received: ReceivedMessage) -> Self {
+        let checkpoint = Box::new(Checkpoint(received.checkpoint()));
+        ffi::ReceivedMessage {
+            partition_id: received.partition_id,
+            current_offset: received.current_offset,
+            message: received.message.into(),
+            checkpoint,
+        }
+    }
 }
diff --git a/foreign/cpp/src/lib.rs b/foreign/cpp/src/lib.rs
index 040a119..d14c3cf 100644
--- a/foreign/cpp/src/lib.rs
+++ b/foreign/cpp/src/lib.rs
@@ -19,7 +19,9 @@
 mod consumer;
 mod identifier;
 mod messages;
+mod pending_poll;
 mod producer;
+mod reader;
 mod type_conversion;
 
 use client::{
@@ -30,8 +32,10 @@
     validate_metadata_changes_query,
 };
 use consumer::Consumer;
-use messages::make_message;
+use messages::{BatchPositions, Checkpoint, Continuation, default_poll_options, make_message};
+use pending_poll::PendingPoll;
 use producer::Producer;
+use reader::PartitionReader;
 use std::sync::LazyLock;
 
 static RUNTIME: LazyLock<tokio::runtime::Runtime> = LazyLock::new(|| {
@@ -50,6 +54,9 @@
         kind: String,
         has_operation_id: bool,
         operation_id: u64,
+        /// The partition that a `partition_lane_blocked` error names.
+        has_blocked_partition_id: bool,
+        blocked_partition_id: u32,
     }
 
     struct Identifier {
@@ -77,9 +84,9 @@
         derived_options: Vec<HeaderEntry>,
     }
 
-    /// Partition incarnation and consumer-group owner captured by a read or a
-    /// poll. Commit an offset under the context that delivered it, so a
-    /// delayed commit fails instead of landing on a recreated partition or
+    /// Partition incarnation and consumer-group owner. Checkpoints and
+    /// continuations keep the context of their batch privately, so a delayed
+    /// commit or read fails instead of landing on a recreated partition or
     /// under a newer owner.
     struct PartitionContext {
         incarnation: u64,
@@ -222,15 +229,171 @@
         user_headers: Vec<HeaderEntry>,
     }
 
+    /// When the offsets of the messages that a read returns are stored.
+    #[repr(u8)]
+    enum CommitPolicy {
+        /// Nothing is stored. The caller commits checkpoints. The default.
+        Manual = 0,
+        /// The server stores the offset of the last message of each read as
+        /// part of the read, in one round trip. While the primary stays up a
+        /// message is delivered at most once. A failover before the store
+        /// replicates can deliver the last batch again.
+        OnRead = 1,
+        /// The reader stores the latest counted position, as the `auto_`
+        /// fields of `PollOptions` say. A one-shot poll refuses it with
+        /// InvalidConfiguration (12), as it has nothing to count.
+        Auto = 2,
+    }
+
+    /// The event on which `CommitPolicy::Auto` also stores.
+    #[repr(u8)]
+    enum CommitEvery {
+        /// Each counted message, or each counted batch of a reader.
+        Message = 0,
+        /// `auto_every_messages` counted messages of a partition since its
+        /// last store.
+        Messages = 1,
+        /// The last message of each batch.
+        Batch = 2,
+    }
+
+    /// When a message counts for `CommitPolicy::Auto`.
+    #[repr(u8)]
+    enum CommitAfter {
+        /// When the caller asks for the next batch or closes the reader.
+        Processing = 0,
+        /// When the batch is handed over.
+        Delivery = 1,
+        /// Only when the caller marks its checkpoint.
+        Marked = 2,
+    }
+
+    /// The limits of one read and when its offsets are stored. A read is a
+    /// poll of one partition, or a fetch that a reader sends for several
+    /// partitions of one primary, which applies `count`, `min_count` and
+    /// `max_bytes` to all of them together. `default_poll_options` returns
+    /// the defaults, whose `auto_` fields hold the defaults of
+    /// `CommitPolicy::Auto`: every 10,000 messages or 5 s, after
+    /// processing. The `auto_` fields count only with `CommitPolicy::Auto`,
+    /// which needs an interval or an event.
+    #[derive(Clone, Copy)]
+    struct PollOptions {
+        /// Most messages in one read, over all partitions of a fetch. Default 1000.
+        count: u32,
+        /// Zero answers at once. At most 600 s.
+        max_wait_micros: u64,
+        /// At least 1 and at most `count`, over all partitions of a fetch. Default 1.
+        min_count: u32,
+        /// Default 1 MiB, over all partitions of a fetch.
+        max_bytes: u32,
+        /// Default `CommitPolicy::Manual`.
+        commit: CommitPolicy,
+        /// Store the latest counted position at this interval, when it
+        /// moved. Not zero.
+        has_auto_interval: bool,
+        auto_interval_micros: u64,
+        /// Also store it on this event.
+        has_auto_every: bool,
+        auto_every: CommitEvery,
+        /// The count of `CommitEvery::Messages`. Not zero.
+        auto_every_messages: u32,
+        auto_after: CommitAfter,
+    }
+
+    /// Where a read starts. `kind` is offset, timestamp, first, last or
+    /// next, and `value` is the offset or the timestamp. To read on after a
+    /// batch, pass its `Continuation` instead.
+    struct PollingStrategy {
+        kind: String,
+        value: u64,
+    }
+
     struct PolledMessages {
         partition_id: u32,
         current_offset: u64,
         count: u32,
         messages: Vec<IggyMessagePolled>,
-        /// Context of the accepted poll. Pass it to `store_consumer_position`
-        /// with an offset from this batch, or to `poll_messages_with_context`
-        /// to continue from one.
-        context: PartitionContext,
+        /// The private part that `checkpoint`, `checkpoint_at` and
+        /// `continuation` read.
+        positions: Box<BatchPositions>,
+    }
+
+    /// The settings of a reader. A setting whose `has_` flag is false keeps
+    /// its default, so a zero-initialized value gives the default reader.
+    struct ReaderOptions {
+        /// The partitions of a standalone consumer, which requires them. Empty
+        /// for a consumer group, which reads the assignment of its member.
+        partitions: Vec<u32>,
+        /// The start of a partition without a position. Default next.
+        has_start: bool,
+        start: PollingStrategy,
+        /// The options of each request. Default: the poll defaults with a
+        /// five-second wait.
+        has_poll_options: bool,
+        poll_options: PollOptions,
+        /// The encoded bytes of the requests in flight and the queued
+        /// batches. Default 16 MiB.
+        has_prefetch_bytes: bool,
+        prefetch_bytes: u32,
+        /// The messages of the requests in flight and the queued batches.
+        /// Default: the larger of 16,000 and `count`.
+        has_prefetch_messages: bool,
+        prefetch_messages: u32,
+    }
+
+    /// The settings of a consumer. A setting whose `has_` flag is false keeps
+    /// its default, so a zero-initialized value gives the default consumer.
+    struct ConsumerOptions {
+        /// The partitions of a standalone consumer, which requires them. A
+        /// consumer group reads the assignment of its member and ignores them.
+        partitions: Vec<u32>,
+        /// The start of a partition without a stored offset. Default next.
+        has_start: bool,
+        start: PollingStrategy,
+        /// The options of each request. Default: the poll defaults with a
+        /// five-second wait.
+        has_poll_options: bool,
+        poll_options: PollOptions,
+        has_prefetch_bytes: bool,
+        prefetch_bytes: u32,
+        has_prefetch_messages: bool,
+        prefetch_messages: u32,
+        /// Hands over again the messages that a seek back reads twice.
+        allow_replay: bool,
+    }
+
+    /// One message that a consumer handed over.
+    struct ReceivedMessage {
+        partition_id: u32,
+        /// The offset of the newest message of the partition when it was polled.
+        current_offset: u64,
+        message: IggyMessagePolled,
+        /// The position after the message, to commit or mark it.
+        checkpoint: Box<Checkpoint>,
+    }
+
+    /// What a wait for a pending poll found. `batch` is empty and belongs to
+    /// no partition while `ready` is false.
+    struct PollOutcome {
+        ready: bool,
+        batch: PolledMessages,
+    }
+
+    /// Local to one `next` call of a reader. Read after a bridge exception.
+    #[derive(Default)]
+    struct ReadErrorInfo {
+        error: ErrorInfo,
+        /// False for an error of the whole reader.
+        has_partition_id: bool,
+        partition_id: u32,
+        /// For a message that does not decode: one continuation after it. The
+        /// reader reads the message again until a `seek` of its partition to
+        /// this continuation passes it. The messages before it arrive in a
+        /// batch first. Empty for every other error: cxx has no `Option`, and
+        /// a `Box` is never empty.
+        // A `rust::Vec` holds an opaque Rust type only behind a `Box`.
+        #[allow(clippy::vec_box)]
+        skip: Vec<Box<Continuation>>,
     }
 
     /// Commit confirmation for one partition written by `send_messages`.
@@ -725,8 +888,28 @@
     }
 
     extern "Rust" {
+        /// The private part of a batch: the partition context and the reader
+        /// stamp of its checkpoints, and its continuation.
+        type BatchPositions;
+        /// The position after one delivered message, from `checkpoint` or
+        /// `checkpoint_at` of a batch. A commit stores its offset, which
+        /// covers every earlier message of its partition. It keeps the
+        /// context of its batch, so a delayed commit fails after a change of
+        /// the partition history, and a reader commits only the checkpoints
+        /// that it delivered.
+        type Checkpoint;
         type Client;
         type Consumer;
+        /// The position after a batch, from `continuation` of the batch or
+        /// `skip` of a read error. It keeps the partition and the context of
+        /// its batch: a read of another partition refuses it, and a read after
+        /// a change of the partition history fails with HistoryUnavailable
+        /// (87).
+        type Continuation;
+        type PartitionReader;
+        /// A poll that runs in the background, from `start_poll_messages`.
+        /// Destroying it cancels the poll.
+        type PendingPoll;
         type Producer;
 
         // Client functions
@@ -873,28 +1056,46 @@
             consumer_id: Identifier,
             error_info: &mut ErrorInfo,
         ) -> Result<()>;
-        /// Stores `offset` under `context`, normally the one returned with the
-        /// polled batch. Every retry keeps it, so a deleted or recreated
-        /// partition fails with HistoryUnavailable (87) and a lost
-        /// consumer-group owner with ConsumerGroupPartitionNotOwned (5009).
-        #[allow(clippy::too_many_arguments)]
+        /// Stores the offset of `checkpoint` under its context. Every retry
+        /// keeps the context, so a deleted or recreated partition fails with
+        /// HistoryUnavailable (87) and a lost consumer-group owner with
+        /// ConsumerGroupPartitionNotOwned (5009).
         fn store_consumer_position(
             self: &Client,
             stream_id: Identifier,
             topic_id: Identifier,
-            partition_id: u32,
             consumer_kind: String,
             consumer_id: Identifier,
-            offset: u64,
-            context: PartitionContext,
+            checkpoint: &Checkpoint,
+            error_info: &mut ErrorInfo,
+        ) -> Result<()>;
+        /// Deletes the stored offset of the partition of `checkpoint` under
+        /// its context, so a deleted or recreated partition fails with
+        /// HistoryUnavailable (87), a lost consumer-group owner with
+        /// ConsumerGroupPartitionNotOwned (5009), and a missing offset with
+        /// ConsumerOffsetNotFound (3021). The offset of `checkpoint` is not
+        /// used.
+        fn delete_consumer_position(
+            self: &Client,
+            stream_id: Identifier,
+            topic_id: Identifier,
+            consumer_kind: String,
+            consumer_id: Identifier,
+            checkpoint: &Checkpoint,
             error_info: &mut ErrorInfo,
         ) -> Result<()>;
 
-        /// The poll takes the context its cached route reports. After another
-        /// client deleted and recreated the partition, one call can fail with
-        /// HistoryUnavailable (87), or ConsumerGroupPartitionNotOwned (5009)
-        /// for a consumer group. The failed route is dropped and the next call
-        /// routes again.
+        fn default_poll_options() -> PollOptions;
+        /// Reads one partition once. A strategy takes the context that the
+        /// cached route reports. After another client deleted and recreated
+        /// the partition, one call can fail with HistoryUnavailable (87), or
+        /// ConsumerGroupPartitionNotOwned (5009) for a consumer group. The
+        /// failed route is dropped and the next call routes again.
+        ///
+        /// With `CommitPolicy::OnRead`, a lost reply fails with
+        /// FetchProgressUncertain (14010) and blocks the partition on this
+        /// client: later polls of it fail with PartitionLaneBlocked (14009)
+        /// until `abandon_operation` of the operation that the error names.
         #[allow(clippy::too_many_arguments)]
         fn poll_messages(
             self: &Client,
@@ -903,29 +1104,151 @@
             partition_id: u32,
             consumer_kind: String,
             consumer_id: Identifier,
-            polling_strategy_kind: String,
-            polling_strategy_value: u64,
-            count: u32,
-            auto_commit: bool,
+            strategy: PollingStrategy,
+            options: PollOptions,
             error_info: &mut ErrorInfo,
         ) -> Result<PolledMessages>;
-        /// Polls under `context`, normally the one returned with the offset the
-        /// poll continues from. Every retry keeps it.
+        /// Reads on from the continuation of a batch of the same partition.
+        /// A continuation of another partition fails with InvalidCommand (3).
+        #[cxx_name = "poll_messages"]
         #[allow(clippy::too_many_arguments)]
-        fn poll_messages_with_context(
+        fn poll_messages_from(
             self: &Client,
             stream_id: Identifier,
             topic_id: Identifier,
             partition_id: u32,
             consumer_kind: String,
             consumer_id: Identifier,
-            polling_strategy_kind: String,
-            polling_strategy_value: u64,
-            count: u32,
-            auto_commit: bool,
-            context: PartitionContext,
+            continuation: &Continuation,
+            options: PollOptions,
             error_info: &mut ErrorInfo,
         ) -> Result<PolledMessages>;
+        /// The position after the last message, to commit the whole batch.
+        /// Fails for an empty batch.
+        fn checkpoint(self: &PolledMessages) -> Result<Box<Checkpoint>>;
+        /// The position after `messages[index]`, to commit a processed part
+        /// of the batch. It names the message delivered at `index`, from
+        /// positions kept apart from `partition_id` and `messages`, so a
+        /// batch edited in C++ commits no offset the reader did not deliver.
+        /// Fails for an index out of range.
+        fn checkpoint_at(self: &PolledMessages, index: usize) -> Result<Box<Checkpoint>>;
+        /// The position after the batch, for the next read of its partition.
+        /// An empty batch keeps the start of its request.
+        fn continuation(self: &PolledMessages) -> Box<Continuation>;
+        fn partition_id(self: &Checkpoint) -> u32;
+        /// The offset of the delivered message, which a commit stores.
+        fn offset(self: &Checkpoint) -> u64;
+        fn copy(self: &Checkpoint) -> Box<Checkpoint>;
+        /// offset after a batch with messages. After an empty batch, the kind
+        /// of its start: offset, timestamp, last or next, where first becomes
+        /// offset 0.
+        fn kind(self: &Continuation) -> String;
+        /// The offset or the timestamp of the next read, zero for last and
+        /// next.
+        fn value(self: &Continuation) -> u64;
+        fn copy(self: &Continuation) -> Box<Continuation>;
+
+        /// Starts a poll in the background, as `poll_messages` does, so the
+        /// caller can wait for it in steps or cancel it.
+        #[allow(clippy::too_many_arguments)]
+        fn start_poll_messages(
+            self: &Client,
+            stream_id: Identifier,
+            topic_id: Identifier,
+            partition_id: u32,
+            consumer_kind: String,
+            consumer_id: Identifier,
+            strategy: PollingStrategy,
+            options: PollOptions,
+        ) -> Result<Box<PendingPoll>>;
+        /// Waits up to `timeout_micros` for the batch of the poll. After the
+        /// batch or an error was returned, or after `cancel`, it fails.
+        fn wait(
+            self: &PendingPoll,
+            timeout_micros: u64,
+            error_info: &mut ErrorInfo,
+        ) -> Result<PollOutcome>;
+        /// Drops the request, also while another thread waits for it. A parked
+        /// deferred poll is then cancelled on the server. It returns after the
+        /// request was dropped and does nothing after the poll ended.
+        fn cancel(self: &PendingPoll);
+
+        /// Builds a reader of the partitions of a topic, with routing and
+        /// prefetch. A consumer group reads the assignment of its member: the
+        /// reader joins the group here, joins again after a session loss, and
+        /// leaves it in `close`.
+        fn reader(
+            self: &Client,
+            stream_id: Identifier,
+            topic_id: Identifier,
+            consumer_kind: String,
+            consumer_id: Identifier,
+            options: ReaderOptions,
+            error_info: &mut ErrorInfo,
+        ) -> Result<Box<PartitionReader>>;
+        /// Waits up to `timeout_micros` for the next batch of one partition.
+        /// An empty batch means that the time ran out. It belongs to no
+        /// partition, so take no checkpoint and no continuation from it. A
+        /// batch that arrives later stays in the reader. The reader stays
+        /// usable after an error and retries by itself. Calls of one reader
+        /// from several threads run one at a time, so another call waits for
+        /// a running `next`, at most for its timeout.
+        fn next(
+            self: &PartitionReader,
+            timeout_micros: u64,
+            error_info: &mut ReadErrorInfo,
+        ) -> Result<PolledMessages>;
+        /// Stores the offset of a checkpoint that this reader delivered. A
+        /// checkpoint of an older generation of its partition, or at or below
+        /// the last commit, stores nothing. Refused with
+        /// `CommitPolicy::OnRead`.
+        fn commit(
+            self: &PartitionReader,
+            checkpoint: &Checkpoint,
+            error_info: &mut ErrorInfo,
+        ) -> Result<()>;
+        /// Counts a checkpoint that this reader delivered under
+        /// `CommitPolicy::Auto` and stores nothing now: the policy stores the
+        /// latest counted position. With `CommitAfter::Marked` only marks
+        /// count. Refused with InvalidCommand (3) without `Auto` and for a
+        /// checkpoint that this reader did not deliver.
+        fn mark(
+            self: &PartitionReader,
+            checkpoint: &Checkpoint,
+            error_info: &mut ErrorInfo,
+        ) -> Result<()>;
+        /// Sets the next read position of a partition and drops its queued
+        /// batches. It resumes a stopped partition. With `CommitPolicy::OnRead`
+        /// a seek to an offset above 0 first stores the offset before it, so a
+        /// restart resumes at the new position, and a failed store fails the
+        /// seek with nothing moved. Every other seek stores nothing.
+        fn seek(
+            self: &PartitionReader,
+            partition_id: u32,
+            start: PollingStrategy,
+            error_info: &mut ErrorInfo,
+        ) -> Result<()>;
+        /// Moves a partition to the continuation of one of its batches, or
+        /// past a message that does not decode with the `skip` of its read
+        /// error. A continuation of another partition fails with
+        /// InvalidCommand (3).
+        #[cxx_name = "seek"]
+        fn seek_to(
+            self: &PartitionReader,
+            partition_id: u32,
+            continuation: &Continuation,
+            error_info: &mut ErrorInfo,
+        ) -> Result<()>;
+        /// The partitions of a standalone consumer, or the current assignment
+        /// of a group member.
+        fn partitions(self: &PartitionReader) -> Result<Vec<u32>>;
+        /// Stops the reader. With `CommitPolicy::Auto`, it stores the latest
+        /// counted position of each partition. With `CommitPolicy::OnRead`,
+        /// it moves the offset of each partition back to its last delivered
+        /// message. A group reader then leaves its group. A second call does nothing,
+        /// and every other call on a closed reader fails. Dropping a reader
+        /// without `close` moves no offset back.
+        fn close(self: &PartitionReader, error_info: &mut ErrorInfo) -> Result<()>;
 
         fn make_message(payload: Vec<u8>, user_headers: Vec<HeaderEntry>) -> IggyMessageToSend;
 
@@ -1079,18 +1402,59 @@
         fn set_string(self: &mut Identifier, id: String) -> Result<()>;
         fn set_numeric(self: &mut Identifier, id: u32) -> Result<()>;
 
-        // Consumer methods
-        // fn name(self: &Consumer) -> Result<String>;
-        // fn topic(self: &Consumer) -> Result<Identifier>;
-        // fn stream(self: &Consumer) -> Result<Identifier>;
-        // fn partition_id(self: &Consumer) -> u32;
-        // fn store_offset(self: &Consumer, offset: u64, partition_id: u32) -> Result<()>;
-        // fn delete_offset(self: &Consumer, partition_id: u32) -> Result<()>;
-        // fn get_last_consumed_offset(self: &Consumer, partition_id: u32) -> Result<u64>;
-        // fn get_last_stored_offset(self: &Consumer, partition_id: u32) -> Result<u64>;
-        // fn init(self: &mut Consumer) -> Result<()>;
-        // fn shutdown(self: &mut Consumer) -> Result<()>;
-        // unsafe fn delete_consumer(consumer: *mut Consumer) -> Result<()>;
+        /// Builds and initializes a consumer named `name`. With `group` it is a
+        /// member of the consumer group `name`, which it creates if needed,
+        /// joins here and leaves in `close`. Otherwise it reads the partitions
+        /// of `options`.
+        fn consumer(
+            self: &Client,
+            name: String,
+            stream_id: Identifier,
+            topic_id: Identifier,
+            group: bool,
+            options: ConsumerOptions,
+            error_info: &mut ErrorInfo,
+        ) -> Result<Box<Consumer>>;
+        /// Waits up to `timeout_micros` for the next message. An empty vector
+        /// means that the time ran out. Under `CommitAfter::Processing` it
+        /// counts the message handed over before. The consumer stays usable
+        /// after an error and retries by itself.
+        fn next(
+            self: &Consumer,
+            timeout_micros: u64,
+            error_info: &mut ReadErrorInfo,
+        ) -> Result<Vec<ReceivedMessage>>;
+        /// Stores the offset of a checkpoint that this consumer handed over.
+        /// Refused with `CommitPolicy::OnRead`.
+        fn commit(
+            self: &Consumer,
+            checkpoint: &Checkpoint,
+            error_info: &mut ErrorInfo,
+        ) -> Result<()>;
+        /// Counts a checkpoint that this consumer handed over, under
+        /// `CommitPolicy::Auto`.
+        fn mark(self: &Consumer, checkpoint: &Checkpoint, error_info: &mut ErrorInfo)
+        -> Result<()>;
+        /// Sets the next read position of a partition. With
+        /// `CommitPolicy::OnRead` a seek to an offset above 0 first stores the
+        /// offset before it, and a failed store fails the seek with nothing
+        /// moved. Every other seek stores nothing.
+        fn seek(
+            self: &Consumer,
+            partition_id: u32,
+            start: PollingStrategy,
+            error_info: &mut ErrorInfo,
+        ) -> Result<()>;
+        #[cxx_name = "seek"]
+        fn seek_to(
+            self: &Consumer,
+            partition_id: u32,
+            continuation: &Continuation,
+            error_info: &mut ErrorInfo,
+        ) -> Result<()>;
+        /// Stops the consumer and stores as its commit policy says, as a
+        /// reader does. A second call does nothing.
+        fn close(self: &Consumer, error_info: &mut ErrorInfo) -> Result<()>;
 
         // Producer methods
         // fn stream(self: &Producer) -> Result<Identifier>;
diff --git a/foreign/cpp/src/messages.rs b/foreign/cpp/src/messages.rs
index 13a4150..1102462 100644
--- a/foreign/cpp/src/messages.rs
+++ b/foreign/cpp/src/messages.rs
@@ -15,8 +15,33 @@
 // specific language governing permissions and limitations
 // under the License.
 
+use iggy::prelude::{
+    Checkpoint as RustCheckpoint, PartitionContext, PollOptions,
+    PolledMessages as RustPolledMessages, PollingStrategy,
+};
+use iggy_common::ReaderStamp;
+
 use crate::ffi;
 
+/// The bridge has no constructor for it, so C++ gets one only from a batch.
+pub struct Checkpoint(pub(crate) RustCheckpoint);
+
+/// The bridge has no constructor for it, so C++ gets one only from a batch or a read error.
+pub struct Continuation(pub(crate) PollingStrategy);
+
+/// The batch fields that Rust keeps private, out of reach of C++. The partition and the offsets
+/// are copies of the public fields, so a checkpoint names a message as it was delivered whatever
+/// C++ does to the batch.
+pub struct BatchPositions {
+    partition_id: u32,
+    offsets: Vec<u64>,
+    context: PartitionContext,
+    stamp: Option<ReaderStamp>,
+    // An empty batch continues from the start of its request, which the Rust batch does not
+    // expose, so the continuation is taken before its messages cross the bridge.
+    continuation: PollingStrategy,
+}
+
 pub fn make_message(
     payload: Vec<u8>,
     user_headers: Vec<ffi::HeaderEntry>,
@@ -28,3 +53,80 @@
         user_headers,
     }
 }
+
+pub fn default_poll_options() -> ffi::PollOptions {
+    PollOptions::default().into()
+}
+
+impl ffi::PolledMessages {
+    pub fn checkpoint(&self) -> Result<Box<Checkpoint>, String> {
+        match self.positions.offsets.len().checked_sub(1) {
+            Some(last) => self.checkpoint_at(last),
+            None => Err("Could not take a checkpoint: the batch is empty".to_owned()),
+        }
+    }
+
+    pub fn checkpoint_at(&self, index: usize) -> Result<Box<Checkpoint>, String> {
+        let positions = &self.positions;
+        let offset = positions.offsets.get(index).ok_or_else(|| {
+            format!(
+                "Could not take a checkpoint: index {index} is out of range for {} messages",
+                positions.offsets.len()
+            )
+        })?;
+        Ok(Box::new(Checkpoint(RustCheckpoint::stamped(
+            positions.partition_id,
+            *offset,
+            positions.context,
+            positions.stamp,
+        ))))
+    }
+
+    pub fn continuation(&self) -> Box<Continuation> {
+        Box::new(Continuation(self.positions.continuation))
+    }
+}
+
+impl From<&RustPolledMessages> for BatchPositions {
+    fn from(batch: &RustPolledMessages) -> Self {
+        Self {
+            partition_id: batch.partition_id,
+            offsets: batch
+                .messages
+                .iter()
+                .map(|message| message.header.offset)
+                .collect(),
+            context: batch.context(),
+            stamp: batch.stamp(),
+            continuation: batch.continuation(),
+        }
+    }
+}
+
+impl Checkpoint {
+    pub fn partition_id(&self) -> u32 {
+        self.0.partition_id()
+    }
+
+    pub fn offset(&self) -> u64 {
+        self.0.offset()
+    }
+
+    pub fn copy(&self) -> Box<Checkpoint> {
+        Box::new(Checkpoint(self.0))
+    }
+}
+
+impl Continuation {
+    pub fn kind(&self) -> String {
+        self.0.kind.to_string()
+    }
+
+    pub fn value(&self) -> u64 {
+        self.0.value
+    }
+
+    pub fn copy(&self) -> Box<Continuation> {
+        Box::new(Continuation(self.0))
+    }
+}
diff --git a/foreign/cpp/src/pending_poll.rs b/foreign/cpp/src/pending_poll.rs
new file mode 100644
index 0000000..85fc7e8
--- /dev/null
+++ b/foreign/cpp/src/pending_poll.rs
@@ -0,0 +1,149 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::sync::{Mutex, MutexGuard, PoisonError};
+use std::time::Duration;
+
+use iggy::prelude::{IggyError, PolledMessages as RustPolledMessages};
+use tokio::task::{AbortHandle, JoinHandle};
+
+use crate::{RUNTIME, ffi};
+
+const ENDED: &str = "the poll already ended";
+
+type PollTask = JoinHandle<Result<RustPolledMessages, IggyError>>;
+
+/// The C++ handle of a poll that runs on the runtime. Dropping the task drops the request,
+/// which cancels a parked deferred poll on the server.
+pub struct PendingPoll {
+    task: Mutex<Option<PollTask>>,
+    // Aborts without the lock, so a cancel does not wait for a running wait to time out.
+    abort: AbortHandle,
+}
+
+impl PendingPoll {
+    pub(crate) fn new(task: PollTask) -> Self {
+        Self {
+            abort: task.abort_handle(),
+            task: Mutex::new(Some(task)),
+        }
+    }
+
+    fn task(&self) -> MutexGuard<'_, Option<PollTask>> {
+        self.task.lock().unwrap_or_else(PoisonError::into_inner)
+    }
+
+    pub fn wait(
+        &self,
+        timeout_micros: u64,
+        error_info: &mut ffi::ErrorInfo,
+    ) -> Result<ffi::PollOutcome, String> {
+        *error_info = ffi::ErrorInfo::default();
+        let mut task = self.task();
+        let handle = task
+            .as_mut()
+            .ok_or_else(|| format!("Could not wait for the poll: {ENDED}"))?;
+        let joined = RUNTIME.block_on(async {
+            tokio::time::timeout(Duration::from_micros(timeout_micros), handle).await
+        });
+        let Ok(joined) = joined else {
+            return Ok(ffi::PollOutcome {
+                ready: false,
+                batch: RustPolledMessages::empty().into(),
+            });
+        };
+        task.take();
+        match joined {
+            Ok(Ok(batch)) => Ok(ffi::PollOutcome {
+                ready: true,
+                batch: batch.into(),
+            }),
+            Ok(Err(error)) => {
+                error_info.record(&error);
+                Err(format!("Could not poll messages: {error}"))
+            }
+            Err(error) if error.is_cancelled() => {
+                Err("Could not poll messages: the poll was cancelled".to_owned())
+            }
+            Err(error) => Err(format!("Could not poll messages: {error}")),
+        }
+    }
+
+    pub fn cancel(&self) {
+        self.abort.abort();
+        if let Some(handle) = self.task().take() {
+            // The task drops the request when it is aborted, so this returns after the drop.
+            let _cancelled = RUNTIME.block_on(handle);
+        }
+    }
+}
+
+impl Drop for PendingPoll {
+    fn drop(&mut self) {
+        self.abort.abort();
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::future::pending;
+
+    #[test]
+    fn given_a_parked_poll_when_cancelled_should_end_it_and_refuse_a_later_wait() {
+        let task = RUNTIME.spawn(pending::<Result<RustPolledMessages, IggyError>>());
+        let poll = PendingPoll::new(task);
+        let mut error_info = ffi::ErrorInfo::default();
+
+        let outcome = poll
+            .wait(1_000, &mut error_info)
+            .expect("a running poll times out");
+        assert!(!outcome.ready, "a parked poll must not be ready");
+
+        poll.cancel();
+        poll.cancel();
+        let refused = poll.wait(1_000, &mut error_info).err();
+        assert_eq!(
+            refused.as_deref(),
+            Some("Could not wait for the poll: the poll already ended")
+        );
+    }
+
+    #[test]
+    fn given_a_finished_poll_when_waited_should_return_its_batch_once() {
+        let task = RUNTIME.spawn(async { Ok(RustPolledMessages::empty()) });
+        let poll = PendingPoll::new(task);
+        let mut error_info = ffi::ErrorInfo::default();
+
+        let outcome = poll
+            .wait(5_000_000, &mut error_info)
+            .expect("a finished poll returns its batch");
+        assert!(outcome.ready, "a finished poll must be ready");
+        assert!(outcome.batch.messages.is_empty());
+        assert!(poll.wait(1_000, &mut error_info).is_err());
+    }
+
+    #[test]
+    fn given_a_failed_poll_when_waited_should_record_its_error() {
+        let task = RUNTIME.spawn(async { Err(IggyError::InvalidConfiguration) });
+        let poll = PendingPoll::new(task);
+        let mut error_info = ffi::ErrorInfo::default();
+
+        assert!(poll.wait(5_000_000, &mut error_info).is_err());
+        assert_eq!(error_info.kind, "invalid_configuration");
+    }
+}
diff --git a/foreign/cpp/src/reader.cpp b/foreign/cpp/src/reader.cpp
new file mode 100644
index 0000000..64edf73
--- /dev/null
+++ b/foreign/cpp/src/reader.cpp
@@ -0,0 +1,336 @@
+/*
+ * 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.
+ */
+
+#include "iggy.hpp"
+
+namespace iggy {
+
+using detail::RethrowAsIggyException;
+
+namespace {
+
+std::optional<CommitEvery> CommitEveryFromFfi(const ffi::PollOptions &options) {
+    if (!options.has_auto_every) {
+        return std::nullopt;
+    }
+    switch (options.auto_every) {
+        case ffi::CommitEvery::Message:
+            return CommitEvery::Message();
+        case ffi::CommitEvery::Messages:
+            return CommitEvery::Messages(options.auto_every_messages);
+        case ffi::CommitEvery::Batch:
+            return CommitEvery::Batch();
+    }
+    return std::nullopt;
+}
+
+AutoCommit AutoCommitFromFfi(const ffi::PollOptions &options) {
+    AutoCommit auto_commit;
+    auto_commit.SetInterval(options.has_auto_interval
+                                ? std::optional(std::chrono::microseconds(options.auto_interval_micros))
+                                : std::nullopt);
+    auto_commit.SetEvery(CommitEveryFromFfi(options));
+    auto_commit.SetAfter(static_cast<CommitAfter>(options.auto_after));
+    return auto_commit;
+}
+
+ffi::PollingStrategy ToFfiStrategy(const PollingStrategy &strategy) {
+    const auto kind = strategy.Kind();
+    return ffi::PollingStrategy{rust::String(kind.data(), kind.size()), strategy.Value()};
+}
+
+}  // namespace
+
+AutoCommit::AutoCommit() {
+    const auto defaults = ffi::default_poll_options();
+    if (defaults.has_auto_interval) {
+        interval_ = std::chrono::microseconds(defaults.auto_interval_micros);
+    }
+    every_ = CommitEveryFromFfi(defaults);
+    after_ = static_cast<CommitAfter>(defaults.auto_after);
+}
+
+PollOptions::PollOptions() : options_(ffi::default_poll_options()) {}
+
+AutoCommit PollOptions::AutoCommitSettings() const {
+    return AutoCommitFromFfi(options_);
+}
+
+PollOptions &PollOptions::SetAutoCommit(const AutoCommit &auto_commit) {
+    const auto interval        = auto_commit.Interval();
+    const auto &every          = auto_commit.Every();
+    options_.commit            = ffi::CommitPolicy::Auto;
+    options_.has_auto_interval = interval.has_value();
+    options_.auto_interval_micros =
+        interval && interval->count() > 0 ? static_cast<std::uint64_t>(interval->count()) : 0;
+    options_.has_auto_every      = every.has_value();
+    options_.auto_every          = every ? static_cast<ffi::CommitEvery>(every->GetKind()) : ffi::CommitEvery::Message;
+    options_.auto_every_messages = every ? every->Count() : 0;
+    options_.auto_after          = static_cast<ffi::CommitAfter>(auto_commit.After());
+    return *this;
+}
+
+PolledMessages::PolledMessages(ffi::PolledMessages batch)
+    : partition_id_(batch.partition_id), current_offset_(batch.current_offset), batch_(std::move(batch)) {
+    messages_.reserve(batch_.messages.size());
+    for (auto &message : batch_.messages) {
+        messages_.push_back(IggyMessagePolled::FromFfi(std::move(message)));
+    }
+    batch_.messages.clear();
+}
+
+::iggy::Checkpoint PolledMessages::Checkpoint() const {
+    return ::iggy::Checkpoint(RethrowAsIggyException([this] { return batch_.checkpoint(); }));
+}
+
+::iggy::Checkpoint PolledMessages::CheckpointAt(std::size_t index) const {
+    return ::iggy::Checkpoint(RethrowAsIggyException([this, index] { return batch_.checkpoint_at(index); }));
+}
+
+::iggy::Continuation PolledMessages::Continuation() const {
+    return ::iggy::Continuation(batch_.continuation());
+}
+
+ReadException::ReadException(const std::string &message, ffi::ReadErrorInfo &error)
+    : IggyException(message, error.error) {
+    if (error.has_partition_id) {
+        partition_id_ = error.partition_id;
+    }
+    if (!error.skip.empty()) {
+        skip_ = std::shared_ptr<const ::iggy::Continuation>(new ::iggy::Continuation(error.skip[0]->copy()));
+    }
+}
+
+ffi::ReaderOptions ReaderOptions::ToFfi() const {
+    ffi::ReaderOptions options{};
+    options.partitions.reserve(partitions_.size());
+    for (const auto partition_id : partitions_) {
+        options.partitions.push_back(partition_id);
+    }
+    if (start_) {
+        options.has_start = true;
+        options.start     = ToFfiStrategy(*start_);
+    }
+    options.has_poll_options      = poll_options_.has_value();
+    options.poll_options          = poll_options_ ? poll_options_->options_ : ffi::default_poll_options();
+    options.has_prefetch_bytes    = prefetch_bytes_.has_value();
+    options.prefetch_bytes        = prefetch_bytes_.value_or(0);
+    options.has_prefetch_messages = prefetch_messages_.has_value();
+    options.prefetch_messages     = prefetch_messages_.value_or(0);
+    return options;
+}
+
+std::optional<PolledMessages> PartitionReader::Next(std::chrono::microseconds timeout) {
+    const auto timeout_micros = timeout.count() > 0 ? static_cast<std::uint64_t>(timeout.count()) : 0;
+    ffi::ReadErrorInfo error{};
+    auto batch = [this, timeout_micros, &error] {
+        try {
+            return reader_->next(timeout_micros, error);
+        } catch (const std::exception &failure) {
+            throw ReadException(failure.what(), error);
+        }
+    }();
+    if (batch.messages.empty()) {
+        return std::nullopt;
+    }
+    return PolledMessages(std::move(batch));
+}
+
+void PartitionReader::Commit(const Checkpoint &checkpoint) {
+    RethrowAsIggyException(
+        [this, &checkpoint](ffi::ErrorInfo &error_info) { reader_->commit(*checkpoint.inner_, error_info); });
+}
+
+void PartitionReader::Mark(const Checkpoint &checkpoint) {
+    RethrowAsIggyException(
+        [this, &checkpoint](ffi::ErrorInfo &error_info) { reader_->mark(*checkpoint.inner_, error_info); });
+}
+
+void PartitionReader::Seek(std::uint32_t partition_id, const PollingStrategy &start) {
+    RethrowAsIggyException([this, partition_id, &start](ffi::ErrorInfo &error_info) {
+        reader_->seek(partition_id, ToFfiStrategy(start), error_info);
+    });
+}
+
+void PartitionReader::Seek(std::uint32_t partition_id, const Continuation &continuation) {
+    RethrowAsIggyException([this, partition_id, &continuation](ffi::ErrorInfo &error_info) {
+        reader_->seek(partition_id, *continuation.inner_, error_info);
+    });
+}
+
+std::vector<std::uint32_t> PartitionReader::Partitions() const {
+    const auto partitions = RethrowAsIggyException([this] { return reader_->partitions(); });
+    return std::vector<std::uint32_t>(partitions.begin(), partitions.end());
+}
+
+void PartitionReader::Close() {
+    RethrowAsIggyException([this](ffi::ErrorInfo &error_info) { reader_->close(error_info); });
+}
+
+std::optional<PolledMessages> PendingPoll::Wait(std::chrono::microseconds timeout) {
+    const auto timeout_micros = timeout.count() > 0 ? static_cast<std::uint64_t>(timeout.count()) : 0;
+    auto outcome              = RethrowAsIggyException(
+        [this, timeout_micros](ffi::ErrorInfo &error_info) { return poll_->wait(timeout_micros, error_info); });
+    if (!outcome.ready) {
+        return std::nullopt;
+    }
+    return PolledMessages(std::move(outcome.batch));
+}
+
+void PendingPoll::Cancel() {
+    poll_->cancel();
+}
+
+ReceivedMessage::ReceivedMessage(ffi::ReceivedMessage &received)
+    : partition_id_(received.partition_id),
+      current_offset_(received.current_offset),
+      message_(IggyMessagePolled::FromFfi(std::move(received.message))),
+      checkpoint_(received.checkpoint->copy()) {}
+
+ffi::ConsumerOptions ConsumerOptions::ToFfi() const {
+    ffi::ConsumerOptions options{};
+    options.partitions.reserve(partitions_.size());
+    for (const auto partition_id : partitions_) {
+        options.partitions.push_back(partition_id);
+    }
+    if (start_) {
+        options.has_start = true;
+        options.start     = ToFfiStrategy(*start_);
+    }
+    options.has_poll_options      = poll_options_.has_value();
+    options.poll_options          = poll_options_ ? poll_options_->options_ : ffi::default_poll_options();
+    options.has_prefetch_bytes    = prefetch_bytes_.has_value();
+    options.prefetch_bytes        = prefetch_bytes_.value_or(0);
+    options.has_prefetch_messages = prefetch_messages_.has_value();
+    options.prefetch_messages     = prefetch_messages_.value_or(0);
+    options.allow_replay          = allow_replay_;
+    return options;
+}
+
+std::optional<ReceivedMessage> IggyConsumer::Next(std::chrono::microseconds timeout) {
+    const auto timeout_micros = timeout.count() > 0 ? static_cast<std::uint64_t>(timeout.count()) : 0;
+    ffi::ReadErrorInfo error{};
+    auto received = [this, timeout_micros, &error] {
+        try {
+            return consumer_->next(timeout_micros, error);
+        } catch (const std::exception &failure) {
+            throw ReadException(failure.what(), error);
+        }
+    }();
+    if (received.empty()) {
+        return std::nullopt;
+    }
+    return ReceivedMessage(received[0]);
+}
+
+void IggyConsumer::Commit(const Checkpoint &checkpoint) {
+    RethrowAsIggyException(
+        [this, &checkpoint](ffi::ErrorInfo &error_info) { consumer_->commit(*checkpoint.inner_, error_info); });
+}
+
+void IggyConsumer::Mark(const Checkpoint &checkpoint) {
+    RethrowAsIggyException(
+        [this, &checkpoint](ffi::ErrorInfo &error_info) { consumer_->mark(*checkpoint.inner_, error_info); });
+}
+
+void IggyConsumer::Seek(std::uint32_t partition_id, const PollingStrategy &start) {
+    RethrowAsIggyException([this, partition_id, &start](ffi::ErrorInfo &error_info) {
+        consumer_->seek(partition_id, ToFfiStrategy(start), error_info);
+    });
+}
+
+void IggyConsumer::Seek(std::uint32_t partition_id, const Continuation &continuation) {
+    RethrowAsIggyException([this, partition_id, &continuation](ffi::ErrorInfo &error_info) {
+        consumer_->seek(partition_id, *continuation.inner_, error_info);
+    });
+}
+
+void IggyConsumer::Close() {
+    RethrowAsIggyException([this](ffi::ErrorInfo &error_info) { consumer_->close(error_info); });
+}
+
+PolledMessages IggyBlockingClient::PollMessages(const Consumer &consumer,
+                                                const Identifier &stream,
+                                                const Identifier &topic,
+                                                const std::uint32_t partition_id,
+                                                const PollingStrategy &strategy,
+                                                const PollOptions &options) {
+    return RethrowAsIggyException([&](ffi::ErrorInfo &error_info) {
+        return PolledMessages(Handle()->poll_messages(stream.ToFfi(), topic.ToFfi(), partition_id,
+                                                      std::string(consumer.KindName()), consumer.Id().ToFfi(),
+                                                      ToFfiStrategy(strategy), options.options_, error_info));
+    });
+}
+
+PolledMessages IggyBlockingClient::PollMessages(const Consumer &consumer,
+                                                const Identifier &stream,
+                                                const Identifier &topic,
+                                                const std::uint32_t partition_id,
+                                                const Continuation &continuation,
+                                                const PollOptions &options) {
+    return RethrowAsIggyException([&](ffi::ErrorInfo &error_info) {
+        return PolledMessages(Handle()->poll_messages(stream.ToFfi(), topic.ToFfi(), partition_id,
+                                                      std::string(consumer.KindName()), consumer.Id().ToFfi(),
+                                                      *continuation.inner_, options.options_, error_info));
+    });
+}
+
+PartitionReader IggyBlockingClient::Reader(const Consumer &consumer,
+                                           const Identifier &stream,
+                                           const Identifier &topic,
+                                           const ReaderOptions &options) {
+    return RethrowAsIggyException([&](ffi::ErrorInfo &error_info) {
+        return PartitionReader(Handle()->reader(stream.ToFfi(), topic.ToFfi(), std::string(consumer.KindName()),
+                                                consumer.Id().ToFfi(), options.ToFfi(), error_info));
+    });
+}
+
+PendingPoll IggyBlockingClient::StartPollMessages(const Consumer &consumer,
+                                                  const Identifier &stream,
+                                                  const Identifier &topic,
+                                                  const std::uint32_t partition_id,
+                                                  const PollingStrategy &strategy,
+                                                  const PollOptions &options) {
+    return RethrowAsIggyException([&] {
+        return PendingPoll(Handle()->start_poll_messages(stream.ToFfi(), topic.ToFfi(), partition_id,
+                                                         std::string(consumer.KindName()), consumer.Id().ToFfi(),
+                                                         ToFfiStrategy(strategy), options.options_));
+    });
+}
+
+IggyConsumer IggyBlockingClient::StandaloneConsumer(const std::string &name,
+                                                    const Identifier &stream,
+                                                    const Identifier &topic,
+                                                    const ConsumerOptions &options) {
+    return RethrowAsIggyException([&](ffi::ErrorInfo &error_info) {
+        return IggyConsumer(
+            Handle()->consumer(name, stream.ToFfi(), topic.ToFfi(), false, options.ToFfi(), error_info));
+    });
+}
+
+IggyConsumer IggyBlockingClient::GroupConsumer(const std::string &name,
+                                               const Identifier &stream,
+                                               const Identifier &topic,
+                                               const ConsumerOptions &options) {
+    return RethrowAsIggyException([&](ffi::ErrorInfo &error_info) {
+        return IggyConsumer(Handle()->consumer(name, stream.ToFfi(), topic.ToFfi(), true, options.ToFfi(), error_info));
+    });
+}
+
+}  // namespace iggy
diff --git a/foreign/cpp/src/reader.rs b/foreign/cpp/src/reader.rs
new file mode 100644
index 0000000..d133680
--- /dev/null
+++ b/foreign/cpp/src/reader.rs
@@ -0,0 +1,311 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::sync::{Mutex, MutexGuard, PoisonError};
+use std::time::Duration;
+
+use iggy::prelude::{
+    PartitionReader as RustPartitionReader, PolledMessages as RustPolledMessages,
+    PollingStrategy as RustPollingStrategy, ReadError,
+};
+
+use crate::{
+    RUNTIME, ffi,
+    messages::{Checkpoint, Continuation},
+};
+
+const CLOSED: &str = "the reader is closed";
+
+/// The C++ handle of a reader. `close` takes the reader out, so every later call fails, except
+/// a second `close`, which does nothing.
+///
+/// C++ can call it from several threads, so the calls run one at a time: a call waits for a
+/// running `next` to end, at most for its timeout.
+pub struct PartitionReader {
+    inner: Mutex<Option<RustPartitionReader>>,
+}
+
+impl PartitionReader {
+    pub(crate) fn new(reader: RustPartitionReader) -> Self {
+        Self {
+            inner: Mutex::new(Some(reader)),
+        }
+    }
+
+    fn reader(&self) -> MutexGuard<'_, Option<RustPartitionReader>> {
+        // A panic in a call leaves the reader as it was, so the next call can use it.
+        self.inner.lock().unwrap_or_else(PoisonError::into_inner)
+    }
+
+    pub fn next(
+        &self,
+        timeout_micros: u64,
+        error_info: &mut ffi::ReadErrorInfo,
+    ) -> Result<ffi::PolledMessages, String> {
+        *error_info = ffi::ReadErrorInfo::default();
+        let mut reader = self.reader();
+        let reader = reader
+            .as_mut()
+            .ok_or_else(|| format!("Could not read messages: {CLOSED}"))?;
+        // `timeout` panics when it is built on a thread outside a runtime, such as the C++ caller,
+        // so it is built inside `block_on`. `next` is cancel safe, so a batch that arrives after
+        // the timeout stays in the reader.
+        let read = RUNTIME.block_on(async {
+            tokio::time::timeout(Duration::from_micros(timeout_micros), reader.next()).await
+        });
+        match read {
+            Ok(Ok(batch)) => Ok(batch.into()),
+            Ok(Err(error)) => {
+                error_info.record(&error);
+                Err(format!("Could not read messages: {error}"))
+            }
+            Err(_) => Ok(RustPolledMessages::empty().into()),
+        }
+    }
+
+    pub fn commit(
+        &self,
+        checkpoint: &Checkpoint,
+        error_info: &mut ffi::ErrorInfo,
+    ) -> Result<(), String> {
+        *error_info = ffi::ErrorInfo::default();
+        let reader = self.reader();
+        let reader = reader
+            .as_ref()
+            .ok_or_else(|| format!("Could not commit checkpoint: {CLOSED}"))?;
+        RUNTIME
+            .block_on(reader.commit(checkpoint.0))
+            .inspect_err(|error| error_info.record(error))
+            .map_err(|error| format!("Could not commit checkpoint: {error}"))
+    }
+
+    pub fn mark(
+        &self,
+        checkpoint: &Checkpoint,
+        error_info: &mut ffi::ErrorInfo,
+    ) -> Result<(), String> {
+        *error_info = ffi::ErrorInfo::default();
+        let reader = self.reader();
+        let reader = reader
+            .as_ref()
+            .ok_or_else(|| format!("Could not mark checkpoint: {CLOSED}"))?;
+        reader
+            .mark(checkpoint.0)
+            .inspect_err(|error| error_info.record(error))
+            .map_err(|error| format!("Could not mark checkpoint: {error}"))
+    }
+
+    pub fn seek(
+        &self,
+        partition_id: u32,
+        start: ffi::PollingStrategy,
+        error_info: &mut ffi::ErrorInfo,
+    ) -> Result<(), String> {
+        *error_info = ffi::ErrorInfo::default();
+        let start = RustPollingStrategy::try_from(start)
+            .map_err(|error| format!("Could not seek partition {partition_id}: {error}"))?;
+        self.set_position(partition_id, start, error_info)
+    }
+
+    pub fn seek_to(
+        &self,
+        partition_id: u32,
+        continuation: &Continuation,
+        error_info: &mut ffi::ErrorInfo,
+    ) -> Result<(), String> {
+        *error_info = ffi::ErrorInfo::default();
+        self.set_position(partition_id, continuation.0, error_info)
+    }
+
+    fn set_position(
+        &self,
+        partition_id: u32,
+        start: RustPollingStrategy,
+        error_info: &mut ffi::ErrorInfo,
+    ) -> Result<(), String> {
+        let mut reader = self.reader();
+        let reader = reader
+            .as_mut()
+            .ok_or_else(|| format!("Could not seek partition {partition_id}: {CLOSED}"))?;
+        RUNTIME
+            .block_on(reader.seek(partition_id, start))
+            .inspect_err(|error| error_info.record(error))
+            .map_err(|error| format!("Could not seek partition {partition_id}: {error}"))
+    }
+
+    pub fn partitions(&self) -> Result<Vec<u32>, String> {
+        self.reader()
+            .as_ref()
+            .map(RustPartitionReader::partitions)
+            .ok_or_else(|| format!("Could not get the reader partitions: {CLOSED}"))
+    }
+
+    pub fn close(&self, error_info: &mut ffi::ErrorInfo) -> Result<(), String> {
+        *error_info = ffi::ErrorInfo::default();
+        let Some(reader) = self.reader().take() else {
+            return Ok(());
+        };
+        RUNTIME
+            .block_on(reader.close())
+            .inspect_err(|error| error_info.record(error))
+            .map_err(|error| format!("Could not close reader: {error}"))
+    }
+}
+
+impl ffi::ReadErrorInfo {
+    pub(crate) fn record(&mut self, error: &ReadError) {
+        self.error.record(&error.error);
+        self.has_partition_id = error.partition_id.is_some();
+        self.partition_id = error.partition_id.unwrap_or_default();
+        self.skip = error
+            .skip
+            .map(|skip| Box::new(Continuation(skip)))
+            .into_iter()
+            .collect();
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use iggy::prelude::{Checkpoint as RustCheckpoint, IggyError, PartitionContext, PollingKind};
+    use std::thread;
+
+    const PARTITION: u32 = 1;
+    const CONTEXT: PartitionContext = PartitionContext {
+        incarnation: 3,
+        owner_generation: 5,
+        metadata_op: 7,
+    };
+
+    fn continuation_at(offset: u64) -> RustPollingStrategy {
+        RustPollingStrategy::continuation(PARTITION, PollingKind::Offset, offset, CONTEXT)
+    }
+
+    #[test]
+    fn should_refuse_every_call_on_a_closed_reader_except_a_second_close() {
+        let reader = PartitionReader {
+            inner: Mutex::new(None),
+        };
+        let mut error_info = ffi::ErrorInfo::default();
+        let mut read_error = ffi::ReadErrorInfo::default();
+        let start = ffi::PollingStrategy {
+            kind: "next".to_owned(),
+            value: 0,
+        };
+
+        assert!(reader.next(0, &mut read_error).is_err());
+        assert!(
+            reader
+                .commit(
+                    &Checkpoint(RustCheckpoint::new(PARTITION, 0, CONTEXT)),
+                    &mut error_info
+                )
+                .is_err()
+        );
+        assert!(
+            reader
+                .mark(
+                    &Checkpoint(RustCheckpoint::new(PARTITION, 0, CONTEXT)),
+                    &mut error_info
+                )
+                .is_err()
+        );
+        assert!(reader.seek(PARTITION, start, &mut error_info).is_err());
+        assert!(
+            reader
+                .seek_to(
+                    PARTITION,
+                    &Continuation(continuation_at(0)),
+                    &mut error_info
+                )
+                .is_err()
+        );
+        assert!(reader.partitions().is_err());
+
+        error_info.record(&IggyError::InvalidCommand);
+        assert_eq!(reader.close(&mut error_info), Ok(()));
+        assert_eq!(
+            error_info.code, 0,
+            "close must clear the error of an earlier call"
+        );
+    }
+
+    #[test]
+    fn given_reader_shared_by_threads_when_calling_should_serve_every_call() {
+        const THREADS: usize = 4;
+        let reader = PartitionReader {
+            inner: Mutex::new(None),
+        };
+
+        thread::scope(|scope| {
+            for _ in 0..THREADS {
+                scope.spawn(|| {
+                    let mut error_info = ffi::ErrorInfo::default();
+                    let mut read_error = ffi::ReadErrorInfo::default();
+                    assert!(reader.next(0, &mut read_error).is_err());
+                    assert!(
+                        reader
+                            .seek_to(
+                                PARTITION,
+                                &Continuation(continuation_at(0)),
+                                &mut error_info
+                            )
+                            .is_err()
+                    );
+                    assert_eq!(reader.close(&mut error_info), Ok(()));
+                });
+            }
+        });
+    }
+
+    #[test]
+    fn should_record_the_skip_of_a_read_error_as_one_continuation() {
+        let skip = continuation_at(5);
+        let mut info = ffi::ReadErrorInfo::default();
+
+        info.record(&ReadError {
+            partition_id: Some(PARTITION),
+            error: IggyError::InvalidMessagePayloadLength,
+            skip: Some(skip),
+            offset: None,
+            record: None,
+        });
+        assert!(info.has_partition_id);
+        assert_eq!(info.partition_id, PARTITION);
+        assert_eq!(
+            info.skip
+                .iter()
+                .map(|continuation| continuation.0)
+                .collect::<Vec<_>>(),
+            [skip]
+        );
+
+        info.record(&ReadError {
+            partition_id: None,
+            error: IggyError::ClientShutdown,
+            skip: None,
+            offset: None,
+            record: None,
+        });
+        assert!(!info.has_partition_id);
+        assert!(
+            info.skip.is_empty(),
+            "a later error must not keep the skip of an earlier one"
+        );
+    }
+}
diff --git a/foreign/cpp/src/type_conversion.rs b/foreign/cpp/src/type_conversion.rs
index 809a2b4..ae1dbdf 100644
--- a/foreign/cpp/src/type_conversion.rs
+++ b/foreign/cpp/src/type_conversion.rs
@@ -17,18 +17,23 @@
 
 use std::{
     collections::{BTreeMap, BTreeSet},
+    num::NonZeroU32,
     str::FromStr,
+    time::Duration,
 };
 
 use bytes::Bytes;
 use iggy::prelude::{
-    CompressionAlgorithm as RustCompressionAlgorithm,
+    AutoCommit as RustAutoCommit, CommitAfter as RustCommitAfter, CommitEvery as RustCommitEvery,
+    CommitPolicy as RustCommitPolicy, CompressionAlgorithm as RustCompressionAlgorithm,
     ConsumerGroupDetails as RustConsumerGroupDetails, Durability as RustDurability, IdKind,
-    Identifier as RustIdentifier, IggyByteSize as RustIggyByteSize, IggyExpiry as RustIggyExpiry,
-    IggyMessage as RustIggyMessage, MaxTopicSize as RustMaxTopicSize, OptionKey,
+    Identifier as RustIdentifier, IggyByteSize as RustIggyByteSize,
+    IggyDuration as RustIggyDuration, IggyExpiry as RustIggyExpiry, IggyMessage as RustIggyMessage,
+    MaxTopicSize as RustMaxTopicSize, NonZeroIggyDuration as RustNonZeroIggyDuration, OptionKey,
     OptionSpec as RustOptionSpec, Partition as RustPartition,
     PartitionContext as RustPartitionContext, PartitionResizePolicy as RustPartitionResizePolicy,
-    PolledMessages as RustPolledMessages,
+    PollOptions as RustPollOptions, PolledMessages as RustPolledMessages,
+    PollingStrategy as RustPollingStrategy,
     SendMessagesConfirmationResponse as RustSendMessagesConfirmationResponse,
     SendMessagesResponse as RustSendMessagesResponse, Stream as RustStream,
     StreamDetails as RustStreamDetails, StreamEncryptionKey,
@@ -66,7 +71,7 @@
     UserInfoDetails as RustUserInfoDetails, UserStatus as RustUserStatus,
 };
 
-use crate::ffi;
+use crate::{ffi, messages::BatchPositions};
 
 impl From<RustIdentifier> for ffi::Identifier {
     fn from(identifier: RustIdentifier) -> Self {
@@ -496,16 +501,6 @@
     }
 }
 
-impl From<ffi::PartitionContext> for RustPartitionContext {
-    fn from(context: ffi::PartitionContext) -> Self {
-        RustPartitionContext {
-            incarnation: context.incarnation,
-            owner_generation: context.owner_generation,
-            metadata_op: context.metadata_op,
-        }
-    }
-}
-
 impl From<RustPartition> for ffi::Partition {
     fn from(partition: RustPartition) -> Self {
         ffi::Partition {
@@ -1163,6 +1158,7 @@
 
 impl From<RustPolledMessages> for ffi::PolledMessages {
     fn from(messages: RustPolledMessages) -> Self {
+        let positions = Box::new(BatchPositions::from(&messages));
         ffi::PolledMessages {
             partition_id: messages.partition_id,
             current_offset: messages.current_offset,
@@ -1172,7 +1168,114 @@
                 .into_iter()
                 .map(ffi::IggyMessagePolled::from)
                 .collect(),
-            context: messages.context.into(),
+            positions,
+        }
+    }
+}
+
+impl From<RustPollOptions> for ffi::PollOptions {
+    fn from(options: RustPollOptions) -> Self {
+        let (commit, auto) = match options.commit {
+            RustCommitPolicy::Manual => (ffi::CommitPolicy::Manual, RustAutoCommit::default()),
+            RustCommitPolicy::OnRead => (ffi::CommitPolicy::OnRead, RustAutoCommit::default()),
+            RustCommitPolicy::Auto(auto) => (ffi::CommitPolicy::Auto, auto),
+        };
+        let (auto_every, auto_every_messages) = match auto.every {
+            Some(RustCommitEvery::Message) | None => (ffi::CommitEvery::Message, 0),
+            Some(RustCommitEvery::Messages(count)) => (ffi::CommitEvery::Messages, count.get()),
+            Some(RustCommitEvery::Batch) => (ffi::CommitEvery::Batch, 0),
+        };
+        ffi::PollOptions {
+            count: options.count,
+            max_wait_micros: options.max_wait.as_micros(),
+            min_count: options.min_count,
+            max_bytes: options.max_bytes,
+            commit,
+            has_auto_interval: auto.interval.is_some(),
+            auto_interval_micros: auto
+                .interval
+                .map_or(0, |interval| interval.get().as_micros()),
+            has_auto_every: auto.every.is_some(),
+            auto_every,
+            auto_every_messages,
+            auto_after: match auto.after {
+                RustCommitAfter::Processing => ffi::CommitAfter::Processing,
+                RustCommitAfter::Delivery => ffi::CommitAfter::Delivery,
+                RustCommitAfter::Marked => ffi::CommitAfter::Marked,
+            },
+        }
+    }
+}
+
+impl TryFrom<ffi::PollOptions> for RustPollOptions {
+    type Error = String;
+
+    fn try_from(options: ffi::PollOptions) -> Result<Self, Self::Error> {
+        let commit = match options.commit {
+            ffi::CommitPolicy::Manual => RustCommitPolicy::Manual,
+            ffi::CommitPolicy::OnRead => RustCommitPolicy::OnRead,
+            ffi::CommitPolicy::Auto => RustCommitPolicy::Auto(RustAutoCommit::try_from(&options)?),
+            _ => return Err("invalid commit policy".to_owned()),
+        };
+        Ok(RustPollOptions {
+            count: options.count,
+            max_wait: RustIggyDuration::from(options.max_wait_micros),
+            min_count: options.min_count,
+            max_bytes: options.max_bytes,
+            commit,
+        })
+    }
+}
+
+impl TryFrom<&ffi::PollOptions> for RustAutoCommit {
+    type Error = String;
+
+    fn try_from(options: &ffi::PollOptions) -> Result<Self, Self::Error> {
+        let interval = options
+            .has_auto_interval
+            .then(|| {
+                RustNonZeroIggyDuration::new(Duration::from_micros(options.auto_interval_micros))
+                    .map_err(|_| "the auto commit interval must not be zero".to_owned())
+            })
+            .transpose()?;
+        let every = options
+            .has_auto_every
+            .then(|| match options.auto_every {
+                ffi::CommitEvery::Message => Ok(RustCommitEvery::Message),
+                ffi::CommitEvery::Messages => NonZeroU32::new(options.auto_every_messages)
+                    .map(RustCommitEvery::Messages)
+                    .ok_or_else(|| "the auto commit message count must not be zero".to_owned()),
+                ffi::CommitEvery::Batch => Ok(RustCommitEvery::Batch),
+                _ => Err("invalid auto commit event".to_owned()),
+            })
+            .transpose()?;
+        let after = match options.auto_after {
+            ffi::CommitAfter::Processing => RustCommitAfter::Processing,
+            ffi::CommitAfter::Delivery => RustCommitAfter::Delivery,
+            ffi::CommitAfter::Marked => RustCommitAfter::Marked,
+            _ => return Err("invalid auto commit point".to_owned()),
+        };
+        Ok(RustAutoCommit {
+            interval,
+            every,
+            after,
+        })
+    }
+}
+
+impl TryFrom<ffi::PollingStrategy> for RustPollingStrategy {
+    type Error = String;
+
+    fn try_from(strategy: ffi::PollingStrategy) -> Result<Self, Self::Error> {
+        match strategy.kind.as_str() {
+            "offset" => Ok(RustPollingStrategy::offset(strategy.value)),
+            "timestamp" => Ok(RustPollingStrategy::timestamp(IggyTimestamp::from(
+                strategy.value,
+            ))),
+            "first" => Ok(RustPollingStrategy::first()),
+            "last" => Ok(RustPollingStrategy::last()),
+            "next" => Ok(RustPollingStrategy::next()),
+            kind => Err(format!("invalid polling strategy: {kind}")),
         }
     }
 }
@@ -1778,7 +1881,11 @@
 #[cfg(test)]
 mod tests {
     use super::*;
-    use iggy::prelude::{HeaderKey, HeaderValue, IggyTimestamp, OptionValue, ResourceOptions};
+    use iggy::prelude::{
+        Checkpoint as RustCheckpoint, HeaderKey, HeaderValue, IggyTimestamp, OptionValue,
+        ResourceOptions,
+    };
+    use iggy_common::ReaderStamp as RustReaderStamp;
 
     // Distinct values so a swapped field fails the comparison.
     const CONTEXT: RustPartitionContext = RustPartitionContext {
@@ -1786,6 +1893,20 @@
         owner_generation: 5,
         metadata_op: 7,
     };
+    const PARTITION: u32 = 2;
+    const STAMP: RustReaderStamp = RustReaderStamp {
+        reader: 11,
+        generation: 13,
+    };
+
+    fn message_at(offset: u64) -> RustIggyMessage {
+        let mut message = RustIggyMessage::builder()
+            .payload(Bytes::from_static(b"payload"))
+            .build()
+            .expect("message builds");
+        message.header.offset = offset;
+        message
+    }
 
     fn fields(context: &ffi::PartitionContext) -> (u64, u64, u64) {
         (
@@ -1997,7 +2118,6 @@
         let bridged = ffi::PartitionContext::from(CONTEXT);
 
         assert_eq!(fields(&bridged), (3, 5, 7));
-        assert_eq!(RustPartitionContext::from(bridged), CONTEXT);
     }
 
     #[test]
@@ -2017,21 +2137,229 @@
 
     #[test]
     fn should_keep_the_partition_context_of_polled_messages() {
-        let polled = RustPolledMessages {
-            context: CONTEXT,
-            partition_id: 1,
-            current_offset: 2,
-            count: 0,
-            messages: Vec::new(),
+        let polled = RustPolledMessages::new(CONTEXT, PARTITION, 8, 1, vec![message_at(7)]);
+
+        let bridged = ffi::PolledMessages::from(polled);
+
+        assert_eq!(
+            bridged.checkpoint().map(|checkpoint| checkpoint.0),
+            Ok(RustCheckpoint::new(PARTITION, 7, CONTEXT)),
+            "A batch of poll_messages belongs to no reader"
+        );
+        assert_eq!(bridged.continuation().0.context(), Some(CONTEXT));
+    }
+
+    #[test]
+    fn given_reader_batch_when_bridged_should_keep_its_checkpoints_and_continuation() {
+        let mut polled =
+            RustPolledMessages::new(CONTEXT, PARTITION, 9, 2, vec![message_at(7), message_at(8)]);
+        polled.set_stamp(STAMP);
+        let last = polled.checkpoint();
+        let first = polled.checkpoint_at(0);
+        let continuation = polled.continuation();
+        assert_eq!(last.and_then(|checkpoint| checkpoint.stamp()), Some(STAMP));
+
+        let bridged = ffi::PolledMessages::from(polled);
+
+        assert_eq!(
+            bridged.checkpoint().map(|checkpoint| checkpoint.0).ok(),
+            last
+        );
+        assert_eq!(
+            bridged.checkpoint_at(0).map(|checkpoint| checkpoint.0).ok(),
+            first
+        );
+        assert!(bridged.checkpoint_at(2).is_err());
+        assert_eq!(bridged.continuation().0, continuation);
+    }
+
+    #[test]
+    fn given_bridged_batch_edited_by_the_caller_when_taking_checkpoints_should_keep_delivered_positions()
+     {
+        let mut polled =
+            RustPolledMessages::new(CONTEXT, PARTITION, 9, 2, vec![message_at(7), message_at(8)]);
+        polled.set_stamp(STAMP);
+        let last = polled.checkpoint();
+        let first = polled.checkpoint_at(0);
+        let mut bridged = ffi::PolledMessages::from(polled);
+
+        bridged.partition_id = PARTITION + 1;
+        bridged.messages.reverse();
+        bridged.messages[0].offset = 100;
+        bridged.messages.pop();
+
+        assert_eq!(
+            bridged.checkpoint().map(|checkpoint| checkpoint.0).ok(),
+            last
+        );
+        assert_eq!(
+            bridged.checkpoint_at(0).map(|checkpoint| checkpoint.0).ok(),
+            first
+        );
+        assert_eq!(
+            bridged.checkpoint_at(1).map(|checkpoint| checkpoint.0).ok(),
+            last
+        );
+        assert!(bridged.checkpoint_at(2).is_err());
+    }
+
+    #[test]
+    fn given_empty_batch_when_bridged_should_continue_from_the_start_of_its_request() {
+        for start in [
+            RustPollingStrategy::offset(4),
+            RustPollingStrategy::timestamp(IggyTimestamp::from(5)),
+            RustPollingStrategy::first(),
+            RustPollingStrategy::last(),
+            RustPollingStrategy::next(),
+        ] {
+            let mut polled = RustPolledMessages::new(CONTEXT, PARTITION, 0, 0, Vec::new());
+            polled.set_request_start(&start);
+            let continuation = polled.continuation();
+
+            let bridged = ffi::PolledMessages::from(polled);
+
+            assert!(bridged.checkpoint().is_err(), "{start}");
+            assert_eq!(bridged.continuation().0, continuation, "{start}");
+        }
+    }
+
+    #[test]
+    fn should_convert_each_kind_to_a_strategy_without_partition_or_context() {
+        for (kind, expected) in [
+            ("offset", RustPollingStrategy::offset(9)),
+            (
+                "timestamp",
+                RustPollingStrategy::timestamp(IggyTimestamp::from(9)),
+            ),
+            ("first", RustPollingStrategy::first()),
+            ("last", RustPollingStrategy::last()),
+            ("next", RustPollingStrategy::next()),
+        ] {
+            let strategy = RustPollingStrategy::try_from(ffi::PollingStrategy {
+                kind: kind.to_owned(),
+                value: 9,
+            });
+
+            assert_eq!(strategy, Ok(expected), "{kind}");
+        }
+    }
+
+    #[test]
+    fn should_reject_an_unknown_polling_strategy() {
+        let strategy = ffi::PollingStrategy {
+            kind: "latest".to_owned(),
+            value: 0,
         };
 
         assert_eq!(
-            fields(&ffi::PolledMessages::from(polled).context),
-            (3, 5, 7)
+            RustPollingStrategy::try_from(strategy).err().as_deref(),
+            Some("invalid polling strategy: latest")
         );
     }
 
     #[test]
+    fn given_each_commit_policy_when_bridged_should_keep_every_poll_option() {
+        let auto = RustAutoCommit {
+            interval: RustNonZeroIggyDuration::new(Duration::from_micros(9)).ok(),
+            every: NonZeroU32::new(11).map(RustCommitEvery::Messages),
+            after: RustCommitAfter::Marked,
+        };
+        for commit in [
+            RustCommitPolicy::Manual,
+            RustCommitPolicy::OnRead,
+            RustCommitPolicy::Auto(auto),
+            RustCommitPolicy::Auto(RustAutoCommit {
+                interval: None,
+                every: Some(RustCommitEvery::Batch),
+                after: RustCommitAfter::Delivery,
+            }),
+            RustCommitPolicy::Auto(RustAutoCommit {
+                every: None,
+                ..RustAutoCommit::default()
+            }),
+        ] {
+            let options = RustPollOptions {
+                count: 3,
+                max_wait: RustIggyDuration::from(5),
+                min_count: 2,
+                max_bytes: 7,
+                commit,
+            };
+
+            assert_eq!(
+                RustPollOptions::try_from(ffi::PollOptions::from(options)),
+                Ok(options),
+                "{commit:?}"
+            );
+        }
+    }
+
+    #[test]
+    fn given_default_options_when_switched_to_auto_should_take_the_auto_commit_defaults() {
+        let mut options = ffi::PollOptions::from(RustPollOptions::default());
+
+        options.commit = ffi::CommitPolicy::Auto;
+
+        assert_eq!(
+            RustPollOptions::try_from(options).map(|options| options.commit),
+            Ok(RustCommitPolicy::Auto(RustAutoCommit::default()))
+        );
+    }
+
+    #[test]
+    fn given_invalid_commit_settings_when_bridged_should_refuse_them() {
+        let auto = ffi::PollOptions {
+            commit: ffi::CommitPolicy::Auto,
+            ..ffi::PollOptions::from(RustPollOptions::default())
+        };
+        let cases = [
+            (
+                ffi::PollOptions {
+                    commit: ffi::CommitPolicy { repr: 3 },
+                    ..ffi::PollOptions::from(RustPollOptions::default())
+                },
+                "invalid commit policy",
+            ),
+            (
+                ffi::PollOptions {
+                    auto_interval_micros: 0,
+                    ..auto
+                },
+                "the auto commit interval must not be zero",
+            ),
+            (
+                ffi::PollOptions {
+                    auto_every: ffi::CommitEvery::Messages,
+                    auto_every_messages: 0,
+                    ..auto
+                },
+                "the auto commit message count must not be zero",
+            ),
+            (
+                ffi::PollOptions {
+                    auto_every: ffi::CommitEvery { repr: 3 },
+                    ..auto
+                },
+                "invalid auto commit event",
+            ),
+            (
+                ffi::PollOptions {
+                    auto_after: ffi::CommitAfter { repr: 3 },
+                    ..auto
+                },
+                "invalid auto commit point",
+            ),
+        ];
+
+        for (options, expected) in cases {
+            assert_eq!(
+                RustPollOptions::try_from(options).err().as_deref(),
+                Some(expected)
+            );
+        }
+    }
+
+    #[test]
     fn given_json_record_of_unknown_kind_when_converting_should_keep_names_and_the_whole_object() {
         let json = r#"{"op":5,"ordinal":1,"checksum":"0000000000000000000000000000002a","timestamp":7,"group":"future_group","kind":"future_kind","extra":true}"#;
         let record: MetadataChangeRecord = serde_json::from_str(json).expect("record parses");
diff --git a/foreign/cpp/src/type_conversions.cpp b/foreign/cpp/src/type_conversions.cpp
index 523af49..9fb7450 100644
--- a/foreign/cpp/src/type_conversions.cpp
+++ b/foreign/cpp/src/type_conversions.cpp
@@ -268,14 +268,6 @@
                  ResourceOptions::FromFfi(std::move(topic.options), std::move(topic.derived_options)));
 }
 
-ffi::PartitionContext PartitionContext::ToFfi() const {
-    ffi::PartitionContext context{};
-    context.incarnation      = incarnation_;
-    context.owner_generation = owner_generation_;
-    context.metadata_op      = metadata_op_;
-    return context;
-}
-
 PartitionContext PartitionContext::FromFfi(ffi::PartitionContext context) {
     return PartitionContext(context.incarnation, context.owner_generation, context.metadata_op);
 }
diff --git a/foreign/cpp/tests/e2e/client.cpp b/foreign/cpp/tests/e2e/client.cpp
index 6adb66d..00f7e71 100644
--- a/foreign/cpp/tests/e2e/client.cpp
+++ b/foreign/cpp/tests/e2e/client.cpp
@@ -23,6 +23,7 @@
 #include <cstddef>
 #include <cstdint>
 #include <limits>
+#include <optional>
 #include <string>
 #include <thread>
 #include <unordered_set>
@@ -2227,12 +2228,12 @@
 
     const auto partition_before_delete = find_partition(0);
 
-    iggy::ffi::PolledMessages polled_before_delete{};
+    std::optional<iggy::ffi::PolledMessages> polled_before_delete;
     ASSERT_NO_THROW({
         iggy::ffi::ErrorInfo error_info{};
-        polled_before_delete =
-            sender->poll_messages(make_numeric_identifier(stream_id), make_numeric_identifier(topic_id), 0, "consumer",
-                                  make_numeric_identifier(1005), "offset", 0, 1000, false, error_info);
+        polled_before_delete = sender->poll_messages(
+            make_numeric_identifier(stream_id), make_numeric_identifier(topic_id), 0, "consumer",
+            make_numeric_identifier(1005), make_polling_strategy("offset", 0), make_poll_options(1000), error_info);
     });
 
     ASSERT_NO_THROW(
@@ -2240,22 +2241,22 @@
 
     const auto partition_after_delete = find_partition(0);
 
-    iggy::ffi::PolledMessages polled_after_delete{};
+    std::optional<iggy::ffi::PolledMessages> polled_after_delete;
     ASSERT_NO_THROW({
         iggy::ffi::ErrorInfo error_info{};
-        polled_after_delete =
-            sender->poll_messages(make_numeric_identifier(stream_id), make_numeric_identifier(topic_id), 0, "consumer",
-                                  make_numeric_identifier(1006), "offset", 0, 1000, false, error_info);
+        polled_after_delete = sender->poll_messages(
+            make_numeric_identifier(stream_id), make_numeric_identifier(topic_id), 0, "consumer",
+            make_numeric_identifier(1006), make_polling_strategy("offset", 0), make_poll_options(1000), error_info);
     });
 
     EXPECT_EQ(partition_after_delete.SegmentsCount(), partition_before_delete.SegmentsCount());
     EXPECT_EQ(partition_after_delete.CurrentOffset(), partition_before_delete.CurrentOffset());
     EXPECT_EQ(partition_after_delete.MessagesCount(), partition_before_delete.MessagesCount());
     EXPECT_EQ(partition_after_delete.SizeBytes(), partition_before_delete.SizeBytes());
-    EXPECT_EQ(polled_after_delete.count, polled_before_delete.count);
-    ASSERT_EQ(polled_after_delete.messages.size(), polled_before_delete.messages.size());
-    for (std::size_t i = 0; i < polled_before_delete.messages.size(); ++i) {
-        EXPECT_EQ(polled_after_delete.messages[i].offset, polled_before_delete.messages[i].offset);
+    EXPECT_EQ(polled_after_delete->count, polled_before_delete->count);
+    ASSERT_EQ(polled_after_delete->messages.size(), polled_before_delete->messages.size());
+    for (std::size_t i = 0; i < polled_before_delete->messages.size(); ++i) {
+        EXPECT_EQ(polled_after_delete->messages[i].offset, polled_before_delete->messages[i].offset);
     }
 }
 
@@ -2300,12 +2301,12 @@
     const auto partition_before_delete = find_partition(0);
     ASSERT_EQ(partition_before_delete.SegmentsCount(), 1u);
 
-    iggy::ffi::PolledMessages polled_before_delete{};
+    std::optional<iggy::ffi::PolledMessages> polled_before_delete;
     ASSERT_NO_THROW({
         iggy::ffi::ErrorInfo error_info{};
-        polled_before_delete =
-            sender->poll_messages(make_numeric_identifier(stream_id), make_numeric_identifier(topic_id), 0, "consumer",
-                                  make_numeric_identifier(1007), "offset", 0, 1000, false, error_info);
+        polled_before_delete = sender->poll_messages(
+            make_numeric_identifier(stream_id), make_numeric_identifier(topic_id), 0, "consumer",
+            make_numeric_identifier(1007), make_polling_strategy("offset", 0), make_poll_options(1000), error_info);
     });
 
     ASSERT_NO_THROW(
@@ -2313,22 +2314,22 @@
 
     const auto partition_after_delete = find_partition(0);
 
-    iggy::ffi::PolledMessages polled_after_delete{};
+    std::optional<iggy::ffi::PolledMessages> polled_after_delete;
     ASSERT_NO_THROW({
         iggy::ffi::ErrorInfo error_info{};
-        polled_after_delete =
-            sender->poll_messages(make_numeric_identifier(stream_id), make_numeric_identifier(topic_id), 0, "consumer",
-                                  make_numeric_identifier(1008), "offset", 0, 1000, false, error_info);
+        polled_after_delete = sender->poll_messages(
+            make_numeric_identifier(stream_id), make_numeric_identifier(topic_id), 0, "consumer",
+            make_numeric_identifier(1008), make_polling_strategy("offset", 0), make_poll_options(1000), error_info);
     });
 
     EXPECT_EQ(partition_after_delete.SegmentsCount(), partition_before_delete.SegmentsCount());
     EXPECT_EQ(partition_after_delete.CurrentOffset(), partition_before_delete.CurrentOffset());
     EXPECT_EQ(partition_after_delete.MessagesCount(), partition_before_delete.MessagesCount());
     EXPECT_EQ(partition_after_delete.SizeBytes(), partition_before_delete.SizeBytes());
-    EXPECT_EQ(polled_after_delete.count, polled_before_delete.count);
-    ASSERT_EQ(polled_after_delete.messages.size(), polled_before_delete.messages.size());
-    for (std::size_t i = 0; i < polled_before_delete.messages.size(); ++i) {
-        EXPECT_EQ(polled_after_delete.messages[i].offset, polled_before_delete.messages[i].offset);
+    EXPECT_EQ(polled_after_delete->count, polled_before_delete->count);
+    ASSERT_EQ(polled_after_delete->messages.size(), polled_before_delete->messages.size());
+    for (std::size_t i = 0; i < polled_before_delete->messages.size(); ++i) {
+        EXPECT_EQ(polled_after_delete->messages[i].offset, polled_before_delete->messages[i].offset);
     }
 }
 
diff --git a/foreign/cpp/tests/e2e/consumer_group.cpp b/foreign/cpp/tests/e2e/consumer_group.cpp
index 15e9b0b..0efe81f 100644
--- a/foreign/cpp/tests/e2e/consumer_group.cpp
+++ b/foreign/cpp/tests/e2e/consumer_group.cpp
@@ -18,6 +18,7 @@
  */
 
 #include <cstddef>
+#include <optional>
 #include <string>
 
 #include <gtest/gtest.h>
@@ -1847,14 +1848,15 @@
                                                   make_string_identifier(topic_name), "partition_id",
                                                   partition_id_bytes(0), std::move(messages), error_info));
 
-    iggy::ffi::PolledMessages polled{};
+    std::optional<iggy::ffi::PolledMessages> polled;
     ASSERT_NO_THROW(polled = message_client->poll_messages(
                         make_string_identifier(stream_name), make_string_identifier(topic_name), 0, "consumer",
-                        make_numeric_identifier(77), "next", 0, 3, true, error_info));
-    ASSERT_EQ(polled.count, 3u);
-    ASSERT_EQ(polled.messages.size(), 3u);
-    EXPECT_EQ(polled.messages.front().offset, 0u);
-    EXPECT_EQ(polled.messages.back().offset, 2u);
+                        make_numeric_identifier(77), make_polling_strategy("next", 0),
+                        make_poll_options(3, iggy::ffi::CommitPolicy::OnRead), error_info));
+    ASSERT_EQ(polled->count, 3u);
+    ASSERT_EQ(polled->messages.size(), 3u);
+    EXPECT_EQ(polled->messages.front().offset, 0u);
+    EXPECT_EQ(polled->messages.back().offset, 2u);
 
     const auto offset = client.GetConsumerOffset(consumer, iggy::Identifier::String(stream_name),
                                                  iggy::Identifier::String(topic_name), 0);
@@ -1863,6 +1865,89 @@
     EXPECT_EQ(offset.StoredOffset(), 2u);
 }
 
+TEST_F(E2E_ConsumerGroup, StoreConsumerPositionStoresTheOffsetOfTheCheckpoint) {
+    iggy::ffi::ErrorInfo error_info{};
+    RecordProperty("description", "Stores the checkpoint of a polled message and reads its offset back.");
+    const std::string stream_name = GetRandomName();
+    const std::string topic_name  = GetRandomName();
+    auto client                   = GetLoggedInHighLevelClient();
+    auto *message_client          = GetLoggedInClient();
+    const auto consumer           = iggy::Consumer::Single(iggy::Identifier::Numeric(99));
+
+    ASSERT_NO_THROW(client.CreateStream(stream_name));
+    TrackStream(stream_name);
+    ASSERT_NO_THROW(client.CreateTopic(iggy::Identifier::String(stream_name), topic_name,
+                                       iggy::TopicCreateOptions().SetPartitionsCount(1)));
+    rust::Vec<iggy::ffi::IggyMessageToSend> messages;
+    for (std::uint32_t index = 0; index < 5; ++index) {
+        messages.push_back(iggy::ffi::make_message(to_payload("position-test-" + std::to_string(index)),
+                                                   rust::Vec<iggy::ffi::HeaderEntry>{}));
+    }
+    ASSERT_NO_THROW(message_client->send_messages(make_string_identifier(stream_name),
+                                                  make_string_identifier(topic_name), "partition_id",
+                                                  partition_id_bytes(0), std::move(messages), error_info));
+
+    std::optional<iggy::PolledMessages> polled;
+    ASSERT_NO_THROW(polled = client.PollMessages(consumer, iggy::Identifier::String(stream_name),
+                                                 iggy::Identifier::String(topic_name), 0,
+                                                 iggy::PollingStrategy::Offset(0), iggy::PollOptions().SetCount(5)));
+    ASSERT_EQ(polled->Messages().size(), 5u);
+
+    ASSERT_NO_THROW(client.StoreConsumerPosition(consumer, iggy::Identifier::String(stream_name),
+                                                 iggy::Identifier::String(topic_name), polled->CheckpointAt(2)));
+    EXPECT_EQ(
+        client
+            .GetConsumerOffset(consumer, iggy::Identifier::String(stream_name), iggy::Identifier::String(topic_name), 0)
+            .StoredOffset(),
+        2u);
+}
+
+TEST_F(E2E_ConsumerGroup, DeleteConsumerPositionDeletesTheOffsetOfThePartition) {
+    iggy::ffi::ErrorInfo error_info{};
+    RecordProperty("description",
+                   "Deletes the offset stored for the partition of a checkpoint, and a second delete finds none.");
+    const std::string stream_name = GetRandomName();
+    const std::string topic_name  = GetRandomName();
+    auto client                   = GetLoggedInHighLevelClient();
+    auto *message_client          = GetLoggedInClient();
+    const auto consumer           = iggy::Consumer::Single(iggy::Identifier::Numeric(98));
+
+    ASSERT_NO_THROW(client.CreateStream(stream_name));
+    TrackStream(stream_name);
+    ASSERT_NO_THROW(client.CreateTopic(iggy::Identifier::String(stream_name), topic_name,
+                                       iggy::TopicCreateOptions().SetPartitionsCount(1)));
+    rust::Vec<iggy::ffi::IggyMessageToSend> messages;
+    for (std::uint32_t index = 0; index < 3; ++index) {
+        messages.push_back(iggy::ffi::make_message(to_payload("delete-position-" + std::to_string(index)),
+                                                   rust::Vec<iggy::ffi::HeaderEntry>{}));
+    }
+    ASSERT_NO_THROW(message_client->send_messages(make_string_identifier(stream_name),
+                                                  make_string_identifier(topic_name), "partition_id",
+                                                  partition_id_bytes(0), std::move(messages), error_info));
+    std::optional<iggy::PolledMessages> polled;
+    ASSERT_NO_THROW(polled = client.PollMessages(consumer, iggy::Identifier::String(stream_name),
+                                                 iggy::Identifier::String(topic_name), 0,
+                                                 iggy::PollingStrategy::Offset(0), iggy::PollOptions().SetCount(3)));
+    ASSERT_EQ(polled->Messages().size(), 3u);
+    const auto checkpoint = polled->Checkpoint();
+    ASSERT_NO_THROW(client.StoreConsumerPosition(consumer, iggy::Identifier::String(stream_name),
+                                                 iggy::Identifier::String(topic_name), checkpoint));
+
+    ASSERT_NO_THROW(client.DeleteConsumerPosition(consumer, iggy::Identifier::String(stream_name),
+                                                  iggy::Identifier::String(topic_name), checkpoint));
+
+    EXPECT_THROW(client.GetConsumerOffset(consumer, iggy::Identifier::String(stream_name),
+                                          iggy::Identifier::String(topic_name), 0),
+                 iggy::IggyException);
+    try {
+        client.DeleteConsumerPosition(consumer, iggy::Identifier::String(stream_name),
+                                      iggy::Identifier::String(topic_name), checkpoint);
+        FAIL() << "a second delete must find no offset";
+    } catch (const iggy::IggyException &error) {
+        EXPECT_EQ(error.Code(), 3021u);
+    }
+}
+
 TEST_F(E2E_ConsumerGroup, DeleteConsumerOffsetForMissingOffsetThrows) {
     iggy::ffi::ErrorInfo error_info{};
     RecordProperty("description", "Rejects deleting offsets that were never stored or were already deleted.");
@@ -2218,11 +2303,12 @@
                                                   make_string_identifier(topic_name), "partition_id",
                                                   partition_id_bytes(0), std::move(messages), error_info));
 
-    iggy::ffi::PolledMessages polled{};
+    std::optional<iggy::ffi::PolledMessages> polled;
     ASSERT_NO_THROW(polled = message_client->poll_messages(
                         make_string_identifier(stream_name), make_string_identifier(topic_name), 0, "consumer",
-                        make_numeric_identifier(88), "next", 0, 3, true, error_info));
-    ASSERT_EQ(polled.count, 3u);
+                        make_numeric_identifier(88), make_polling_strategy("next", 0),
+                        make_poll_options(3, iggy::ffi::CommitPolicy::OnRead), error_info));
+    ASSERT_EQ(polled->count, 3u);
     EXPECT_EQ(
         client
             .GetConsumerOffset(consumer, iggy::Identifier::String(stream_name), iggy::Identifier::String(topic_name), 0)
diff --git a/foreign/cpp/tests/e2e/message.cpp b/foreign/cpp/tests/e2e/message.cpp
index f1ac823..de53a0c 100644
--- a/foreign/cpp/tests/e2e/message.cpp
+++ b/foreign/cpp/tests/e2e/message.cpp
@@ -20,6 +20,8 @@
 #include <cstdint>
 #include <string>
 #include <thread>
+#include <utility>
+#include <vector>
 
 #include <gtest/gtest.h>
 
@@ -59,7 +61,8 @@
     EXPECT_EQ(sent.confirmations.front().partition_id, 0u);
 
     auto polled = client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), 0, "consumer",
-                                        make_numeric_identifier(1), "offset", 0, 100, false, error_info);
+                                        make_numeric_identifier(1), make_polling_strategy("offset", 0),
+                                        make_poll_options(100), error_info);
 
     ASSERT_EQ(polled.partition_id, 0u) << "Polled partition_id mismatches the partition we sent to";
     ASSERT_EQ(polled.count, 10u);
@@ -95,7 +98,8 @@
                           partition_id_bytes(0), std::move(messages), error_info);
 
     auto polled = client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), 0, "consumer",
-                                        make_numeric_identifier(1), "offset", 0, 100, false, error_info);
+                                        make_numeric_identifier(1), make_polling_strategy("offset", 0),
+                                        make_poll_options(100), error_info);
 
     ASSERT_EQ(polled.messages.size(), 1u);
     ASSERT_EQ(polled.messages[0].id_lo, 42u);
@@ -116,7 +120,8 @@
                          make_topic_create_options(1, "none", "never_expire", 0, "server_default"), error_info);
 
     auto polled = client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), 0, "consumer",
-                                        make_numeric_identifier(1), "offset", 0, 100, false, error_info);
+                                        make_numeric_identifier(1), make_polling_strategy("offset", 0),
+                                        make_poll_options(100), error_info);
 
     ASSERT_EQ(polled.count, 0u);
     ASSERT_EQ(polled.messages.size(), 0u);
@@ -251,15 +256,15 @@
     client->send_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), "partition_id",
                           partition_id_bytes(0), std::move(messages), error_info);
 
-    auto polled_part0 =
-        client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), 0, "consumer",
-                              make_numeric_identifier(1), "offset", 0, 100, false, error_info);
+    auto polled_part0 = client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), 0,
+                                              "consumer", make_numeric_identifier(1),
+                                              make_polling_strategy("offset", 0), make_poll_options(100), error_info);
     ASSERT_EQ(polled_part0.partition_id, 0u);
     ASSERT_EQ(polled_part0.count, 5u);
 
-    auto polled_part1 =
-        client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), 1, "consumer",
-                              make_numeric_identifier(1), "offset", 0, 100, false, error_info);
+    auto polled_part1 = client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), 1,
+                                              "consumer", make_numeric_identifier(1),
+                                              make_polling_strategy("offset", 0), make_poll_options(100), error_info);
     ASSERT_EQ(polled_part1.partition_id, 1u);
     ASSERT_EQ(polled_part1.count, 0u);
 }
@@ -361,7 +366,8 @@
                           partition_id_bytes(0), std::move(messages), error_info);
 
     auto polled = client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), 0, "consumer",
-                                        make_numeric_identifier(1), "offset", 0, 100, false, error_info);
+                                        make_numeric_identifier(1), make_polling_strategy("offset", 0),
+                                        make_poll_options(100), error_info);
 
     ASSERT_EQ(polled.count, 50u);
     for (std::uint32_t i = 0; i < 50; i++) {
@@ -398,7 +404,8 @@
                                           "partition_id", partition_id_bytes(0), std::move(messages), error_info));
 
     auto polled = client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), 0, "consumer",
-                                        make_numeric_identifier(1), "offset", 0, 100, false, error_info);
+                                        make_numeric_identifier(1), make_polling_strategy("offset", 0),
+                                        make_poll_options(100), error_info);
 
     ASSERT_EQ(polled.count, 3u);
     for (const auto &message : polled.messages) {
@@ -450,7 +457,8 @@
                           partition_id_bytes(0), std::move(messages), error_info);
 
     auto polled = client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), 0, "consumer",
-                                        make_numeric_identifier(1), "offset", 0, 100, false, error_info);
+                                        make_numeric_identifier(1), make_polling_strategy("offset", 0),
+                                        make_poll_options(100), error_info);
 
     ASSERT_EQ(polled.count, 4u);
 
@@ -545,7 +553,8 @@
                                           "partition_id", partition_id_bytes(0), std::move(messages), error_info));
 
     auto polled = client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), 0, "consumer",
-                                        make_numeric_identifier(1), "offset", 0, 100, false, error_info);
+                                        make_numeric_identifier(1), make_polling_strategy("offset", 0),
+                                        make_poll_options(100), error_info);
 
     constexpr std::size_t expected_message_count = sizeof(expected_messages) / sizeof(expected_messages[0]);
     ASSERT_EQ(polled.count, expected_message_count);
@@ -820,7 +829,8 @@
                                           error_info));
 
     auto polled = client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), 0, "consumer",
-                                        make_numeric_identifier(1), "offset", 0, 10, false, error_info);
+                                        make_numeric_identifier(1), make_polling_strategy("offset", 0),
+                                        make_poll_options(10), error_info);
 
     ASSERT_EQ(polled.count, 1u);
     ASSERT_EQ(polled.messages.size(), 1u);
@@ -837,12 +847,14 @@
     ASSERT_NO_THROW(client->connect());
 
     ASSERT_THROW(client->poll_messages(make_numeric_identifier(1), make_numeric_identifier(0), 0, "consumer",
-                                       make_numeric_identifier(1), "offset", 0, 10, false, error_info),
+                                       make_numeric_identifier(1), make_polling_strategy("offset", 0),
+                                       make_poll_options(10), error_info),
                  std::exception);
     ASSERT_NO_THROW(client->login_user("iggy", "iggy", error_info));
     ASSERT_NO_THROW(client->disconnect());
     ASSERT_THROW(client->poll_messages(make_numeric_identifier(1), make_numeric_identifier(0), 0, "consumer",
-                                       make_numeric_identifier(1), "offset", 0, 10, false, error_info),
+                                       make_numeric_identifier(1), make_polling_strategy("offset", 0),
+                                       make_poll_options(10), error_info),
                  std::exception);
 }
 
@@ -855,9 +867,10 @@
     invalid_id.kind   = "invalid";
     invalid_id.length = 0;
 
-    ASSERT_THROW(client->poll_messages(invalid_id, make_numeric_identifier(0), 0, "consumer",
-                                       make_numeric_identifier(1), "offset", 0, 10, false, error_info),
-                 std::exception);
+    ASSERT_THROW(
+        client->poll_messages(invalid_id, make_numeric_identifier(0), 0, "consumer", make_numeric_identifier(1),
+                              make_polling_strategy("offset", 0), make_poll_options(10), error_info),
+        std::exception);
 }
 
 TEST_F(LowLevelE2E_Message, PollMessagesFromNonExistentStreamThrows) {
@@ -866,7 +879,8 @@
     iggy::ffi::Client *client = GetLoggedInClient();
 
     ASSERT_THROW(client->poll_messages(make_string_identifier("nonexistent-stream-poll"), make_numeric_identifier(0), 0,
-                                       "consumer", make_numeric_identifier(1), "offset", 0, 10, false, error_info),
+                                       "consumer", make_numeric_identifier(1), make_polling_strategy("offset", 0),
+                                       make_poll_options(10), error_info),
                  std::exception);
 }
 
@@ -884,7 +898,8 @@
                          make_topic_create_options(1, "none", "never_expire", 0, "server_default"), error_info);
 
     ASSERT_THROW(client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), 0, "invalid",
-                                       make_numeric_identifier(1), "offset", 0, 10, false, error_info),
+                                       make_numeric_identifier(1), make_polling_strategy("offset", 0),
+                                       make_poll_options(10), error_info),
                  std::exception);
 }
 
@@ -902,7 +917,8 @@
                          make_topic_create_options(1, "none", "never_expire", 0, "server_default"), error_info);
 
     ASSERT_THROW(client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), 0, "consumer",
-                                       make_numeric_identifier(1), "invalid", 0, 10, false, error_info),
+                                       make_numeric_identifier(1), make_polling_strategy("invalid", 0),
+                                       make_poll_options(10), error_info),
                  std::exception);
 }
 
@@ -929,7 +945,8 @@
                           partition_id_bytes(0), std::move(messages), error_info);
 
     auto polled = client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), 0, "consumer",
-                                        make_numeric_identifier(1), "offset", 0, 5, false, error_info);
+                                        make_numeric_identifier(1), make_polling_strategy("offset", 0),
+                                        make_poll_options(5), error_info);
 
     ASSERT_EQ(polled.count, 5u);
     ASSERT_EQ(polled.messages.size(), 5u);
@@ -958,7 +975,8 @@
                           partition_id_bytes(0), std::move(messages), error_info);
 
     auto polled = client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), 0, "consumer",
-                                        make_numeric_identifier(1), "offset", 999999, 100, false, error_info);
+                                        make_numeric_identifier(1), make_polling_strategy("offset", 999999),
+                                        make_poll_options(100), error_info);
 
     ASSERT_EQ(polled.count, 0u);
     ASSERT_EQ(polled.messages.size(), 0u);
@@ -987,7 +1005,8 @@
                           partition_id_bytes(0), std::move(messages), error_info);
 
     auto polled = client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), 0, "consumer",
-                                        make_numeric_identifier(1), "first", 0, 3, false, error_info);
+                                        make_numeric_identifier(1), make_polling_strategy("first", 0),
+                                        make_poll_options(3), error_info);
 
     ASSERT_EQ(polled.count, 3u);
     ASSERT_EQ(polled.messages.size(), 3u);
@@ -1023,7 +1042,8 @@
                           partition_id_bytes(0), std::move(messages), error_info);
 
     auto polled = client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), 0, "consumer",
-                                        make_numeric_identifier(1), "last", 0, 3, false, error_info);
+                                        make_numeric_identifier(1), make_polling_strategy("last", 0),
+                                        make_poll_options(3), error_info);
 
     ASSERT_EQ(polled.count, 3u);
     ASSERT_EQ(polled.messages.size(), 3u);
@@ -1060,11 +1080,13 @@
                           partition_id_bytes(0), std::move(messages), error_info);
 
     auto polled1 = client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), 0, "consumer",
-                                         make_numeric_identifier(1), "next", 0, 100, false, error_info);
+                                         make_numeric_identifier(1), make_polling_strategy("next", 0),
+                                         make_poll_options(100), error_info);
     ASSERT_EQ(polled1.count, 5u);
 
     auto polled2 = client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), 0, "consumer",
-                                         make_numeric_identifier(1), "next", 0, 100, false, error_info);
+                                         make_numeric_identifier(1), make_polling_strategy("next", 0),
+                                         make_poll_options(100), error_info);
     ASSERT_EQ(polled2.count, 5u);
     for (std::uint32_t i = 0; i < 5; i++) {
         EXPECT_EQ(polled1.messages[i].offset, static_cast<std::uint64_t>(i));
@@ -1103,13 +1125,15 @@
                           partition_id_bytes(0), std::move(messages), error_info);
 
     auto polled1 = client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), 0, "consumer",
-                                         make_numeric_identifier(1), "next", 0, 5, true, error_info);
+                                         make_numeric_identifier(1), make_polling_strategy("next", 0),
+                                         make_poll_options(5, iggy::ffi::CommitPolicy::OnRead), error_info);
     ASSERT_EQ(polled1.count, 5u);
     EXPECT_EQ(polled1.messages[0].offset, 0u);
     EXPECT_EQ(polled1.messages[4].offset, 4u);
 
     auto polled2 = client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), 0, "consumer",
-                                         make_numeric_identifier(1), "next", 0, 5, true, error_info);
+                                         make_numeric_identifier(1), make_polling_strategy("next", 0),
+                                         make_poll_options(5, iggy::ffi::CommitPolicy::OnRead), error_info);
     ASSERT_EQ(polled2.count, 5u);
     EXPECT_EQ(polled2.messages[0].offset, 5u);
     EXPECT_EQ(polled2.messages[4].offset, 9u);
@@ -1125,7 +1149,8 @@
     }
 
     auto polled3 = client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), 0, "consumer",
-                                         make_numeric_identifier(1), "next", 0, 5, true, error_info);
+                                         make_numeric_identifier(1), make_polling_strategy("next", 0),
+                                         make_poll_options(5, iggy::ffi::CommitPolicy::OnRead), error_info);
     ASSERT_EQ(polled3.count, 0u);
 }
 
@@ -1152,16 +1177,18 @@
                           partition_id_bytes(0), std::move(messages), error_info);
 
     auto polled_c1 = client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), 0,
-                                           "consumer", make_numeric_identifier(1), "next", 0, 3, true, error_info);
+                                           "consumer", make_numeric_identifier(1), make_polling_strategy("next", 0),
+                                           make_poll_options(3, iggy::ffi::CommitPolicy::OnRead), error_info);
     ASSERT_EQ(polled_c1.count, 3u);
 
     auto polled_c2 = client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), 0,
-                                           "consumer", make_numeric_identifier(2), "next", 0, 5, true, error_info);
+                                           "consumer", make_numeric_identifier(2), make_polling_strategy("next", 0),
+                                           make_poll_options(5, iggy::ffi::CommitPolicy::OnRead), error_info);
     ASSERT_EQ(polled_c2.count, 5u);
 
-    auto polled_c1_again =
-        client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), 0, "consumer",
-                              make_numeric_identifier(1), "next", 0, 5, true, error_info);
+    auto polled_c1_again = client->poll_messages(
+        make_numeric_identifier(stream.id), make_numeric_identifier(0), 0, "consumer", make_numeric_identifier(1),
+        make_polling_strategy("next", 0), make_poll_options(5, iggy::ffi::CommitPolicy::OnRead), error_info);
     ASSERT_EQ(polled_c1_again.count, 2u);
 }
 
@@ -1197,7 +1224,8 @@
                           partition_id_bytes(0), std::move(batch2), error_info);
 
     auto polled = client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), 0, "consumer",
-                                        make_numeric_identifier(1), "offset", 0, 100, false, error_info);
+                                        make_numeric_identifier(1), make_polling_strategy("offset", 0),
+                                        make_poll_options(100), error_info);
 
     ASSERT_EQ(polled.count, 10u);
     for (std::uint32_t i = 0; i < 10; i++) {
@@ -1241,7 +1269,8 @@
                           partition_id_bytes(0), std::move(messages), error_info);
 
     auto polled = client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), 0, "consumer",
-                                        make_numeric_identifier(1), "offset", 0, 100, false, error_info);
+                                        make_numeric_identifier(1), make_polling_strategy("offset", 0),
+                                        make_poll_options(100), error_info);
 
     ASSERT_EQ(polled.count, 5u);
     for (std::uint32_t i = 0; i < 5; i++) {
@@ -1275,7 +1304,8 @@
     ForgetTrackedStream(saved_stream_id);
 
     ASSERT_THROW(client->poll_messages(make_numeric_identifier(saved_stream_id), make_numeric_identifier(0), 0,
-                                       "consumer", make_numeric_identifier(1), "offset", 0, 10, false, error_info),
+                                       "consumer", make_numeric_identifier(1), make_polling_strategy("offset", 0),
+                                       make_poll_options(10), error_info),
                  std::exception);
 }
 
@@ -1293,7 +1323,8 @@
                          make_topic_create_options(1, "none", "never_expire", 0, "server_default"), error_info);
 
     ASSERT_THROW(client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), 9999, "consumer",
-                                       make_numeric_identifier(1), "offset", 0, 10, false, error_info),
+                                       make_numeric_identifier(1), make_polling_strategy("offset", 0),
+                                       make_poll_options(10), error_info),
                  std::exception);
 }
 
@@ -1311,14 +1342,15 @@
                          make_topic_create_options(1, "none", "never_expire", 0, "server_default"), error_info);
 
     ASSERT_THROW(client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), 0, "consumer",
-                                       make_numeric_identifier(1), "offset", 0, 0, false, error_info),
+                                       make_numeric_identifier(1), make_polling_strategy("offset", 0),
+                                       make_poll_options(0), error_info),
                  std::exception);
 }
 
-TEST_F(LowLevelE2E_Message, PollMessagesWithoutSpecifyingPartition) {
+TEST_F(LowLevelE2E_Message, PollMessagesWithMaximumPartitionIdThrows) {
     iggy::ffi::ErrorInfo error_info{};
     RecordProperty("description",
-                   "Verifies polling with partition_id=u32::MAX defaults to partition 0 and returns messages.");
+                   "Verifies that partition_id=u32::MAX is an ordinary partition ID, not a request to pick one.");
     const std::string stream_name = GetRandomName();
     iggy::ffi::Client *client     = GetLoggedInClient();
 
@@ -1337,19 +1369,11 @@
     client->send_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), "partition_id",
                           partition_id_bytes(0), std::move(messages), error_info);
 
-    auto polled = client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), UINT32_MAX,
-                                        "consumer", make_numeric_identifier(1), "offset", 0, 100, false, error_info);
-
-    // The Rust side maps UINT32_MAX to None, so the server picks a partition. With a single
-    // partition topic that should always be partition 0.
-    ASSERT_EQ(polled.partition_id, 0u) << "u32::MAX sentinel did not map to None — partition_id sentinel regression?";
-    ASSERT_EQ(polled.count, 5u);
-    ASSERT_EQ(polled.messages.size(), 5u);
-    for (std::uint32_t i = 0; i < 5; i++) {
-        std::string expected = "msg-" + std::to_string(i);
-        std::string actual(polled.messages[i].payload.begin(), polled.messages[i].payload.end());
-        EXPECT_EQ(actual, expected) << "Payload mismatch at index " << i;
-    }
+    // A poll requires its partition, so the old sentinel must not read partition 0.
+    ASSERT_THROW(client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), UINT32_MAX,
+                                       "consumer", make_numeric_identifier(1), make_polling_strategy("offset", 0),
+                                       make_poll_options(100), error_info),
+                 std::exception);
 }
 
 TEST_F(LowLevelE2E_Message, PollMessagesTimestampStrategy) {
@@ -1387,7 +1411,8 @@
                           partition_id_bytes(0), std::move(batch2), error_info);
 
     auto all = client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), 0, "consumer",
-                                     make_numeric_identifier(1), "offset", 0, 100, false, error_info);
+                                     make_numeric_identifier(1), make_polling_strategy("offset", 0),
+                                     make_poll_options(100), error_info);
     ASSERT_EQ(all.count, 10u);
 
     // IggyTimestamp::now() is microsecond-resolution and we slept 100ms between batches; a gap
@@ -1400,9 +1425,9 @@
         << "Timestamp gap collapsed (" << (batch2_timestamp - batch1_timestamp)
         << "us) — test no longer exercises timestamp filtering";
 
-    auto polled =
-        client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), 0, "consumer",
-                              make_numeric_identifier(2), "timestamp", batch2_timestamp, 100, false, error_info);
+    auto polled = client->poll_messages(
+        make_numeric_identifier(stream.id), make_numeric_identifier(0), 0, "consumer", make_numeric_identifier(2),
+        make_polling_strategy("timestamp", batch2_timestamp), make_poll_options(100), error_info);
 
     ASSERT_GE(polled.count, 5u);
     // The server contract is `timestamp >= polling_strategy_value`. If a batch1 message lands on
@@ -1443,9 +1468,9 @@
     std::uint64_t expected_offset = 0;
     for (int chunk = 0; chunk < 4; chunk++) {
         iggy::ffi::ErrorInfo error_info{};
-        auto polled =
-            client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), 0, "consumer",
-                                  make_numeric_identifier(1), "offset", expected_offset, 5, false, error_info);
+        auto polled = client->poll_messages(
+            make_numeric_identifier(stream.id), make_numeric_identifier(0), 0, "consumer", make_numeric_identifier(1),
+            make_polling_strategy("offset", expected_offset), make_poll_options(5), error_info);
 
         ASSERT_EQ(polled.count, 5u) << "Chunk " << chunk;
         ASSERT_EQ(polled.messages.size(), 5u) << "Chunk " << chunk;
@@ -1483,7 +1508,8 @@
                                           "partition_id", partition_id_bytes(0), std::move(messages), error_info));
 
     auto polled = client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), 0, "consumer",
-                                        make_numeric_identifier(1), "offset", 0, 1000, false, error_info);
+                                        make_numeric_identifier(1), make_polling_strategy("offset", 0),
+                                        make_poll_options(1000), error_info);
 
     ASSERT_EQ(polled.count, 1000u);
     ASSERT_EQ(polled.messages.size(), 1000u);
@@ -1518,9 +1544,10 @@
     invalid_id.kind   = "invalid";
     invalid_id.length = 0;
 
-    ASSERT_THROW(client->poll_messages(make_numeric_identifier(1), invalid_id, 0, "consumer",
-                                       make_numeric_identifier(1), "offset", 0, 10, false, error_info),
-                 std::exception);
+    ASSERT_THROW(
+        client->poll_messages(make_numeric_identifier(1), invalid_id, 0, "consumer", make_numeric_identifier(1),
+                              make_polling_strategy("offset", 0), make_poll_options(10), error_info),
+        std::exception);
 }
 
 TEST_F(LowLevelE2E_Message, PollMessagesWithInvalidConsumerIdThrows) {
@@ -1540,9 +1567,10 @@
     invalid_id.kind   = "invalid";
     invalid_id.length = 0;
 
-    ASSERT_THROW(client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), 0, "consumer",
-                                       invalid_id, "offset", 0, 10, false, error_info),
-                 std::exception);
+    ASSERT_THROW(
+        client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), 0, "consumer", invalid_id,
+                              make_polling_strategy("offset", 0), make_poll_options(10), error_info),
+        std::exception);
 }
 
 TEST_F(LowLevelE2E_Message, ConsumerGroupCreateJoinAndPollMessages) {
@@ -1582,9 +1610,9 @@
     client->send_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), "partition_id",
                           partition_id_bytes(0), std::move(messages), error_info);
 
-    auto polled =
-        client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), 0, "consumer_group",
-                              make_numeric_identifier(group.id), "offset", 0, 100, false, error_info);
+    auto polled = client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), 0,
+                                        "consumer_group", make_numeric_identifier(group.id),
+                                        make_polling_strategy("offset", 0), make_poll_options(100), error_info);
 
     ASSERT_EQ(polled.count, 10u);
     ASSERT_EQ(polled.messages.size(), 10u);
@@ -1601,3 +1629,110 @@
                                                         make_numeric_identifier(group.id));
     ASSERT_EQ(group_after_leave.members_count, 0u);
 }
+
+TEST_F(LowLevelE2E_Message, PollMessagesContinuationReadsTheNextBatch) {
+    iggy::ffi::ErrorInfo error_info{};
+    RecordProperty("description",
+                   "Polls a partition in batches, each from the continuation of the batch before it, and checks that "
+                   "the continuation of an empty batch keeps its start.");
+    const std::string stream_name = GetRandomName();
+    iggy::ffi::Client *client     = GetLoggedInClient();
+
+    client->create_stream(stream_name, {}, error_info);
+    auto stream = client->get_stream(make_string_identifier(stream_name));
+    TrackStream(stream.id);
+    client->create_topic(make_numeric_identifier(stream.id), GetRandomName(), make_topic_create_options(1), error_info);
+
+    rust::Vec<iggy::ffi::IggyMessageToSend> messages;
+    for (std::uint32_t i = 0; i < 10; i++) {
+        messages.push_back(
+            iggy::ffi::make_message(to_payload("msg-" + std::to_string(i)), rust::Vec<iggy::ffi::HeaderEntry>()));
+    }
+    client->send_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), "partition_id",
+                          partition_id_bytes(0), std::move(messages), error_info);
+
+    std::vector<std::uint64_t> offsets;
+    auto polled = client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), 0, "consumer",
+                                        make_numeric_identifier(1), make_polling_strategy("offset", 0),
+                                        make_poll_options(4), error_info);
+    while (offsets.size() < 10) {
+        ASSERT_FALSE(polled.messages.empty()) << "Empty batch after " << offsets.size() << " messages";
+        for (const auto &message : polled.messages) {
+            offsets.push_back(message.offset);
+        }
+        polled =
+            client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), 0, "consumer",
+                                  make_numeric_identifier(1), *polled.continuation(), make_poll_options(4), error_info);
+    }
+    for (std::uint32_t i = 0; i < 10; i++) {
+        EXPECT_EQ(offsets[i], static_cast<std::uint64_t>(i)) << "Offset mismatch at index " << i;
+    }
+
+    EXPECT_TRUE(polled.messages.empty());
+    const auto continuation = polled.continuation();
+    EXPECT_EQ(std::string(continuation->kind()), "offset");
+    EXPECT_EQ(continuation->value(), 10u) << "An empty batch must keep the start of its request";
+}
+
+TEST_F(LowLevelE2E_Message, PollMessagesContinuationOfAnotherPartitionThrows) {
+    iggy::ffi::ErrorInfo error_info{};
+    RecordProperty("description", "A continuation of partition 0 is refused by a poll of partition 1.");
+    const std::string stream_name = GetRandomName();
+    iggy::ffi::Client *client     = GetLoggedInClient();
+
+    client->create_stream(stream_name, {}, error_info);
+    auto stream = client->get_stream(make_string_identifier(stream_name));
+    TrackStream(stream.id);
+    client->create_topic(make_numeric_identifier(stream.id), GetRandomName(), make_topic_create_options(2), error_info);
+
+    auto polled = client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), 0, "consumer",
+                                        make_numeric_identifier(1), make_polling_strategy("offset", 0),
+                                        make_poll_options(4), error_info);
+    ASSERT_THROW(
+        client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), 1, "consumer",
+                              make_numeric_identifier(1), *polled.continuation(), make_poll_options(4), error_info),
+        std::exception);
+    EXPECT_EQ(error_info.kind, "invalid_command");
+}
+
+TEST_F(LowLevelE2E_Message, StoreConsumerPositionStoresTheOffsetOfTheCheckpoint) {
+    iggy::ffi::ErrorInfo error_info{};
+    RecordProperty("description",
+                   "Stores the checkpoint of a processed part of a batch and reads the stored offset back. An empty "
+                   "batch has no checkpoint.");
+    const std::string stream_name = GetRandomName();
+    iggy::ffi::Client *client     = GetLoggedInClient();
+
+    client->create_stream(stream_name, {}, error_info);
+    auto stream = client->get_stream(make_string_identifier(stream_name));
+    TrackStream(stream.id);
+    client->create_topic(make_numeric_identifier(stream.id), GetRandomName(), make_topic_create_options(1), error_info);
+
+    auto empty = client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), 0, "consumer",
+                                       make_numeric_identifier(1), make_polling_strategy("offset", 0),
+                                       make_poll_options(10), error_info);
+    EXPECT_THROW(empty.checkpoint(), std::exception);
+
+    rust::Vec<iggy::ffi::IggyMessageToSend> messages;
+    for (std::uint32_t i = 0; i < 10; i++) {
+        messages.push_back(
+            iggy::ffi::make_message(to_payload("msg-" + std::to_string(i)), rust::Vec<iggy::ffi::HeaderEntry>()));
+    }
+    client->send_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), "partition_id",
+                          partition_id_bytes(0), std::move(messages), error_info);
+    auto polled = client->poll_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), 0, "consumer",
+                                        make_numeric_identifier(1), make_polling_strategy("offset", 0),
+                                        make_poll_options(10), error_info);
+    ASSERT_EQ(polled.messages.size(), 10u);
+    EXPECT_EQ(polled.checkpoint()->offset(), 9u);
+    EXPECT_THROW(polled.checkpoint_at(10), std::exception);
+
+    const auto checkpoint = polled.checkpoint_at(4);
+    EXPECT_EQ(checkpoint->partition_id(), 0u);
+    EXPECT_EQ(checkpoint->offset(), 4u);
+    ASSERT_NO_THROW(client->store_consumer_position(make_numeric_identifier(stream.id), make_numeric_identifier(0),
+                                                    "consumer", make_numeric_identifier(1), *checkpoint, error_info));
+    const auto stored = client->get_consumer_offset(make_numeric_identifier(stream.id), make_numeric_identifier(0), 0,
+                                                    "consumer", make_numeric_identifier(1));
+    EXPECT_EQ(stored.stored_offset, 4u);
+}
diff --git a/foreign/cpp/tests/e2e/partition_reader.cpp b/foreign/cpp/tests/e2e/partition_reader.cpp
new file mode 100644
index 0000000..3bb10a7
--- /dev/null
+++ b/foreign/cpp/tests/e2e/partition_reader.cpp
@@ -0,0 +1,401 @@
+/*
+ * 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.
+ */
+
+#include <chrono>
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <thread>
+#include <vector>
+
+#include <gtest/gtest.h>
+
+#include "iggy.hpp"
+#include "tests/e2e/test_helpers.hpp"
+
+namespace {
+
+constexpr auto kReadTimeout            = std::chrono::seconds(10);
+constexpr auto kIdleReadTimeout        = std::chrono::milliseconds(200);
+constexpr std::uint32_t kMessagesCount = 10;
+constexpr std::uint32_t kBatchCount    = 4;
+
+std::vector<std::uint64_t> offsets_of(const iggy::PolledMessages &batch) {
+    std::vector<std::uint64_t> offsets;
+    for (const auto &message : batch.Messages()) {
+        offsets.push_back(message.Offset());
+    }
+    return offsets;
+}
+
+iggy::ReaderOptions reader_options(const iggy::PollOptions &poll_options) {
+    return iggy::ReaderOptions()
+        .SetPartitions({0})
+        .SetStart(iggy::PollingStrategy::Offset(0))
+        .SetPollOptions(poll_options);
+}
+
+}  // namespace
+
+class E2E_PartitionReader : public E2ETestFixture {
+  protected:
+    // One topic with one partition. The messages go to partition 0 and carry their index as payload.
+    iggy::Identifier CreateTopicWithMessages(const std::uint32_t messages_count) {
+        iggy::ffi::ErrorInfo error_info{};
+        iggy::ffi::Client *client     = GetLoggedInClient();
+        const std::string stream_name = GetRandomName();
+        client->create_stream(stream_name, {}, error_info);
+        stream_id_ = client->get_stream(make_string_identifier(stream_name)).id;
+        TrackStream(stream_id_);
+        client->create_topic(make_numeric_identifier(stream_id_), GetRandomName(), make_topic_create_options(1),
+                             error_info);
+        SendMessages(messages_count);
+        return iggy::Identifier::Numeric(stream_id_);
+    }
+
+    void SendMessages(const std::uint32_t messages_count) {
+        if (messages_count == 0) {
+            return;
+        }
+        iggy::ffi::ErrorInfo error_info{};
+        rust::Vec<iggy::ffi::IggyMessageToSend> messages;
+        for (std::uint32_t i = 0; i < messages_count; i++) {
+            messages.push_back(
+                iggy::ffi::make_message(to_payload(std::to_string(i)), rust::Vec<iggy::ffi::HeaderEntry>()));
+        }
+        GetLoggedInClient()->send_messages(make_numeric_identifier(stream_id_), make_numeric_identifier(0),
+                                           "partition_id", partition_id_bytes(0), std::move(messages), error_info);
+    }
+
+  private:
+    std::uint32_t stream_id_ = 0;
+};
+
+TEST_F(E2E_PartitionReader, PollMessagesReadsOnFromTheContinuationOfABatch) {
+    RecordProperty("description",
+                   "Polls a batch, reads on from its continuation and checks its checkpoints. An empty batch has no "
+                   "checkpoint.");
+    auto client         = GetLoggedInHighLevelClient();
+    const auto consumer = iggy::Consumer::Single(iggy::Identifier::Numeric(1));
+    const auto stream   = CreateTopicWithMessages(kMessagesCount);
+    const auto topic    = iggy::Identifier::Numeric(0);
+    const auto options  = iggy::PollOptions().SetCount(kBatchCount);
+
+    const auto first = client.PollMessages(consumer, stream, topic, 0, iggy::PollingStrategy::Offset(0), options);
+    EXPECT_EQ(first.PartitionId(), 0u);
+    EXPECT_EQ(first.CurrentOffset(), kMessagesCount - 1);
+    EXPECT_EQ(offsets_of(first), (std::vector<std::uint64_t>{0, 1, 2, 3}));
+    EXPECT_EQ(first.Checkpoint().Offset(), 3u);
+    EXPECT_EQ(first.CheckpointAt(1).Offset(), 1u);
+    EXPECT_EQ(first.CheckpointAt(1).PartitionId(), 0u);
+    EXPECT_THROW((void)first.CheckpointAt(kBatchCount), iggy::IggyException);
+
+    const auto continuation = first.Continuation();
+    EXPECT_EQ(continuation.Kind(), "offset");
+    EXPECT_EQ(continuation.Value(), 4u);
+    const auto second = client.PollMessages(consumer, stream, topic, 0, continuation, options);
+    EXPECT_EQ(offsets_of(second), (std::vector<std::uint64_t>{4, 5, 6, 7}));
+
+    const auto empty =
+        client.PollMessages(consumer, stream, topic, 0, iggy::PollingStrategy::Offset(kMessagesCount), options);
+    EXPECT_TRUE(empty.Messages().empty());
+    EXPECT_THROW((void)empty.Checkpoint(), iggy::IggyException);
+}
+
+TEST_F(E2E_PartitionReader, PollMessagesRefusesAutoCommit) {
+    RecordProperty("description", "A one-shot poll refuses the auto commit policy, which only a reader runs.");
+    auto client       = GetLoggedInHighLevelClient();
+    const auto stream = CreateTopicWithMessages(1);
+
+    try {
+        (void)client.PollMessages(iggy::Consumer::Single(iggy::Identifier::Numeric(1)), stream,
+                                  iggy::Identifier::Numeric(0), 0, iggy::PollingStrategy::Offset(0),
+                                  iggy::PollOptions().SetAutoCommit(iggy::AutoCommit()));
+        FAIL() << "a one-shot poll must refuse CommitPolicy::Auto";
+    } catch (const iggy::IggyException &error) {
+        EXPECT_EQ(error.Kind(), "invalid_configuration");
+    }
+}
+
+TEST_F(E2E_PartitionReader, ReaderReadsEveryMessageAndCommitStoresTheOffset) {
+    RecordProperty("description",
+                   "Reads every message of one partition with a reader, commits the last checkpoint and reads the "
+                   "stored offset back. Every call but Close throws after Close.");
+    auto client         = GetLoggedInHighLevelClient();
+    const auto consumer = iggy::Consumer::Single(iggy::Identifier::Numeric(2));
+    const auto stream   = CreateTopicWithMessages(kMessagesCount);
+    const auto topic    = iggy::Identifier::Numeric(0);
+    auto reader = client.Reader(consumer, stream, topic, reader_options(iggy::PollOptions().SetCount(kBatchCount)));
+    EXPECT_EQ(reader.Partitions(), (std::vector<std::uint32_t>{0}));
+
+    std::vector<std::uint64_t> offsets;
+    std::optional<iggy::Checkpoint> last;
+    while (offsets.size() < kMessagesCount) {
+        auto batch = reader.Next(kReadTimeout);
+        ASSERT_TRUE(batch.has_value()) << "the reader must deliver every message";
+        EXPECT_EQ(batch->PartitionId(), 0u);
+        for (const auto offset : offsets_of(*batch)) {
+            offsets.push_back(offset);
+        }
+        last.emplace(batch->Checkpoint());
+    }
+    EXPECT_EQ(offsets, (std::vector<std::uint64_t>{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}));
+    EXPECT_FALSE(reader.Next(kIdleReadTimeout).has_value());
+
+    reader.Commit(*last);
+    EXPECT_EQ(client.GetConsumerOffset(consumer, stream, topic, 0).StoredOffset(), kMessagesCount - 1);
+
+    reader.Close();
+    reader.Close();
+    EXPECT_THROW((void)reader.Next(kIdleReadTimeout), iggy::IggyException);
+    EXPECT_THROW(reader.Commit(*last), iggy::IggyException);
+}
+
+TEST_F(E2E_PartitionReader, SeekReadsThePartitionAgain) {
+    RecordProperty("description",
+                   "Seeks a partition back to its first message, then to the continuation of a batch, and reads from "
+                   "each new start.");
+    auto client       = GetLoggedInHighLevelClient();
+    const auto stream = CreateTopicWithMessages(kMessagesCount);
+    auto reader =
+        client.Reader(iggy::Consumer::Single(iggy::Identifier::Numeric(3)), stream, iggy::Identifier::Numeric(0),
+                      reader_options(iggy::PollOptions().SetCount(kBatchCount)));
+
+    auto first = reader.Next(kReadTimeout);
+    ASSERT_TRUE(first.has_value());
+    EXPECT_EQ(offsets_of(*first), (std::vector<std::uint64_t>{0, 1, 2, 3}));
+
+    reader.Seek(0, iggy::PollingStrategy::First());
+    auto again = reader.Next(kReadTimeout);
+    ASSERT_TRUE(again.has_value());
+    EXPECT_EQ(offsets_of(*again), (std::vector<std::uint64_t>{0, 1, 2, 3}));
+
+    reader.Seek(0, again->Continuation());
+    auto next = reader.Next(kReadTimeout);
+    ASSERT_TRUE(next.has_value());
+    EXPECT_EQ(offsets_of(*next), (std::vector<std::uint64_t>{4, 5, 6, 7}));
+    reader.Close();
+}
+
+TEST_F(E2E_PartitionReader, CommitIsRefusedWhenTheServerCommitsOnRead) {
+    RecordProperty("description", "A reader with the on read policy refuses a commit, and Mark needs auto commit.");
+    auto client       = GetLoggedInHighLevelClient();
+    const auto stream = CreateTopicWithMessages(kMessagesCount);
+    auto reader =
+        client.Reader(iggy::Consumer::Single(iggy::Identifier::Numeric(4)), stream, iggy::Identifier::Numeric(0),
+                      reader_options(iggy::PollOptions().SetCount(kBatchCount).SetCommit(iggy::CommitPolicy::OnRead)));
+
+    auto batch = reader.Next(kReadTimeout);
+    ASSERT_TRUE(batch.has_value());
+    EXPECT_THROW(reader.Commit(batch->Checkpoint()), iggy::IggyException);
+    EXPECT_THROW(reader.Mark(batch->Checkpoint()), iggy::IggyException);
+    reader.Close();
+}
+
+TEST_F(E2E_PartitionReader, OnReadCloseMovesTheOffsetBackAndASecondSessionResumesAfterIt) {
+    RecordProperty("description",
+                   "A reader with the on read policy that delivered one batch of a prefetched partition moves the "
+                   "stored offset back to that batch in Close, and a reader of another session resumes after it.");
+    auto client         = GetLoggedInHighLevelClient();
+    const auto consumer = iggy::Consumer::Single(iggy::Identifier::Numeric(9));
+    const auto stream   = CreateTopicWithMessages(kMessagesCount);
+    const auto topic    = iggy::Identifier::Numeric(0);
+    const auto on_read  = iggy::PollOptions().SetCount(kBatchCount).SetCommit(iggy::CommitPolicy::OnRead);
+    auto reader         = client.Reader(consumer, stream, topic, reader_options(on_read));
+
+    const auto batch = reader.Next(kReadTimeout);
+    ASSERT_TRUE(batch.has_value());
+    EXPECT_EQ(offsets_of(*batch), (std::vector<std::uint64_t>{0, 1, 2, 3}));
+    reader.Close();
+    EXPECT_EQ(client.GetConsumerOffset(consumer, stream, topic, 0).StoredOffset(), kBatchCount - 1);
+
+    auto second_session = GetLoggedInHighLevelClient();
+    auto resumed        = second_session.Reader(consumer, stream, topic,
+                                                iggy::ReaderOptions().SetPartitions({0}).SetPollOptions(on_read));
+    const auto next     = resumed.Next(kReadTimeout);
+    ASSERT_TRUE(next.has_value());
+    EXPECT_EQ(offsets_of(*next), (std::vector<std::uint64_t>{4, 5, 6, 7}));
+    resumed.Close();
+}
+
+TEST_F(E2E_PartitionReader, AutoCommitStoresTheMarkedPositionWhenTheReaderCloses) {
+    RecordProperty("description",
+                   "Under auto commit after marked, Close stores the latest marked checkpoint and nothing past it.");
+    auto client         = GetLoggedInHighLevelClient();
+    const auto consumer = iggy::Consumer::Single(iggy::Identifier::Numeric(5));
+    const auto stream   = CreateTopicWithMessages(kMessagesCount);
+    const auto topic    = iggy::Identifier::Numeric(0);
+    const auto options =
+        iggy::PollOptions()
+            .SetCount(kBatchCount)
+            .SetAutoCommit(iggy::AutoCommit().SetEvery(std::nullopt).SetAfter(iggy::CommitAfter::Marked));
+    auto reader = client.Reader(consumer, stream, topic, reader_options(options));
+
+    auto batch = reader.Next(kReadTimeout);
+    ASSERT_TRUE(batch.has_value());
+    reader.Mark(batch->CheckpointAt(1));
+    reader.Close();
+
+    EXPECT_EQ(client.GetConsumerOffset(consumer, stream, topic, 0).StoredOffset(), 1u);
+}
+
+TEST_F(E2E_PartitionReader, ConsumerGroupReaderReadsItsAssignment) {
+    RecordProperty("description",
+                   "A consumer group reader joins in Reader, reads the assigned partition and leaves in Close.");
+    auto client                  = GetLoggedInHighLevelClient();
+    const auto stream            = CreateTopicWithMessages(kMessagesCount);
+    const auto topic             = iggy::Identifier::Numeric(0);
+    const std::string group_name = GetRandomName();
+    client.CreateConsumerGroup(stream, topic, group_name);
+    const auto consumer = iggy::Consumer::Group(iggy::Identifier::String(group_name));
+
+    auto reader = client.Reader(consumer, stream, topic,
+                                iggy::ReaderOptions()
+                                    .SetStart(iggy::PollingStrategy::Offset(0))
+                                    .SetPollOptions(iggy::PollOptions().SetCount(kMessagesCount)));
+    auto batch  = reader.Next(kReadTimeout);
+    ASSERT_TRUE(batch.has_value());
+    EXPECT_EQ(batch->Messages().size(), kMessagesCount);
+    // The assignment arrives with the reads, so it is checked after the first batch.
+    EXPECT_EQ(reader.Partitions(), (std::vector<std::uint32_t>{0}));
+    reader.Commit(batch->Checkpoint());
+    reader.Close();
+
+    EXPECT_EQ(client.GetConsumerOffset(consumer, stream, topic, 0).StoredOffset(), kMessagesCount - 1);
+    EXPECT_EQ(client.GetConsumerGroup(stream, topic, iggy::Identifier::String(group_name)).MembersCount(), 0u);
+}
+
+TEST_F(E2E_PartitionReader, PendingPollReturnsTheBatchOfAParkedPollWhenAMessageArrives) {
+    RecordProperty("description",
+                   "A deferred poll started in the background stays parked on an empty partition and returns the "
+                   "message that arrives. A second wait is refused.");
+    auto client       = GetLoggedInHighLevelClient();
+    const auto stream = CreateTopicWithMessages(0);
+    auto poll         = client.StartPollMessages(iggy::Consumer::Single(iggy::Identifier::Numeric(6)), stream,
+                                                 iggy::Identifier::Numeric(0), 0, iggy::PollingStrategy::Offset(0),
+                                                 iggy::PollOptions().SetMaxWait(kReadTimeout));
+
+    EXPECT_FALSE(poll.Wait(kIdleReadTimeout).has_value());
+    SendMessages(1);
+    const auto batch = poll.Wait(kReadTimeout);
+    ASSERT_TRUE(batch.has_value());
+    EXPECT_EQ(offsets_of(*batch), (std::vector<std::uint64_t>{0}));
+    EXPECT_THROW((void)poll.Wait(kIdleReadTimeout), iggy::IggyException);
+}
+
+TEST_F(E2E_PartitionReader, CancelledPollThatCommitsOnReadStoresNoOffset) {
+    RecordProperty("description",
+                   "A parked deferred poll with the on read policy that is cancelled before it answers stores no "
+                   "offset, and a wait after the cancel is refused.");
+    auto client         = GetLoggedInHighLevelClient();
+    const auto consumer = iggy::Consumer::Single(iggy::Identifier::Numeric(7));
+    const auto stream   = CreateTopicWithMessages(1);
+    const auto topic    = iggy::Identifier::Numeric(0);
+    auto poll           = client.StartPollMessages(
+        consumer, stream, topic, 0, iggy::PollingStrategy::Next(),
+        iggy::PollOptions().SetMinCount(2).SetMaxWait(std::chrono::seconds(1)).SetCommit(iggy::CommitPolicy::OnRead));
+
+    EXPECT_FALSE(poll.Wait(kIdleReadTimeout).has_value());
+    poll.Cancel();
+    poll.Cancel();
+    EXPECT_THROW((void)poll.Wait(kIdleReadTimeout), iggy::IggyException);
+
+    // Past the max wait, after which an uncancelled poll would have stored its offset.
+    std::this_thread::sleep_for(std::chrono::seconds(2));
+    EXPECT_THROW((void)client.GetConsumerOffset(consumer, stream, topic, 0), iggy::IggyException);
+}
+
+TEST_F(E2E_PartitionReader, StandaloneConsumerHandsOverEveryMessageAndStoresTheLastOneOnClose) {
+    RecordProperty("description",
+                   "A standalone consumer under auto commit after processing hands over every message and Close "
+                   "stores the last one. Every call but Close throws after Close.");
+    auto client            = GetLoggedInHighLevelClient();
+    const auto stream      = CreateTopicWithMessages(kMessagesCount);
+    const auto topic       = iggy::Identifier::Numeric(0);
+    const std::string name = GetRandomName();
+    auto consumer          = client.StandaloneConsumer(
+        name, stream, topic,
+        iggy::ConsumerOptions()
+            .SetPartitions({0})
+            .SetStart(iggy::PollingStrategy::Offset(0))
+            .SetPollOptions(iggy::PollOptions().SetCount(kBatchCount).SetAutoCommit(iggy::AutoCommit())));
+
+    std::vector<std::uint64_t> offsets;
+    while (offsets.size() < kMessagesCount) {
+        const auto received = consumer.Next(kReadTimeout);
+        ASSERT_TRUE(received.has_value()) << "the consumer must hand over every message";
+        EXPECT_EQ(received->PartitionId(), 0u);
+        EXPECT_EQ(received->Checkpoint().Offset(), received->Message().Offset());
+        offsets.push_back(received->Message().Offset());
+    }
+    EXPECT_EQ(offsets, (std::vector<std::uint64_t>{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}));
+    consumer.Close();
+    consumer.Close();
+    EXPECT_THROW((void)consumer.Next(kIdleReadTimeout), iggy::IggyException);
+
+    EXPECT_EQ(client.GetConsumerOffset(iggy::Consumer::Single(iggy::Identifier::String(name)), stream, topic, 0)
+                  .StoredOffset(),
+              kMessagesCount - 1);
+}
+
+TEST_F(E2E_PartitionReader, GroupConsumerReadsItsAssignmentAndLeavesTheGroupOnClose) {
+    RecordProperty("description",
+                   "A group consumer creates and joins its group, commits a checkpoint it handed over and leaves the "
+                   "group in Close. Mark needs auto commit.");
+    auto client                  = GetLoggedInHighLevelClient();
+    const auto stream            = CreateTopicWithMessages(kMessagesCount);
+    const auto topic             = iggy::Identifier::Numeric(0);
+    const std::string group_name = GetRandomName();
+    auto consumer                = client.GroupConsumer(group_name, stream, topic,
+                                                        iggy::ConsumerOptions().SetStart(iggy::PollingStrategy::Offset(0)));
+    EXPECT_EQ(client.GetConsumerGroup(stream, topic, iggy::Identifier::String(group_name)).MembersCount(), 1u);
+
+    std::optional<iggy::ReceivedMessage> last;
+    for (std::uint32_t i = 0; i < kMessagesCount; i++) {
+        last = consumer.Next(kReadTimeout);
+        ASSERT_TRUE(last.has_value()) << "the consumer must hand over every message";
+    }
+    EXPECT_EQ(last->Message().Offset(), kMessagesCount - 1);
+    EXPECT_THROW(consumer.Mark(last->Checkpoint()), iggy::IggyException);
+    consumer.Commit(last->Checkpoint());
+    consumer.Close();
+
+    const auto group = iggy::Consumer::Group(iggy::Identifier::String(group_name));
+    EXPECT_EQ(client.GetConsumerOffset(group, stream, topic, 0).StoredOffset(), kMessagesCount - 1);
+    EXPECT_EQ(client.GetConsumerGroup(stream, topic, iggy::Identifier::String(group_name)).MembersCount(), 0u);
+}
+
+TEST_F(E2E_PartitionReader, ConsumerSeeksToAContinuationAndRefusesAnotherPartition) {
+    auto client       = GetLoggedInHighLevelClient();
+    const auto stream = CreateTopicWithMessages(kMessagesCount);
+    const auto topic  = iggy::Identifier::Numeric(0);
+    const auto batch  = client.PollMessages(iggy::Consumer::Single(iggy::Identifier::Numeric(8)), stream, topic, 0,
+                                            iggy::PollingStrategy::Offset(0), iggy::PollOptions().SetCount(2));
+    const auto continuation = batch.Continuation();
+    auto consumer           = client.StandaloneConsumer(
+        GetRandomName(), stream, topic,
+        iggy::ConsumerOptions().SetPartitions({0}).SetStart(iggy::PollingStrategy::Offset(0)));
+
+    consumer.Seek(0, continuation);
+    const auto received = consumer.Next(kReadTimeout);
+    ASSERT_TRUE(received.has_value());
+    EXPECT_EQ(received->Message().Offset(), 2u);
+    EXPECT_THROW(consumer.Seek(1, continuation), iggy::IggyException);
+    consumer.Close();
+}
diff --git a/foreign/cpp/tests/e2e/reader.cpp b/foreign/cpp/tests/e2e/reader.cpp
new file mode 100644
index 0000000..c63c31e
--- /dev/null
+++ b/foreign/cpp/tests/e2e/reader.cpp
@@ -0,0 +1,286 @@
+/*
+ * 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.
+ */
+
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <vector>
+
+#include <gtest/gtest.h>
+
+#include "lib.rs.h"
+#include "tests/e2e/test_helpers.hpp"
+
+namespace {
+
+constexpr std::uint64_t kReadTimeoutMicros     = 10'000'000;
+constexpr std::uint64_t kIdleReadTimeoutMicros = 200'000;
+constexpr std::uint32_t kMessagesCount         = 10;
+constexpr std::uint32_t kBatchCount            = 4;
+
+iggy::ffi::ReaderOptions standalone_options(const std::uint32_t partition_id, const iggy::ffi::CommitPolicy commit) {
+    iggy::ffi::ReaderOptions options{};
+    options.partitions.push_back(partition_id);
+    options.has_start        = true;
+    options.start            = make_polling_strategy("offset", 0);
+    options.has_poll_options = true;
+    options.poll_options     = make_poll_options(kBatchCount, commit);
+    return options;
+}
+
+}  // namespace
+
+class LowLevelE2E_Reader : public E2ETestFixture {
+  protected:
+    // One topic with one partition. The messages go to partition 0 and carry their index as payload.
+    std::uint32_t CreateTopicWithMessages(iggy::ffi::Client *client, const std::uint32_t messages_count) {
+        iggy::ffi::ErrorInfo error_info{};
+        const std::string stream_name = GetRandomName();
+        client->create_stream(stream_name, {}, error_info);
+        const auto stream = client->get_stream(make_string_identifier(stream_name));
+        TrackStream(stream.id);
+        client->create_topic(make_numeric_identifier(stream.id), GetRandomName(), make_topic_create_options(1),
+                             error_info);
+        if (messages_count > 0) {
+            rust::Vec<iggy::ffi::IggyMessageToSend> messages;
+            for (std::uint32_t i = 0; i < messages_count; i++) {
+                messages.push_back(
+                    iggy::ffi::make_message(to_payload(std::to_string(i)), rust::Vec<iggy::ffi::HeaderEntry>()));
+            }
+            client->send_messages(make_numeric_identifier(stream.id), make_numeric_identifier(0), "partition_id",
+                                  partition_id_bytes(0), std::move(messages), error_info);
+        }
+        return stream.id;
+    }
+};
+
+TEST_F(LowLevelE2E_Reader, ReadsEveryMessageAndCommitStoresTheOffset) {
+    RecordProperty("description",
+                   "Reads every message of one partition with a reader, commits the last checkpoint and reads the "
+                   "stored offset back.");
+    iggy::ffi::ErrorInfo error_info{};
+    iggy::ffi::ReadErrorInfo read_error{};
+    iggy::ffi::Client *client     = GetLoggedInClient();
+    const std::uint32_t stream_id = CreateTopicWithMessages(client, kMessagesCount);
+
+    auto reader =
+        client->reader(make_numeric_identifier(stream_id), make_numeric_identifier(0), "consumer",
+                       make_numeric_identifier(1), standalone_options(0, iggy::ffi::CommitPolicy::Manual), error_info);
+    const auto partitions = reader->partitions();
+    ASSERT_EQ(partitions.size(), 1u);
+    EXPECT_EQ(partitions[0], 0u);
+
+    std::vector<std::uint64_t> offsets;
+    std::optional<rust::Box<iggy::ffi::Checkpoint>> last;
+    while (offsets.size() < kMessagesCount) {
+        auto batch = reader->next(kReadTimeoutMicros, read_error);
+        ASSERT_FALSE(batch.messages.empty()) << "No batch before the timeout after " << offsets.size() << " messages";
+        EXPECT_EQ(batch.partition_id, 0u);
+        for (const auto &message : batch.messages) {
+            offsets.push_back(message.offset);
+        }
+        last = batch.checkpoint();
+    }
+    for (std::uint32_t i = 0; i < kMessagesCount; i++) {
+        EXPECT_EQ(offsets[i], static_cast<std::uint64_t>(i)) << "Offset mismatch at index " << i;
+    }
+    ASSERT_TRUE(last.has_value());
+    const auto &checkpoint = **last;
+    EXPECT_EQ(checkpoint.partition_id(), 0u);
+    EXPECT_EQ(checkpoint.offset(), kMessagesCount - 1);
+
+    ASSERT_NO_THROW(reader->commit(checkpoint, error_info));
+    const auto stored = client->get_consumer_offset(make_numeric_identifier(stream_id), make_numeric_identifier(0), 0,
+                                                    "consumer", make_numeric_identifier(1));
+    EXPECT_EQ(stored.stored_offset, kMessagesCount - 1);
+    ASSERT_NO_THROW(reader->close(error_info));
+}
+
+TEST_F(LowLevelE2E_Reader, SeekReadsThePartitionAgainFromTheNewStart) {
+    RecordProperty("description",
+                   "Reads one batch, seeks back to offset 0 and expects the next batch to start there. Then reads on, "
+                   "seeks to the continuation of the first batch and expects the next batch to start after it.");
+    iggy::ffi::ErrorInfo error_info{};
+    iggy::ffi::ReadErrorInfo read_error{};
+    iggy::ffi::Client *client     = GetLoggedInClient();
+    const std::uint32_t stream_id = CreateTopicWithMessages(client, kMessagesCount);
+    auto reader =
+        client->reader(make_numeric_identifier(stream_id), make_numeric_identifier(0), "consumer",
+                       make_numeric_identifier(1), standalone_options(0, iggy::ffi::CommitPolicy::Manual), error_info);
+
+    const auto first = reader->next(kReadTimeoutMicros, read_error);
+    ASSERT_FALSE(first.messages.empty());
+    EXPECT_EQ(first.messages.front().offset, 0u);
+
+    ASSERT_NO_THROW(reader->seek(0, make_polling_strategy("offset", 0), error_info));
+    const auto again = reader->next(kReadTimeoutMicros, read_error);
+    ASSERT_FALSE(again.messages.empty());
+    EXPECT_EQ(again.messages.front().offset, 0u) << "A batch queued before the seek was delivered after it";
+
+    const auto later = reader->next(kReadTimeoutMicros, read_error);
+    ASSERT_FALSE(later.messages.empty());
+    ASSERT_NO_THROW(reader->seek(0, *first.continuation(), error_info));
+    const auto resumed = reader->next(kReadTimeoutMicros, read_error);
+    ASSERT_FALSE(resumed.messages.empty());
+    EXPECT_EQ(resumed.messages.front().offset, first.messages.back().offset + 1)
+        << "A seek to a continuation must read on after its batch";
+
+    EXPECT_THROW(reader->seek(1, make_polling_strategy("offset", 0), error_info), std::exception)
+        << "A seek of a partition that the reader does not read must fail";
+    EXPECT_EQ(error_info.kind, "invalid_command");
+    ASSERT_NO_THROW(reader->close(error_info));
+}
+
+TEST_F(LowLevelE2E_Reader, NextReturnsAnEmptyBatchWhenTheTimeRunsOut) {
+    RecordProperty("description", "Waits for the next batch of an empty partition and gets an empty batch.");
+    iggy::ffi::ErrorInfo error_info{};
+    iggy::ffi::ReadErrorInfo read_error{};
+    iggy::ffi::Client *client     = GetLoggedInClient();
+    const std::uint32_t stream_id = CreateTopicWithMessages(client, 0);
+    auto reader =
+        client->reader(make_numeric_identifier(stream_id), make_numeric_identifier(0), "consumer",
+                       make_numeric_identifier(1), standalone_options(0, iggy::ffi::CommitPolicy::Manual), error_info);
+
+    const auto batch = reader->next(kIdleReadTimeoutMicros, read_error);
+    EXPECT_TRUE(batch.messages.empty());
+    EXPECT_EQ(batch.count, 0u);
+    EXPECT_THROW(batch.checkpoint(), std::exception);
+    ASSERT_NO_THROW(reader->close(error_info));
+}
+
+TEST_F(LowLevelE2E_Reader, CommitIsRefusedWhenTheServerCommitsOnRead) {
+    RecordProperty("description", "A reader whose server commits on read refuses a commit from the caller.");
+    iggy::ffi::ErrorInfo error_info{};
+    iggy::ffi::ReadErrorInfo read_error{};
+    iggy::ffi::Client *client     = GetLoggedInClient();
+    const std::uint32_t stream_id = CreateTopicWithMessages(client, kMessagesCount);
+    auto reader =
+        client->reader(make_numeric_identifier(stream_id), make_numeric_identifier(0), "consumer",
+                       make_numeric_identifier(1), standalone_options(0, iggy::ffi::CommitPolicy::OnRead), error_info);
+
+    const auto batch = reader->next(kReadTimeoutMicros, read_error);
+    ASSERT_FALSE(batch.messages.empty());
+    EXPECT_THROW(reader->commit(*batch.checkpoint(), error_info), std::exception);
+    EXPECT_EQ(error_info.kind, "invalid_command");
+    ASSERT_NO_THROW(reader->close(error_info));
+}
+
+TEST_F(LowLevelE2E_Reader, EveryCallButCloseThrowsAfterClose) {
+    RecordProperty("description", "A closed reader refuses every later call, and a second close does nothing.");
+    iggy::ffi::ErrorInfo error_info{};
+    iggy::ffi::ReadErrorInfo read_error{};
+    iggy::ffi::Client *client     = GetLoggedInClient();
+    const std::uint32_t stream_id = CreateTopicWithMessages(client, kMessagesCount);
+    auto reader =
+        client->reader(make_numeric_identifier(stream_id), make_numeric_identifier(0), "consumer",
+                       make_numeric_identifier(1), standalone_options(0, iggy::ffi::CommitPolicy::Manual), error_info);
+    const auto batch = reader->next(kReadTimeoutMicros, read_error);
+    ASSERT_FALSE(batch.messages.empty());
+
+    ASSERT_NO_THROW(reader->close(error_info));
+    EXPECT_THROW(reader->next(kIdleReadTimeoutMicros, read_error), std::exception);
+    EXPECT_THROW(reader->commit(*batch.checkpoint(), error_info), std::exception);
+    EXPECT_THROW(reader->seek(0, make_polling_strategy("offset", 0), error_info), std::exception);
+    EXPECT_THROW(reader->seek(0, *batch.continuation(), error_info), std::exception);
+    EXPECT_THROW(reader->partitions(), std::exception);
+    EXPECT_NO_THROW(reader->close(error_info));
+}
+
+TEST_F(LowLevelE2E_Reader, ConsumerGroupReaderJoinsInBuildAndLeavesInClose) {
+    RecordProperty("description",
+                   "A group reader joins its group when it is built, reads the assigned partition and leaves the "
+                   "group when it is closed.");
+    iggy::ffi::ErrorInfo error_info{};
+    iggy::ffi::ReadErrorInfo read_error{};
+    iggy::ffi::Client *client     = GetLoggedInClient();
+    const std::uint32_t stream_id = CreateTopicWithMessages(client, kMessagesCount);
+    const auto group = client->create_consumer_group(make_numeric_identifier(stream_id), make_numeric_identifier(0),
+                                                     GetRandomName(), error_info);
+
+    iggy::ffi::ReaderOptions options{};
+    options.has_start = true;
+    options.start     = make_polling_strategy("offset", 0);
+    auto reader       = client->reader(make_numeric_identifier(stream_id), make_numeric_identifier(0), "consumer_group",
+                                       make_numeric_identifier(group.id), std::move(options), error_info);
+    EXPECT_EQ(client
+                  ->get_consumer_group(make_numeric_identifier(stream_id), make_numeric_identifier(0),
+                                       make_numeric_identifier(group.id))
+                  .members_count,
+              1u);
+
+    const auto batch = reader->next(kReadTimeoutMicros, read_error);
+    ASSERT_FALSE(batch.messages.empty());
+    EXPECT_EQ(batch.partition_id, 0u);
+    EXPECT_EQ(batch.messages.front().offset, 0u);
+
+    ASSERT_NO_THROW(reader->close(error_info));
+    EXPECT_EQ(client
+                  ->get_consumer_group(make_numeric_identifier(stream_id), make_numeric_identifier(0),
+                                       make_numeric_identifier(group.id))
+                  .members_count,
+              0u);
+}
+
+TEST_F(LowLevelE2E_Reader, AutoCommitStoresTheMarkedPositionWhenTheReaderCloses) {
+    RecordProperty("description",
+                   "Marks the second message of a batch under CommitPolicy::Auto with CommitAfter::Marked, closes the "
+                   "reader and reads back the offset of the mark. A reader without Auto refuses the mark.");
+    iggy::ffi::ErrorInfo error_info{};
+    iggy::ffi::ReadErrorInfo read_error{};
+    iggy::ffi::Client *client              = GetLoggedInClient();
+    const std::uint32_t stream_id          = CreateTopicWithMessages(client, kMessagesCount);
+    auto options                           = standalone_options(0, iggy::ffi::CommitPolicy::Auto);
+    options.poll_options.has_auto_interval = false;
+    options.poll_options.has_auto_every    = true;
+    options.poll_options.auto_every        = iggy::ffi::CommitEvery::Batch;
+    options.poll_options.auto_after        = iggy::ffi::CommitAfter::Marked;
+    auto reader = client->reader(make_numeric_identifier(stream_id), make_numeric_identifier(0), "consumer",
+                                 make_numeric_identifier(1), options, error_info);
+
+    const auto batch = reader->next(kReadTimeoutMicros, read_error);
+    ASSERT_GE(batch.messages.size(), 2u);
+    ASSERT_NO_THROW(reader->mark(*batch.checkpoint_at(1), error_info));
+    ASSERT_NO_THROW(reader->close(error_info));
+
+    const auto stored = client->get_consumer_offset(make_numeric_identifier(stream_id), make_numeric_identifier(0), 0,
+                                                    "consumer", make_numeric_identifier(1));
+    EXPECT_EQ(stored.stored_offset, 1u);
+
+    auto manual =
+        client->reader(make_numeric_identifier(stream_id), make_numeric_identifier(0), "consumer",
+                       make_numeric_identifier(2), standalone_options(0, iggy::ffi::CommitPolicy::Manual), error_info);
+    const auto manual_batch = manual->next(kReadTimeoutMicros, read_error);
+    ASSERT_FALSE(manual_batch.messages.empty());
+    EXPECT_THROW(manual->mark(*manual_batch.checkpoint(), error_info), std::exception);
+    EXPECT_EQ(error_info.kind, "invalid_command");
+    ASSERT_NO_THROW(manual->close(error_info));
+}
+
+TEST_F(LowLevelE2E_Reader, OneShotPollRefusesAutoCommit) {
+    RecordProperty("description", "A one-shot poll has nothing to count, so CommitPolicy::Auto is refused.");
+    iggy::ffi::ErrorInfo error_info{};
+    iggy::ffi::Client *client     = GetLoggedInClient();
+    const std::uint32_t stream_id = CreateTopicWithMessages(client, 1);
+
+    EXPECT_THROW(client->poll_messages(make_numeric_identifier(stream_id), make_numeric_identifier(0), 0, "consumer",
+                                       make_numeric_identifier(1), make_polling_strategy("offset", 0),
+                                       make_poll_options(1, iggy::ffi::CommitPolicy::Auto), error_info),
+                 std::exception);
+    EXPECT_EQ(error_info.kind, "invalid_configuration");
+}
diff --git a/foreign/cpp/tests/e2e/resource_options.cpp b/foreign/cpp/tests/e2e/resource_options.cpp
index 62f00ea..d671539 100644
--- a/foreign/cpp/tests/e2e/resource_options.cpp
+++ b/foreign/cpp/tests/e2e/resource_options.cpp
@@ -272,9 +272,9 @@
             return payloads;
         }
         iggy::ffi::ErrorInfo error_info{};
-        const auto polled =
-            client->poll_messages(make_numeric_identifier(stream_id), make_numeric_identifier(topic_id), 0, "consumer",
-                                  make_numeric_identifier(1), "offset", 0, 10, false, error_info);
+        const auto polled = client->poll_messages(
+            make_numeric_identifier(stream_id), make_numeric_identifier(topic_id), 0, "consumer",
+            make_numeric_identifier(1), make_polling_strategy("offset", 0), make_poll_options(10), error_info);
         for (const auto &message : polled.messages) {
             payloads.emplace_back(message.payload.begin(), message.payload.end());
         }
diff --git a/foreign/cpp/tests/e2e/test_helpers.hpp b/foreign/cpp/tests/e2e/test_helpers.hpp
index a28c358..b4b5a34 100644
--- a/foreign/cpp/tests/e2e/test_helpers.hpp
+++ b/foreign/cpp/tests/e2e/test_helpers.hpp
@@ -64,6 +64,22 @@
     return v;
 }
 
+inline iggy::ffi::PollingStrategy make_polling_strategy(const std::string &kind, const std::uint64_t value) {
+    iggy::ffi::PollingStrategy strategy{};
+    strategy.kind  = kind;
+    strategy.value = value;
+    return strategy;
+}
+
+inline iggy::ffi::PollOptions make_poll_options(
+    const std::uint32_t count,
+    const iggy::ffi::CommitPolicy commit = iggy::ffi::CommitPolicy::Manual) {
+    iggy::ffi::PollOptions options = iggy::ffi::default_poll_options();
+    options.count                  = count;
+    options.commit                 = commit;
+    return options;
+}
+
 inline rust::Vec<rust::String> make_snapshot_types(std::initializer_list<const char *> values) {
     rust::Vec<rust::String> snapshot_types;
     for (const auto *const value : values) {
diff --git a/foreign/cpp/tests/e2e/topic.cpp b/foreign/cpp/tests/e2e/topic.cpp
index ce516a0..9202863 100644
--- a/foreign/cpp/tests/e2e/topic.cpp
+++ b/foreign/cpp/tests/e2e/topic.cpp
@@ -1014,7 +1014,7 @@
         iggy::ffi::ErrorInfo error_info{};
         const auto polled = message_client->poll_messages(
             make_numeric_identifier(created_stream.Id()), make_string_identifier(updated_topic_name), 0, "consumer",
-            make_numeric_identifier(1), "offset", 0, 10, false, error_info);
+            make_numeric_identifier(1), make_polling_strategy("offset", 0), make_poll_options(10), error_info);
         ASSERT_EQ(polled.count, 1u);
         ASSERT_EQ(polled.messages.size(), 1u);
         const std::string actual(polled.messages[0].payload.begin(), polled.messages[0].payload.end());
diff --git a/foreign/cpp/tests/fixtures/deferred_polling.json b/foreign/cpp/tests/fixtures/deferred_polling.json
new file mode 100644
index 0000000..452b461
--- /dev/null
+++ b/foreign/cpp/tests/fixtures/deferred_polling.json
@@ -0,0 +1,610 @@
+{
+  "schema_version": 1,
+  "generated_by": "core/common/examples/deferred_polling_fixtures.rs",
+  "conventions": {
+    "body_hex": "Every *_hex field is lowercase hex without separators. body_hex is the command payload without framing.",
+    "header_hex": "header_hex is the whole request header of a cancellation, which has no body, before the transport stamps its checksum and checksum_body.",
+    "oversized": "A reply may carry a first record above max_bytes, up to max_record_size, so a client reply cap is at least max_record_reply_size.",
+    "refused": "Every refused fetch fails to decode or to validate. Every refused fetch reply fails to decode.",
+    "status": "A section status of status_ok was read. Any other status is the error code of that partition alone, and its section holds no records.",
+    "strategy": "strategy.kind is the wire kind: 1 offset, 2 timestamp, 3 first, 4 last, 5 next."
+  },
+  "constants": {
+    "codes": {
+      "cancel_deferred_request": 108,
+      "fetch_messages": 109,
+      "poll_messages": 100,
+      "poll_messages_deferred": 106
+    },
+    "status_ok": 0,
+    "errors": {
+      "deferred_poll_capacity_exceeded": 90,
+      "fetch_progress_uncertain": 14010,
+      "history_unavailable": 87,
+      "invalid_size_bytes": 80,
+      "not_primary": 89,
+      "partition_lane_blocked": 14009,
+      "too_many_consumer_offsets": 3024,
+      "transient_not_accepted": 58,
+      "transient_not_committed": 57
+    },
+    "min_deferred_poll_max_bytes": 344,
+    "max_deferred_poll_wait_us": 600000000,
+    "max_deferred_poll_timeout_us": 630000000,
+    "min_deferred_poll_reply_margin_us": 1000000,
+    "fetch_reply_margin_us": 200000,
+    "max_partitions_per_request": 1000,
+    "max_record_size": 64100304,
+    "max_record_reply_size": 64156564,
+    "request_header_size": 256,
+    "fetch_section_header_size": 56
+  },
+  "deferred_polls": [
+    {
+      "case": "consumer_offset_without_commit",
+      "request": {
+        "consumer": {
+          "kind": 1,
+          "id": {
+            "kind": "numeric",
+            "value": 1
+          }
+        },
+        "stream_id": {
+          "kind": "numeric",
+          "value": 1
+        },
+        "topic_id": {
+          "kind": "named",
+          "value": "orders"
+        },
+        "partition_id": 2,
+        "strategy": {
+          "kind": 1,
+          "value": 41
+        },
+        "count": 100,
+        "auto_commit": false,
+        "wait_us": 5000000,
+        "min_count": 1,
+        "max_bytes": 1048576,
+        "request_timeout_us": 6000000
+      },
+      "body_hex": "0101040100000001040100000002066f726465727301020000000129000000000000006400000000404b4c00000000000100000000001000808d5b0000000000"
+    },
+    {
+      "case": "group_next_with_commit",
+      "request": {
+        "consumer": {
+          "kind": 2,
+          "id": {
+            "kind": "named",
+            "value": "orders-group"
+          }
+        },
+        "stream_id": {
+          "kind": "numeric",
+          "value": 1
+        },
+        "topic_id": {
+          "kind": "named",
+          "value": "orders"
+        },
+        "partition_id": 2,
+        "strategy": {
+          "kind": 5,
+          "value": 0
+        },
+        "count": 100,
+        "auto_commit": true,
+        "wait_us": 5000000,
+        "min_count": 1,
+        "max_bytes": 1048576,
+        "request_timeout_us": 6000000
+      },
+      "body_hex": "02020c6f72646572732d67726f757001040100000002066f726465727301020000000500000000000000006400000001404b4c00000000000100000000001000808d5b0000000000"
+    }
+  ],
+  "fetches": [
+    {
+      "case": "consumer_two_targets_without_commit",
+      "request": {
+        "consumer": {
+          "kind": 1,
+          "id": {
+            "kind": "numeric",
+            "value": 1
+          }
+        },
+        "stream_id": {
+          "kind": "numeric",
+          "value": 1
+        },
+        "topic_id": {
+          "kind": "numeric",
+          "value": 2
+        },
+        "wait_us": 5000000,
+        "request_timeout_us": 6000000,
+        "min_count": 1,
+        "count": 1000,
+        "max_bytes": 1048576,
+        "auto_commit": false,
+        "targets": [
+          {
+            "partition_id": 1,
+            "strategy": {
+              "kind": 1,
+              "value": 41
+            },
+            "max_bytes": 524288,
+            "incarnation": 11,
+            "owner_generation": 0
+          },
+          {
+            "partition_id": 3,
+            "strategy": {
+              "kind": 5,
+              "value": 0
+            },
+            "max_bytes": 524288,
+            "incarnation": 11,
+            "owner_generation": 0
+          }
+        ]
+      },
+      "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b000000000001000000e803000000001000000200000001000000012900000000000000000008000b00000000000000000000000000000003000000050000000000000000000008000b000000000000000000000000000000"
+    },
+    {
+      "case": "group_three_targets_with_commit",
+      "request": {
+        "consumer": {
+          "kind": 2,
+          "id": {
+            "kind": "numeric",
+            "value": 9
+          }
+        },
+        "stream_id": {
+          "kind": "numeric",
+          "value": 1
+        },
+        "topic_id": {
+          "kind": "numeric",
+          "value": 2
+        },
+        "wait_us": 5000000,
+        "request_timeout_us": 6000000,
+        "min_count": 1,
+        "count": 1000,
+        "max_bytes": 1048576,
+        "auto_commit": true,
+        "targets": [
+          {
+            "partition_id": 1,
+            "strategy": {
+              "kind": 5,
+              "value": 0
+            },
+            "max_bytes": 524288,
+            "incarnation": 11,
+            "owner_generation": 4
+          },
+          {
+            "partition_id": 2,
+            "strategy": {
+              "kind": 5,
+              "value": 0
+            },
+            "max_bytes": 524288,
+            "incarnation": 11,
+            "owner_generation": 4
+          },
+          {
+            "partition_id": 4,
+            "strategy": {
+              "kind": 1,
+              "value": 7
+            },
+            "max_bytes": 524288,
+            "incarnation": 11,
+            "owner_generation": 4
+          }
+        ]
+      },
+      "body_hex": "02010409000000010401000000010402000000404b4c0000000000808d5b000000000001000000e803000000001000010300000001000000050000000000000000000008000b00000000000000040000000000000002000000050000000000000000000008000b00000000000000040000000000000004000000010700000000000000000008000b000000000000000400000000000000"
+    }
+  ],
+  "cancellations": [
+    {
+      "case": "cancel_deferred_poll",
+      "cancels_code": 106,
+      "client": 7,
+      "request": 21,
+      "session": 3,
+      "header_hex": "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000050000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007000000000000000000000000000000000000000000000000000000000000000000000000000000150000000000000002000000000000000300000000000000000000006c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
+    },
+    {
+      "case": "cancel_fetch",
+      "cancels_code": 109,
+      "client": 7,
+      "request": 22,
+      "session": 3,
+      "header_hex": "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000050000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007000000000000000000000000000000000000000000000000000000000000000000000000000000160000000000000002000000000000000300000000000000000000006c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
+    }
+  ],
+  "fetch_replies": [
+    {
+      "case": "ok_section_and_empty_start_section",
+      "sections": [
+        {
+          "partition_id": 1,
+          "status": 0,
+          "current_offset": 44,
+          "messages_count": 3,
+          "context": {
+            "incarnation": 11,
+            "owner_generation": 4,
+            "metadata_op": 42
+          },
+          "next_offset": 44,
+          "records_len": 416,
+          "messages": [
+            {
+              "offset": 41,
+              "id": "258",
+              "timestamp": 1700000000000500,
+              "origin_timestamp": 1700000000000000,
+              "payload": "first"
+            },
+            {
+              "offset": 42,
+              "id": "259",
+              "timestamp": 1700000000000500,
+              "origin_timestamp": 1700000000000000,
+              "payload": "second"
+            },
+            {
+              "offset": 43,
+              "id": "260",
+              "timestamp": 1700000000000500,
+              "origin_timestamp": 1700000000000000,
+              "payload": "third"
+            }
+          ]
+        },
+        {
+          "partition_id": 3,
+          "status": 0,
+          "current_offset": 9,
+          "messages_count": 0,
+          "context": {
+            "incarnation": 11,
+            "owner_generation": 4,
+            "metadata_op": 42
+          },
+          "next_offset": 10,
+          "records_len": 0,
+          "messages": []
+        }
+      ],
+      "body_hex": "0200000001000000000000002c00000000000000030000000b0000000000000004000000000000002a000000000000002c00000000000000a001000003000000000000000900000000000000000000000b0000000000000004000000000000002a000000000000000a000000000000000000000001000000000000002900000000000000f4411e18240a060000401e18240a0600a00100000000000088fc3268f2c0e02103000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000bb5b1cd43b71761a02010000000000000000000000000000000000000000000000000000050000000000000000000000666972737453c25c376f98c265030100000000000000000000000000000100000000000000000000000600000000000000000000007365636f6e64a761c18f632fb022040100000000000000000000000000000200000000000000000000000500000000000000000000007468697264"
+    },
+    {
+      "case": "ok_section_beside_error_sections",
+      "sections": [
+        {
+          "partition_id": 1,
+          "status": 89,
+          "current_offset": 0,
+          "messages_count": 0,
+          "context": {
+            "incarnation": 0,
+            "owner_generation": 0,
+            "metadata_op": 0
+          },
+          "next_offset": 0,
+          "records_len": 0,
+          "messages": []
+        },
+        {
+          "partition_id": 2,
+          "status": 0,
+          "current_offset": 7,
+          "messages_count": 1,
+          "context": {
+            "incarnation": 11,
+            "owner_generation": 4,
+            "metadata_op": 42
+          },
+          "next_offset": 7,
+          "records_len": 308,
+          "messages": [
+            {
+              "offset": 6,
+              "id": "340282366920938463463374607431768211455",
+              "timestamp": 1700000000000500,
+              "origin_timestamp": 1700000000000000,
+              "payload": "only"
+            }
+          ]
+        },
+        {
+          "partition_id": 4,
+          "status": 58,
+          "current_offset": 0,
+          "messages_count": 0,
+          "context": {
+            "incarnation": 0,
+            "owner_generation": 0,
+            "metadata_op": 0
+          },
+          "next_offset": 0,
+          "records_len": 0,
+          "messages": []
+        }
+      ],
+      "body_hex": "03000000010000005900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000700000000000000010000000b0000000000000004000000000000002a00000000000000070000000000000034010000040000003a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000600000000000000f4411e18240a060000401e18240a060034010000000000003e89bfefc17b805301000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000fbb4aeb35a85bdaeffffffffffffffffffffffffffffffff0000000000000000000000000400000000000000000000006f6e6c79"
+    },
+    {
+      "case": "no_sections",
+      "sections": [],
+      "body_hex": "00000000"
+    }
+  ],
+  "refused": {
+    "fetches": [
+      {
+        "case": "no_targets",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b000000000001000000e8030000000010000000000000"
+      },
+      {
+        "case": "duplicate_partition",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b000000000001000000e803000000001000000200000001000000010000000000000000000008000b00000000000000000000000000000001000000050000000000000000000008000b000000000000000000000000000000"
+      },
+      {
+        "case": "first_start_point",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b000000000001000000e803000000001000000100000001000000030000000000000000000008000b000000000000000000000000000000"
+      },
+      {
+        "case": "min_count_above_count",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b0000000000e9030000e803000000001000000100000001000000010000000000000000000008000b000000000000000000000000000000"
+      },
+      {
+        "case": "target_bytes_above_request_bytes",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b000000000001000000e803000000001000000100000001000000010000000000000000010010000b000000000000000000000000000000"
+      },
+      {
+        "case": "timeout_without_reply_margin",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000404b4c000000000001000000e803000000001000000100000001000000010000000000000000000008000b000000000000000000000000000000"
+      }
+    ],
+    "fetch_replies": [
+      {
+        "case": "error_section_with_records",
+        "body_hex": "01000000010000005900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000"
+      },
+      {
+        "case": "truncated_table",
+        "body_hex": "02000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
+      }
+    ]
+  },
+  "commit_outcomes": {
+    "rule": "A committing deferred poll (106) or fetch (109) that fails with a certain status stored nothing. Every other status, a status not listed here, a lost reply and a transport failure after the send leave the outcome unknown.",
+    "request": [
+      {
+        "status": 5,
+        "error": "feature_unavailable",
+        "certain": true
+      },
+      {
+        "status": 6,
+        "error": "invalid_identifier",
+        "certain": true
+      },
+      {
+        "status": 30,
+        "error": "stale_client",
+        "certain": false
+      },
+      {
+        "status": 40,
+        "error": "unauthenticated",
+        "certain": false
+      },
+      {
+        "status": 41,
+        "error": "unauthorized",
+        "certain": false
+      },
+      {
+        "status": 57,
+        "error": "transient_not_committed",
+        "certain": false
+      },
+      {
+        "status": 58,
+        "error": "transient_not_accepted",
+        "certain": true
+      },
+      {
+        "status": 72,
+        "error": "cannot_decrypt_data",
+        "certain": false
+      },
+      {
+        "status": 80,
+        "error": "invalid_size_bytes",
+        "certain": true
+      },
+      {
+        "status": 87,
+        "error": "history_unavailable",
+        "certain": true
+      },
+      {
+        "status": 89,
+        "error": "not_primary",
+        "certain": true
+      },
+      {
+        "status": 90,
+        "error": "deferred_poll_capacity_exceeded",
+        "certain": true
+      },
+      {
+        "status": 1009,
+        "error": "stream_id_not_found",
+        "certain": true
+      },
+      {
+        "status": 1010,
+        "error": "stream_name_not_found",
+        "certain": true
+      },
+      {
+        "status": 2010,
+        "error": "topic_id_not_found",
+        "certain": true
+      },
+      {
+        "status": 2011,
+        "error": "topic_name_not_found",
+        "certain": true
+      },
+      {
+        "status": 3007,
+        "error": "partition_not_found",
+        "certain": true
+      },
+      {
+        "status": 3024,
+        "error": "too_many_consumer_offsets",
+        "certain": true
+      },
+      {
+        "status": 4100,
+        "error": "invalid_offset",
+        "certain": true
+      },
+      {
+        "status": 5009,
+        "error": "consumer_group_partition_not_owned",
+        "certain": true
+      },
+      {
+        "status": 11001,
+        "error": "shard_communication_error",
+        "certain": false
+      }
+    ],
+    "section": [
+      {
+        "status": 5,
+        "error": "feature_unavailable",
+        "certain": false
+      },
+      {
+        "status": 6,
+        "error": "invalid_identifier",
+        "certain": false
+      },
+      {
+        "status": 30,
+        "error": "stale_client",
+        "certain": false
+      },
+      {
+        "status": 40,
+        "error": "unauthenticated",
+        "certain": false
+      },
+      {
+        "status": 41,
+        "error": "unauthorized",
+        "certain": false
+      },
+      {
+        "status": 57,
+        "error": "transient_not_committed",
+        "certain": false
+      },
+      {
+        "status": 58,
+        "error": "transient_not_accepted",
+        "certain": true
+      },
+      {
+        "status": 72,
+        "error": "cannot_decrypt_data",
+        "certain": false
+      },
+      {
+        "status": 80,
+        "error": "invalid_size_bytes",
+        "certain": true
+      },
+      {
+        "status": 87,
+        "error": "history_unavailable",
+        "certain": true
+      },
+      {
+        "status": 89,
+        "error": "not_primary",
+        "certain": true
+      },
+      {
+        "status": 90,
+        "error": "deferred_poll_capacity_exceeded",
+        "certain": false
+      },
+      {
+        "status": 1009,
+        "error": "stream_id_not_found",
+        "certain": false
+      },
+      {
+        "status": 1010,
+        "error": "stream_name_not_found",
+        "certain": false
+      },
+      {
+        "status": 2010,
+        "error": "topic_id_not_found",
+        "certain": false
+      },
+      {
+        "status": 2011,
+        "error": "topic_name_not_found",
+        "certain": false
+      },
+      {
+        "status": 3007,
+        "error": "partition_not_found",
+        "certain": true
+      },
+      {
+        "status": 3024,
+        "error": "too_many_consumer_offsets",
+        "certain": true
+      },
+      {
+        "status": 4100,
+        "error": "invalid_offset",
+        "certain": false
+      },
+      {
+        "status": 5009,
+        "error": "consumer_group_partition_not_owned",
+        "certain": true
+      },
+      {
+        "status": 11001,
+        "error": "shard_communication_error",
+        "certain": false
+      }
+    ]
+  }
+}
diff --git a/foreign/cpp/tests/fixtures/resource_options.json b/foreign/cpp/tests/fixtures/resource_options.json
index c5cc2ac..202888f 100644
--- a/foreign/cpp/tests/fixtures/resource_options.json
+++ b/foreign/cpp/tests/fixtures/resource_options.json
@@ -16,18 +16,18 @@
   },
   "constants": {
     "protocol_version": {
-      "packed": 12288,
-      "text": "0.12.0",
+      "packed": 12289,
+      "text": "0.12.1",
       "major": 0,
       "minor": 12,
-      "patch": 0
+      "patch": 1
     },
     "protocol_version_min": {
-      "packed": 12288,
-      "text": "0.12.0",
+      "packed": 12289,
+      "text": "0.12.1",
       "major": 0,
       "minor": 12,
-      "patch": 0
+      "patch": 1
     },
     "command_codes": {
       "create_stream": {
diff --git a/foreign/cpp/tests/unit/poll_options.cpp b/foreign/cpp/tests/unit/poll_options.cpp
new file mode 100644
index 0000000..2bf9b9f
--- /dev/null
+++ b/foreign/cpp/tests/unit/poll_options.cpp
@@ -0,0 +1,79 @@
+/*
+ * 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.
+ */
+
+#include <chrono>
+#include <optional>
+
+#include <gtest/gtest.h>
+
+#include "iggy.hpp"
+
+TEST(PollOptionsTest, DefaultOptionsReadAtOnceAndStoreNothing) {
+    const iggy::PollOptions options;
+
+    EXPECT_EQ(options.Count(), 1000u);
+    EXPECT_EQ(options.MaxWait(), std::chrono::microseconds(0));
+    EXPECT_EQ(options.MinCount(), 1u);
+    EXPECT_EQ(options.MaxBytes(), 1024u * 1024u);
+    EXPECT_EQ(options.Commit(), iggy::CommitPolicy::Manual);
+}
+
+TEST(PollOptionsTest, DefaultAutoCommitStoresEveryTenThousandMessagesOrFiveSecondsAfterProcessing) {
+    const iggy::AutoCommit auto_commit;
+
+    EXPECT_EQ(auto_commit.Interval(), std::optional(std::chrono::microseconds(std::chrono::seconds(5))));
+    ASSERT_TRUE(auto_commit.Every().has_value());
+    EXPECT_EQ(auto_commit.Every()->GetKind(), iggy::CommitEvery::Kind::Messages);
+    EXPECT_EQ(auto_commit.Every()->Count(), 10'000u);
+    EXPECT_EQ(auto_commit.After(), iggy::CommitAfter::Processing);
+}
+
+TEST(PollOptionsTest, SetAutoCommitSwitchesToAutoAndKeepsItsSettings) {
+    iggy::PollOptions options;
+    options.SetCount(7).SetMaxWait(std::chrono::milliseconds(250)).SetMinCount(2).SetMaxBytes(4096);
+    options.SetAutoCommit(iggy::AutoCommit()
+                              .SetInterval(std::nullopt)
+                              .SetEvery(iggy::CommitEvery::Batch())
+                              .SetAfter(iggy::CommitAfter::Marked));
+
+    EXPECT_EQ(options.Count(), 7u);
+    EXPECT_EQ(options.MaxWait(), std::chrono::microseconds(250'000));
+    EXPECT_EQ(options.MinCount(), 2u);
+    EXPECT_EQ(options.MaxBytes(), 4096u);
+    EXPECT_EQ(options.Commit(), iggy::CommitPolicy::Auto);
+    const auto auto_commit = options.AutoCommitSettings();
+    EXPECT_FALSE(auto_commit.Interval().has_value());
+    ASSERT_TRUE(auto_commit.Every().has_value());
+    EXPECT_EQ(auto_commit.Every()->GetKind(), iggy::CommitEvery::Kind::Batch);
+    EXPECT_EQ(auto_commit.After(), iggy::CommitAfter::Marked);
+}
+
+TEST(PollOptionsTest, NegativeWaitAnswersAtOnce) {
+    iggy::PollOptions options;
+    options.SetMaxWait(std::chrono::microseconds(-1));
+
+    EXPECT_EQ(options.MaxWait(), std::chrono::microseconds(0));
+}
+
+TEST(PollOptionsTest, ConsumerAcceptsAContinuation) {
+    using SeekContinuation      = void (iggy::IggyConsumer::*)(std::uint32_t, const iggy::Continuation &);
+    const SeekContinuation seek = &iggy::IggyConsumer::Seek;
+
+    EXPECT_NE(seek, nullptr);
+}
diff --git a/foreign/cpp/tests/unit/unit_tests.cpp b/foreign/cpp/tests/unit/unit_tests.cpp
index c97b193..4bb7315 100644
--- a/foreign/cpp/tests/unit/unit_tests.cpp
+++ b/foreign/cpp/tests/unit/unit_tests.cpp
@@ -556,8 +556,6 @@
     EXPECT_THROW(client.JoinConsumerGroup(stream, topic, group), iggy::IggyException);
     EXPECT_THROW(client.LeaveConsumerGroup(stream, topic, group), iggy::IggyException);
     EXPECT_THROW(client.StoreConsumerOffset(consumer, stream, topic, 0, 0), iggy::IggyException);
-    EXPECT_THROW(client.StoreConsumerPosition(consumer, stream, topic, 0, 0, iggy::PartitionContext(1, 0, 0)),
-                 iggy::IggyException);
     EXPECT_THROW(client.GetConsumerOffset(consumer, stream, topic, 0), iggy::IggyException);
     EXPECT_THROW(client.DeleteConsumerOffset(consumer, stream, topic, 0), iggy::IggyException);
     EXPECT_THROW((void)client.DescribeOptions(iggy::OptionsScope::Stream), iggy::IggyException);
diff --git a/foreign/csharp/Iggy_SDK.Tests.BDD/Iggy_SDK.Tests.BDD.csproj b/foreign/csharp/Iggy_SDK.Tests.BDD/Iggy_SDK.Tests.BDD.csproj
index 6784190..d6ede40 100644
--- a/foreign/csharp/Iggy_SDK.Tests.BDD/Iggy_SDK.Tests.BDD.csproj
+++ b/foreign/csharp/Iggy_SDK.Tests.BDD/Iggy_SDK.Tests.BDD.csproj
@@ -76,6 +76,9 @@
         <ReqnrollFeatureFile Include="..\..\..\bdd\scenarios\metadata_changes.feature">
             <Link>Features\metadata_changes.feature</Link>
         </ReqnrollFeatureFile>
+        <ReqnrollFeatureFile Include="..\..\..\bdd\scenarios\deferred_polling.feature">
+            <Link>Features\deferred_polling.feature</Link>
+        </ReqnrollFeatureFile>
         <ReqnrollFeatureFile Include="..\..\..\bdd\scenarios\session_contract.feature">
             <Link>Features\session_contract.feature</Link>
         </ReqnrollFeatureFile>
diff --git a/foreign/csharp/Iggy_SDK.Tests.BDD/StepDefinitions/DeferredPollingSteps.cs b/foreign/csharp/Iggy_SDK.Tests.BDD/StepDefinitions/DeferredPollingSteps.cs
new file mode 100644
index 0000000..c40861a
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK.Tests.BDD/StepDefinitions/DeferredPollingSteps.cs
@@ -0,0 +1,360 @@
+// 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.
+
+using System.Diagnostics;
+using System.Text;
+using Apache.Iggy.Consumers;
+using Apache.Iggy.Contracts;
+using Apache.Iggy.Exceptions;
+using Apache.Iggy.Extensions;
+using Apache.Iggy.IggyClient.Implementations;
+using Apache.Iggy.Kinds;
+using Apache.Iggy.Messages;
+using Microsoft.Extensions.Logging.Abstractions;
+using Reqnroll;
+using Shouldly;
+using Partitioning = Apache.Iggy.Kinds.Partitioning;
+using TestContext = Apache.Iggy.Tests.BDD.Context.TestContext;
+using TestEnvironment = Apache.Iggy.Tests.BDD.Context.TestEnvironment;
+
+namespace Apache.Iggy.Tests.BDD.StepDefinitions;
+
+/// <summary>
+///     A start step leaves its deferred poll, reader or consumer waiting on the server, and a later step sends the
+///     message that wakes it. All of them share the client of the scenario.
+/// </summary>
+[Binding]
+public sealed class DeferredPollingSteps : IAsyncDisposable
+{
+    private const uint MessagesCount = 10;
+
+    /// <summary>
+    ///     A message that reaches the server before a poll answers it at once, so a start step waits until its poll
+    ///     parks.
+    /// </summary>
+    private static readonly TimeSpan ParkTime = TimeSpan.FromMilliseconds(100);
+
+    private static readonly Consumer DefaultConsumer = Consumer.New(0);
+
+    private readonly CancellationTokenSource _cancellation = new();
+    private readonly TestContext _context;
+    private IggyConsumer? _consumer;
+    private ulong? _lostOperation;
+    private IAsyncEnumerator<ReceivedMessage>? _messages;
+    private Task<PolledMessages>? _poll;
+    private Stopwatch? _pollStarted;
+    private PartitionReader? _reader;
+    private StreamResponse? _stream;
+    private TopicResponse? _topic;
+
+    public DeferredPollingSteps(TestContext context)
+    {
+        _context = context;
+    }
+
+    public async ValueTask DisposeAsync()
+    {
+        await CloseConsumerAsync();
+        if (_reader is not null)
+        {
+            await _reader.DisposeAsync();
+        }
+
+        _cancellation.Dispose();
+    }
+
+    // Other features expect a server without streams.
+    [AfterScenario(Order = 0)]
+    public async Task DeleteCreatedStream()
+    {
+        await CloseConsumerAsync();
+        if (_reader is not null)
+        {
+            await _reader.DisposeAsync();
+            _reader = null;
+        }
+
+        if (_stream is not null)
+        {
+            await _context.IggyClient.DeleteStreamAsync(StreamId());
+        }
+    }
+
+    [Given("a stream named {string} with a topic named {string} with {int} partitions exists")]
+    public async Task CreateStreamWithTopic(string streamName, string topicName, int partitions)
+    {
+        if (await _context.IggyClient.GetStreamByIdAsync(Identifier.String(streamName)) is not null)
+        {
+            await _context.IggyClient.DeleteStreamAsync(Identifier.String(streamName));
+        }
+
+        _stream = await _context.IggyClient.CreateStreamAsync(streamName);
+        _topic = await _context.IggyClient.CreateTopicAsync(StreamId(), topicName, (uint)partitions);
+    }
+
+    [When("I start a deferred poll of partition {int} from offset {int} with a max wait of {int} second(s)")]
+    public Task StartDeferredPoll(int partition, int offset, int maxWaitSeconds)
+    {
+        return StartDeferredPollAsync(partition, DefaultConsumer, PollingStrategy.Offset((ulong)offset),
+            Options(maxWaitSeconds));
+    }
+
+    [When("I start a deferred poll of partition {int} for consumer {string} with the on read commit policy, a min count of {int} and a max wait of {int} second(s)")]
+    public Task StartCommittingDeferredPoll(int partition, string consumerName, int minCount, int maxWaitSeconds)
+    {
+        return StartDeferredPollAsync(partition, Consumer.New(consumerName), PollingStrategy.Next(),
+            Options(maxWaitSeconds) with { MinCount = (uint)minCount, Commit = CommitPolicy.OnRead });
+    }
+
+    [When("I start a deferred poll of partition {int} for consumer {string} with the on read commit policy and a max wait of {int} second(s)")]
+    public async Task StartOnReadDeferredPoll(int partition, string consumerName, int maxWaitSeconds)
+    {
+        // A read that stores nothing resolves the route first, so the committing poll is on the wire when it parks.
+        await _context.IggyClient.PollMessagesAsync(StreamId(), TopicId(), (uint)partition, Consumer.New(consumerName),
+            PollingStrategy.Offset(0), new PollOptions { Count = 1 });
+        await StartDeferredPollAsync(partition, Consumer.New(consumerName), PollingStrategy.Next(),
+            Options(maxWaitSeconds) with { Commit = CommitPolicy.OnRead });
+    }
+
+    [When("the client disconnects before the deferred poll answers")]
+    public Task DisconnectBeforeThePollAnswers()
+    {
+        _poll.ShouldNotBeNull().IsCompleted.ShouldBeFalse("the deferred poll answered before the disconnect");
+        // The client has no public disconnect, so the step drops its links without a sign-out, as a lost network does.
+        return _context.IggyClient.ShouldBeOfType<TcpMessageStream>().DisconnectAsync();
+    }
+
+    [Then("the deferred poll should fail as uncertain")]
+    public async Task DeferredPollFailsAsUncertain()
+    {
+        var error = await Record.ExceptionAsync(() => _poll.ShouldNotBeNull());
+        var uncertain = error.ShouldBeOfType<VsrFetchProgressUncertainException>();
+        uncertain.StatusCode.ShouldBe(VsrFetchProgressUncertainException.STATUS_CODE);
+        _lostOperation = uncertain.OperationId.ShouldNotBeNull();
+        _poll = null;
+    }
+
+    [When("the client connects again")]
+    public async Task ConnectAgain()
+    {
+        await _context.IggyClient.ConnectAsync();
+        await _context.IggyClient.LoginUserAsync(TestEnvironment.RootUsername, TestEnvironment.RootPassword);
+    }
+
+    [Then("a poll of partition {int} for consumer {string} with the next strategy and the on read commit policy should fail as blocked by the deferred poll")]
+    public async Task OnReadPollIsBlocked(int partition, string consumerName)
+    {
+        var error = await Record.ExceptionAsync(() => _context.IggyClient.PollMessagesAsync(StreamId(), TopicId(),
+            (uint)partition, Consumer.New(consumerName), PollingStrategy.Next(),
+            new PollOptions { Count = 1, Commit = CommitPolicy.OnRead }));
+        var blocked = error.ShouldBeOfType<VsrPartitionLaneBlockedException>();
+        blocked.StatusCode.ShouldBe(VsrPartitionLaneBlockedException.STATUS_CODE);
+        blocked.OperationId.ShouldBe(_lostOperation.ShouldNotBeNull());
+    }
+
+    [When("I abandon the operation of the deferred poll")]
+    public Task AbandonDeferredPoll()
+    {
+        return _context.IggyClient.AbandonOperationAsync(_lostOperation.ShouldNotBeNull());
+    }
+
+    [When("I send a message with payload {string} to partition {int}")]
+    public async Task SendToPartition(string payload, int partition)
+    {
+        await _context.IggyClient.SendMessagesAsync(StreamId(), TopicId(), Partitioning.PartitionId(partition),
+            [new Message(Guid.NewGuid(), Encoding.UTF8.GetBytes(payload))]);
+    }
+
+    [Then("the deferred poll should return the payload {string} within {int} second(s)")]
+    public async Task DeferredPollReturns(string payload, int withinSeconds)
+    {
+        var polled = await Within(_poll.ShouldNotBeNull(), TimeSpan.FromSeconds(withinSeconds), "deferred poll");
+        ShouldHaveSinglePayload(polled, payload);
+    }
+
+    [Then("the deferred poll should return no messages between {int} and {int} second(s) after it started")]
+    public async Task DeferredPollReturnsNothing(int fromSeconds, int toSeconds)
+    {
+        var started = _pollStarted.ShouldNotBeNull();
+        var polled = await Within(_poll.ShouldNotBeNull(), TimeSpan.FromSeconds(toSeconds) - started.Elapsed,
+            "deferred poll");
+
+        started.Elapsed.ShouldBeGreaterThanOrEqualTo(TimeSpan.FromSeconds(fromSeconds));
+        polled.Messages.ShouldBeEmpty();
+    }
+
+    [Then("a poll of partition {int} for consumer {string} with the next strategy and the on read commit policy should return the payload {string}")]
+    public async Task OnReadPollReturns(int partition, string consumerName, string payload)
+    {
+        var polled = await _context.IggyClient.PollMessagesAsync(StreamId(), TopicId(), (uint)partition,
+            Consumer.New(consumerName), PollingStrategy.Next(),
+            new PollOptions { Count = 1, Commit = CommitPolicy.OnRead });
+        ShouldHaveSinglePayload(polled, payload);
+    }
+
+    [Then("a poll of partition {int} for consumer {string} with the next strategy should return the payload {string}")]
+    public async Task NextPollReturns(int partition, string consumerName, string payload)
+    {
+        var polled = await _context.IggyClient.PollMessagesAsync(StreamId(), TopicId(), (uint)partition,
+            Consumer.New(consumerName), PollingStrategy.Next(), new PollOptions { Count = MessagesCount });
+        polled.Messages.Select(message => Encoding.UTF8.GetString(message.Payload)).FirstOrDefault()
+            .ShouldBe(payload);
+    }
+
+    [When("I cancel the deferred poll after {int} milliseconds")]
+    public async Task CancelDeferredPoll(int milliseconds)
+    {
+        var poll = _poll.ShouldNotBeNull();
+        await Task.Delay(milliseconds);
+        await _cancellation.CancelAsync();
+
+        // The server answers a cancelled poll that commits once: refused when the cancel won, so the poll ends as
+        // canceled and stores nothing.
+        var error = await Record.ExceptionAsync(() => poll);
+        error.ShouldBeAssignableTo<OperationCanceledException>(
+            $"the deferred poll must end as canceled, not with {error?.ToString() ?? "a result"}");
+    }
+
+    [When("I wait {int} second(s)")]
+    public Task Wait(int seconds)
+    {
+        return Task.Delay(TimeSpan.FromSeconds(seconds));
+    }
+
+    [When("I open a partition reader on partitions {int}, {int} and {int} from offset {int} with a max wait of {int} second(s)")]
+    public async Task OpenReader(int first, int second, int third, int offset, int maxWaitSeconds)
+    {
+        _reader = await _context.IggyClient.Reader(StreamId(), TopicId(), DefaultConsumer)
+            .WithPartitions((uint)first, (uint)second, (uint)third)
+            .WithStart(PollingStrategy.Offset((ulong)offset))
+            .WithOptions(Options(maxWaitSeconds))
+            .BuildAsync();
+    }
+
+    [Then("the partition reader should return the payload {string} from partition {int} within {int} second(s)")]
+    public async Task ReaderReturns(string payload, int partition, int withinSeconds)
+    {
+        var batch = await Within(_reader.ShouldNotBeNull().NextAsync().AsTask(),
+            TimeSpan.FromSeconds(withinSeconds), "partition reader");
+
+        batch.PartitionId.ShouldBe((uint)partition);
+        ShouldHaveSinglePayload(batch, payload);
+    }
+
+    [When("I open a consumer in group {string} on the topic with a max wait of {int} second(s)")]
+    public Task OpenGroupConsumer(string group, int maxWaitSeconds)
+    {
+        return OpenConsumerAsync(group, Options(maxWaitSeconds));
+    }
+
+    [When("I open a consumer in group {string} on the topic that commits after processing")]
+    public Task OpenCommittingGroupConsumer(string group)
+    {
+        return OpenConsumerAsync(group, new PollOptions
+        {
+            MaxWait = TimeSpan.FromSeconds(5),
+            Commit = CommitPolicy.Auto(new AutoCommit { Interval = TimeSpan.FromSeconds(5), After = CommitAfter.Processing })
+        });
+    }
+
+    [Then("the consumer should return the payload {string} within {int} second(s)")]
+    public async Task ConsumerReturns(string payload, int withinSeconds)
+    {
+        var messages = _messages.ShouldNotBeNull();
+        (await Within(messages.MoveNextAsync().AsTask(), TimeSpan.FromSeconds(withinSeconds), "consumer"))
+            .ShouldBeTrue();
+        Encoding.UTF8.GetString(messages.Current.Message.Payload).ShouldBe(payload);
+    }
+
+    [When("I close the consumer")]
+    public Task CloseConsumer()
+    {
+        return CloseConsumerAsync();
+    }
+
+    private async Task OpenConsumerAsync(string group, PollOptions options)
+    {
+        _consumer = new IggyConsumer(_context.IggyClient, new IggyConsumerConfig
+        {
+            StreamId = StreamId(),
+            TopicId = TopicId(),
+            Consumer = Consumer.Group(group),
+            PollingStrategy = PollingStrategy.Next(),
+            PollOptions = options
+        }, NullLoggerFactory.Instance);
+        await _consumer.InitAsync();
+        _messages = _consumer.ReceiveAsync().GetAsyncEnumerator();
+    }
+
+    private async Task CloseConsumerAsync()
+    {
+        if (_consumer is null)
+        {
+            return;
+        }
+
+        await _consumer.ShutdownAsync();
+        if (_messages is not null)
+        {
+            await _messages.DisposeAsync();
+        }
+
+        _consumer = null;
+        _messages = null;
+    }
+
+    private async Task StartDeferredPollAsync(int partition, Consumer consumer, PollingStrategy strategy,
+        PollOptions options)
+    {
+        _pollStarted = Stopwatch.StartNew();
+        _poll = _context.IggyClient.PollMessagesAsync(StreamId(), TopicId(), (uint)partition, consumer, strategy,
+            options, _cancellation.Token);
+
+        await Task.Delay(ParkTime);
+        _poll.IsCompleted.ShouldBeFalse("the deferred poll must park");
+    }
+
+    private static PollOptions Options(int maxWaitSeconds)
+    {
+        return new PollOptions { Count = MessagesCount, MaxWait = TimeSpan.FromSeconds(maxWaitSeconds) };
+    }
+
+    private static async Task<T> Within<T>(Task<T> call, TimeSpan limit, string name)
+    {
+        if (await Task.WhenAny(call, Task.Delay(limit)) != call)
+        {
+            Assert.Fail($"The {name} did not return within {limit}.");
+        }
+
+        return await call;
+    }
+
+    private static void ShouldHaveSinglePayload(PolledMessages polled, string payload)
+    {
+        Encoding.UTF8.GetString(polled.Messages.ShouldHaveSingleItem().Payload).ShouldBe(payload);
+    }
+
+    private Identifier StreamId()
+    {
+        return Identifier.Numeric(_stream.ShouldNotBeNull().Id);
+    }
+
+    private Identifier TopicId()
+    {
+        return Identifier.Numeric(_topic.ShouldNotBeNull().Id);
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/DeferredPollTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/DeferredPollTests.cs
new file mode 100644
index 0000000..42dd239
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/DeferredPollTests.cs
@@ -0,0 +1,196 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+using System.Diagnostics;
+using System.Text;
+using Apache.Iggy.Contracts;
+using Apache.Iggy.Enums;
+using Apache.Iggy.Extensions;
+using Apache.Iggy.IggyClient;
+using Apache.Iggy.Kinds;
+using Apache.Iggy.Messages;
+using Apache.Iggy.Tests.Integrations.Fixtures;
+using Shouldly;
+using Partitioning = Apache.Iggy.Kinds.Partitioning;
+
+namespace Apache.Iggy.Tests.Integrations;
+
+/// <summary>
+///     Deferred polls park on the server until messages arrive or their max wait ends. The
+///     tests send while a request waits, so a request that the server did not park, or did not wake, fails them.
+///     They run alone, because the load of the other tests on the shared server stretches a reply past the bounds.
+/// </summary>
+[NotInParallel]
+public class DeferredPollTests
+{
+    private const uint PartitionsCount = 3;
+    private const string TopicName = "deferred-topic";
+
+    /// <summary>Longer than any test waits, so only an arriving message ends a parked request in time.</summary>
+    private static readonly PollOptions LongWait = new() { Count = 10, MaxWait = TimeSpan.FromSeconds(10) };
+
+    private static readonly PollOptions ShortWait = new() { Count = 10, MaxWait = TimeSpan.FromMilliseconds(300) };
+
+    /// <summary>How long a request parks before the test sends the message that wakes it.</summary>
+    private static readonly TimeSpan ParkDelay = TimeSpan.FromMilliseconds(500);
+
+    /// <summary>A woken request replies well inside this, and a request that stays parked takes its max wait.</summary>
+    private static readonly TimeSpan PromptReply = TimeSpan.FromSeconds(2);
+
+    [ClassDataSource<IggyServerFixture>(Shared = SharedType.PerAssembly)]
+    public required IggyServerFixture Fixture { get; init; }
+
+    [Test]
+    [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
+    public async Task DeferredPoll_Should_Return_AMessageThatArrivesWhileItWaits(Protocol protocol)
+    {
+        var (client, streamName) = await CreateTopicAsync(protocol);
+        var polling = PollAsync(client, streamName, LongWait, CancellationToken.None);
+        await Task.Delay(ParkDelay);
+        polling.IsCompleted.ShouldBeFalse();
+
+        var sent = Stopwatch.StartNew();
+        await SendAsync(client, streamName, 0);
+        var polled = await polling;
+
+        sent.Elapsed.ShouldBeLessThan(PromptReply);
+        polled.Messages.Count.ShouldBe(1);
+    }
+
+    [Test]
+    [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
+    public async Task DeferredPoll_Without_Messages_Should_ReturnEmpty_After_MaxWait(Protocol protocol)
+    {
+        var (client, streamName) = await CreateTopicAsync(protocol);
+        var started = Stopwatch.StartNew();
+
+        var polled = await PollAsync(client, streamName, ShortWait, CancellationToken.None);
+
+        started.Elapsed.ShouldBeGreaterThanOrEqualTo(ShortWait.MaxWait);
+        polled.Messages.ShouldBeEmpty();
+    }
+
+    /// <summary>
+    ///     The server holds a bounded number of parked requests per session. Canceled polls free their places at
+    ///     once, so a later poll without messages parks and ends after its own max wait. A server that still holds
+    ///     the canceled polls refuses it until they time out. A message would hide that, because the server answers
+    ///     a poll that finds messages without a place.
+    /// </summary>
+    [Test]
+    public async Task DeferredPoll_After_ParkedPollsAreCanceled_Should_Park_For_ItsOwnMaxWait()
+    {
+        var (client, streamName) = await CreateTopicAsync(Protocol.Tcp);
+        using var canceled = new CancellationTokenSource();
+        var parked = Enumerable.Range(0, DeferredPollOptions.MAX_PARKED_REQUESTS)
+            .Select(_ => PollAsync(client, streamName, LongWait, canceled.Token))
+            .ToArray();
+        await Task.Delay(ParkDelay);
+        await canceled.CancelAsync();
+        foreach (var poll in parked)
+        {
+            await Should.ThrowAsync<OperationCanceledException>(poll);
+        }
+
+        var started = Stopwatch.StartNew();
+        var polled = await PollAsync(client, streamName, ShortWait, CancellationToken.None);
+
+        started.Elapsed.ShouldBeInRange(ShortWait.MaxWait, PromptReply);
+        polled.Messages.ShouldBeEmpty();
+    }
+
+    /// <summary>
+    ///     On a cluster the three partitions have primaries on different nodes. The reader groups them by primary by
+    ///     itself, and a message of each partition reaches it while its reads wait.
+    /// </summary>
+    [Test]
+    [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
+    public async Task PartitionReader_Should_Return_EveryPartition_ThatGetsAMessage(Protocol protocol)
+    {
+        var (client, streamName) = await CreateTopicAsync(protocol);
+        await using var reader = await client.Reader(Identifier.String(streamName), Identifier.String(TopicName),
+                Consumer.New(1))
+            .WithPartitions(0, 1, 2)
+            .WithStart(PollingStrategy.Offset(0))
+            .WithOptions(LongWait)
+            .BuildAsync();
+        using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
+
+        HashSet<uint> served = [];
+        for (var partition = 2u; partition < PartitionsCount + 2; partition++)
+        {
+            await SendAsync(client, streamName, partition % PartitionsCount);
+            var batch = await reader.NextAsync(timeout.Token);
+            served.Add(batch.PartitionId);
+        }
+
+        served.ShouldBe([0u, 1u, 2u], ignoreOrder: true);
+    }
+
+    /// <summary>
+    ///     A reader that commits on read stores the offset of each read, and its close moves the offset back to the
+    ///     last batch that the application took, so a new reader resumes after it.
+    /// </summary>
+    [Test]
+    [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
+    public async Task OnReadReader_Should_Resume_AfterTheLastBatchTaken(Protocol protocol)
+    {
+        var (client, streamName) = await CreateTopicAsync(protocol);
+        var stream = Identifier.String(streamName);
+        var topic = Identifier.String(TopicName);
+        var options = new PollOptions { Count = 1, MaxWait = TimeSpan.FromSeconds(10), Commit = CommitPolicy.OnRead };
+        for (var message = 0; message < 3; message++)
+        {
+            await SendAsync(client, streamName, 0);
+        }
+
+        var reader = await client.Reader(stream, topic, Consumer.New(7)).WithPartitions(0).WithOptions(options)
+            .BuildAsync();
+        var first = await reader.NextAsync();
+        await reader.CloseAsync();
+        await using var resumed = await client.Reader(stream, topic, Consumer.New(7)).WithPartitions(0)
+            .WithOptions(options).BuildAsync();
+        var second = await resumed.NextAsync();
+
+        first.Messages.Single().Header.Offset.ShouldBe(0ul);
+        second.Messages.Single().Header.Offset.ShouldBe(1ul);
+    }
+
+    private async Task<(IIggyClient Client, string StreamName)> CreateTopicAsync(Protocol protocol)
+    {
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
+        var streamName = $"deferred-{Guid.NewGuid():N}";
+
+        await client.CreateStreamAsync(streamName);
+        await client.CreateTopicAsync(Identifier.String(streamName), TopicName, PartitionsCount);
+
+        return (client, streamName);
+    }
+
+    private static Task SendAsync(IIggyClient client, string streamName, uint partitionId)
+    {
+        return client.SendMessagesAsync(Identifier.String(streamName), Identifier.String(TopicName),
+            Partitioning.PartitionId(partitionId),
+            new Message(Guid.NewGuid(), Encoding.UTF8.GetBytes($"deferred-payload-{partitionId}")));
+    }
+
+    private static Task<PolledMessages> PollAsync(IIggyClient client, string streamName, PollOptions options,
+        CancellationToken token)
+    {
+        return client.PollMessagesAsync(Identifier.String(streamName), Identifier.String(TopicName), 0,
+            Consumer.New(1), PollingStrategy.Offset(0), options, token);
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/HeaderEncryptionIntegrationTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/HeaderEncryptionIntegrationTests.cs
index 9de9665..36e5136 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/HeaderEncryptionIntegrationTests.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/HeaderEncryptionIntegrationTests.cs
@@ -43,7 +43,7 @@
     {
         var encryptor = CreateEncryptor();
 
-        // Publisher on an encrypting client; a plain client raw-polls the same topic to prove the wire bytes
+        // Publisher on an encrypting client. A plain client raw-polls the same topic to prove the wire bytes
         // stay encrypted.
         var encryptingClient = await Fixture.CreateAuthenticatedClient(protocol, encryptor: encryptor);
         var plainClient = await Fixture.CreateAuthenticatedClient(protocol);
@@ -131,9 +131,7 @@
         var consumer = IggyConsumerBuilder
             .Create(client, streamId, topicId, Consumer.New(1))
             .WithPollingStrategy(PollingStrategy.Next())
-            .WithBatchSize(1)
-            .WithPartitionId(0)
-            .WithAutoCommitMode(AutoCommitMode.Disabled)
+            .WithPartitions(0)
             .Build();
 
         await consumer.InitAsync();
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/IggyConsumerTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/IggyConsumerTests.cs
index 75a76c5..65651dc 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/IggyConsumerTests.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/IggyConsumerTests.cs
@@ -15,8 +15,10 @@
 // specific language governing permissions and limitations
 // under the License.
 
+using System.Diagnostics;
 using System.Text;
 using Apache.Iggy.Consumers;
+using Apache.Iggy.Contracts;
 using Apache.Iggy.Enums;
 using Apache.Iggy.Exceptions;
 using Apache.Iggy.IggyClient;
@@ -48,8 +50,7 @@
                 Identifier.String(testStream.TopicId),
                 Consumer.New(1))
             .WithPollingStrategy(PollingStrategy.Next())
-            .WithBatchSize(10)
-            .WithPartitionId(0)
+            .WithPartitions(0)
             .Build();
 
         await Should.NotThrowAsync(() => consumer.InitAsync());
@@ -71,7 +72,6 @@
                 Identifier.String(testStream.TopicId),
                 Consumer.Group("test-group-init"))
             .WithPollingStrategy(PollingStrategy.Next())
-            .WithBatchSize(10)
             .WithConsumerGroup("test-group-init")
             .Build();
 
@@ -96,7 +96,7 @@
                 Consumer.New(2))
             .WithConnection(protocol, clientAddress, "iggy", "iggy")
             .WithPollingStrategy(PollingStrategy.Next())
-            .WithBatchSize(10)
+            .WithPartitions(1)
             .WithConsumerGroup("test-group-init")
             .Build();
 
@@ -118,8 +118,7 @@
                 Identifier.String(testStream.TopicId),
                 Consumer.New(2))
             .WithPollingStrategy(PollingStrategy.Next())
-            .WithBatchSize(10)
-            .WithPartitionId(1)
+            .WithPartitions(1)
             .Build();
 
         await consumer.InitAsync();
@@ -141,8 +140,7 @@
                 Identifier.String(testStream.TopicId),
                 Consumer.New(3))
             .WithPollingStrategy(PollingStrategy.Next())
-            .WithBatchSize(10)
-            .WithPartitionId(1)
+            .WithPartitions(1)
             .Build();
 
         await Should.ThrowAsync<ConsumerNotInitializedException>(async () =>
@@ -170,7 +168,6 @@
                 Identifier.String(testStream.TopicId),
                 Consumer.Group(groupName))
             .WithPollingStrategy(PollingStrategy.Next())
-            .WithBatchSize(10)
             .WithConsumerGroup(groupName)
             .Build();
 
@@ -203,7 +200,6 @@
                 Identifier.String(testStream.TopicId),
                 Consumer.Group(groupName))
             .WithPollingStrategy(PollingStrategy.Next())
-            .WithBatchSize(10)
             .WithConsumerGroup(groupName, false)
             .Build();
 
@@ -232,7 +228,6 @@
                 Identifier.String(testStream.TopicId),
                 Consumer.Group(groupName))
             .WithPollingStrategy(PollingStrategy.Next())
-            .WithBatchSize(10)
             .WithConsumerGroup(groupName, false)
             .Build();
 
@@ -257,7 +252,6 @@
                 Identifier.String(testStream.TopicId),
                 Consumer.Group(groupName))
             .WithPollingStrategy(PollingStrategy.Next())
-            .WithBatchSize(10)
             .WithConsumerGroup(groupName)
             .Build();
 
@@ -286,9 +280,7 @@
                 Identifier.String(testStream.TopicId),
                 Consumer.New(10))
             .WithPollingStrategy(PollingStrategy.Next())
-            .WithBatchSize(10)
-            .WithPartitionId(1)
-            .WithAutoCommitMode(AutoCommitMode.Disabled)
+            .WithPartitions(1)
             .Build();
 
         await consumer.InitAsync();
@@ -330,9 +322,8 @@
                 Identifier.String(testStream.TopicId),
                 Consumer.New(11))
             .WithPollingStrategy(PollingStrategy.Next())
-            .WithBatchSize(batchSize)
-            .WithPartitionId(1)
-            .WithAutoCommitMode(AutoCommitMode.Disabled)
+            .WithPollOptions(new PollOptions { Count = batchSize, MaxWait = TimeSpan.FromSeconds(1) })
+            .WithPartitions(1)
             .Build();
 
         await consumer.InitAsync();
@@ -354,43 +345,51 @@
         await consumer.DisposeAsync();
     }
 
+    /// <summary>
+    ///     The poll after the backlog waits on the server, so a message sent after an idle period wakes it long
+    ///     before its max wait ends, and no sleep between polls delays it. It runs alone, because the load of the other
+    ///     tests stretches the delivery past the bound.
+    /// </summary>
     [Test]
+    [NotInParallel]
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
-    public async Task ReceiveAsync_WithPollingInterval_Should_RespectInterval(Protocol protocol)
+    public async Task ReceiveAsync_After_AnIdlePeriod_Should_Deliver_ANewMessage_Promptly(Protocol protocol)
     {
+        var idlePeriod = TimeSpan.FromSeconds(1);
+        var promptDelivery = TimeSpan.FromSeconds(2);
         var client = await Fixture.CreateAuthenticatedClient(protocol);
 
-        var testStream = await CreateTestStreamWithMessages(client, protocol);
+        var testStream = await CreateTestStreamWithMessages(client, protocol, 1, 1);
 
-        var pollingInterval = TimeSpan.FromMilliseconds(100);
         var consumer = IggyConsumerBuilder
             .Create(client,
                 Identifier.String(testStream.StreamId),
                 Identifier.String(testStream.TopicId),
                 Consumer.New(12))
             .WithPollingStrategy(PollingStrategy.Next())
-            .WithBatchSize(5)
-            .WithPartitionId(1)
-            .WithPollingInterval(pollingInterval)
-            .WithAutoCommitMode(AutoCommitMode.Disabled)
+            .WithPartitions(0)
+            .WithPollOptions(new PollOptions { MaxWait = TimeSpan.FromSeconds(10) })
             .Build();
 
         await consumer.InitAsync();
 
-        var receivedCount = 0;
-        var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
+        using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
+        await using var messages = consumer.ReceiveAsync(cts.Token).GetAsyncEnumerator(cts.Token);
+        (await messages.MoveNextAsync()).ShouldBeTrue();
+        messages.Current.Message.Header.Offset.ShouldBe(0ul);
 
-        await foreach (var message in consumer.ReceiveAsync(cts.Token))
-        {
-            message.ShouldNotBeNull();
-            receivedCount++;
-            if (receivedCount >= 3)
-            {
-                break;
-            }
-        }
+        var next = messages.MoveNextAsync().AsTask();
+        await Task.Delay(idlePeriod);
+        next.IsCompleted.ShouldBeFalse();
+        var sent = Stopwatch.StartNew();
+        await client.SendMessagesAsync(Identifier.String(testStream.StreamId),
+            Identifier.String(testStream.TopicId),
+            Partitioning.PartitionId(0),
+            new Message(Guid.NewGuid(), "after the idle period"u8.ToArray()));
 
-        receivedCount.ShouldBeGreaterThanOrEqualTo(3);
+        (await next).ShouldBeTrue();
+        sent.Elapsed.ShouldBeLessThan(promptDelivery);
+        messages.Current.Message.Header.Offset.ShouldBe(1ul);
         await consumer.DisposeAsync();
     }
 
@@ -409,9 +408,12 @@
                 Identifier.String(testStream.TopicId),
                 Consumer.New(consumerId))
             .WithPollingStrategy(PollingStrategy.First())
-            .WithBatchSize(10)
-            .WithPartitionId(1)
-            .WithAutoCommitMode(AutoCommitMode.AfterReceive)
+            .WithPartitions(1)
+            .WithPollOptions(new PollOptions
+            {
+                MaxWait = TimeSpan.FromSeconds(1),
+                Commit = CommitPolicy.Auto(new AutoCommit { Every = CommitEvery.Message })
+            })
             .Build();
 
         await consumer.InitAsync();
@@ -440,8 +442,8 @@
         offset.ShouldNotBeNull();
         receivedCount.ShouldBe(5);
         lastOffset.ShouldBe(4ul);
-        // The loop broke inside the body of offset 4, so that message is delivered again.
-        offset.StoredOffset.ShouldBe(3ul);
+        // Shutdown counts the last message handed over.
+        offset.StoredOffset.ShouldBe(4ul);
     }
 
     [Test]
@@ -459,9 +461,8 @@
                 Identifier.String(testStream.TopicId),
                 Consumer.New(consumerId))
             .WithPollingStrategy(PollingStrategy.First())
-            .WithBatchSize(5)
-            .WithPartitionId(1)
-            .WithAutoCommitMode(AutoCommitMode.Auto)
+            .WithPartitions(1)
+            .WithPollOptions(new PollOptions { MaxWait = TimeSpan.FromSeconds(1), Commit = CommitPolicy.OnRead })
             .Build();
 
         await consumer.InitAsync();
@@ -493,7 +494,7 @@
 
     [Test]
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
-    public async Task StoreOffsetAsync_Should_StoreOffset_Successfully(Protocol protocol)
+    public async Task CommitAsync_Should_StoreTheOffsetOfAReceivedMessage(Protocol protocol)
     {
         var client = await Fixture.CreateAuthenticatedClient(protocol);
 
@@ -505,16 +506,20 @@
                 Identifier.String(testStream.StreamId),
                 Identifier.String(testStream.TopicId),
                 Consumer.New(consumerId))
-            .WithPollingStrategy(PollingStrategy.Next())
-            .WithBatchSize(5)
-            .WithPartitionId(1)
-            .WithAutoCommitMode(AutoCommitMode.Disabled)
+            .WithPollingStrategy(PollingStrategy.First())
+            .WithPartitions(1)
             .Build();
 
         await consumer.InitAsync();
+        var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
+        ReceivedMessage? received = null;
+        await foreach (var message in consumer.ReceiveAsync(cts.Token))
+        {
+            received = message;
+            break;
+        }
 
-        var testOffset = 42ul;
-        await Should.NotThrowAsync(() => consumer.StoreOffsetAsync(testOffset, 1));
+        await consumer.CommitAsync(received!.Checkpoint!);
 
         var offset = await client.GetOffsetAsync(Consumer.New(consumerId),
             Identifier.String(testStream.StreamId),
@@ -522,51 +527,7 @@
             1u);
 
         offset.ShouldNotBeNull();
-        offset.StoredOffset.ShouldBe(testOffset);
-
-        await consumer.DisposeAsync();
-    }
-
-    [Test]
-    [SkipHttp]
-    [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
-    public async Task DeleteOffsetAsync_Should_DeleteOffset_Successfully(Protocol protocol)
-    {
-        var client = await Fixture.CreateAuthenticatedClient(protocol);
-
-        var testStream = await CreateTestStreamWithMessages(client, protocol);
-
-        var consumerId = protocol == Protocol.Tcp ? 31 : 131;
-        var consumer = IggyConsumerBuilder
-            .Create(client,
-                Identifier.String(testStream.StreamId),
-                Identifier.String(testStream.TopicId),
-                Consumer.New(consumerId))
-            .WithPollingStrategy(PollingStrategy.Next())
-            .WithBatchSize(5)
-            .WithPartitionId(1)
-            .WithAutoCommitMode(AutoCommitMode.Disabled)
-            .Build();
-
-        await consumer.InitAsync();
-
-        var testOffset = 50ul;
-        await consumer.StoreOffsetAsync(testOffset, 1);
-
-        var offset = await client.GetOffsetAsync(Consumer.New(consumerId),
-            Identifier.String(testStream.StreamId),
-            Identifier.String(testStream.TopicId),
-            1u);
-        offset.ShouldNotBeNull();
-
-        await Should.NotThrowAsync(() => consumer.DeleteOffsetAsync(1));
-
-        var deletedOffset = await client.GetOffsetAsync(Consumer.New(consumerId),
-            Identifier.String(testStream.StreamId),
-            Identifier.String(testStream.TopicId),
-            1u);
-
-        deletedOffset.ShouldBeNull();
+        offset.StoredOffset.ShouldBe(received.CurrentOffset);
 
         await consumer.DisposeAsync();
     }
@@ -585,8 +546,7 @@
                 Identifier.String(testStream.TopicId),
                 Consumer.New(40))
             .WithPollingStrategy(PollingStrategy.Next())
-            .WithBatchSize(10)
-            .WithPartitionId(1)
+            .WithPartitions(1)
             .Build();
 
         await consumer.InitAsync();
@@ -607,8 +567,7 @@
                 Identifier.String(testStream.TopicId),
                 Consumer.New(41))
             .WithPollingStrategy(PollingStrategy.Next())
-            .WithBatchSize(10)
-            .WithPartitionId(1)
+            .WithPartitions(1)
             .Build();
 
         await consumer.InitAsync();
@@ -630,14 +589,18 @@
                 Identifier.String(testStream.TopicId),
                 Consumer.New(42))
             .WithPollingStrategy(PollingStrategy.Next())
-            .WithBatchSize(10)
-            .WithPartitionId(1)
+            .WithPartitions(1)
             .Build();
 
         await Should.NotThrowAsync(async () => await consumer.DisposeAsync());
     }
 
+    /// <summary>
+    ///     Over TCP the routing of a missing partition fails at once. Over HTTP the poll of a missing partition is a
+    ///     lasting refusal, which the reader retries with a growing wait and reports only after six in a row.
+    /// </summary>
     [Test]
+    [SkipHttp]
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task OnPollingError_Should_Fire_WhenPollingFails(Protocol protocol)
     {
@@ -654,9 +617,7 @@
                 Identifier.String(testStream.TopicId),
                 Consumer.New(50))
             .WithPollingStrategy(PollingStrategy.Next())
-            .WithBatchSize(10)
-            .WithPartitionId(999)
-            .WithAutoCommitMode(AutoCommitMode.Disabled)
+            .WithPartitions(999)
             .SubscribeOnPollingError(error =>
             {
                 errorFired = true;
@@ -667,7 +628,7 @@
 
         await consumer.InitAsync();
 
-        var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1));
+        var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
 
         try
         {
@@ -701,9 +662,7 @@
                 Identifier.String(testStream.TopicId),
                 Consumer.New(60))
             .WithPollingStrategy(PollingStrategy.Offset(startOffset))
-            .WithBatchSize(5)
-            .WithPartitionId(1)
-            .WithAutoCommitMode(AutoCommitMode.Disabled)
+            .WithPartitions(1)
             .Build();
 
         await consumer.InitAsync();
@@ -737,9 +696,7 @@
                 Identifier.String(testStream.TopicId),
                 Consumer.New(61))
             .WithPollingStrategy(PollingStrategy.First())
-            .WithBatchSize(1)
-            .WithPartitionId(1)
-            .WithAutoCommitMode(AutoCommitMode.Disabled)
+            .WithPartitions(1)
             .Build();
 
         await consumer.InitAsync();
@@ -769,9 +726,8 @@
                 Identifier.String(testStream.TopicId),
                 Consumer.New(62))
             .WithPollingStrategy(PollingStrategy.Last())
-            .WithBatchSize(1)
-            .WithPartitionId(1)
-            .WithAutoCommitMode(AutoCommitMode.Disabled)
+            .WithPartitions(1)
+            .WithPollOptions(new PollOptions { Count = 10, MaxWait = TimeSpan.FromSeconds(1) })
             .Build();
 
         await consumer.InitAsync();
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/IggyTypedConsumerTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/IggyTypedConsumerTests.cs
index f8d7c2f..e0ccf8f 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/IggyTypedConsumerTests.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/IggyTypedConsumerTests.cs
@@ -17,6 +17,7 @@
 
 using System.Text;
 using Apache.Iggy.Consumers;
+using Apache.Iggy.Contracts;
 using Apache.Iggy.Enums;
 using Apache.Iggy.Exceptions;
 using Apache.Iggy.IggyClient;
@@ -43,8 +44,7 @@
         var testStream = await CreateTestStreamWithMessages(client, protocol);
 
         var consumerId = protocol == Protocol.Tcp ? 200 : 300;
-        IggyConsumer<string> consumer = BuildTypedConsumer(client, testStream, Consumer.New(consumerId),
-            AutoCommitMode.Disabled, new Utf8StringDeserializer());
+        IggyConsumer<string> consumer = BuildTypedConsumer(client, testStream, Consumer.New(consumerId), new Utf8StringDeserializer());
 
         await consumer.InitAsync();
 
@@ -80,8 +80,7 @@
         var testStream = await CreateTestStreamWithMessages(client, protocol);
 
         var consumerId = protocol == Protocol.Tcp ? 201 : 301;
-        IggyConsumer<string> consumer = BuildTypedConsumer(client, testStream, Consumer.New(consumerId),
-            AutoCommitMode.Disabled, new Utf8StringDeserializer());
+        IggyConsumer<string> consumer = BuildTypedConsumer(client, testStream, Consumer.New(consumerId), new Utf8StringDeserializer());
 
         await Should.ThrowAsync<ConsumerNotInitializedException>(async () =>
         {
@@ -103,8 +102,8 @@
 
         var consumerId = protocol == Protocol.Tcp ? 202 : 302;
         IggyConsumer<string> consumer = BuildTypedConsumer(client, testStream, Consumer.New(consumerId),
-            AutoCommitMode.AfterReceive, new Utf8StringDeserializer(),
-            PollingStrategy.First());
+            new Utf8StringDeserializer(), PollingStrategy.First(),
+            CommitPolicy.Auto(new AutoCommit { Every = CommitEvery.Message }));
 
         await consumer.InitAsync();
 
@@ -129,8 +128,8 @@
 
         offset.ShouldNotBeNull();
         count.ShouldBe(5);
-        // The loop broke inside the body of offset 4, so that message is delivered again.
-        offset.StoredOffset.ShouldBe(3ul);
+        // Shutdown counts the last message handed over.
+        offset.StoredOffset.ShouldBe(4ul);
     }
 
     [Test]
@@ -144,7 +143,7 @@
 
         var consumerId = protocol == Protocol.Tcp ? 203 : 303;
         IggyConsumer<string> consumer = BuildTypedConsumer(client, testStream, Consumer.New(consumerId),
-            AutoCommitMode.Disabled, new FailingDeserializer());
+            new FailingDeserializer());
 
         await consumer.InitAsync();
 
@@ -171,8 +170,7 @@
         var testStream = await CreateTestStreamWithMessages(client, protocol);
 
         var consumerId = protocol == Protocol.Tcp ? 204 : 304;
-        await using IggyConsumer<string> consumer = BuildTypedConsumer(client, testStream, Consumer.New(consumerId),
-            AutoCommitMode.Disabled, new Utf8StringDeserializer());
+        await using IggyConsumer<string> consumer = BuildTypedConsumer(client, testStream, Consumer.New(consumerId), new Utf8StringDeserializer());
 
         await consumer.InitAsync();
 
@@ -195,9 +193,9 @@
     private static IggyConsumer<string> BuildTypedConsumer(IIggyClient client,
         TestStreamInfo stream,
         Consumer consumer,
-        AutoCommitMode autoCommitMode,
         IDeserializer<string> deserializer,
-        PollingStrategy? pollingStrategy = null)
+        PollingStrategy? pollingStrategy = null,
+        CommitPolicy? commit = null)
     {
         var config = new IggyConsumerConfig<string>
         {
@@ -205,11 +203,13 @@
             TopicId = Identifier.String(stream.TopicId),
             Consumer = consumer,
             PollingStrategy = pollingStrategy ?? PollingStrategy.Next(),
-            BatchSize = 10,
-            PartitionId = 1,
-            AutoCommitMode = autoCommitMode,
-            AutoCommit = autoCommitMode != AutoCommitMode.Disabled,
-            PollingIntervalMs = 0,
+            Partitions = [1],
+            PollOptions = new PollOptions
+            {
+                Count = 10,
+                MaxWait = TimeSpan.FromSeconds(1),
+                Commit = commit ?? CommitPolicy.Manual
+            },
             Deserializer = deserializer
         };
         return new IggyConsumer<string>(client, config, NullLoggerFactory.Instance);
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/MessageEncryptionIntegrationTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/MessageEncryptionIntegrationTests.cs
index 96426ee..1a371ae 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/MessageEncryptionIntegrationTests.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/MessageEncryptionIntegrationTests.cs
@@ -15,17 +15,21 @@
 // specific language governing permissions and limitations
 // under the License.
 
+using System.Buffers.Binary;
 using System.Text;
 using Apache.Iggy.Consumers;
+using Apache.Iggy.Contracts;
 using Apache.Iggy.Encryption;
 using Apache.Iggy.Enums;
 using Apache.Iggy.Exceptions;
+using Apache.Iggy.Extensions;
 using Apache.Iggy.Headers;
 using Apache.Iggy.IggyClient;
 using Apache.Iggy.Kinds;
 using Apache.Iggy.Messages;
 using Apache.Iggy.Publishers;
 using Apache.Iggy.Tests.Integrations.Fixtures;
+using Apache.Iggy.Vsr;
 using Shouldly;
 using Partitioning = Apache.Iggy.Kinds.Partitioning;
 
@@ -151,26 +155,64 @@
         var consumer = IggyConsumerBuilder
             .Create(wrongKeyClient, streamId, topicId, Consumer.New(3))
             .WithPollingStrategy(PollingStrategy.First())
-            .WithBatchSize(10)
-            .WithPartitionId(0)
-            .WithAutoCommitMode(AutoCommitMode.Disabled)
+            .WithPartitions(0)
             .Build();
 
         await consumer.InitAsync();
 
-        // A bad key is not transient: it must surface out of the stream, not loop re-polling the same offset.
+        // A bad key is not transient: it must surface out of the stream with the continuation that skips it.
         var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
-        await Should.ThrowAsync<MessageDecryptionException>(async () =>
+        var error = await Should.ThrowAsync<ReadException>(async () =>
         {
             await foreach (var _ in consumer.ReceiveAsync(cts.Token))
             {
                 break;
             }
         });
+        error.InnerException.ShouldBeOfType<MessageDecryptionException>();
+        error.Skip.ShouldNotBeNull();
 
         await consumer.DisposeAsync();
     }
 
+    [Test]
+    public async Task Reader_WithWrongKey_Should_ReportTheRecordAsServed()
+    {
+        var publisherClient = await CreateClient(Protocol.Tcp, CreateEncryptor());
+        var (streamId, topicId) = await CreateTestStream(publisherClient, Protocol.Tcp);
+        await SendBatch(publisherClient, streamId, topicId, true);
+
+        var wrongKeyClient = await CreateClient(Protocol.Tcp, CreateEncryptor());
+        await using var reader = await wrongKeyClient.Reader(streamId, topicId, Consumer.New(4))
+            .WithPartitions(0)
+            .WithStart(PollingStrategy.First())
+            .BuildAsync();
+        using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
+
+        var error = await Should.ThrowAsync<ReadException>(async () => await reader.NextAsync(cts.Token));
+        await reader.SeekAsync(0, error.Skip!.Value, cts.Token);
+        var next = await Should.ThrowAsync<ReadException>(async () => await reader.NextAsync(cts.Token));
+
+        error.InnerException.ShouldBeOfType<MessageDecryptionException>();
+        error.Offset.ShouldBe(0ul);
+        error.Record.ShouldBe(await FrameAsServed(wrongKeyClient, streamId, topicId, 0));
+        next.Offset.ShouldBe(1ul);
+        next.Record.ShouldBe(await FrameAsServed(wrongKeyClient, streamId, topicId, 1));
+    }
+
+    /// <summary>The ciphertext frame of the message at <paramref name="offset" />, as a fetch serves it.</summary>
+    private static async Task<byte[]> FrameAsServed(IIggyClient client, Identifier streamId, Identifier topicId,
+        ulong offset)
+    {
+        using var fetched = await ((IPartitionFetchClient)client).FetchMessagesAsync(streamId, topicId,
+            [(0u, PollingStrategy.Offset(offset))], Consumer.New(5), 1, false, new PollOptions().Wire);
+        var records = fetched.Sections.Single().Records.Span;
+        var frame = records[BatchWireFormat.BATCH_HEADER_SIZE..];
+        var headersLength = BinaryPrimitives.ReadInt32LittleEndian(frame[32..]);
+        var payloadLength = BinaryPrimitives.ReadInt32LittleEndian(frame[36..]);
+        return frame[..(BatchWireFormat.FRAME_HEADER_SIZE + payloadLength + headersLength)].ToArray();
+    }
+
     private async Task SendBatch(IIggyClient client, Identifier streamId, Identifier topicId, bool withHeaders)
     {
         var publisher = IggyPublisherBuilder
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/Vsr/VsrConsumerGroupTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/Vsr/VsrConsumerGroupTests.cs
index 3f22561..60229c4 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/Vsr/VsrConsumerGroupTests.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/Vsr/VsrConsumerGroupTests.cs
@@ -15,8 +15,12 @@
 // specific language governing permissions and limitations
 // under the License.
 
+using System.Diagnostics;
 using System.Text;
+using Apache.Iggy.Consumers;
 using Apache.Iggy.Contracts;
+using Apache.Iggy.Extensions;
+using Apache.Iggy.IggyClient.Implementations;
 using Apache.Iggy.Enums;
 using Apache.Iggy.Exceptions;
 using Apache.Iggy.IggyClient;
@@ -52,7 +56,7 @@
             await SendAsync(client, streamName, partitionId);
         }
 
-        // One poll per assigned partition drains it; the sole member owns them all, so the round-robin has to
+        // One poll per assigned partition drains it. The sole member owns them all, so the round-robin has to
         // hand out every partition exactly once before it wraps.
         var polled = await DrainGroupAsync(client, streamName, groupName, PartitionsCount);
 
@@ -163,13 +167,94 @@
         }
 
         // The first member still holds the pre-rebalance assignment, so its polls fence until they re-sync.
-        // Between them the two members have to see every partition; a lost one means a fence was swallowed.
+        // Between them the two members have to see every partition. A lost one means a fence was swallowed.
         var drained = await DrainGroupAsync(first, streamName, groupName, PartitionsCount);
         drained += await DrainGroupAsync(second, streamName, groupName, PartitionsCount);
 
         drained.ShouldBe((int)PartitionsCount);
     }
 
+    /// <summary>
+    ///     A group reader waits on every assigned partition, one read per primary, and returns the one that holds
+    ///     messages. The partitions get messages in descending order, so a reader that waited on one partition alone
+    ///     would park in at least one round. It runs alone, because the load of the other tests stretches a reply past
+    ///     the bound.
+    /// </summary>
+    [Test]
+    [NotInParallel]
+    public async Task GroupReader_Should_Read_ThePartitionThatHoldsMessages()
+    {
+        var promptReply = TimeSpan.FromSeconds(2);
+        var (client, streamName, groupName) = await CreateGroup();
+        await using var reader = await client.Reader(Identifier.String(streamName), Identifier.String(TopicName),
+                Consumer.Group(groupName))
+            .WithOptions(new PollOptions { Count = 10, MaxWait = TimeSpan.FromSeconds(10) })
+            .BuildAsync();
+        await WaitForAssignmentAsync(reader, (int)PartitionsCount);
+
+        for (var partition = (int)PartitionsCount - 1; partition >= 0; partition--)
+        {
+            await SendAsync(client, streamName, (uint)partition);
+            var started = Stopwatch.StartNew();
+            var batch = await reader.NextAsync();
+
+            started.Elapsed.ShouldBeLessThan(promptReply);
+            batch.PartitionId.ShouldBe((uint)partition);
+            batch.Messages.Count.ShouldBe(1);
+        }
+    }
+
+    /// <summary>
+    ///     A partition with a backlog does not hide a partition of the same primary that holds a message too: the
+    ///     reader gives the partition that the backlog left out a fetch of its own beside the next read of the
+    ///     backlog. One partition more than nodes puts two partitions on one primary.
+    /// </summary>
+    [Test]
+    public async Task GroupReader_With_ABacklog_Should_Serve_AnotherPartitionOfItsPrimary()
+    {
+        const int BacklogMessages = 20;
+        var client = await Fixture.CreateAuthenticatedClient(Protocol.Tcp);
+        var metadata = (await client.GetClusterMetadataAsync()).ShouldNotBeNull();
+        var partitionsCount = (uint)metadata.Nodes.Length + 1;
+        var (streamName, groupName) = await CreateGroup(client, partitionsCount);
+        var assigned = Enumerable.Range(0, (int)partitionsCount).Select(partitionId => (uint)partitionId).ToList();
+        var shared = (await ((TcpMessageStream)client).ReadinessGroupsAsync(Identifier.String(streamName),
+            Identifier.String(TopicName), assigned)).FirstOrDefault(group => group.Count > 1).ShouldNotBeNull();
+        for (var message = 0; message < BacklogMessages; message++)
+        {
+            await SendAsync(client, streamName, shared[0]);
+        }
+
+        await SendAsync(client, streamName, shared[^1]);
+        await using var reader = await client.Reader(Identifier.String(streamName), Identifier.String(TopicName),
+                Consumer.Group(groupName))
+            .WithStart(PollingStrategy.Offset(0))
+            .WithOptions(new PollOptions { Count = 10, MaxWait = TimeSpan.FromSeconds(10) })
+            .BuildAsync();
+        await WaitForAssignmentAsync(reader, (int)partitionsCount);
+
+        // A fetch from an offset fills its count from the backlog in request order, so the first reply may hold the
+        // backlog alone. The other partition then gets a fetch of its own beside the next backlog fetch, whose replies
+        // land in either order, so it shows by the third batch, and one more backlog reply may overtake its reply.
+        List<uint> read = [];
+        while (read.Count < 4 && !read.Contains(shared[^1]))
+        {
+            read.Add((await reader.NextAsync()).PartitionId);
+        }
+
+        read.ShouldContain(shared[^1], $"read {string.Join(", ", read)} before {shared[^1]}");
+    }
+
+    private static async Task WaitForAssignmentAsync(PartitionReader reader, int partitions)
+    {
+        var deadline = Stopwatch.StartNew();
+        while (reader.Partitions.Count < partitions)
+        {
+            deadline.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10), "the member never owned every partition");
+            await Task.Delay(100);
+        }
+    }
+
     /// <summary>Joins, then waits until the members own every partition, so the next group poll syncs them.</summary>
     private static async Task JoinAsync(IIggyClient client, string streamName, string topicName, string groupName,
         uint membersCount)
@@ -183,14 +268,22 @@
     private async Task<(IIggyClient Client, string StreamName, string GroupName)> CreateGroup()
     {
         var client = await Fixture.CreateAuthenticatedClient(Protocol.Tcp);
+        var (streamName, groupName) = await CreateGroup(client, PartitionsCount);
+
+        return (client, streamName, groupName);
+    }
+
+    private static async Task<(string StreamName, string GroupName)> CreateGroup(IIggyClient client,
+        uint partitionsCount)
+    {
         var streamName = $"vsr-group-{Guid.NewGuid():N}";
         var groupName = $"vsr-group-name-{Guid.NewGuid():N}";
 
         await client.CreateStreamAsync(streamName);
-        await client.CreateTopicAsync(Identifier.String(streamName), TopicName, PartitionsCount);
+        await client.CreateTopicAsync(Identifier.String(streamName), TopicName, partitionsCount);
         await client.CreateConsumerGroupAsync(Identifier.String(streamName), Identifier.String(TopicName), groupName);
 
-        return (client, streamName, groupName);
+        return (streamName, groupName);
     }
 
     private static Task SendAsync(IIggyClient client, string streamName, uint partitionId)
diff --git a/foreign/csharp/Iggy_SDK/Configuration/IggyClientConfigurator.cs b/foreign/csharp/Iggy_SDK/Configuration/IggyClientConfigurator.cs
index cbc1178..0912e4a 100644
--- a/foreign/csharp/Iggy_SDK/Configuration/IggyClientConfigurator.cs
+++ b/foreign/csharp/Iggy_SDK/Configuration/IggyClientConfigurator.cs
@@ -50,6 +50,11 @@
     ///     The largest response frame accepted over <see cref="Enums.Protocol.Tcp" />, in bytes.
     ///     Default is 64 MiB, minimum is the 256-byte header.
     /// </summary>
+    /// <remarks>
+    ///     A read returns its first message even when the message is larger than the byte limit of the read, and a
+    ///     message can take up to 64,000,000 bytes of payload and 100,000 bytes of user headers. The default holds
+    ///     such a reply with its headers (64,156,564 bytes). A lower limit fails the read of a larger message.
+    /// </remarks>
     public int MaxResponseFrameSize { get; set; } = 64 * 1024 * 1024;
 
     /// <summary>
@@ -60,7 +65,7 @@
 
     /// <summary>
     ///     Maximum bytes of frozen mutation headers and bodies across active and retained operations.
-    ///     A replay shares its original reservation. Default is 64 MiB; minimum is one 256-byte header.
+    ///     A replay shares its original reservation. Default is 64 MiB. Minimum is one 256-byte header.
     /// </summary>
     public long MaxPendingOperationBytes { get; init; } = DEFAULT_MAX_PENDING_OPERATION_BYTES;
 
@@ -83,7 +88,7 @@
     ///     connected, so an idle session survives the server's heartbeat verification and consumer-group
     ///     assignments stay fresh. Each heartbeat also restarts the replica links that used up their
     ///     <see cref="ReconnectionSettings.MaxRetries" />, with a fresh budget, so a signed-in client recovers
-    ///     from an outage longer than one reconnect burst. Default is 5 seconds; must be between 1 millisecond
+    ///     from an outage longer than one reconnect burst. Default is 5 seconds. Must be between 1 millisecond
     ///     and about 49 days. Init-only: the client validates the interval once and hands it to a timer that
     ///     would fault on an out-of-range value.
     /// </summary>
diff --git a/foreign/csharp/Iggy_SDK/Consumers/AutoCommitMode.cs b/foreign/csharp/Iggy_SDK/Consumers/AutoCommitMode.cs
deleted file mode 100644
index c9ea5e3..0000000
--- a/foreign/csharp/Iggy_SDK/Consumers/AutoCommitMode.cs
+++ /dev/null
@@ -1,41 +0,0 @@
-// 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.
-
-namespace Apache.Iggy.Consumers;
-
-/// <summary>
-///     Auto commit modes
-/// </summary>
-public enum AutoCommitMode
-{
-    /// <summary>
-    ///     Set auto commit to true on polling messages
-    /// </summary>
-    Auto,
-
-    /// <summary>
-    ///     Stores the offset of a message once the receive loop asks for the next message. Neither this store nor the
-    ///     final checkpoint on dispose covers a message whose loop body threw, broke or was still running, so that
-    ///     message is delivered again.
-    /// </summary>
-    AfterReceive,
-
-    /// <summary>
-    ///     Offset will not be stored automatically
-    /// </summary>
-    Disabled
-}
diff --git a/foreign/csharp/Iggy_SDK/Consumers/IggyConsumer.Logging.cs b/foreign/csharp/Iggy_SDK/Consumers/IggyConsumer.Logging.cs
deleted file mode 100644
index 013b4c6..0000000
--- a/foreign/csharp/Iggy_SDK/Consumers/IggyConsumer.Logging.cs
+++ /dev/null
@@ -1,139 +0,0 @@
-// 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.
-
-using Apache.Iggy.Enums;
-using Microsoft.Extensions.Logging;
-
-namespace Apache.Iggy.Consumers;
-
-public partial class IggyConsumer
-{
-    [LoggerMessage(EventId = 100,
-        Level = LogLevel.Information,
-        Message = "Creating consumer group '{GroupName}' for stream {StreamId}, topic {TopicId}")]
-    private partial void LogCreatingConsumerGroup(string groupName, Identifier streamId, Identifier topicId);
-
-    [LoggerMessage(EventId = 101,
-        Level = LogLevel.Information,
-        Message = "Successfully created consumer group '{GroupName}'")]
-    private partial void LogConsumerGroupCreated(string groupName);
-
-    [LoggerMessage(EventId = 102,
-        Level = LogLevel.Information,
-        Message = "Joining consumer group '{GroupName}' for stream {StreamId}, topic {TopicId}")]
-    private partial void LogJoiningConsumerGroup(string groupName, Identifier streamId, Identifier topicId);
-
-    [LoggerMessage(EventId = 103,
-        Level = LogLevel.Information,
-        Message = "Successfully joined consumer group '{GroupName}'")]
-    private partial void LogConsumerGroupJoined(string groupName);
-
-    [LoggerMessage(EventId = 200,
-        Level = LogLevel.Trace,
-        Message = "Left consumer group '{GroupName}'")]
-    private partial void LogLeftConsumerGroup(string groupName);
-
-    [LoggerMessage(EventId = 302,
-        Level = LogLevel.Warning,
-        Message = "Failed to leave consumer group '{GroupName}'")]
-    private partial void LogFailedToLeaveConsumerGroup(Exception exception, string groupName);
-
-    [LoggerMessage(EventId = 303,
-        Level = LogLevel.Warning,
-        Message = "Failed to logout user or dispose client")]
-    private partial void LogFailedToLogoutOrDispose(Exception exception);
-
-    [LoggerMessage(EventId = 400,
-        Level = LogLevel.Error,
-        Message = "Failed to initialize consumer group '{GroupName}'")]
-    private partial void LogFailedToInitializeConsumerGroup(Exception exception, string groupName);
-
-    [LoggerMessage(EventId = 401,
-        Level = LogLevel.Error,
-        Message = "Failed to create consumer group '{GroupName}'")]
-    private partial void LogFailedToCreateConsumerGroup(Exception exception, string groupName);
-
-    [LoggerMessage(EventId = 1,
-        Level = LogLevel.Debug,
-        Message = "Polling task cancelled")]
-    private partial void LogPollingTaskCancelled();
-
-    [LoggerMessage(EventId = 2,
-        Level = LogLevel.Debug,
-        Message = "Message polling stopped")]
-    private partial void LogMessagePollingStopped();
-
-    [LoggerMessage(EventId = 3,
-        Level = LogLevel.Debug,
-        Message = "Consumer group not joined yet. Skipping polling")]
-    partial void LogConsumerGroupNotJoinedYetSkippingPolling();
-
-    [LoggerMessage(EventId = 4,
-        Level = LogLevel.Debug,
-        Message = "Consumer group name is empty. Skipping rejoining consumer group")]
-    partial void LogConsumerGroupNameIsEmptySkippingRejoiningConsumerGroup();
-
-    [LoggerMessage(EventId = 402,
-        Level = LogLevel.Error,
-        Message = "Failed to decrypt message at offset {Offset}, partition ID {PartitionId}")]
-    private partial void LogFailedToDecryptMessage(Exception exception, ulong offset, uint partitionId);
-
-    [LoggerMessage(EventId = 403,
-        Level = LogLevel.Error,
-        Message = "Failed to poll messages")]
-    private partial void LogFailedToPollMessages(Exception exception);
-
-    [LoggerMessage(EventId = 404,
-        Level = LogLevel.Error,
-        Message = "Failed to rejoin consumer group '{GroupName}' after reconnection")]
-    private partial void LogFailedToRejoinConsumerGroup(Exception exception, string groupName);
-
-    [LoggerMessage(EventId = 105,
-        Level = LogLevel.Information,
-        Message = "Client connection state changed: {PreviousState} -> {CurrentState}")]
-    private partial void LogConnectionStateChanged(ConnectionState previousState, ConnectionState currentState);
-
-    [LoggerMessage(EventId = 300,
-        Level = LogLevel.Warning,
-        Message = "Returned monotonic time went backwards, now < lastPolledAt: ({Now} < {LastPolledAt})")]
-    private partial void LogMonotonicTimeWentBackwards(long now, long lastPolledAt);
-
-    [LoggerMessage(EventId = 201,
-        Level = LogLevel.Trace,
-        Message = "No need to wait before polling messages. {Now} - {LastPolledAt} = {Elapsed}")]
-    private partial void LogNoNeedToWaitBeforePolling(long now, long lastPolledAt, long elapsed);
-
-    [LoggerMessage(EventId = 202,
-        Level = LogLevel.Trace,
-        Message = "Waiting for {Remaining} milliseconds before polling messages")]
-    private partial void LogWaitingBeforePolling(long remaining);
-
-    [LoggerMessage(EventId = 203,
-        Level = LogLevel.Debug,
-        Message = "No partition assigned to this group member, backing off for {BackoffMs} milliseconds")]
-    private partial void LogNoPartitionAssignedBackingOff(int backoffMs);
-
-    [LoggerMessage(EventId = 301,
-        Level = LogLevel.Warning,
-        Message = "PartitionId is ignored when ConsumerType is ConsumerGroup")]
-    partial void LogPartitionIdIsIgnoredWhenConsumerTypeIsConsumerGroup();
-
-    [LoggerMessage(EventId = 106,
-        Level = LogLevel.Information,
-        Message = "Rejoining consumer group {ConsumerGroupName} after reconnection")]
-    partial void LogRejoiningConsumerGroupConsumerGroupNameAfterReconnection(string consumerGroupName);
-}
diff --git a/foreign/csharp/Iggy_SDK/Consumers/IggyConsumer.Rented.cs b/foreign/csharp/Iggy_SDK/Consumers/IggyConsumer.Rented.cs
index 171e91f..d4331c1 100644
--- a/foreign/csharp/Iggy_SDK/Consumers/IggyConsumer.Rented.cs
+++ b/foreign/csharp/Iggy_SDK/Consumers/IggyConsumer.Rented.cs
@@ -16,241 +16,58 @@
 // under the License.
 
 using System.Runtime.CompilerServices;
-using Apache.Iggy.Contracts;
-using Apache.Iggy.Enums;
 using Apache.Iggy.Exceptions;
-using Apache.Iggy.Kinds;
-using Microsoft.Extensions.Logging;
+using Apache.Iggy.Mappers;
 
 namespace Apache.Iggy.Consumers;
 
 public partial class IggyConsumer
 {
     /// <summary>
-    ///     Receives messages asynchronously as an async stream of rented messages. Each yielded
-    ///     <see cref="ReceivedRentedMessage" /> shares its underlying pooled buffer with the other messages from the
-    ///     same poll and MUST be disposed by the caller when processing is complete. The buffer is returned to the
-    ///     pool once every message of its batch has been disposed. The stream ends once the consumer is disposed.
+    ///     Receives messages as <see cref="ReceiveAsync" /> does, each a <see cref="ReceivedRentedMessage" /> that shares
+    ///     the pooled buffer of its batch and MUST be disposed by the caller when processing is complete. The buffer
+    ///     returns to the pool once every message of its batch has been disposed. The stream ends once the consumer
+    ///     shuts down.
     /// </summary>
     /// <param name="ct">Cancellation token to stop receiving messages.</param>
-    /// <returns>An async enumerable of rented messages.</returns>
     /// <exception cref="ConsumerNotInitializedException">Thrown when <see cref="InitAsync" /> has not been called.</exception>
     public async IAsyncEnumerable<ReceivedRentedMessage> ReceiveRentedAsync(
         [EnumeratorCancellation] CancellationToken ct = default)
     {
-        if (IsDisposed)
+        ThrowIfNotReading();
+        while (await NextBatchAsync(ct) is { } batch)
         {
-            yield break;
-        }
-        if (!_isInitialized)
-        {
-            throw new ConsumerNotInitializedException();
-        }
-
-        do
-        {
-            if (!_rentedChannel.Reader.TryRead(out var message))
+            var checkpoints = Enumerable.Range(0, batch.Messages.Count).Select(batch.CheckpointAt).ToArray();
+            var handle = new RentedBatchHandle(BinaryMapper.ToRentedMessages(batch));
+            try
             {
-                await PollRentedMessagesCoreAsync(ct);
-                continue;
-            }
-
-            if (!BeginDelivery(message.CurrentOffset, message.PartitionId, message.Context))
-            {
-                message.Dispose();
-                yield break;
-            }
-            yield return message;
-
-            if (_config.AutoCommitMode == AutoCommitMode.AfterReceive)
-            {
-                await CommitDeliveredAsync(message.CurrentOffset, message.PartitionId, message.Context, ct);
-            }
-        } while (!ct.IsCancellationRequested && !IsDisposed);
-    }
-
-    /// <summary>
-    ///     Publishes a single rented message from a polled batch to the consumer channel. Called once per
-    ///     message during <see cref="PollRentedMessagesAsync" />. The caller acquires a reference on
-    ///     <paramref name="rental" /> before invocation; the channel reader is expected to release that
-    ///     reference by disposing the produced <see cref="ReceivedRentedMessage" />. Override to redirect
-    ///     rented batches to a different sink (e.g. typed deserialization) — overrides must ensure the
-    ///     acquired reference is released exactly once on every path.
-    /// </summary>
-    /// <param name="rental">Reference-counted handle around the polled batch. Caller has already acquired one reference.</param>
-    /// <param name="message">
-    ///     The rented message to publish. Payload and raw headers are slices of pooled memory tied to
-    ///     <paramref name="rental" />.
-    /// </param>
-    /// <param name="partitionId">Partition the message was polled from.</param>
-    /// <param name="status">Outcome of any prior processing (e.g. decryption).</param>
-    /// <param name="error">Exception captured if <paramref name="status" /> is non-success; otherwise null.</param>
-    /// <param name="ct">Cancellation token.</param>
-    protected virtual async Task PublishRentedAsync(RentedBatchHandle rental,
-        RentedMessageResponse message,
-        uint partitionId,
-        MessageStatus status,
-        Exception? error,
-        CancellationToken ct)
-    {
-        await _rentedChannel.Writer.WriteAsync(new ReceivedRentedMessage
-        {
-            Handle = rental,
-            Message = message,
-            CurrentOffset = message.Header.Offset,
-            PartitionId = partitionId,
-            Context = rental.Context,
-            Status = status,
-            Error = error
-        }, ct);
-    }
-
-    /// <summary>
-    ///     Polls a rented batch from the server and publishes it via <see cref="PublishRentedAsync" />.
-    ///     Handles offset tracking and auto-commit logic. Rental lifetime is managed via
-    ///     <see cref="RentedBatchHandle" /> shared by every produced message.
-    /// </summary>
-    /// <exception cref="ObjectDisposedException">The consumer is disposed.</exception>
-    protected async Task PollRentedMessagesAsync(CancellationToken ct)
-    {
-        ObjectDisposedException.ThrowIf(IsDisposed, this);
-        await PollRentedMessagesCoreAsync(ct);
-    }
-
-    /// <summary>
-    ///     The poll of the built-in receive loops. Once the consumer is disposed it returns without polling, and the
-    ///     loop ends instead of failing.
-    /// </summary>
-    private protected async Task PollRentedMessagesCoreAsync(CancellationToken ct)
-    {
-        if (IsDisposed)
-        {
-            return;
-        }
-
-        if (!_joinedConsumerGroup || !IsGroupMembershipCurrent())
-        {
-            LogConsumerGroupNotJoinedYetSkippingPolling();
-            await TryRecoverGroupMembershipAsync(ct);
-            return;
-        }
-
-        ThrowIfAutoCommitWithEncryptor();
-
-        await _pollingSemaphore.WaitAsync(ct);
-
-        PolledMessagesRental? rental = null;
-        RentedBatchHandle? batchHandle = null;
-        var polledPartition = PolledMessages.NoAssignedPartition;
-        try
-        {
-            if (_config.PollingIntervalMs > 0)
-            {
-                await WaitBeforePollingAsync(ct);
-            }
-
-            rental = await _client.PollMessagesRentedAsync(_config.StreamId, _config.TopicId,
-                _config.PartitionId, _config.Consumer, partitionId =>
+                var rental = handle.Rental;
+                for (var index = 0; index < rental.Messages.Count; index++)
                 {
-                    polledPartition = partitionId;
-                    return PollingStrategyFor(partitionId);
-                }, _config.BatchSize, _config.AutoCommit, ct);
-
-            if (rental.Messages.Count == 0)
-            {
-                if (rental.PartitionId == PolledMessages.NoAssignedPartition)
-                {
-                    // Partitions come back under a new owner generation, which refuses a kept continuation.
-                    _nextPollingStrategies.Clear();
-                    LogNoPartitionAssignedBackingOff(NoAssignedPartitionBackoffMs);
-                    await Task.Delay(NoAssignedPartitionBackoffMs, ct);
-                    return;
-                }
-
-                if (_logger.IsEnabled(LogLevel.Debug))
-                {
-                    _logger.LogDebug("No messages received from poll for partition {PartitionId}", rental.PartitionId);
-                }
-
-                return;
-            }
-
-            RememberPolledContext(rental.PartitionId, rental.Context);
-            var hasLastOffset = _lastPolledOffset.TryGetValue(rental.PartitionId, out var lastPolledPartitionOffset);
-
-            var currentOffset = 0ul;
-
-            batchHandle = new RentedBatchHandle(rental);
-            var anyNewMessages = false;
-            foreach (var message in rental.Messages)
-            {
-                if (hasLastOffset && message.Header.Offset <= lastPolledPartitionOffset)
-                {
-                    continue;
-                }
-
-                currentOffset = message.Header.Offset;
-                batchHandle.Acquire();
-                try
-                {
-                    await PublishRentedAsync(batchHandle, message, rental.PartitionId, MessageStatus.Success, null, ct);
-                }
-                catch
-                {
-                    batchHandle.Release();
-                    throw;
-                }
-
-                anyNewMessages = true;
-            }
-
-            if (!anyNewMessages)
-            {
-                if (_config.AutoCommitMode != AutoCommitMode.Disabled)
-                {
-                    if (_logger.IsEnabled(LogLevel.Debug))
+                    if (ct.IsCancellationRequested || IsDisposed)
                     {
-                        _logger.LogDebug(
-                            "No new messages found, committing offset {Offset} for partition {PartitionId}",
-                            lastPolledPartitionOffset, rental.PartitionId);
+                        yield break;
                     }
 
-                    await StoreOffsetAsync(lastPolledPartitionOffset, rental.PartitionId, rental.Context, ct);
+                    var message = rental.Messages[index];
+                    Hand(checkpoints[index]!, index == rental.Messages.Count - 1);
+                    handle.Acquire();
+                    yield return new ReceivedRentedMessage
+                    {
+                        Handle = handle,
+                        Message = message,
+                        CurrentOffset = message.Header.Offset,
+                        PartitionId = batch.PartitionId,
+                        Context = batch.Context,
+                        Checkpoint = checkpoints[index]
+                    };
+                    CountProcessed();
                 }
-
-                return;
             }
-
-            _lastPolledOffset[rental.PartitionId] = currentOffset;
-            RememberNextPollingStrategy(rental.PartitionId, currentOffset, rental.Context);
-        }
-        catch (OperationCanceledException) when (ct.IsCancellationRequested)
-        {
-            throw;
-        }
-        catch (MessageDecryptionException ex)
-        {
-            LogFailedToDecryptMessage(ex, ex.Offset, ex.PartitionId);
-            throw;
-        }
-        // Without an operation id nothing is retained to resolve, so the next poll can run.
-        catch (Exception ex) when (ex is not (MalformedResponseException
-            or VsrRequestOutcomeUnknownException { OperationId: not null } or VsrLaneBlockedException))
-        {
-            await ReportPollFailureAsync(ex, polledPartition, ct);
-        }
-        finally
-        {
-            if (batchHandle is not null)
+            finally
             {
-                batchHandle.Release();
+                handle.Release();
             }
-            else
-            {
-                rental?.Dispose();
-            }
-
-            _pollingSemaphore.Release();
         }
     }
 }
diff --git a/foreign/csharp/Iggy_SDK/Consumers/IggyConsumer.cs b/foreign/csharp/Iggy_SDK/Consumers/IggyConsumer.cs
index 3a1dcda..7d9c336 100644
--- a/foreign/csharp/Iggy_SDK/Consumers/IggyConsumer.cs
+++ b/foreign/csharp/Iggy_SDK/Consumers/IggyConsumer.cs
@@ -15,102 +15,58 @@
 // specific language governing permissions and limitations
 // under the License.
 
-using System.Collections.Concurrent;
 using System.Runtime.CompilerServices;
-using System.Runtime.ExceptionServices;
-using System.Threading.Channels;
 using Apache.Iggy.Contracts;
 using Apache.Iggy.Enums;
 using Apache.Iggy.Exceptions;
+using Apache.Iggy.Extensions;
 using Apache.Iggy.IggyClient;
 using Apache.Iggy.Kinds;
 using Apache.Iggy.Utils;
-using Apache.Iggy.Vsr;
 using Microsoft.Extensions.Logging;
 
 namespace Apache.Iggy.Consumers;
 
 /// <summary>
-///     High-level consumer for receiving messages from Iggy streams.
-///     Provides automatic polling, offset management, and consumer group support.
+///     Hands over the messages of a topic one at a time, on a <see cref="PartitionReader" />: a standalone consumer
+///     reads the partitions of its configuration, a consumer group reads the assignment of its member.
 /// </summary>
+/// <remarks>
+///     <para>
+///         <see cref="IggyConsumerConfig.PollOptions" /> holds the <see cref="CommitPolicy" />. With
+///         <see cref="CommitMode.Manual" /> the application commits <see cref="ReceivedMessage.Checkpoint" /> with
+///         <see cref="CommitAsync" />. With <see cref="CommitMode.OnRead" /> the server stores the offset of each read,
+///         and <see cref="ShutdownAsync" /> moves it back to the last message handed over. With
+///         <see cref="CommitMode.Auto" /> the consumer counts each message, with <see cref="CommitAfter.Delivery" />
+///         when it is handed over, with <see cref="CommitAfter.Processing" /> once the stream is read again, or once
+///         the handler of <see cref="ConsumeAsync" /> returns with success, and with <see cref="CommitAfter.Marked" />
+///         on <see cref="Mark" />. It stores the latest counted position at the interval or the event of the policy,
+///         before a rebalance takes a partition away, and on shutdown.
+///     </para>
+///     <para>
+///         A read error does not end the stream: the reader retries by itself, and the error reaches the callbacks of
+///         <see cref="SubscribeToErrorEvents" />.
+///     </para>
+/// </remarks>
 public partial class IggyConsumer : IAsyncDisposable
 {
-    /// <summary>
-    ///     Backoff between poll iterations that found the group membership missing. It bounds the retry rate of
-    ///     the poll-side rejoin below and keeps the receive loop from spinning while the membership is
-    ///     unrecoverable (client disconnected, group deleted).
-    /// </summary>
-    private const int GroupRejoinRetryDelayMs = 1_000;
+    private const int ConsumerGroupAlreadyExists = 5004;
+    private const int StateReading = 1;
+    private const int StateShutDown = 2;
 
-    /// <summary>
-    ///     Backoff after a group poll reported <see cref="PolledMessages.NoAssignedPartition" />. Without it a
-    ///     member holding zero partitions re-polls in a hot loop whenever <c>PollingIntervalMs</c> is zero.
-    /// </summary>
-    private const int NoAssignedPartitionBackoffMs = 100;
-
-    /// <summary>
-    ///     Delay after a failed poll, the default polling retry interval of the Rust consumer. Without it a client
-    ///     that keeps failing, for example while it is disconnected, is polled in a hot loop whenever
-    ///     <c>PollingIntervalMs</c> is zero.
-    /// </summary>
-    private const int PollRetryDelayMs = 1_000;
-    private const int OperationTimeoutMs = 30_000;
-
-    private readonly Channel<ReceivedMessage> _channel;
     private readonly IIggyClient _client;
     private readonly IggyConsumerConfig _config;
-
-    /// <summary>
-    ///     Never disposed, like <see cref="_pollingSemaphore" />: a group rejoin that runs during
-    ///     <see cref="DisposeAsync" />, from a poll or from a state event, still releases it. A poll or a state event
-    ///     that takes it after the dispose started skips the rejoin.
-    /// </summary>
-    private readonly SemaphoreSlim _connectionStateSemaphore = new(1, 1);
-
     private readonly EventAggregator<ConsumerErrorEventArgs> _consumerErrorEvents;
-    private readonly ConcurrentDictionary<uint, PartitionContext> _lastPolledContext = new();
-    private readonly ConcurrentDictionary<uint, ulong> _lastPolledOffset = new();
+    private readonly AsyncLocal<bool> _handlerRunning = new();
+    private readonly SemaphoreSlim _initialization = new(1, 1);
     private readonly ILogger<IggyConsumer> _logger;
-
-    /// <summary>
-    ///     The poll that continues each partition after the last batch it delivered, under the context of that batch.
-    ///     The configured strategy only says where reading a partition starts, as in the Rust consumer. A
-    ///     continuation pinned to its context is refused by a recreated partition instead of reading a later offset
-    ///     of the new incarnation, and it never moves to another partition of a consumer group.
-    /// </summary>
-    private readonly ConcurrentDictionary<uint, PollingStrategy> _nextPollingStrategies = new();
-
-    /// <summary>
-    ///     Never disposed: a poll in flight during <see cref="DisposeAsync" /> still releases it, and a semaphore whose
-    ///     wait handle is never used holds nothing to free.
-    /// </summary>
-    private readonly SemaphoreSlim _pollingSemaphore = new(1, 1);
-
-    private readonly Channel<ReceivedRentedMessage> _rentedChannel;
-    private string? _consumerGroupName;
-    private readonly object _deliveryGate = new();
-    private readonly Dictionary<uint, (ulong Offset, PartitionContext Context)> _deliveredOffsets = [];
-    private readonly Dictionary<uint, (ulong Offset, PartitionContext Context)> _storedOffsets = [];
-    private readonly SemaphoreSlim _offsetSemaphore = new(1, 1);
+    private readonly ILoggerFactory _loggerFactory;
     private readonly CancellationTokenSource _lifetime = new();
-    private int _disposeState;
-    private volatile bool _isInitialized;
-    private volatile bool _joinedConsumerGroup;
-
-    /// <summary>
-    ///     Consensus session generation the group membership was established under. A later generation means the
-    ///     server session that held the membership is gone and the group must be rejoined.
-    /// </summary>
-    private ulong _joinedSessionGeneration;
-
-    private long _lastPolledAtMs;
-
-    /// <summary>Whether this consumer has been initialized via <see cref="InitAsync" />.</summary>
-    protected bool IsInitialized => _isInitialized;
-
-    /// <summary>Whether <see cref="DisposeAsync" /> has started. The receive loops end once it has.</summary>
-    private protected bool IsDisposed => Volatile.Read(ref _disposeState) == 1;
+    private TaskCompletionSource? _handling;
+    private TaskCompletionSource? _shutdown;
+    private PartitionReader? _reader;
+    private int _state;
+    private (Checkpoint Checkpoint, bool EndsBatch)? _uncounted;
 
     /// <summary>
     ///     Initializes a new instance of the <see cref="IggyConsumer" /> class
@@ -122,332 +78,278 @@
     {
         _client = client;
         _config = config;
+        _loggerFactory = loggerFactory;
         _logger = loggerFactory.CreateLogger<IggyConsumer>();
-
-        _channel = Channel.CreateBounded<ReceivedMessage>(new BoundedChannelOptions((int)config.BatchSize * 2));
-        _rentedChannel
-            = Channel.CreateBounded<ReceivedRentedMessage>(new BoundedChannelOptions((int)config.BatchSize * 2));
         _consumerErrorEvents = new EventAggregator<ConsumerErrorEventArgs>(loggerFactory);
     }
 
+    /// <summary>Whether this consumer has been initialized via <see cref="InitAsync" />.</summary>
+    protected bool IsInitialized => Volatile.Read(ref _state) == StateReading;
+
+    /// <summary>Whether <see cref="ShutdownAsync" /> has started. The receive loops end once it has.</summary>
+    private protected bool IsDisposed => Volatile.Read(ref _state) == StateShutDown;
+
     /// <summary>
-    ///     Disposes the consumer, leaving consumer groups and logging out if applicable
+    ///     The partitions that the consumer reads: those of its configuration, or the current assignment of a group
+    ///     member.
     /// </summary>
+    public IReadOnlyList<uint> Partitions => _reader?.Partitions ?? [];
+
+    /// <summary>Shuts the consumer down as <see cref="ShutdownAsync" /> does, and logs its error instead of throwing it.</summary>
     public async ValueTask DisposeAsync()
     {
-        lock (_deliveryGate)
-        {
-            if (IsDisposed)
-            {
-                return;
-            }
-            Volatile.Write(ref _disposeState, 1);
-        }
-        _lifetime.Cancel();
-        CompleteReceiveChannels();
-        await _connectionStateSemaphore.WaitAsync();
         try
         {
-            if (_isInitialized)
-            {
-                _client.UnsubscribeConnectionEvents(OnClientConnectionStateChangedAsync);
-            }
-            // Cancellation drains stores already in flight before the final checkpoint and leave.
-            await _offsetSemaphore.WaitAsync();
-            try
-            {
-                using var checkpoint = new CancellationTokenSource(OperationTimeoutMs);
-                if (_config.AutoCommitMode != AutoCommitMode.Disabled)
-                {
-                    foreach (var (partition, position) in _deliveredOffsets)
-                    {
-                        if (checkpoint.IsCancellationRequested)
-                        {
-                            break;
-                        }
-                        if (_storedOffsets.TryGetValue(partition, out var stored)
-                            && SamePartition(stored.Context, position.Context) && stored.Offset >= position.Offset)
-                        {
-                            continue;
-                        }
-                        try
-                        {
-                            await WithLaneRecoveryAsync(token => _client.StoreOffsetAsync(_config.Consumer,
-                                _config.StreamId, _config.TopicId, position.Offset, partition, position.Context, token),
-                                checkpoint.Token);
-                        }
-                        catch (Exception error)
-                        {
-                            _logger.LogWarning(error, "Failed to checkpoint consumed offset {Offset} for partition {PartitionId}",
-                                position.Offset, partition);
-                        }
-                    }
-                }
-            }
-            finally
-            {
-                _offsetSemaphore.Release();
-            }
-            if (!string.IsNullOrEmpty(_consumerGroupName) && _joinedConsumerGroup)
-            {
-                await LeaveConsumerGroupAsync(_consumerGroupName);
-                _joinedConsumerGroup = false;
-            }
-            if (_config.CreateIggyClient)
-            {
-                try
-                {
-                    try
-                    {
-                        if (_isInitialized)
-                        {
-                            await _client.LogoutUserAsync();
-                        }
-                    }
-                    finally
-                    {
-                        _client.Dispose();
-                    }
-                }
-                catch (Exception error)
-                {
-                    LogFailedToLogoutOrDispose(error);
-                }
-            }
-            _isInitialized = false;
-            _consumerErrorEvents.Clear();
+            await ShutdownAsync();
         }
-        finally
+        catch (Exception error)
         {
-            _connectionStateSemaphore.Release();
+            _logger.LogWarning(error, "Shutting the consumer down failed.");
         }
+
+        GC.SuppressFinalize(this);
     }
 
-    private protected virtual void CompleteReceiveChannels()
-    {
-        _channel.Writer.TryComplete();
-        _rentedChannel.Writer.TryComplete();
-        while (_channel.Reader.TryRead(out _)) { }
-        while (_rentedChannel.Reader.TryRead(out var queued))
-        {
-            queued.Dispose();
-        }
-    }
-
-    // Under AfterReceive a message counts as delivered once its loop body completes, in CommitDeliveredAsync.
-    private protected bool BeginDelivery(ulong offset, uint partition, PartitionContext context) =>
-        _config.AutoCommitMode == AutoCommitMode.AfterReceive ? !IsDisposed : RecordDelivery(offset, partition, context);
-
-    private bool RecordDelivery(ulong offset, uint partition, PartitionContext context)
-    {
-        lock (_deliveryGate)
-        {
-            if (IsDisposed)
-            {
-                return false;
-            }
-            if (!_deliveredOffsets.TryGetValue(partition, out var previous)
-                || !SamePartition(previous.Context, context) || previous.Offset < offset)
-            {
-                _deliveredOffsets[partition] = (offset, context);
-            }
-            return true;
-        }
-    }
-
-    private static bool SamePartition(PartitionContext first, PartitionContext second) =>
-        first.Incarnation == second.Incarnation && first.OwnerGeneration == second.OwnerGeneration;
-
-    private protected Task CommitDeliveredAsync(ulong offset, uint partition, PartitionContext context, CancellationToken ct) =>
-        RecordDelivery(offset, partition, context)
-            ? StoreOffsetCoreAsync(offset, partition, context, ct, afterReceive: true)
-            : Task.CompletedTask;
-
     /// <summary>
-    ///     Initializes the consumer by logging in (if needed) and setting up consumer groups
+    ///     Connects the client when the consumer created it, creates the consumer group when it is missing and the
+    ///     configuration asks for it, and starts the reader, which joins the group.
     /// </summary>
-    /// <param name="ct">Cancellation token</param>
-    /// <exception cref="InvalidConsumerGroupNameException">Thrown when consumer group name is invalid</exception>
-    /// <exception cref="ConsumerGroupNotFoundException">Thrown when consumer group doesn't exist and auto-creation is disabled</exception>
-    /// <exception cref="ObjectDisposedException">The consumer is disposed before it was initialized.</exception>
+    /// <exception cref="ObjectDisposedException">The consumer was shut down.</exception>
     public async Task InitAsync(CancellationToken ct = default)
     {
-        ObjectDisposedException.ThrowIf(IsDisposed, this);
-        if (_isInitialized)
-        {
-            return;
-        }
-        using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(ct, _lifetime.Token);
-        ct = cancellation.Token;
-        await _connectionStateSemaphore.WaitAsync(ct);
+        await _initialization.WaitAsync(ct);
         try
         {
             ObjectDisposedException.ThrowIf(IsDisposed, this);
-            if (_isInitialized)
+            if (IsInitialized)
             {
                 return;
             }
 
-            if (_config.Consumer.Type == ConsumerType.ConsumerGroup && _config.PartitionId != null)
-            {
-                LogPartitionIdIsIgnoredWhenConsumerTypeIsConsumerGroup();
-                _config.PartitionId = null;
-            }
-
             await _client.ConnectAsync(ct);
-
             if (!string.IsNullOrEmpty(_config.Login) && !_config.CreateIggyClient)
             {
                 await _client.LoginUserAsync(_config.Login, _config.Password, ct);
             }
 
-            await InitializeConsumerGroupAsync(ct);
-            lock (_deliveryGate)
+            var consumer = _config.Consumer;
+            if (consumer.Type == ConsumerType.ConsumerGroup)
             {
-                ObjectDisposedException.ThrowIf(IsDisposed, this);
-                _client.SubscribeConnectionEvents(OnClientConnectionStateChangedAsync);
-                _isInitialized = true;
+                consumer = await PrepareGroupAsync(ct);
             }
+
+            var builder = _client.Reader(_config.StreamId, _config.TopicId, consumer)
+                .WithStart(_config.PollingStrategy)
+                .WithOptions(_config.PollOptions)
+                .WithLoggerFactory(_loggerFactory)
+                .CountingMessages();
+            if (consumer.Type == ConsumerType.Consumer)
+            {
+                builder.WithPartitions([.. _config.Partitions]);
+            }
+
+            _reader = await builder.BuildAsync(ct);
+            Volatile.Write(ref _state, StateReading);
         }
         finally
         {
-            _connectionStateSemaphore.Release();
+            _initialization.Release();
         }
     }
 
     /// <summary>
-    ///     Receives messages asynchronously from the consumer as an async stream.
-    ///     Messages are automatically polled from the server and buffered in a bounded channel.
-    ///     The stream ends once the consumer is disposed.
+    ///     Receives messages one at a time. The stream ends once the consumer shuts down. With
+    ///     <see cref="CommitAfter.Processing" /> a message counts once the stream is read again, or on shutdown.
     /// </summary>
     /// <param name="ct">Cancellation token to stop receiving messages</param>
-    /// <returns>An async enumerable of received messages</returns>
     /// <exception cref="ConsumerNotInitializedException">Thrown when InitAsync has not been called</exception>
     public async IAsyncEnumerable<ReceivedMessage> ReceiveAsync([EnumeratorCancellation] CancellationToken ct = default)
     {
-        if (IsDisposed)
+        ThrowIfNotReading();
+        while (await NextBatchAsync(ct) is { } batch)
         {
-            yield break;
-        }
-        if (!_isInitialized)
-        {
-            throw new ConsumerNotInitializedException();
-        }
-
-        do
-        {
-            if (!_channel.Reader.TryRead(out var message))
+            for (var index = 0; index < batch.Messages.Count; index++)
             {
-                await PollMessagesAsync(ct);
-                continue;
-            }
-
-            if (!BeginDelivery(message.CurrentOffset, message.PartitionId, message.Context))
-            {
-                yield break;
-            }
-            yield return message;
-
-            if (_config.AutoCommitMode == AutoCommitMode.AfterReceive)
-            {
-                await CommitDeliveredAsync(message.CurrentOffset, message.PartitionId, message.Context, ct);
-            }
-        } while (!ct.IsCancellationRequested && !IsDisposed);
-    }
-
-    /// <summary>
-    ///     Manually stores the consumer offset for a specific partition.
-    ///     Use this when auto-commit is disabled or when you need manual offset control.
-    ///     The offset is stored under the context of the partition's last poll, so the server refuses it when the
-    ///     partition was deleted and recreated since then or moved to another member of the consumer group.
-    ///     An offset taken from a message of an earlier poll belongs under that message's context: store it with
-    ///     <see cref="StoreOffsetAsync(ulong, uint, PartitionContext, CancellationToken)" /> instead.
-    ///     Before the first poll of the partition the store takes the context the partition's route reports. It can
-    ///     then fail once with status 87, or 5009 for a consumer group, after another client recreated the
-    ///     partition. The failed route is dropped and the next call routes again.
-    /// </summary>
-    /// <param name="offset">The offset to store</param>
-    /// <param name="partitionId">The partition ID</param>
-    /// <param name="resetLastPolled">
-    ///     When true, changes the cached duplicate-filter offset for the partition.
-    ///     Does not change the polling strategy or clear buffered messages.
-    /// </param>
-    /// <param name="ct">Cancellation token</param>
-    public async Task StoreOffsetAsync(ulong offset, uint partitionId, bool resetLastPolled = false,
-        CancellationToken ct = default)
-    {
-        PartitionContext? context = _lastPolledContext.TryGetValue(partitionId, out var polled) ? polled : null;
-        await StoreOffsetCoreAsync(offset, partitionId, context, ct);
-
-        if (resetLastPolled)
-        {
-            _lastPolledOffset[partitionId] = offset;
-        }
-    }
-
-    /// <summary>
-    ///     Stores the offset of a received message under the context of the poll that delivered it, taken from
-    ///     <see cref="ReceivedMessage.Context" />. The server refuses it with status 87 when the partition was
-    ///     deleted and recreated after that poll, and with status 5009 when the partition moved to another member
-    ///     of the consumer group, so a late commit never lands on the newer partition.
-    /// </summary>
-    /// <param name="offset">The offset to store</param>
-    /// <param name="partitionId">The partition that delivered the offset</param>
-    /// <param name="context">The context of the message the offset belongs to</param>
-    /// <param name="ct">Cancellation token</param>
-    public async Task StoreOffsetAsync(ulong offset, uint partitionId, PartitionContext context,
-        CancellationToken ct = default)
-    {
-        await StoreOffsetCoreAsync(offset, partitionId, context, ct);
-    }
-
-    private async Task StoreOffsetCoreAsync(ulong offset, uint partitionId, PartitionContext? context,
-        CancellationToken ct, bool afterReceive = false)
-    {
-        if (afterReceive && IsDisposed) { return; }
-        ObjectDisposedException.ThrowIf(IsDisposed, this);
-        using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(ct, _lifetime.Token);
-        try
-        {
-            await _offsetSemaphore.WaitAsync(cancellation.Token);
-            try
-            {
-                if (afterReceive && IsDisposed) { return; }
-                ObjectDisposedException.ThrowIf(IsDisposed, this);
-                await WithLaneRecoveryAsync(token => context is { } captured
-                    ? _client.StoreOffsetAsync(_config.Consumer, _config.StreamId, _config.TopicId,
-                        offset, partitionId, captured, token)
-                    : _client.StoreOffsetAsync(_config.Consumer, _config.StreamId, _config.TopicId,
-                        offset, partitionId, token), cancellation.Token);
-                if (context is { } stored && (!_storedOffsets.TryGetValue(partitionId, out var previous)
-                    || !SamePartition(previous.Context, stored) || previous.Offset < offset))
+                if (ct.IsCancellationRequested || IsDisposed)
                 {
-                    _storedOffsets[partitionId] = (offset, stored);
+                    yield break;
+                }
+
+                var message = batch.Messages[index];
+                var checkpoint = batch.CheckpointAt(index)!;
+                Hand(checkpoint, index == batch.Messages.Count - 1);
+                yield return new ReceivedMessage
+                {
+                    Message = message,
+                    CurrentOffset = message.Header.Offset,
+                    PartitionId = batch.PartitionId,
+                    Context = batch.Context,
+                    Checkpoint = checkpoint
+                };
+                CountProcessed();
+            }
+        }
+    }
+
+    /// <summary>
+    ///     Hands each message to <paramref name="handler" /> until the consumer shuts down or a handler fails. With
+    ///     <see cref="CommitAfter.Delivery" /> a message counts before its handler runs, with
+    ///     <see cref="CommitAfter.Processing" /> only after its handler returned with success. A
+    ///     <see cref="ShutdownAsync" /> from outside the handler waits for the handler that runs.
+    /// </summary>
+    /// <exception cref="Exception">The error of the first handler that failed. Its message is not counted.</exception>
+    public async Task ConsumeAsync(Func<ReceivedMessage, CancellationToken, Task> handler,
+        CancellationToken ct = default)
+    {
+        ArgumentNullException.ThrowIfNull(handler);
+        ThrowIfNotReading();
+        while (await NextBatchAsync(ct) is { } batch)
+        {
+            for (var index = 0; index < batch.Messages.Count; index++)
+            {
+                if (ct.IsCancellationRequested || IsDisposed)
+                {
+                    return;
+                }
+
+                var message = batch.Messages[index];
+                var checkpoint = batch.CheckpointAt(index)!;
+                var endsBatch = index == batch.Messages.Count - 1;
+                Hand(checkpoint, endsBatch, false);
+                var handling = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+                Volatile.Write(ref _handling, handling);
+                _handlerRunning.Value = true;
+                try
+                {
+                    await handler(new ReceivedMessage
+                    {
+                        Message = message,
+                        CurrentOffset = message.Header.Offset,
+                        PartitionId = batch.PartitionId,
+                        Context = batch.Context,
+                        Checkpoint = checkpoint
+                    }, ct);
+                    if (_config.PollOptions.Commit.Settings?.After == CommitAfter.Processing)
+                    {
+                        Reader.CountDelivered(checkpoint, 1, endsBatch);
+                    }
+                }
+                finally
+                {
+                    _handlerRunning.Value = false;
+                    handling.SetResult();
                 }
             }
-            finally { _offsetSemaphore.Release(); }
-        }
-        catch (OperationCanceledException) when (afterReceive && IsDisposed && !ct.IsCancellationRequested)
-        {
-        }
-        catch (VsrRequestOutcomeUnknownException) when (afterReceive && IsDisposed && !ct.IsCancellationRequested)
-        {
         }
     }
 
-    /// <summary>
-    ///     Deletes the stored consumer offset for a specific partition.
-    ///     The local duplicate filter, polling strategy, and buffered messages remain unchanged.
-    ///     The delete takes the context the partition's route reports, so it can fail once with status 87, or 5009
-    ///     for a consumer group, after another client recreated the partition. The failed route is dropped and the
-    ///     next call routes again.
-    /// </summary>
-    /// <param name="partitionId">The partition ID</param>
-    /// <param name="ct">Cancellation token</param>
-    public async Task DeleteOffsetAsync(uint partitionId, CancellationToken ct = default)
+    /// <summary>Stores the offset of a checkpoint that this consumer handed over.</summary>
+    /// <exception cref="IggyInvalidStatusCodeException">
+    ///     Status 3 with <see cref="CommitMode.OnRead" />, and for a checkpoint of another consumer.
+    /// </exception>
+    public Task CommitAsync(Checkpoint checkpoint, CancellationToken ct = default)
     {
-        await WithLaneRecoveryAsync(token => _client.DeleteOffsetAsync(_config.Consumer, _config.StreamId,
-            _config.TopicId, partitionId, token), ct);
+        return Reader.CommitAsync(checkpoint, ct);
+    }
+
+    /// <summary>
+    ///     Counts a checkpoint under <see cref="CommitMode.Auto" />, for messages processed concurrently with
+    ///     <see cref="CommitAfter.Marked" />.
+    /// </summary>
+    /// <exception cref="IggyInvalidStatusCodeException">Status 3 without <see cref="CommitMode.Auto" />.</exception>
+    public void Mark(Checkpoint checkpoint)
+    {
+        Reader.Mark(checkpoint);
+    }
+
+    /// <summary>Sets the next read position of a partition, as <see cref="PartitionReader.SeekAsync" /> does.</summary>
+    public Task SeekAsync(uint partitionId, PollingStrategy start, CancellationToken ct = default)
+    {
+        return Reader.SeekAsync(partitionId, start, ct);
+    }
+
+    /// <summary>
+    ///     Stops reading. It waits for a handler of <see cref="ConsumeAsync" /> that runs, unless that handler calls
+    ///     it, counts the last message of <see cref="ReceiveAsync" /> under <see cref="CommitAfter.Processing" />,
+    ///     closes the reader, which stores the counted positions or moves an on read offset back and leaves the
+    ///     group, and disposes a client that the consumer created. A later call waits for the first shutdown and
+    ///     ends as it does, unless the handler that the first one waits for makes it.
+    /// </summary>
+    /// <exception cref="Exception">The first failed store of the close.</exception>
+    public async Task ShutdownAsync()
+    {
+        var shutdown = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+        if (Interlocked.CompareExchange(ref _shutdown, shutdown, null) is { } running)
+        {
+            if (!_handlerRunning.Value)
+            {
+                await running.Task;
+            }
+
+            return;
+        }
+
+        Volatile.Write(ref _state, StateShutDown);
+        try
+        {
+            await ShutdownOnceAsync();
+            shutdown.SetResult();
+        }
+        catch (Exception error)
+        {
+            shutdown.SetException(error);
+        }
+
+        await shutdown.Task;
+    }
+
+    private async Task ShutdownOnceAsync()
+    {
+        await _lifetime.CancelAsync();
+        // The message of a running handler counts only once the handler returns with success.
+        if (!_handlerRunning.Value && Volatile.Read(ref _handling) is { } handling)
+        {
+            await handling.Task;
+        }
+
+        Exception? failure = null;
+        if (_reader is { } reader)
+        {
+            CountProcessed();
+            try
+            {
+                await reader.CloseAsync();
+            }
+            catch (Exception error)
+            {
+                failure = error;
+            }
+        }
+
+        if (_config.CreateIggyClient)
+        {
+            try
+            {
+                if (_reader is not null)
+                {
+                    await _client.LogoutUserAsync();
+                }
+            }
+            catch (Exception error)
+            {
+                _logger.LogWarning(error, "The consumer could not log out its client.");
+            }
+            finally
+            {
+                _client.Dispose();
+            }
+        }
+
+        _consumerErrorEvents.Clear();
+        if (failure is not null)
+        {
+            throw failure;
+        }
     }
 
     /// <summary>
@@ -468,563 +370,103 @@
         _consumerErrorEvents.Unsubscribe(callback);
     }
 
+    private PartitionReader Reader => _reader ?? throw new ConsumerNotInitializedException();
+
     /// <summary>
-    ///     Initializes consumer group if configured, creating and joining as needed
+    ///     The next batch of the reader, or null once the consumer shut down or <paramref name="ct" /> was canceled. A
+    ///     read error goes to the error events and the reader goes on. A message that does not decode ends the stream
+    ///     with its <see cref="ReadException" />, whose <see cref="ReadException.Skip" /> passes it.
     /// </summary>
-    private async Task InitializeConsumerGroupAsync(CancellationToken ct = default)
+    private protected async Task<PolledMessages?> NextBatchAsync(CancellationToken ct)
     {
         using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(ct, _lifetime.Token);
-        ct = cancellation.Token;
-        if (IsDisposed || _joinedConsumerGroup)
+        while (!IsDisposed && !ct.IsCancellationRequested)
         {
-            return;
+            try
+            {
+                return await Reader.NextAsync(cancellation.Token);
+            }
+            catch (ReadException error) when (error.Skip is null)
+            {
+                _consumerErrorEvents.Publish(new ConsumerErrorEventArgs(error, error.Message));
+            }
+            catch (OperationCanceledException) when (IsDisposed || ct.IsCancellationRequested)
+            {
+                return null;
+            }
+            catch (Exception) when (IsDisposed)
+            {
+                return null;
+            }
         }
 
-        // Captured before the join: a session reset racing the join lands the membership on a later generation,
-        // and the mismatch triggers one redundant (idempotent) rejoin instead of a missed one.
-        var sessionGeneration = (_client as ISessionGenerationProvider)?.SessionGeneration ?? 0;
+        return null;
+    }
 
-        if (_config.Consumer.Type == ConsumerType.Consumer)
+    /// <summary>
+    ///     Records a message as handed over, and counts it there under <see cref="CommitAfter.Delivery" />. Under
+    ///     <see cref="CommitAfter.Processing" /> a message of a stream counts once the stream is read again.
+    /// </summary>
+    private protected void Hand(Checkpoint checkpoint, bool endsBatch, bool streamed = true)
+    {
+        Reader.Delivered(checkpoint);
+        switch (_config.PollOptions.Commit.Settings?.After)
         {
-            Interlocked.Exchange(ref _joinedSessionGeneration, sessionGeneration);
-            _joinedConsumerGroup = true;
-            return;
+            case CommitAfter.Delivery:
+                Reader.CountDelivered(checkpoint, 1, endsBatch);
+                break;
+            case CommitAfter.Processing when streamed:
+                _uncounted = (checkpoint, endsBatch);
+                break;
         }
+    }
 
-        _consumerGroupName = _config.Consumer.ConsumerId.Kind == IdKind.String
+    /// <summary>Under <see cref="CommitAfter.Processing" />, counts the message that was processed last.</summary>
+    private protected void CountProcessed()
+    {
+        if (_uncounted is { } processed)
+        {
+            _uncounted = null;
+            Reader.CountDelivered(processed.Checkpoint, 1, processed.EndsBatch);
+        }
+    }
+
+    private void ThrowIfNotReading()
+    {
+        if (!IsInitialized && !IsDisposed)
+        {
+            throw new ConsumerNotInitializedException();
+        }
+    }
+
+    /// <summary>Creates a missing group when the configuration asks for it, and returns the group to read.</summary>
+    private async Task<Consumer> PrepareGroupAsync(CancellationToken ct)
+    {
+        var name = _config.Consumer.ConsumerId.Kind == IdKind.String
             ? _config.Consumer.ConsumerId.GetString()
             : _config.ConsumerGroupName;
-
-        if (string.IsNullOrEmpty(_consumerGroupName))
+        if (string.IsNullOrEmpty(name))
         {
             throw new InvalidConsumerGroupNameException("Consumer group name is empty or null.");
         }
 
-        try
+        var group = Identifier.String(name);
+        if (await _client.GetConsumerGroupByIdAsync(_config.StreamId, _config.TopicId, group, ct) is null)
         {
-            var existingGroup = await _client.GetConsumerGroupByIdAsync(_config.StreamId, _config.TopicId,
-                Identifier.String(_consumerGroupName), ct);
-
-            if (existingGroup == null && _config.CreateConsumerGroupIfNotExists)
+            if (!_config.CreateConsumerGroupIfNotExists)
             {
-                LogCreatingConsumerGroup(_consumerGroupName, _config.StreamId, _config.TopicId);
-
-                var createdGroup = await TryCreateConsumerGroupAsync(_consumerGroupName, ct);
-
-                if (createdGroup)
-                {
-                    LogConsumerGroupCreated(_consumerGroupName);
-                }
-            }
-            else if (existingGroup == null)
-            {
-                throw new ConsumerGroupNotFoundException(_consumerGroupName);
+                throw new ConsumerGroupNotFoundException(name);
             }
 
-            if (_config.JoinConsumerGroup && !IsDisposed)
-            {
-                LogJoiningConsumerGroup(_consumerGroupName, _config.StreamId, _config.TopicId);
-
-                await _client.JoinConsumerGroupAsync(_config.StreamId, _config.TopicId,
-                    Identifier.String(_consumerGroupName), ct);
-
-                Interlocked.Exchange(ref _joinedSessionGeneration, sessionGeneration);
-                _joinedConsumerGroup = true;
-                LogConsumerGroupJoined(_consumerGroupName);
-            }
-        }
-        catch (Exception ex)
-        {
-            LogFailedToInitializeConsumerGroup(ex, _consumerGroupName);
-            throw;
-        }
-    }
-
-    private async Task LeaveConsumerGroupAsync(string groupName)
-    {
-        try
-        {
-            await _client.LeaveConsumerGroupAsync(_config.StreamId, _config.TopicId, Identifier.String(groupName));
-            LogLeftConsumerGroup(groupName);
-        }
-        catch (Exception e)
-        {
-            LogFailedToLeaveConsumerGroup(e, groupName);
-        }
-    }
-
-    /// <summary>
-    ///     Attempts to create a consumer group, handling the case where it already exists
-    /// </summary>
-    /// <returns>True if the group was created or already exists, false on error</returns>
-    private async Task<bool> TryCreateConsumerGroupAsync(string groupName, CancellationToken ct)
-    {
-        try
-        {
-            await _client.CreateConsumerGroupAsync(_config.StreamId, _config.TopicId,
-                groupName, ct);
-        }
-        catch (IggyInvalidStatusCodeException ex)
-        {
-            // 5004 - Consumer group already exists TODO: refactor errors
-            if (ex.StatusCode != 5004)
-            {
-                LogFailedToCreateConsumerGroup(ex, groupName);
-                return false;
-            }
-
-            return true;
-        }
-
-        return true;
-    }
-
-    /// <summary>
-    ///     Guards the wire-driving auto-commit flag against an encrypting client. The builder enforces this via
-    ///     AutoCommitMode, but AutoCommit and AutoCommitMode are independent config fields and the consumer is
-    ///     constructible without the builder, so the load-bearing check lives here at the poll site.
-    /// </summary>
-    private void ThrowIfAutoCommitWithEncryptor()
-    {
-        if (_config.AutoCommit && _client.MessageEncryptor != null)
-        {
-            throw new InvalidOperationException(
-                "AutoCommit with a message encryptor risks silent message loss: the offset is committed before decryption. Use AutoCommitMode.AfterReceive or AutoCommitMode.Disabled.");
-        }
-    }
-
-    /// <summary>
-    ///     Polls messages from the server and writes them to the internal channel.
-    ///     Handles offset tracking and auto-commit logic.
-    ///     Uses semaphore to ensure single concurrent polling operation.
-    ///     Once the consumer is disposed it returns without polling.
-    /// </summary>
-    private async Task PollMessagesAsync(CancellationToken ct)
-    {
-        if (IsDisposed)
-        {
-            return;
-        }
-
-        if (!_joinedConsumerGroup || !IsGroupMembershipCurrent())
-        {
-            LogConsumerGroupNotJoinedYetSkippingPolling();
-            await TryRecoverGroupMembershipAsync(ct);
-            return;
-        }
-
-        ThrowIfAutoCommitWithEncryptor();
-
-        await _pollingSemaphore.WaitAsync(ct);
-
-        var polledPartition = PolledMessages.NoAssignedPartition;
-        try
-        {
-            if (_config.PollingIntervalMs > 0)
-            {
-                await WaitBeforePollingAsync(ct);
-            }
-
-            var messages = await _client.PollMessagesAsync(_config.StreamId, _config.TopicId,
-                _config.PartitionId, _config.Consumer, partitionId =>
-                {
-                    polledPartition = partitionId;
-                    return PollingStrategyFor(partitionId);
-                }, _config.BatchSize, _config.AutoCommit, ct);
-
-            if (messages.Messages.Count == 0)
-            {
-                if (messages.PartitionId == PolledMessages.NoAssignedPartition)
-                {
-                    // Partitions come back under a new owner generation, which refuses a kept continuation.
-                    _nextPollingStrategies.Clear();
-                    LogNoPartitionAssignedBackingOff(NoAssignedPartitionBackoffMs);
-                    await Task.Delay(NoAssignedPartitionBackoffMs, ct);
-                }
-
-                return;
-            }
-
-            RememberPolledContext(messages.PartitionId, messages.Context);
-            var hasLastOffset = _lastPolledOffset.TryGetValue(messages.PartitionId,
-                out var lastPolledPartitionOffset);
-
-            var currentOffset = 0ul;
-            var anyNewMessages = false;
-            foreach (var message in messages.Messages)
-            {
-                if (hasLastOffset && message.Header.Offset <= lastPolledPartitionOffset)
-                {
-                    continue;
-                }
-
-                var receivedMessage = new ReceivedMessage
-                {
-                    Message = message,
-                    CurrentOffset = message.Header.Offset,
-                    PartitionId = messages.PartitionId,
-                    Context = messages.Context,
-                    Status = MessageStatus.Success,
-                    Error = null
-                };
-
-                await _channel.Writer.WriteAsync(receivedMessage, ct);
-                currentOffset = receivedMessage.CurrentOffset;
-                anyNewMessages = true;
-            }
-
-            if (!anyNewMessages)
-            {
-                if (_config.AutoCommitMode != AutoCommitMode.Disabled)
-                {
-                    _logger.LogDebug("No new messages found, committing offset {Offset} for partition {PartitionId}",
-                        lastPolledPartitionOffset, messages.PartitionId);
-                    await StoreOffsetAsync(lastPolledPartitionOffset, messages.PartitionId, messages.Context, ct);
-                }
-
-                return;
-            }
-
-            _lastPolledOffset[messages.PartitionId] = currentOffset;
-            RememberNextPollingStrategy(messages.PartitionId, currentOffset, messages.Context);
-        }
-        catch (OperationCanceledException) when (ct.IsCancellationRequested)
-        {
-            throw;
-        }
-        catch (MessageDecryptionException ex)
-        {
-            LogFailedToDecryptMessage(ex, ex.Offset, ex.PartitionId);
-            throw;
-        }
-        // Without an operation id nothing is retained to resolve, so the next poll can run.
-        catch (Exception ex) when (ex is not (MalformedResponseException
-            or VsrRequestOutcomeUnknownException { OperationId: not null } or VsrLaneBlockedException))
-        {
-            await ReportPollFailureAsync(ex, polledPartition, ct);
-        }
-        finally
-        {
-            _pollingSemaphore.Release();
-        }
-    }
-
-    /// <summary>
-    ///     Publishes a failed poll as an error event and waits <see cref="PollRetryDelayMs" /> before the next poll.
-    ///     A cancellation can interrupt an in-flight poll with another exception, so after a cancellation the failure
-    ///     ends the receive loop as a cancellation instead. A dispose can fail an in-flight poll too, and no poll
-    ///     follows it, so then the failure is neither reported nor waited out.
-    /// </summary>
-    private async Task ReportPollFailureAsync(Exception error, uint polledPartition, CancellationToken ct)
-    {
-        ForgetRefusedPollingStrategy(error, polledPartition);
-        if (ct.IsCancellationRequested)
-        {
-            throw new OperationCanceledException("The poll was canceled.", error, ct);
-        }
-
-        if (IsDisposed)
-        {
-            return;
-        }
-
-        LogFailedToPollMessages(error);
-        _consumerErrorEvents.Publish(new ConsumerErrorEventArgs(error, "Failed to poll messages"));
-        await Task.Delay(PollRetryDelayMs, ct);
-    }
-
-    private async Task WithLaneRecoveryAsync(Func<CancellationToken, Task> operation, CancellationToken ct)
-    {
-        var deadline = Environment.TickCount64 + OperationTimeoutMs;
-        using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(ct);
-        cancellation.CancelAfter(OperationTimeoutMs);
-        try
-        {
             try
             {
-                await operation(cancellation.Token);
+                await _client.CreateConsumerGroupAsync(_config.StreamId, _config.TopicId, name, ct);
             }
-            catch (VsrLaneBlockedException blocked)
+            catch (IggyInvalidStatusCodeException error) when (error.StatusCode == ConsumerGroupAlreadyExists)
             {
-                ct.ThrowIfCancellationRequested();
-                var remainingMs = deadline - Environment.TickCount64;
-                if (remainingMs <= 0)
-                {
-                    throw;
-                }
-
-                try
-                {
-                    await _client.ResolveOperationAsync(blocked.OperationId, TimeSpan.FromMilliseconds(remainingMs),
-                        cancellation.Token);
-                }
-                catch (VsrRequestOutcomeUnknownException)
-                {
-                    ct.ThrowIfCancellationRequested();
-                    ExceptionDispatchInfo.Throw(blocked);
-                }
-                catch (OperationCanceledException) when (cancellation.IsCancellationRequested && !ct.IsCancellationRequested)
-                {
-                    ExceptionDispatchInfo.Throw(blocked);
-                }
-                catch (VsrOperationNotRetainedException)
-                {
-                }
-                catch (IggyInvalidStatusCodeException error) when (error.Committed)
-                {
-                    // The blocker can belong to the application, which reads this rejection through
-                    // ResolveOperationAsync. The store only needs the free lane.
-                }
-
-                cancellation.Token.ThrowIfCancellationRequested();
-                if (Environment.TickCount64 >= deadline)
-                {
-                    throw VsrError.Exception(VsrError.TRANSIENT_NOT_ACCEPTED, "The consumer operation deadline expired.");
-                }
-
-                await operation(cancellation.Token);
-            }
-        }
-        catch (OperationCanceledException) when (cancellation.IsCancellationRequested && !ct.IsCancellationRequested)
-        {
-            throw VsrError.Exception(VsrError.TRANSIENT_NOT_ACCEPTED, "The consumer operation deadline expired.");
-        }
-    }
-
-    /// <summary>
-    ///     The strategy for the partition a poll reads: the continuation after its last batch, or the configured
-    ///     strategy before the first batch and after a refused continuation.
-    /// </summary>
-    private PollingStrategy PollingStrategyFor(uint partitionId)
-    {
-        return _nextPollingStrategies.TryGetValue(partitionId, out var next) ? next : _config.PollingStrategy;
-    }
-
-    /// <summary>
-    ///     Under <see cref="MessagePolling.Next" /> the server keeps the position, so nothing is recorded.
-    /// </summary>
-    private void RememberNextPollingStrategy(uint partitionId, ulong lastOffset, PartitionContext context)
-    {
-        if (_config.PollingStrategy.Kind != MessagePolling.Next)
-        {
-            _nextPollingStrategies[partitionId] = PollingStrategy.Offset(lastOffset + 1).WithContext(context);
-        }
-    }
-
-    /// <summary>
-    ///     A refusal names no partition: it answers the poll of the partition the client asked a strategy for last.
-    ///     Status 87 or 5009 means the partition no longer has the incarnation or owner the continuation was pinned
-    ///     to, so its next poll starts over from the configured strategy.
-    /// </summary>
-    private void ForgetRefusedPollingStrategy(Exception error, uint polledPartition)
-    {
-        if (error is IggyInvalidStatusCodeException
-            {
-                FromServer: true,
-                StatusCode: VsrError.HISTORY_UNAVAILABLE or VsrError.CONSUMER_GROUP_PARTITION_NOT_OWNED
-            })
-        {
-            _nextPollingStrategies.TryRemove(polledPartition, out _);
-        }
-    }
-
-    /// <summary>
-    ///     Records the context a poll of the partition was served under. Offsets compare only within one
-    ///     incarnation and owner, as in the Rust consumer: a recreated partition restarts at offset 0, so a duplicate
-    ///     filter carried over from the old incarnation would drop the replacement's messages and then commit the
-    ///     old offset into it. The metadata operation is left out because unrelated metadata commits advance it.
-    /// </summary>
-    private void RememberPolledContext(uint partitionId, PartitionContext context)
-    {
-        if (_lastPolledContext.TryGetValue(partitionId, out var previous)
-            && (previous.Incarnation != context.Incarnation || previous.OwnerGeneration != context.OwnerGeneration))
-        {
-            _lastPolledOffset.TryRemove(partitionId, out _);
-        }
-
-        _lastPolledContext[partitionId] = context;
-    }
-
-    /// <summary>
-    ///     Whether the session generation the group membership was stamped under is still the transport's
-    ///     current one. Gating the poll here instead of clearing the joined flag on a Disconnected event survives
-    ///     a late event landing after a rejoin already re-stamped the generation: state events are published
-    ///     outside the state lock, so their order is not guaranteed. Runs outside
-    ///     <see cref="_connectionStateSemaphore" />, hence the interlocked read.
-    /// </summary>
-    private bool IsGroupMembershipCurrent()
-    {
-        if (_config.Consumer.Type != ConsumerType.ConsumerGroup
-            || _client is not ISessionGenerationProvider generationProvider)
-        {
-            return true;
-        }
-
-        return generationProvider.SessionGeneration == Interlocked.Read(ref _joinedSessionGeneration);
-    }
-
-    /// <summary>
-    ///     Poll-side rejoin for a membership found missing or stamped under a dead session. The state-event
-    ///     rejoin swallows its failures so the event loop survives them, and the transport suppresses a repeat
-    ///     event for an unchanged state, so without this backstop one failed rejoin would park the consumer for
-    ///     good. The trailing delay keeps the receive loop from spinning while the membership stays gone.
-    /// </summary>
-    private async Task TryRecoverGroupMembershipAsync(CancellationToken ct)
-    {
-        if (_config.Consumer.Type == ConsumerType.ConsumerGroup && _config.JoinConsumerGroup)
-        {
-            await _connectionStateSemaphore.WaitAsync(ct);
-            try
-            {
-                // The dispose left the group, so a rejoin now would add a member that never polls.
-                if (IsDisposed)
-                {
-                    return;
-                }
-
-                if (!_joinedConsumerGroup || !IsGroupMembershipCurrent())
-                {
-                    _joinedConsumerGroup = false;
-                    await RejoinConsumerGroupOnReconnectionAsync();
-                }
-            }
-            finally
-            {
-                _connectionStateSemaphore.Release();
-            }
-
-            if (_joinedConsumerGroup && IsGroupMembershipCurrent())
-            {
-                return;
             }
         }
 
-        await Task.Delay(GroupRejoinRetryDelayMs, ct);
-    }
-
-    /// <summary>
-    ///     Implements polling interval throttling to avoid excessive server requests.
-    ///     Uses monotonic time tracking to ensure proper intervals even with clock adjustments.
-    /// </summary>
-    private async Task WaitBeforePollingAsync(CancellationToken ct)
-    {
-        var intervalMs = _config.PollingIntervalMs;
-        if (intervalMs <= 0)
-        {
-            return;
-        }
-
-        var nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
-        var lastPolledAtMs = Interlocked.Read(ref _lastPolledAtMs);
-
-        if (nowMs < lastPolledAtMs)
-        {
-            LogMonotonicTimeWentBackwards(nowMs, lastPolledAtMs);
-            await Task.Delay(intervalMs, ct);
-            Interlocked.Exchange(ref _lastPolledAtMs, nowMs);
-            return;
-        }
-
-        var elapsedMs = nowMs - lastPolledAtMs;
-        if (elapsedMs >= intervalMs)
-        {
-            LogNoNeedToWaitBeforePolling(nowMs, lastPolledAtMs, elapsedMs);
-            Interlocked.Exchange(ref _lastPolledAtMs, nowMs);
-            return;
-        }
-
-        var remainingMs = intervalMs - elapsedMs;
-        LogWaitingBeforePolling(remainingMs);
-
-        if (remainingMs > 0)
-        {
-            await Task.Delay((int)remainingMs, ct);
-        }
-
-        Interlocked.Exchange(ref _lastPolledAtMs, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
-    }
-
-    /// <summary>
-    ///     Handles connection state changes from the client.
-    /// </summary>
-    /// <param name="e">Event object</param>
-    private async Task OnClientConnectionStateChangedAsync(ConnectionStateChangedEventArgs e)
-    {
-        LogConnectionStateChanged(e.PreviousState, e.CurrentState);
-
-        if (_config.Consumer.Type == ConsumerType.Consumer)
-        {
-            return;
-        }
-
-        await _connectionStateSemaphore.WaitAsync();
-        try
-        {
-            if (IsDisposed)
-            {
-                return;
-            }
-
-            if (_client is ISessionGenerationProvider generationProvider)
-            {
-                if (e.CurrentState != ConnectionState.Authenticated)
-                {
-                    return;
-                }
-
-                if (_joinedConsumerGroup
-                    && generationProvider.SessionGeneration == Interlocked.Read(ref _joinedSessionGeneration))
-                {
-                    return;
-                }
-
-                _joinedConsumerGroup = false;
-                await RejoinConsumerGroupOnReconnectionAsync();
-
-                return;
-            }
-
-            if (e.CurrentState == ConnectionState.Disconnected)
-            {
-                _joinedConsumerGroup = false;
-            }
-
-            if (e.CurrentState != ConnectionState.Authenticated
-                || e.PreviousState == ConnectionState.Authenticated
-                || _joinedConsumerGroup)
-            {
-                return;
-            }
-
-            await RejoinConsumerGroupOnReconnectionAsync();
-        }
-        finally
-        {
-            _connectionStateSemaphore.Release();
-        }
-    }
-
-    /// <summary>
-    ///     Asynchronously rejoins the consumer group after a client reconnection.
-    ///     This restores the consumer group membership that was lost during the connection failure.
-    /// </summary>
-    private async Task RejoinConsumerGroupOnReconnectionAsync()
-    {
-        if (string.IsNullOrEmpty(_consumerGroupName))
-        {
-            LogConsumerGroupNameIsEmptySkippingRejoiningConsumerGroup();
-            return;
-        }
-
-        try
-        {
-            await InitializeConsumerGroupAsync();
-        }
-        catch (Exception ex)
-        {
-            LogFailedToRejoinConsumerGroup(ex, _consumerGroupName);
-            _consumerErrorEvents.Publish(new ConsumerErrorEventArgs(ex,
-                "Failed to rejoin consumer group after reconnection"));
-        }
+        return Consumer.Group(name);
     }
 }
diff --git a/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerBuilder.cs b/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerBuilder.cs
index 913089a..c1c02e9 100644
--- a/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerBuilder.cs
+++ b/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerBuilder.cs
@@ -16,8 +16,10 @@
 // under the License.
 
 using Apache.Iggy.Configuration;
+using Apache.Iggy.Contracts;
 using Apache.Iggy.Encryption;
 using Apache.Iggy.Enums;
+using Apache.Iggy.Exceptions;
 using Apache.Iggy.Factory;
 using Apache.Iggy.IggyClient;
 using Apache.Iggy.Kinds;
@@ -150,9 +152,9 @@
     /// <summary>
     ///     Configures message encryption on the client this builder creates. Encryption is a client-level concern:
     ///     the encryptor decrypts on poll (and encrypts on send) for the whole connection. Only valid when the
-    ///     builder creates its own client; when an external client is supplied, configure the encryptor on that
+    ///     builder creates its own client. When an external client is supplied, configure the encryptor on that
     ///     client via <see cref="Configuration.IggyClientConfigurator.MessageEncryptor" /> instead.
-    ///     The caller owns the encryptor and must dispose it after the consumer is disposed; the builder never
+    ///     The caller owns the encryptor and must dispose it after the consumer is disposed. The builder never
     ///     disposes it (the same instance may be shared across clients).
     /// </summary>
     /// <param name="encryptor">The message encryptor to use.</param>
@@ -165,13 +167,13 @@
     }
 
     /// <summary>
-    ///     Specifies the partition for the consumer to consume messages from.
+    ///     The partitions of a standalone consumer. A consumer group reads the assignment of its member.
     /// </summary>
-    /// <param name="partitionId">The identifier of the partition to consume from.</param>
+    /// <param name="partitions">The partitions to consume from.</param>
     /// <returns>The current instance of <see cref="IggyConsumerBuilder" /> to allow method chaining.</returns>
-    public IggyConsumerBuilder WithPartitionId(uint partitionId)
+    public IggyConsumerBuilder WithPartitions(params uint[] partitions)
     {
-        Config.PartitionId = partitionId;
+        Config.Partitions = [.. partitions.Distinct()];
 
         return this;
     }
@@ -191,31 +193,6 @@
     }
 
     /// <summary>
-    ///     Sets the batch size for the consumer. Default is 100.
-    /// </summary>
-    /// <param name="batchSize">The size of the batch to be consumed at one time.</param>
-    /// <returns>The current instance of <see cref="IggyConsumerBuilder" /> to allow method chaining.</returns>
-    public IggyConsumerBuilder WithBatchSize(uint batchSize)
-    {
-        Config.BatchSize = batchSize;
-
-        return this;
-    }
-
-    /// <summary>
-    ///     Configures the consumer builder with the specified auto-commit mode.
-    /// </summary>
-    /// <param name="autoCommit">The auto-commit mode to set for the consumer.</param>
-    /// <returns>The current instance of <see cref="IggyConsumerBuilder" /> to allow method chaining.</returns>
-    public IggyConsumerBuilder WithAutoCommitMode(AutoCommitMode autoCommit)
-    {
-        Config.AutoCommit = autoCommit == AutoCommitMode.Auto;
-        Config.AutoCommitMode = autoCommit;
-
-        return this;
-    }
-
-    /// <summary>
     ///     Sets the logger factory for the consumer builder.
     /// </summary>
     /// <param name="loggerFactory">The logger factory to be used for logging.</param>
@@ -232,25 +209,24 @@
     /// </summary>
     /// <param name="groupName">The name of the consumer group if consumer kind is numeric</param>
     /// <param name="createIfNotExists">Whether to create the consumer group if it doesn't exist.</param>
-    /// <param name="joinGroup">Whether to join the consumer group after creation/verification.</param>
     /// <returns>The current instance of <see cref="IggyConsumerBuilder" /> to allow method chaining.</returns>
-    public IggyConsumerBuilder WithConsumerGroup(string groupName, bool createIfNotExists = true, bool joinGroup = true)
+    public IggyConsumerBuilder WithConsumerGroup(string groupName, bool createIfNotExists = true)
     {
         Config.ConsumerGroupName = groupName;
         Config.CreateConsumerGroupIfNotExists = createIfNotExists;
-        Config.JoinConsumerGroup = joinGroup;
 
         return this;
     }
 
     /// <summary>
-    ///     Sets the interval between polling attempts to control the rate of server requests
+    ///     Sets the limits of each read and the commit policy. See <see cref="IggyConsumerConfig.PollOptions" />.
     /// </summary>
-    /// <param name="interval">The polling interval as a TimeSpan. Set to TimeSpan.Zero to disable throttling.</param>
+    /// <param name="options">The limits of each read and the commit policy.</param>
     /// <returns>The current instance of <see cref="IggyConsumerBuilder" /> to allow method chaining</returns>
-    public IggyConsumerBuilder WithPollingInterval(TimeSpan interval)
+    public IggyConsumerBuilder WithPollOptions(PollOptions options)
     {
-        Config.PollingIntervalMs = (int)interval.TotalMilliseconds;
+        ArgumentNullException.ThrowIfNull(options);
+        Config.PollOptions = options;
         return this;
     }
 
@@ -357,10 +333,10 @@
         }
 
         var hasEncryptor = _encryptor != null || IggyClient?.MessageEncryptor != null;
-        if (hasEncryptor && Config.AutoCommitMode == AutoCommitMode.Auto)
+        if (hasEncryptor && Config.PollOptions?.Commit.Mode == CommitMode.OnRead)
         {
             throw new InvalidOperationException(
-                "AutoCommitMode.Auto with a message encryptor risks silent message loss: the offset is committed before decryption. Use AutoCommitMode.AfterReceive or AutoCommitMode.Disabled.");
+                "Storing the offset on read with a message encryptor loses a batch that does not decrypt. Commit with CommitPolicy.Manual or CommitPolicy.Auto instead.");
         }
 
         if (Config.ReceiveBufferSize is <= 0)
@@ -373,19 +349,28 @@
             throw new InvalidOperationException("SendBufferSize must be greater than 0 when set.");
         }
 
-        if (Config.BatchSize == 0)
+        if (Config.PollOptions is null)
         {
-            throw new InvalidOperationException("BatchSize must be greater than 0.");
+            throw new InvalidOperationException("PollOptions must be set.");
         }
 
-        if (Config.BatchSize > int.MaxValue / 2)
+        try
         {
-            throw new InvalidOperationException($"BatchSize must be less than or equal to {int.MaxValue / 2}.");
+            Config.PollOptions.Validate();
+        }
+        catch (IggyInvalidStatusCodeException error)
+        {
+            throw new InvalidOperationException($"PollOptions are invalid: {error.Message}", error);
         }
 
-        if (Config.PollingIntervalMs < 0)
+        if (Config.Consumer.Type == ConsumerType.Consumer && Config.Partitions.Count == 0)
         {
-            throw new InvalidOperationException("PollingIntervalMs cannot be negative.");
+            throw new InvalidOperationException("A standalone consumer needs partitions, set them with WithPartitions.");
+        }
+
+        if (Config.Consumer.Type == ConsumerType.ConsumerGroup && Config.Partitions.Count > 0)
+        {
+            throw new InvalidOperationException("A consumer group reads the partitions of its assignment.");
         }
 
         if (Config.Consumer.Type == ConsumerType.ConsumerGroup)
diff --git a/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerConfig.cs b/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerConfig.cs
index 12aeddb..b9d0232 100644
--- a/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerConfig.cs
+++ b/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerConfig.cs
@@ -16,6 +16,7 @@
 // under the License.
 
 using Apache.Iggy.Configuration;
+using Apache.Iggy.Contracts;
 using Apache.Iggy.Enums;
 using Apache.Iggy.Kinds;
 using Microsoft.Extensions.Logging;
@@ -100,10 +101,10 @@
     public Identifier TopicId { get; set; }
 
     /// <summary>
-    ///     Optional partition ID to consume from. If null, selects partition 0.
-    ///     Note: This is ignored when using consumer groups.
+    ///     The partitions of a standalone consumer. A consumer group reads the assignment of its member and refuses
+    ///     partitions here.
     /// </summary>
-    public uint? PartitionId { get; set; }
+    public IReadOnlyList<uint> Partitions { get; set; } = [];
 
     /// <summary>
     ///     The consumer configuration (single consumer or consumer group)
@@ -113,22 +114,7 @@
     /// <summary>
     ///     The polling strategy defining from where to start consuming messages
     /// </summary>
-    public PollingStrategy PollingStrategy { get; set; }
-
-    /// <summary>
-    ///     The maximum number of messages to fetch in a single poll. Default is 100.
-    /// </summary>
-    public uint BatchSize { get; set; } = 100;
-
-    /// <summary>
-    ///     Whether to enable automatic offset committing
-    /// </summary>
-    public bool AutoCommit { get; set; }
-
-    /// <summary>
-    ///     The auto-commit mode determining when offsets are stored
-    /// </summary>
-    public AutoCommitMode AutoCommitMode { get; set; }
+    public PollingStrategy PollingStrategy { get; set; } = PollingStrategy.Next();
 
     /// <summary>
     ///     The name of the consumer group (used when consumer ID is numeric)
@@ -141,14 +127,12 @@
     public bool CreateConsumerGroupIfNotExists { get; set; } = true;
 
     /// <summary>
-    ///     Whether to automatically join the consumer group. Default is true.
+    ///     The limits of each read and the commit policy. By default a read waits up to 5 seconds for messages,
+    ///     returns up to 1000 messages and 1 MiB, and nothing is stored: commit
+    ///     <see cref="ReceivedMessage.Checkpoint" /> with <see cref="IggyConsumer.CommitAsync" />, or choose
+    ///     <see cref="CommitPolicy.OnRead" /> or <see cref="CommitPolicy.Auto" />.
     /// </summary>
-    public bool JoinConsumerGroup { get; set; } = true;
-
-    /// <summary>
-    ///     The interval in milliseconds between message polls. Default is 100ms.
-    /// </summary>
-    public int PollingIntervalMs { get; set; } = 100;
+    public PollOptions PollOptions { get; set; } = new() { MaxWait = TimeSpan.FromSeconds(5) };
 
     /// <summary>
     ///     Optional logger factory for creating loggers
diff --git a/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerOfT.cs b/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerOfT.cs
index 416675f..8a8b78a 100644
--- a/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerOfT.cs
+++ b/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerOfT.cs
@@ -16,9 +16,8 @@
 // under the License.
 
 using System.Runtime.CompilerServices;
-using System.Threading.Channels;
-using Apache.Iggy.Contracts;
 using Apache.Iggy.Exceptions;
+using Apache.Iggy.Headers;
 using Apache.Iggy.IggyClient;
 using Microsoft.Extensions.Logging;
 
@@ -31,9 +30,6 @@
 /// <typeparam name="T">The type to deserialize message payloads to</typeparam>
 public class IggyConsumer<T> : IggyConsumer
 {
-    private readonly Channel<ReceivedMessage<T>> _deserializedChannel =
-        Channel.CreateUnbounded<ReceivedMessage<T>>();
-
     private readonly IggyConsumerConfig<T> _typedConfig;
     private readonly ILogger<IggyConsumer<T>> _typedLogger;
 
@@ -51,106 +47,50 @@
     }
 
     /// <summary>
-    ///     Receives and deserializes messages via the rented poll path. Each polled batch is deserialized
-    ///     in full before any message is yielded — the rented buffer is returned immediately after
-    ///     deserialization, independently of how fast the caller iterates. The caller does not need to
-    ///     dispose anything. The stream ends once the consumer is disposed.
+    ///     Receives and deserializes messages one at a time, as <see cref="IggyConsumer.ReceiveAsync" /> receives them.
+    ///     A payload that does not deserialize comes with <see cref="MessageStatus.DeserializationFailed" /> and its
+    ///     error.
     /// </summary>
-    /// <param name="ct">Cancellation token.</param>
-    /// <returns>Async enumerable of deserialized messages with status.</returns>
+    /// <param name="ct">Cancellation token to stop receiving messages</param>
+    /// <exception cref="ConsumerNotInitializedException">Thrown when InitAsync has not been called</exception>
     public async IAsyncEnumerable<ReceivedMessage<T>> ReceiveDeserializedAsync(
         [EnumeratorCancellation] CancellationToken ct = default)
     {
-        if (IsDisposed)
+        await foreach (var received in ReceiveAsync(ct))
         {
-            yield break;
-        }
-        if (!IsInitialized)
-        {
-            throw new ConsumerNotInitializedException();
-        }
-
-        do
-        {
-            if (!_deserializedChannel.Reader.TryRead(out ReceivedMessage<T>? message))
-            {
-                await PollRentedMessagesCoreAsync(ct);
-                continue;
-            }
-
-            if (!BeginDelivery(message.Header.Offset, message.PartitionId, message.Context))
-            {
-                yield break;
-            }
-            yield return message;
-
-            if (_typedConfig.AutoCommitMode == AutoCommitMode.AfterReceive)
-            {
-                await CommitDeliveredAsync(message.Header.Offset, message.PartitionId, message.Context, ct);
-            }
-        } while (!ct.IsCancellationRequested && !IsDisposed);
-    }
-
-    private protected override void CompleteReceiveChannels()
-    {
-        base.CompleteReceiveChannels();
-        _deserializedChannel.Writer.TryComplete();
-        while (_deserializedChannel.Reader.TryRead(out _)) { }
-    }
-
-    /// <summary>
-    ///     Overrides the base batch-publishing step: instead of routing rented messages through the base
-    ///     class channel, deserializes the entire batch immediately (releasing all rented buffer refs via
-    ///     <see cref="IDisposable.Dispose" />) and writes the deserialized results to
-    ///     <see cref="_deserializedChannel" />. Auto-commit is also handled here since the base-class
-    ///     yield path is bypassed.
-    /// </summary>
-    protected override async Task PublishRentedAsync(RentedBatchHandle rental, RentedMessageResponse message,
-        uint partitionId, MessageStatus status,
-        Exception? error, CancellationToken ct)
-    {
-        T? data = default;
-        var deserError = status != MessageStatus.Success ? error : null;
-        var msgStatus = status;
-
-        if (status == MessageStatus.Success)
-        {
+            T? data = default;
+            Dictionary<HeaderKey, HeaderValue>? headers = null;
+            var status = MessageStatus.Success;
+            Exception? error = null;
             try
             {
-                data = Deserialize(message.Payload);
+                data = Deserialize(received.Message.Payload);
+                headers = received.Message.UserHeaders;
             }
-            catch (Exception ex)
+            catch (Exception exception)
             {
-                _typedLogger.LogError(ex, "Failed to deserialize message at offset {Offset}",
-                    message.Header.Offset);
-                msgStatus = MessageStatus.DeserializationFailed;
-                deserError = ex;
+                _typedLogger.LogError(exception, "Failed to deserialize message at offset {Offset}",
+                    received.Message.Header.Offset);
+                status = MessageStatus.DeserializationFailed;
+                error = exception;
             }
+
+            yield return new ReceivedMessage<T>
+            {
+                Data = data,
+                Header = received.Message.Header,
+                UserHeaders = headers,
+                CurrentOffset = received.CurrentOffset,
+                PartitionId = received.PartitionId,
+                Context = received.Context,
+                Checkpoint = received.Checkpoint,
+                Status = status,
+                Error = error
+            };
         }
-
-        var deserialized = new ReceivedMessage<T>
-        {
-            Data = data,
-            Header = message.Header,
-            UserHeaders = message.UserHeaders,
-            CurrentOffset = message.Header.Offset,
-            PartitionId = partitionId,
-            Context = rental.Context,
-            Status = msgStatus,
-            Error = deserError
-        };
-
-        await _deserializedChannel.Writer.WriteAsync(deserialized, ct);
-
-        rental.Release();
     }
 
-    /// <summary>
-    ///     Deserializes a message payload from a span using the configured deserializer. Zero-copy when the
-    ///     deserializer overrides the span overload; otherwise falls back to a one-time array copy.
-    /// </summary>
-    /// <param name="payload">The payload memory to deserialize.</param>
-    /// <returns>The deserialized object of type T.</returns>
+    /// <summary>Deserializes a payload with the deserializer of the configuration.</summary>
     public T Deserialize(ReadOnlyMemory<byte> payload)
     {
         return _typedConfig.Deserializer.Deserialize(payload);
diff --git a/foreign/csharp/Iggy_SDK/Consumers/PartitionReader.cs b/foreign/csharp/Iggy_SDK/Consumers/PartitionReader.cs
new file mode 100644
index 0000000..3e759ac
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK/Consumers/PartitionReader.cs
@@ -0,0 +1,1770 @@
+// 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.
+
+using System.Runtime.CompilerServices;
+using Apache.Iggy.Contracts;
+using Apache.Iggy.Enums;
+using Apache.Iggy.Exceptions;
+using Apache.Iggy.IggyClient;
+using Apache.Iggy.Kinds;
+using Apache.Iggy.Mappers;
+using Apache.Iggy.Vsr;
+using Microsoft.Extensions.Logging;
+
+namespace Apache.Iggy.Consumers;
+
+/// <summary>
+///     Reads the partitions of a topic in batches, with routing, read ahead and the position of each partition
+///     inside. Build one with <see cref="Extensions.IggyClientExtension.Reader" />.
+/// </summary>
+/// <remarks>
+///     <list type="bullet">
+///         <item>
+///             A standalone consumer reads the partitions of its builder. A consumer group reads the assignment of its
+///             member: the reader joins the group, joins again after a session loss and leaves it in
+///             <see cref="CloseAsync" />.
+///         </item>
+///         <item>
+///             Over TCP the reader reads the partitions of one primary in one request, and finds a moved primary by
+///             itself. Over HTTP it reads each partition with its own poll, and a consumer group is not available.
+///         </item>
+///         <item>
+///             <see cref="NextAsync" /> returns the next batch of one partition, in offset order within one generation
+///             of the partition: from its first read, from each <see cref="SeekAsync" />, and from each new assignment
+///             to this member.
+///         </item>
+///         <item>
+///             The <see cref="CommitPolicy" /> of the options decides who stores offsets. With
+///             <see cref="CommitMode.Manual" />, <see cref="CommitAsync" /> stores the offset of a checkpoint. With
+///             <see cref="CommitMode.OnRead" />, the server stores the offset of each read, the reader reads only
+///             while the application waits, and <see cref="CloseAsync" /> moves the offset back to the last delivered
+///             message. With <see cref="CommitMode.Auto" />, the reader counts each batch as its
+///             <see cref="CommitAfter" /> says, or each <see cref="Mark" />, and stores the latest counted position at
+///             the interval or the event of the policy and in <see cref="CloseAsync" />.
+///         </item>
+///         <item>
+///             The reader stays usable after a <see cref="ReadException" /> and retries by itself. Status 87
+///             (HistoryUnavailable) stops its partition until a <see cref="SeekAsync" /> when the position came from an
+///             earlier read of a history that is gone. A refusal that a quick retry does not change, such as status 41
+///             (Unauthorized), 3007 (PartitionNotFound) or 5009 (ConsumerGroupPartitionNotOwned), is retried after a
+///             wait that doubles each time, and after six in a row it stops its partition until a
+///             <see cref="SeekAsync" /> with one error. Status 5009 also drops the position of its partition, which
+///             reads again from the start of the reader. After a read whose outcome is unknown the partition waits the
+///             retry interval. With <see cref="CommitMode.OnRead" />, a read whose reply was lost is repaired: the next
+///             read finds the messages that the lost read stored and reads them again without a commit before it goes
+///             on. A read that starts past its position while no read was lost delivers from there.
+///         </item>
+///         <item>One caller at a time calls <see cref="NextAsync" />. The other members are safe to call concurrently.</item>
+///     </list>
+/// </remarks>
+public sealed class PartitionReader : IAsyncDisposable
+{
+    private const int MaxLastingRefusals = 6;
+    private const int MaxFetchSlots = 16;
+    private const int ConsumerOffsetNotFound = 3021;
+    private static readonly TimeSpan GroupRefreshInterval = TimeSpan.FromSeconds(1);
+    private static long _readers;
+
+    private readonly IConsumerGroupAssignmentProvider? _assignment;
+    private readonly IIggyClient _client;
+    private readonly IPartitionFetchClient? _fetcher;
+    private readonly object _gate = new();
+    private readonly long _id = Interlocked.Increment(ref _readers);
+    private readonly CancellationTokenSource _lifetime = new();
+    private readonly ILogger _logger;
+    private readonly LinkedList<Entry> _queue = new();
+    private readonly HashSet<Task> _reads = [];
+    private readonly ReaderSettings _settings;
+    private readonly SortedDictionary<uint, PartitionState> _states = [];
+    private readonly Dictionary<uint, ulong> _sought = [];
+    private readonly SemaphoreSlim _storeSignal = new(0, 1);
+    private readonly SemaphoreSlim _wake = new(0, 1);
+    private readonly Task _committer;
+    private readonly Task _worker;
+    private TaskCompletionSource _arrival = NewArrival();
+    private bool _closed;
+    private Task? _closing;
+    private bool _demand;
+    private int _fetching;
+    private readonly List<Task> _seeks = [];
+    private ulong _claims;
+    private ulong _generations;
+    private ulong _serves;
+    private ulong _joinedSession;
+    private long _queuedBytes;
+    private long _queuedMessages;
+    private bool _resync;
+    private long? _syncedAt;
+    private (Checkpoint Checkpoint, uint Messages)? _uncounted;
+
+    internal PartitionReader(IIggyClient client, IConsumerGroupAssignmentProvider? assignment,
+        ReaderSettings settings, ILogger logger)
+    {
+        _client = client;
+        _assignment = assignment;
+        _fetcher = client as IPartitionFetchClient;
+        _settings = settings;
+        _logger = logger;
+        _joinedSession = (client as ISessionGenerationProvider)?.SessionGeneration ?? 0;
+        foreach (var partition in settings.Partitions)
+        {
+            _states[partition] = new PartitionState(settings.Start, ++_generations);
+        }
+
+        _worker = Task.Run(RunAsync);
+        _committer = AutoSettings is { } auto ? Task.Run(() => CommitLoopAsync(auto)) : Task.CompletedTask;
+    }
+
+    /// <summary>
+    ///     The partitions that the reader reads: those of a standalone consumer, or the current assignment of a group
+    ///     member. A group reader holds no partition until the first refresh of its assignment.
+    /// </summary>
+    public IReadOnlyList<uint> Partitions
+    {
+        get
+        {
+            lock (_gate)
+            {
+                return [.. _states.Keys];
+            }
+        }
+    }
+
+    private bool OnRead => _settings.Options.Commit.Mode == CommitMode.OnRead;
+
+    private AutoCommit? AutoSettings => _settings.Options.Commit.Settings;
+
+    /// <summary>Closes the reader as <see cref="CloseAsync" /> does, and logs the error of the close instead of throwing it.</summary>
+    public async ValueTask DisposeAsync()
+    {
+        try
+        {
+            await CloseAsync();
+        }
+        catch (Exception error)
+        {
+            _logger.LogWarning(error, "Closing the partition reader failed.");
+        }
+    }
+
+    /// <summary>
+    ///     The next batch of one partition. Never empty, and it waits until a batch arrives. A canceled call leaves
+    ///     the batches in the reader.
+    /// </summary>
+    /// <exception cref="ReadException">An error of a read. The reader stays usable.</exception>
+    /// <exception cref="ObjectDisposedException">The reader is closed.</exception>
+    public async ValueTask<PolledMessages> NextAsync(CancellationToken token = default)
+    {
+        CountUncounted();
+        while (true)
+        {
+            Task arrival;
+            lock (_gate)
+            {
+                ObjectDisposedException.ThrowIf(_closed, this);
+                while (_queue.First is { } node)
+                {
+                    _queue.RemoveFirst();
+                    var entry = node.Value;
+                    Release(entry);
+                    if (!IsCurrent(entry))
+                    {
+                        continue;
+                    }
+
+                    _demand = false;
+                    if (entry.Error is { } error)
+                    {
+                        throw error;
+                    }
+
+                    return Deliver(entry.Batch!);
+                }
+
+                _demand = true;
+                arrival = _arrival.Task;
+            }
+
+            Wake();
+            await arrival.WaitAsync(token);
+        }
+    }
+
+    /// <summary>Every batch, until a read fails or <paramref name="token" /> is canceled.</summary>
+    /// <exception cref="ReadException">The first error of a read.</exception>
+    public async IAsyncEnumerable<PolledMessages> ReadAllAsync([EnumeratorCancellation] CancellationToken token = default)
+    {
+        while (true)
+        {
+            yield return await NextAsync(token);
+        }
+    }
+
+    /// <summary>
+    ///     Counts a checkpoint that this reader delivered, under <see cref="CommitMode.Auto" />. It stores nothing
+    ///     now: the policy stores the latest counted position of the partition. With <see cref="CommitAfter.Marked" />
+    ///     only marks count, so batches processed concurrently count once they are done.
+    /// </summary>
+    /// <exception cref="IggyInvalidStatusCodeException">
+    ///     Status 3 without <see cref="CommitMode.Auto" />, and for a checkpoint that this reader did not deliver.
+    /// </exception>
+    public void Mark(Checkpoint checkpoint)
+    {
+        ArgumentNullException.ThrowIfNull(checkpoint);
+        if (AutoSettings is null)
+        {
+            throw VsrError.Exception(VsrError.INVALID_COMMAND, "Only an automatic commit policy counts marks.");
+        }
+
+        ThrowIfForeign(checkpoint);
+        Count(checkpoint, 1, false);
+    }
+
+    /// <summary>
+    ///     Stores the offset of a checkpoint that this reader delivered. A checkpoint of an older generation of its
+    ///     partition, or at or below the last commit of its generation, stores nothing.
+    /// </summary>
+    /// <exception cref="IggyInvalidStatusCodeException">
+    ///     Status 3 with <see cref="CommitMode.OnRead" />, and for a checkpoint that this reader did not deliver. The
+    ///     errors of the offset store.
+    /// </exception>
+    public async Task CommitAsync(Checkpoint checkpoint, CancellationToken token = default)
+    {
+        ArgumentNullException.ThrowIfNull(checkpoint);
+        if (OnRead)
+        {
+            throw VsrError.Exception(VsrError.INVALID_COMMAND, "The server stores the offsets of an on read policy.");
+        }
+
+        ThrowIfForeign(checkpoint);
+        lock (_gate)
+        {
+            if (!_states.TryGetValue(checkpoint.PartitionId, out var state)
+                || state.Generation != checkpoint.Stamp!.Value.Generation
+                || state.Stored is { } stored && stored.Offset >= checkpoint.Offset)
+            {
+                return;
+            }
+        }
+
+        await _client.StoreConsumerPositionAsync(_settings.Consumer, _settings.StreamId, _settings.TopicId,
+            checkpoint, token);
+        RecordStored(checkpoint);
+    }
+
+    /// <summary>
+    ///     Sets the next read position of <paramref name="partitionId" /> and starts a new generation of it. It stores
+    ///     the queued batches of the partition, ignores its late replies, and resumes a stopped partition. With
+    ///     <see cref="CommitMode.OnRead" />, a seek to an offset above 0 first stores the offset before it on the
+    ///     server, so a restart resumes at the sought offset, a refused store fails the seek and moves nothing, and the
+    ///     seek then waits for the read of the partition in flight, whose batch it drops. Every other seek stores
+    ///     nothing.
+    /// </summary>
+    /// <exception cref="IggyInvalidStatusCodeException">
+    ///     Status 3 for a partition that the reader does not read, and for a continuation of another partition.
+    /// </exception>
+    /// <exception cref="ObjectDisposedException">The reader is closed.</exception>
+    public async Task SeekAsync(uint partitionId, PollingStrategy start, CancellationToken token = default)
+    {
+        var seek = SeekOnceAsync(partitionId, start, token);
+        lock (_gate)
+        {
+            _seeks.Add(seek);
+        }
+
+        try
+        {
+            await seek;
+        }
+        finally
+        {
+            lock (_gate)
+            {
+                _seeks.Remove(seek);
+            }
+        }
+    }
+
+    private async Task SeekOnceAsync(uint partitionId, PollingStrategy start, CancellationToken token)
+    {
+        start.ValidatePartition(partitionId);
+        if (start.Kind == MessagePolling.First)
+        {
+            start = start with { Kind = MessagePolling.Offset, Value = 0 };
+        }
+
+        (ulong Offset, PartitionContext Context)? stored = null;
+        lock (_gate)
+        {
+            ObjectDisposedException.ThrowIf(_closed, this);
+            var state = State(partitionId);
+            if (OnRead && start.Kind == MessagePolling.Offset && start.Value > 0)
+            {
+                stored = (start.Value - 1, start.Context ?? state.Handed?.Context ?? default);
+            }
+        }
+
+        if (stored is { } store)
+        {
+            await _client.StoreOffsetAsync(_settings.Consumer, _settings.StreamId, _settings.TopicId, store.Offset,
+                partitionId, store.Context, token);
+        }
+
+        Task idle;
+        bool raced;
+        lock (_gate)
+        {
+            var state = State(partitionId);
+            idle = state.InFlight && OnRead ? state.Idle.Task : Task.CompletedTask;
+            raced = state.InFlight || _queue.Any(entry => entry.PartitionId == partitionId && entry.Batch is not null);
+        }
+
+        await idle.WaitAsync(token);
+
+        lock (_gate)
+        {
+            var state = State(partitionId);
+            state.Generation = ++_generations;
+            _sought[partitionId] = state.Generation;
+            state.Position = start with { PartitionId = partitionId };
+            state.Stopped = null;
+            state.Lasting = 0;
+            state.RetryAt = 0;
+            state.CatchUpUntil = null;
+            state.CatchUpBatch = null;
+            // A committing read that the server admitted after the store moved the offset on, so the close puts it back.
+            state.PendingStart = raced && stored is not null ? state.Position : null;
+            state.Lost = false;
+            state.AloneUntil = null;
+            state.Counted = null;
+            state.CountedSinceStore = 0;
+            state.Stored = null;
+            state.Delivered = stored;
+            state.Handed = stored;
+            state.FirstHanded = null;
+            for (var node = _queue.First; node is not null;)
+            {
+                var next = node.Next;
+                if (node.Value.PartitionId == partitionId)
+                {
+                    Release(node.Value);
+                    _queue.Remove(node);
+                }
+
+                node = next;
+            }
+        }
+
+        Wake();
+    }
+
+    /// <summary>
+    ///     Stops the reader. It waits for the reads in flight. With <see cref="CommitMode.Manual" /> it commits
+    ///     nothing. With <see cref="CommitMode.Auto" />, it counts the last batch under
+    ///     <see cref="CommitAfter.Processing" /> and stores the latest counted position of each partition. With
+    ///     <see cref="CommitMode.OnRead" />, it moves the offset of each partition with undelivered batches back to
+    ///     its last delivered message. A partition that delivered nothing and started at Next gets the offset before
+    ///     its first undelivered message, or no offset at offset 0. A group reader then leaves its group.
+    /// </summary>
+    /// <remarks>A later call waits for the first close and ends as it does.</remarks>
+    /// <exception cref="Exception">The first failed store of the close.</exception>
+    public async ValueTask CloseAsync()
+    {
+        TaskCompletionSource? closing = null;
+        Task closed;
+        lock (_gate)
+        {
+            if (_closing is null)
+            {
+                closing = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+                _closing = closing.Task;
+                _closed = true;
+            }
+
+            closed = _closing;
+        }
+
+        if (closing is not null)
+        {
+            try
+            {
+                await CloseOnceAsync();
+                closing.SetResult();
+            }
+            catch (Exception error)
+            {
+                closing.SetException(error);
+            }
+        }
+
+        await closed;
+    }
+
+    private async Task CloseOnceAsync()
+    {
+        CountUncounted();
+        await _lifetime.CancelAsync();
+        await _worker;
+        Task[] reads;
+        lock (_gate)
+        {
+            reads = [.. _reads];
+        }
+
+        await Task.WhenAll(reads);
+        Task[] seeks;
+        lock (_gate)
+        {
+            seeks = [.. _seeks];
+        }
+
+        // A seek whose store waited for a read still applies, so the rewind sees its position and not the dropped batch.
+        foreach (var seek in seeks)
+        {
+            try
+            {
+                await seek;
+            }
+            catch (Exception)
+            {
+                // The seek reports its own failure.
+            }
+        }
+
+        await _committer;
+        Exception? failure = null;
+        if (OnRead)
+        {
+            failure = await RewindAsync();
+        }
+        else if (AutoSettings is not null)
+        {
+            failure = await FlushCountedAsync();
+        }
+
+        if (_assignment is not null)
+        {
+            try
+            {
+                await _client.LeaveConsumerGroupAsync(_settings.StreamId, _settings.TopicId,
+                    _settings.Consumer.ConsumerId);
+            }
+            catch (Exception error)
+            {
+                _logger.LogWarning(error, "The partition reader could not leave its consumer group.");
+            }
+        }
+
+        lock (_gate)
+        {
+            _queue.Clear();
+        }
+
+        _lifetime.Dispose();
+        if (failure is not null)
+        {
+            throw failure;
+        }
+    }
+
+    private static TaskCompletionSource NewArrival()
+    {
+        return new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+    }
+
+    /// <summary>
+    ///     Records the last message that the owner of a reader built for a consumer handed over, so the close of an
+    ///     on read policy moves the offset back to it.
+    /// </summary>
+    internal void Delivered(Checkpoint checkpoint)
+    {
+        lock (_gate)
+        {
+            if (_states.TryGetValue(checkpoint.PartitionId, out var state)
+                && state.Generation == checkpoint.Stamp?.Generation)
+            {
+                state.Delivered = (checkpoint.Offset, state.Handed?.Context ?? default);
+            }
+        }
+    }
+
+    /// <summary>Counts <paramref name="messages" /> up to <paramref name="checkpoint" /> under an automatic policy.</summary>
+    internal void CountDelivered(Checkpoint checkpoint, uint messages, bool endsBatch)
+    {
+        Count(checkpoint, messages, endsBatch);
+    }
+
+    private PolledMessages Deliver(PolledMessages batch)
+    {
+        var checkpoint = batch.Checkpoint!;
+        if (_states.TryGetValue(batch.PartitionId, out var state) && state.Generation == checkpoint.Stamp?.Generation)
+        {
+            state.FirstHanded ??= batch.Messages[0].Header.Offset;
+            state.Handed = (checkpoint.Offset, batch.Context);
+            if (!_settings.CountsMessages)
+            {
+                state.Delivered = state.Handed;
+            }
+        }
+
+        if (_settings.CountsMessages)
+        {
+            return batch;
+        }
+
+        switch (AutoSettings?.After)
+        {
+            case CommitAfter.Delivery:
+                Count(checkpoint, (uint)batch.Messages.Count, true);
+                break;
+            case CommitAfter.Processing:
+                _uncounted = (checkpoint, (uint)batch.Messages.Count);
+                break;
+        }
+
+        return batch;
+    }
+
+    private void CountUncounted()
+    {
+        (Checkpoint Checkpoint, uint Messages)? uncounted;
+        lock (_gate)
+        {
+            uncounted = _uncounted;
+            _uncounted = null;
+        }
+
+        if (uncounted is { } batch)
+        {
+            Count(batch.Checkpoint, batch.Messages, true);
+        }
+    }
+
+    private void Count(Checkpoint checkpoint, uint messages, bool endsBatch)
+    {
+        var due = false;
+        lock (_gate)
+        {
+            if (!_states.TryGetValue(checkpoint.PartitionId, out var state)
+                || state.Generation != checkpoint.Stamp?.Generation)
+            {
+                return;
+            }
+
+            if (state.Counted is null || state.Counted.Offset < checkpoint.Offset)
+            {
+                state.Counted = checkpoint;
+            }
+
+            state.CountedSinceStore += messages;
+            due = AutoSettings?.Every is { } every && every.Kind switch
+            {
+                CommitEveryKind.Message => true,
+                CommitEveryKind.Messages => state.CountedSinceStore >= every.Count,
+                _ => endsBatch
+            };
+        }
+
+        if (due)
+        {
+            Signal(_storeSignal);
+        }
+    }
+
+    private void ThrowIfForeign(Checkpoint checkpoint)
+    {
+        if (checkpoint.Stamp?.Reader != _id)
+        {
+            throw VsrError.Exception(VsrError.INVALID_COMMAND, "The checkpoint was not delivered by this reader.");
+        }
+    }
+
+    private void RecordStored(Checkpoint checkpoint)
+    {
+        lock (_gate)
+        {
+            if (_states.TryGetValue(checkpoint.PartitionId, out var state)
+                && state.Generation == checkpoint.Stamp?.Generation
+                && (state.Stored is null || state.Stored.Offset < checkpoint.Offset))
+            {
+                state.Stored = checkpoint;
+                if (state.Counted is null || state.Counted.Offset <= checkpoint.Offset)
+                {
+                    state.CountedSinceStore = 0;
+                }
+            }
+        }
+    }
+
+    private async Task CommitLoopAsync(AutoCommit auto)
+    {
+        var token = _lifetime.Token;
+        while (true)
+        {
+            try
+            {
+                await _storeSignal.WaitAsync(auto.Interval ?? Timeout.InfiniteTimeSpan, token);
+            }
+            catch (OperationCanceledException)
+            {
+                return;
+            }
+
+            if (await FlushCountedAsync() is { } error)
+            {
+                _logger.LogWarning(error, "The partition reader could not store a counted position.");
+            }
+        }
+    }
+
+    /// <summary>Stores the latest counted position of each partition ahead of its last store, one at a time.</summary>
+    private async Task<Exception?> FlushCountedAsync()
+    {
+        List<Checkpoint> due;
+        lock (_gate)
+        {
+            due =
+            [
+                .. _states.Values
+                    .Where(state => state.Counted is { } counted && counted.Stamp?.Generation == state.Generation
+                                                                && (state.Stored is null
+                                                                    || state.Stored.Offset < counted.Offset))
+                    .Select(state => state.Counted!)
+            ];
+        }
+
+        Exception? failure = null;
+        foreach (var checkpoint in due)
+        {
+            try
+            {
+                await _client.StoreConsumerPositionAsync(_settings.Consumer, _settings.StreamId, _settings.TopicId,
+                    checkpoint);
+                RecordStored(checkpoint);
+            }
+            catch (Exception error)
+            {
+                failure ??= error;
+            }
+        }
+
+        return failure;
+    }
+
+    /// <summary>
+    ///     With <see cref="CommitMode.OnRead" /> the server stored the offsets of batches that the application never
+    ///     took, so each partition with one moves its offset back.
+    /// </summary>
+    private async Task<Exception?> RewindAsync()
+    {
+        List<(uint Partition, ulong? Offset, PartitionContext Context)> rewinds = [];
+        lock (_gate)
+        {
+            var undelivered = new Dictionary<uint, PolledMessages>();
+            foreach (var entry in _queue)
+            {
+                if (entry.Batch is { } batch && IsCurrent(entry))
+                {
+                    undelivered.TryAdd(batch.PartitionId, batch);
+                }
+            }
+
+            foreach (var (partition, state) in _states)
+            {
+                var handedOver = state.Handed is { } handed
+                                 && (state.Delivered is not { } last || last.Offset < handed.Offset);
+                undelivered.TryGetValue(partition, out var queued);
+                queued ??= state.CatchUpBatch;
+                if (!handedOver && queued is null && state.PendingStart is null)
+                {
+                    continue;
+                }
+
+                if (state.Delivered is { } delivered)
+                {
+                    rewinds.Add((partition, delivered.Offset, delivered.Context));
+                }
+                else if (_settings.Start.Kind == MessagePolling.Next)
+                {
+                    // A lost read stored from its start, which lies before the batch that the next read held back.
+                    var first = Math.Min(state.FirstHanded ?? queued?.Messages[0].Header.Offset ?? ulong.MaxValue,
+                        state.PendingStart?.Value ?? ulong.MaxValue);
+                    rewinds.Add((partition, first == 0 ? null : first - 1,
+                        state.Handed?.Context ?? queued?.Context ?? state.PendingStart!.Value.Context!.Value));
+                }
+            }
+        }
+
+        Exception? failure = null;
+        foreach (var (partition, offset, context) in rewinds)
+        {
+            try
+            {
+                if (offset is { } stored)
+                {
+                    await _client.StoreOffsetAsync(_settings.Consumer, _settings.StreamId, _settings.TopicId, stored,
+                        partition, context);
+                }
+                else
+                {
+                    await DeleteOffsetAsync(partition, context, CancellationToken.None);
+                }
+            }
+            catch (Exception error)
+            {
+                failure ??= error;
+            }
+        }
+
+        return failure;
+    }
+
+    private async Task DeleteOffsetAsync(uint partition, PartitionContext? context, CancellationToken token)
+    {
+        try
+        {
+            if (context is { } known && _fetcher is not null)
+            {
+                await _client.DeleteOffsetAsync(_settings.Consumer, _settings.StreamId, _settings.TopicId, partition,
+                    known, token);
+            }
+            else
+            {
+                await _client.DeleteOffsetAsync(_settings.Consumer, _settings.StreamId, _settings.TopicId, partition,
+                    token);
+            }
+        }
+        catch (IggyInvalidStatusCodeException error) when (error.StatusCode == ConsumerOffsetNotFound)
+        {
+        }
+    }
+
+    private async Task RunAsync()
+    {
+        var token = _lifetime.Token;
+        while (!token.IsCancellationRequested)
+        {
+            TimeSpan wait;
+            var failed = false;
+            try
+            {
+                if (_assignment is not null)
+                {
+                    await RefreshAssignmentAsync(token);
+                }
+
+                wait = await StartReadsAsync(token);
+            }
+            catch (OperationCanceledException) when (token.IsCancellationRequested)
+            {
+                return;
+            }
+            catch (Exception error)
+            {
+                if (IsTransient(error))
+                {
+                    _logger.LogWarning(error, "The partition reader retries a refused refresh.");
+                }
+                else
+                {
+                    Report(null, 0, error);
+                }
+
+                wait = _settings.RetryInterval;
+                failed = true;
+            }
+
+            try
+            {
+                // A caller that asks again after a failure does not cut the retry interval short.
+                if (failed)
+                {
+                    await Task.Delay(wait, token);
+                }
+                else
+                {
+                    await _wake.WaitAsync(wait, token);
+                }
+            }
+            catch (OperationCanceledException)
+            {
+                return;
+            }
+        }
+    }
+
+    private async Task RefreshAssignmentAsync(CancellationToken token)
+    {
+        if (_client is ISessionGenerationProvider session && session.SessionGeneration != _joinedSession)
+        {
+            var generation = session.SessionGeneration;
+            await _client.JoinConsumerGroupAsync(_settings.StreamId, _settings.TopicId, _settings.Consumer.ConsumerId,
+                token);
+            _joinedSession = generation;
+            _resync = true;
+        }
+
+        bool resync;
+        var now = Environment.TickCount64;
+        lock (_gate)
+        {
+            // A joining member gets its partitions one at a time, so the reader asks the coordinator each interval.
+            resync = _resync || _syncedAt is not { } synced
+                             || now - synced >= (long)GroupRefreshInterval.TotalMilliseconds;
+            _resync = false;
+        }
+
+        var assigned = await _assignment!.GetAssignedPartitionsAsync(_settings.StreamId, _settings.TopicId,
+            _settings.Consumer.ConsumerId, resync, token);
+        if (resync)
+        {
+            _syncedAt = now;
+        }
+        List<Checkpoint> leaving = [];
+        lock (_gate)
+        {
+            var owned = assigned.ToHashSet();
+            foreach (var partition in _states.Keys.Where(partition => !owned.Contains(partition)).ToList())
+            {
+                var state = _states[partition];
+                if (state.Counted is { } counted && (state.Stored is null || state.Stored.Offset < counted.Offset))
+                {
+                    leaving.Add(counted);
+                }
+
+                GiveUpGap(partition, state);
+                _states.Remove(partition);
+                state.Idle.TrySetResult();
+            }
+
+            foreach (var partition in owned.Where(partition => !_states.ContainsKey(partition)))
+            {
+                _states[partition] = new PartitionState(_settings.Start, ++_generations);
+            }
+        }
+
+        foreach (var checkpoint in leaving)
+        {
+            try
+            {
+                await _client.StoreConsumerPositionAsync(_settings.Consumer, _settings.StreamId, _settings.TopicId,
+                    checkpoint, token);
+            }
+            catch (Exception error) when (error is not OperationCanceledException)
+            {
+                _logger.LogWarning(error, "Partition {Partition} left the reader before its counted position was stored.",
+                    checkpoint.PartitionId);
+            }
+        }
+    }
+
+    /// <summary>
+    ///     Starts a read for each partition that may read now, and returns how long the worker may sleep. Over TCP the
+    ///     partitions of one primary share one request. A partition reads with its own poll for a start a request of
+    ///     several partitions cannot take, while it reads again what a lost read stored, and while it reads a section
+    ///     that did not decode one message at a time.
+    /// </summary>
+    private async Task<TimeSpan> StartReadsAsync(CancellationToken token)
+    {
+        var now = Environment.TickCount64;
+        var nextRetry = long.MaxValue;
+        List<uint> grouped = [];
+        List<uint> alone = [];
+        lock (_gate)
+        {
+            var reading = OnRead
+                ? _demand && _queue.Count == 0
+                : _queuedMessages < _settings.PrefetchMessages && _queuedBytes < _settings.PrefetchBytes;
+            if (reading)
+            {
+                foreach (var (partition, state) in _states)
+                {
+                    if (state.InFlight || state.Stopped is not null)
+                    {
+                        continue;
+                    }
+
+                    if (state.RetryAt > now)
+                    {
+                        nextRetry = Math.Min(nextRetry, state.RetryAt);
+                        continue;
+                    }
+
+                    if (_fetcher is not null && state.Position.Kind is MessagePolling.Offset or MessagePolling.Next
+                                             && state.CatchUpUntil is null && state.AloneUntil is null
+                                             && !NeedsStart(state.Position))
+                    {
+                        grouped.Add(partition);
+                    }
+                    else
+                    {
+                        alone.Add(partition);
+                    }
+                }
+            }
+        }
+
+        foreach (var partition in alone)
+        {
+            StartPoll(partition);
+        }
+
+        if (grouped.Count > 0)
+        {
+            // A fetch reads its targets in order until it holds its count, so the partitions served last go last.
+            lock (_gate)
+            {
+                grouped = [.. grouped.OrderBy(partition => _states.TryGetValue(partition, out var state) ? state.ServedAt : 0)];
+            }
+
+            var groups = await _fetcher!.ReadinessGroupsAsync(_settings.StreamId, _settings.TopicId, grouped, token);
+            foreach (var fetch in Spread(groups, alone.Count))
+            {
+                StartFetch(fetch);
+            }
+        }
+
+        var wait = nextRetry == long.MaxValue
+            ? Timeout.InfiniteTimeSpan
+            : TimeSpan.FromMilliseconds(Math.Max(1, nextRetry - Environment.TickCount64));
+        if (_assignment is not null && (wait == Timeout.InfiniteTimeSpan || wait > GroupRefreshInterval))
+        {
+            wait = GroupRefreshInterval;
+        }
+
+        return wait;
+    }
+
+    /// <summary>
+    ///     Deals the backlogged partitions of each group over the fetch slots that are free, one fetch with the full
+    ///     limits each, so a backlog keeps as many reads in flight as the prefetch holds. Every group gets one fetch
+    ///     first, and its quiet partitions share one fetch, which may wait on the server. With
+    ///     <see cref="CommitMode.OnRead" /> a turn starts only while <see cref="NextAsync" /> waits, and a partition
+    ///     is in one committing read at a time, so the fetches of a turn commit disjoint partitions.
+    /// </summary>
+    private List<IReadOnlyList<uint>> Spread(IReadOnlyList<IReadOnlyList<uint>> groups, int alone)
+    {
+        List<(List<uint> Backlogged, List<uint> Quiet)> split;
+        int free;
+        lock (_gate)
+        {
+            split =
+            [
+                .. groups.Select(group => (
+                    group.Where(partition => _states.TryGetValue(partition, out var state) && state.Backlogged).ToList(),
+                    group.Where(partition => !_states.TryGetValue(partition, out var state) || !state.Backlogged).ToList()))
+            ];
+            var slots = Math.Min(_settings.PrefetchMessages / Math.Max(_settings.Options.Count, 1),
+                _settings.PrefetchBytes / Math.Max(_settings.Options.MaxBytes, 1));
+            free = (int)Math.Clamp(slots, 1, MaxFetchSlots) - _fetching - groups.Count - alone;
+        }
+
+        // Every fetch holds at least one partition, so a group without quiet partitions keeps one backlogged.
+        var extras = new int[split.Count];
+        for (var dealt = true; dealt && free > 0;)
+        {
+            dealt = false;
+            for (var index = 0; index < split.Count && free > 0; index++)
+            {
+                var (backlogged, quiet) = split[index];
+                if (extras[index] < backlogged.Count - (quiet.Count > 0 ? 0 : 1))
+                {
+                    extras[index]++;
+                    free--;
+                    dealt = true;
+                }
+            }
+        }
+
+        List<IReadOnlyList<uint>> fetches = [];
+        for (var index = 0; index < split.Count; index++)
+        {
+            var (backlogged, quiet) = split[index];
+            if (extras[index] == 0)
+            {
+                fetches.Add(groups[index]);
+                continue;
+            }
+
+            if (quiet.Count > 0)
+            {
+                fetches.Add(quiet);
+            }
+
+            var shares = quiet.Count > 0 ? extras[index] : extras[index] + 1;
+            fetches.AddRange(backlogged.Select((partition, position) => (partition, position))
+                .GroupBy(entry => entry.position % shares, entry => entry.partition)
+                .Select(share => (IReadOnlyList<uint>)[.. share]));
+        }
+
+        return fetches;
+    }
+
+    private void StartPoll(uint partition)
+    {
+        Target target;
+        bool commits;
+        bool single;
+        bool check;
+        ulong? catchUpUntil;
+        lock (_gate)
+        {
+            if (Claim(partition) is not { } claimed)
+            {
+                return;
+            }
+
+            target = claimed;
+            var state = _states[partition];
+            single = state.AloneUntil is not null;
+            check = NeedsStart(target.Sent);
+            catchUpUntil = target.Sent.Kind == MessagePolling.Offset && state.CatchUpUntil is { } until
+                                                                     && target.Sent.Value <= until
+                ? until
+                : null;
+            commits = OnRead && state.CatchUpUntil is null && !single && !check;
+            if (commits && target.Sent.Context is not null)
+            {
+                state.PendingStart ??= target.Sent;
+            }
+        }
+
+        Track(PollAsync(target, commits, single, check, catchUpUntil));
+    }
+
+    private void StartFetch(IReadOnlyList<uint> group)
+    {
+        List<Target> targets = [];
+        lock (_gate)
+        {
+            foreach (var partition in group)
+            {
+                if (Claim(partition) is { } target)
+                {
+                    targets.Add(target);
+                    if (OnRead && target.Sent.Context is not null)
+                    {
+                        _states[partition].PendingStart ??= target.Sent;
+                    }
+                }
+            }
+        }
+
+        if (targets.Count > 0)
+        {
+            lock (_gate)
+            {
+                _fetching++;
+            }
+
+            Track(FetchAsync(targets));
+        }
+    }
+
+    /// <summary>Marks an idle partition as reading, under the lock.</summary>
+    private Target? Claim(uint partition)
+    {
+        if (!_states.TryGetValue(partition, out var state) || state.InFlight || state.Stopped is not null)
+        {
+            return null;
+        }
+
+        state.InFlight = true;
+        state.Claim = ++_claims;
+        state.Idle = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+        return new Target(partition, state.Position, state.Generation, state.Claim);
+    }
+
+    private void Track(Task read)
+    {
+        lock (_gate)
+        {
+            _reads.Add(read);
+        }
+
+        read.ContinueWith(completed =>
+        {
+            lock (_gate)
+            {
+                _reads.Remove(completed);
+            }
+
+            Wake();
+        }, TaskScheduler.Default);
+    }
+
+    private async Task FetchAsync(List<Target> targets)
+    {
+        try
+        {
+            await FetchOnceAsync(targets);
+        }
+        finally
+        {
+            lock (_gate)
+            {
+                _fetching--;
+            }
+        }
+    }
+
+    private async Task FetchOnceAsync(List<Target> targets)
+    {
+        FetchedMessages reply;
+        try
+        {
+            reply = await _fetcher!.FetchMessagesAsync(_settings.StreamId, _settings.TopicId,
+                targets.ConvertAll(target => (target.Partition, target.Sent)), _settings.Consumer,
+                _settings.Options.Count, OnRead, _settings.Options.Wire, _lifetime.Token);
+        }
+        catch (Exception error)
+        {
+            await AbandonLostAsync(error);
+            foreach (var target in targets)
+            {
+                Refuse(target, error);
+            }
+
+            return;
+        }
+
+        using (reply)
+        {
+            List<(Target Target, FetchedSection? Section, PolledMessages? Polled, Exception? Error)> outcomes =
+                new(targets.Count);
+            foreach (var target in targets)
+            {
+                var section = reply.Sections.FirstOrDefault(section => section.PartitionId == target.Partition);
+                if (section is null || section.Status != 0)
+                {
+                    outcomes.Add((target, section, null, section?.Refusal()));
+                    continue;
+                }
+
+                try
+                {
+                    using var rental = section.DecodeRented();
+                    outcomes.Add((target, section, BinaryMapper.MaterializeMessages(rental), null));
+                }
+                catch (Exception error) when (error is MessageDecryptionException or MalformedResponseException)
+                {
+                    outcomes.Add((target, section, null, error));
+                }
+            }
+
+            foreach (var outcome in outcomes.Where(outcome => outcome.Section is { Status: not 0 }))
+            {
+                await AbandonLostAsync(outcome.Error!);
+            }
+
+            // Every target of the reply settles before a new read starts, so the next fetch keeps the turn order.
+            lock (_gate)
+            {
+                foreach (var (target, section, polled, error) in outcomes)
+                {
+                    if (section is null)
+                    {
+                        // A read that does not wait would ask the server again at once.
+                        Retry(target,
+                            _settings.Options.MaxWait == TimeSpan.Zero ? (long)_settings.RetryInterval.TotalMilliseconds : 0);
+                    }
+                    else if (section.Status != 0)
+                    {
+                        Refuse(target, error!);
+                    }
+                    else if (polled is null)
+                    {
+                        ReadAlone(target, section, error!);
+                    }
+                    else
+                    {
+                        Complete(target, polled, section.NextOffset);
+                    }
+                }
+            }
+        }
+    }
+
+    private bool NeedsStart(PollingStrategy position)
+    {
+        return OnRead && position.Kind == MessagePolling.Next && position.Context is null;
+    }
+
+    private async Task PollAsync(Target target, bool commits, bool single, bool check, ulong? catchUpUntil)
+    {
+        var options = _settings.Options with
+        {
+            Commit = commits ? CommitPolicy.OnRead : CommitPolicy.Manual,
+            Count = single || check ? 1 : _settings.Options.Count,
+            MinCount = single || check ? 1 : _settings.Options.MinCount
+        };
+        if (!single && !check && catchUpUntil is { } until)
+        {
+            // The server stored these messages already, so the read takes them and no more.
+            options = options with
+            {
+                Count = (uint)Math.Min(until - target.Sent.Value + 1, options.Count),
+                MinCount = 1,
+                MaxWait = TimeSpan.Zero
+            };
+        }
+        PolledMessages polled;
+        try
+        {
+            polled = await _client.PollMessagesAsync(_settings.StreamId, _settings.TopicId, target.Partition,
+                _settings.Consumer, target.Sent, options, _lifetime.Token);
+        }
+        catch (Exception error) when (target.Sent.Kind == MessagePolling.Offset
+                                      && error is MessageDecryptionException or MalformedResponseException)
+        {
+            if (single)
+            {
+                Undecodable(target, error);
+            }
+            else
+            {
+                ReadAloneFrom(target, error);
+            }
+
+            return;
+        }
+        catch (Exception error) when (commits && error is MalformedResponseException)
+        {
+            // The server stored this read, so the next read finds the range it skipped and reads it again by offset.
+            lock (_gate)
+            {
+                if (Settle(target) is { } state)
+                {
+                    state.Lost = true;
+                    state.RetryAt = Environment.TickCount64 + (long)_settings.RetryInterval.TotalMilliseconds;
+                }
+            }
+
+            _logger.LogWarning(error, "A committing read of partition {Partition} from offset {Offset} does not decode.",
+                target.Partition, target.Sent.Value);
+            return;
+        }
+        catch (Exception error)
+        {
+            await AbandonLostAsync(error);
+            Refuse(target, error);
+            return;
+        }
+
+        if (check)
+        {
+            lock (_gate)
+            {
+                if (Settle(target) is { } state)
+                {
+                    if (polled.Messages.Count > 0)
+                    {
+                        state.Position = PollingStrategy.Continuation(target.Partition, MessagePolling.Next,
+                            polled.Messages[0].Header.Offset, polled.Context);
+                    }
+                    else if (_settings.Options.MaxWait == TimeSpan.Zero)
+                    {
+                        state.RetryAt = Environment.TickCount64 + (long)_settings.RetryInterval.TotalMilliseconds;
+                    }
+                }
+            }
+
+            return;
+        }
+
+        Complete(target, polled);
+    }
+
+    /// <summary>
+    ///     Abandons the operation of a lost committing read of this reader, so its partitions read again. The next
+    ///     read finds what it stored.
+    /// </summary>
+    private async Task AbandonLostAsync(Exception error)
+    {
+        if (error is not VsrFetchProgressUncertainException { OperationId: { } operation })
+        {
+            return;
+        }
+
+        try
+        {
+            await _client.AbandonOperationAsync(operation);
+        }
+        catch (Exception abandon)
+        {
+            _logger.LogWarning(abandon, "The partition reader could not abandon its lost read {Operation}.", operation);
+        }
+    }
+
+    /// <summary>Hands a reply of a read to the application and moves the position of its partition after it.</summary>
+    private void Complete(Target target, PolledMessages polled, ulong? nextOffset = null)
+    {
+        lock (_gate)
+        {
+            if (Settle(target) is not { } state)
+            {
+                // The server stored this read, so a partition that left the assignment still hands it over.
+                if (OnRead && polled.Messages.Count > 0 && Keeps(target.Partition, target.Generation))
+                {
+                    EnqueueBatch(target, polled);
+                }
+
+                return;
+            }
+
+            state.Lasting = 0;
+            // A fetch section that ended before the head of its partition reads again at once.
+            state.Backlogged = nextOffset is { } sectionEnd && sectionEnd <= polled.CurrentOffset
+                                                            && (polled.Messages.Count > 0 || polled.CurrentOffset > 0);
+            if (polled.Messages.Count == 0)
+            {
+                var emptyNext = nextOffset ?? polled.CurrentOffset + 1;
+                if (OnRead && state.Lost && target.Sent is { Kind: MessagePolling.Next, Context: { } context }
+                           && SameHistory(context, polled.Context) && emptyNext > target.Sent.Value)
+                {
+                    state.Position = PollingStrategy.Continuation(target.Partition, MessagePolling.Offset,
+                        target.Sent.Value, polled.Context);
+                    state.CatchUpUntil = emptyNext - 1;
+                    return;
+                }
+
+                if (target.Sent.Kind != MessagePolling.Next || !OnRead)
+                {
+                    polled.RequestStart = target.Sent;
+                    state.Position = polled.Context == default ? target.Sent : polled.Continuation;
+                }
+
+                // A read again of a lost range does not wait either.
+                if (_settings.Options.MaxWait == TimeSpan.Zero || state.CatchUpUntil is not null)
+                {
+                    state.RetryAt = Environment.TickCount64 + (long)_settings.RetryInterval.TotalMilliseconds;
+                }
+
+                return;
+            }
+
+            var first = polled.Messages[0].Header.Offset;
+            var last = polled.Messages[^1].Header.Offset;
+            if (OnRead && state.Lost && target.Sent is { Kind: MessagePolling.Next, Context: { } sentContext }
+                       && first > target.Sent.Value && SameHistory(sentContext, polled.Context))
+            {
+                _logger.LogWarning(
+                    "Partition {Partition} skipped offsets from {From} to {Until}, which a lost read stored. They are read again without a commit.",
+                    target.Partition, target.Sent.Value, last);
+                state.Position = PollingStrategy.Continuation(target.Partition, MessagePolling.Offset,
+                    target.Sent.Value, polled.Context);
+                state.CatchUpUntil = last;
+                state.CatchUpBatch = polled;
+                return;
+            }
+
+            state.PendingStart = null;
+            // This read stored its offset, which fences out the lost read.
+            state.Lost &= target.Sent.Kind != MessagePolling.Next;
+            var next = last + 1;
+            var byOffset = state.AloneUntil is { } aloneUntil && next < aloneUntil
+                           || state.CatchUpUntil is { } catchUpUntil && next <= catchUpUntil;
+            if (state.AloneUntil is { } until && next >= until)
+            {
+                state.AloneUntil = null;
+            }
+
+            if (state.CatchUpUntil is { } end && next > end)
+            {
+                state.CatchUpUntil = null;
+                state.CatchUpBatch = null;
+            }
+
+            state.Position = PollingStrategy.Continuation(target.Partition,
+                OnRead && !byOffset ? MessagePolling.Next : MessagePolling.Offset, next, polled.Context);
+            state.ServedAt = ++_serves;
+            EnqueueBatch(target, polled);
+        }
+    }
+
+    private void EnqueueBatch(Target target, PolledMessages polled)
+    {
+        polled.RequestStart = target.Sent;
+        polled.Stamp = new ReaderStamp(_id, target.Generation);
+        Enqueue(new Entry(target.Partition, target.Generation, polled, null,
+            polled.Messages.Count, polled.Messages.Sum(message => (long)message.Payload.Length)));
+    }
+
+    /// <summary>A section stored nothing that can be read: its partition reads it again one message at a time.</summary>
+    private void ReadAlone(Target target, FetchedSection section, Exception error)
+    {
+        lock (_gate)
+        {
+            if (Settle(target) is not { } state)
+            {
+                return;
+            }
+
+            var start = section.NextOffset - Math.Min(section.NextOffset, section.MessagesCount);
+            _logger.LogWarning(error,
+                "A batch of partition {Partition} from offset {Offset} does not decode. It is read one message at a time.",
+                target.Partition, start);
+            state.Position = PollingStrategy.Continuation(target.Partition, MessagePolling.Offset, start,
+                section.Context);
+            state.AloneUntil = section.NextOffset;
+        }
+    }
+
+    /// <summary>A poll from an offset did not decode, so its partition reads that range one message at a time.</summary>
+    private void ReadAloneFrom(Target target, Exception error)
+    {
+        lock (_gate)
+        {
+            if (Settle(target) is not { } state)
+            {
+                return;
+            }
+
+            _logger.LogWarning(error,
+                "A batch of partition {Partition} from offset {Offset} does not decode. It is read one message at a time.",
+                target.Partition, target.Sent.Value);
+            state.AloneUntil = target.Sent.Value + _settings.Options.Count;
+        }
+    }
+
+    /// <summary>The message at the sent offset does not decode. The partition reads it again until a seek past it.</summary>
+    private void Undecodable(Target target, Exception error)
+    {
+        PollingStrategy skip;
+        lock (_gate)
+        {
+            if (Settle(target) is not { } state)
+            {
+                return;
+            }
+
+            state.AloneUntil ??= target.Sent.Value + 1;
+            state.RetryAt = Environment.TickCount64 + (long)_settings.RetryInterval.TotalMilliseconds;
+            skip = PollingStrategy.Continuation(target.Partition, MessagePolling.Offset, target.Sent.Value + 1,
+                target.Sent.Context ?? default);
+        }
+
+        Report(target.Partition, target.Generation, error, skip);
+    }
+
+    /// <summary>
+    ///     A read that did not return its partition. A refusal before the read retries, and only a lasting or a
+    ///     history refusal stops the partition until a seek.
+    /// </summary>
+    private void Refuse(Target target, Exception error)
+    {
+        ReadException? report = null;
+        lock (_gate)
+        {
+            if (Settle(target) is not { } state || _lifetime.IsCancellationRequested)
+            {
+                return;
+            }
+
+            var now = Environment.TickCount64;
+            var interval = (long)_settings.RetryInterval.TotalMilliseconds;
+            switch (error)
+            {
+                case VsrRequestOutcomeUnknownException:
+                    // A lost reply. With an on read policy the next read finds what it stored.
+                    state.Lost |= OnRead && error is VsrFetchProgressUncertainException;
+                    state.RetryAt = now + interval;
+                    return;
+                case IggyInvalidStatusCodeException { StatusCode: VsrError.NOT_PRIMARY }:
+                    return;
+                case IggyInvalidStatusCodeException { StatusCode: VsrError.CONSUMER_GROUP_PARTITION_NOT_OWNED }:
+                    GiveUpGap(target.Partition, state);
+                    // A rejoin gives the partition a new owner generation, which the old position still names.
+                    state.Position = _settings.Start;
+                    state.PendingStart = null;
+                    state.Lost = false;
+                    _resync = true;
+                    state.Lasting++;
+                    if (state.Lasting >= MaxLastingRefusals)
+                    {
+                        report = new ReadException(target.Partition, error);
+                        state.Stopped = report;
+                        break;
+                    }
+
+                    state.RetryAt = now + interval * (1L << (state.Lasting - 1));
+                    return;
+                case IggyInvalidStatusCodeException { StatusCode: VsrError.HISTORY_UNAVAILABLE }
+                    when target.Sent.Context is null:
+                    _logger.LogWarning(
+                        "Partition {Partition} was refused with a history that the reader never read. It reads again.",
+                        target.Partition);
+                    state.RetryAt = now + interval;
+                    return;
+                case IggyInvalidStatusCodeException { StatusCode: VsrError.HISTORY_UNAVAILABLE or VsrError.INVALID_SIZE_BYTES }:
+                    report = new ReadException(target.Partition, error);
+                    state.Stopped = report;
+                    break;
+                case IggyInvalidStatusCodeException status when IsLasting(status.StatusCode):
+                    state.Lasting++;
+                    if (state.Lasting >= MaxLastingRefusals)
+                    {
+                        report = new ReadException(target.Partition, error);
+                        state.Stopped = report;
+                        break;
+                    }
+
+                    _logger.LogWarning(error, "Partition {Partition} was refused, it reads again later.",
+                        target.Partition);
+                    state.RetryAt = now + interval * (1L << (state.Lasting - 1));
+                    return;
+                default:
+                    if (IsTransient(error))
+                    {
+                        state.RetryAt = now + interval;
+                        return;
+                    }
+
+                    report = IsReaderWide(error) ? null : new ReadException(target.Partition, error);
+                    state.RetryAt = now + interval;
+                    break;
+            }
+        }
+
+        if (report is not null)
+        {
+            Enqueue(target.Partition, target.Generation, report);
+        }
+        else
+        {
+            Report(null, 0, error);
+        }
+    }
+
+    private void GiveUpGap(uint partition, PartitionState state)
+    {
+        if (state.CatchUpUntil is not { } until)
+        {
+            return;
+        }
+
+        var held = state.CatchUpBatch;
+        var target = new Target(partition, state.Position, state.Generation);
+        var first = held?.Messages[0].Header.Offset ?? until + 1;
+        if (state.Position.Value < first)
+        {
+            Enqueue(partition, state.Generation, new ReadException(partition,
+                VsrError.Exception(VsrError.FETCH_PROGRESS_UNCERTAIN,
+                    $"A lost read stored offsets {state.Position.Value} to {first - 1} of partition " +
+                    $"{partition}, and the partition moved before they were read again.")));
+        }
+
+        var next = held is null ? until + 1 : held.Messages[^1].Header.Offset + 1;
+        if (held is not null)
+        {
+            held.Messages = held.Messages.Where(message => message.Header.Offset >= state.Position.Value).ToArray();
+        }
+
+        state.CatchUpUntil = null;
+        state.CatchUpBatch = null;
+        state.Position = PollingStrategy.Continuation(partition, MessagePolling.Next, next,
+            held?.Context ?? state.Position.Context!.Value);
+        if (held is { Messages.Count: > 0 })
+        {
+            EnqueueBatch(target, held);
+        }
+    }
+
+    private void Retry(Target target, long delay)
+    {
+        lock (_gate)
+        {
+            if (Settle(target) is { } state)
+            {
+                state.RetryAt = delay == 0 ? 0 : Environment.TickCount64 + delay;
+            }
+        }
+    }
+
+    /// <summary>
+    ///     Ends the read of a target, under the lock. Null when the generation of its partition moved on. The state of
+    ///     a partition assigned again has a read of its own, which the old read leaves in flight.
+    /// </summary>
+    private PartitionState? Settle(Target target)
+    {
+        if (!_states.TryGetValue(target.Partition, out var state) || state.Claim != target.Claim)
+        {
+            return null;
+        }
+
+        state.InFlight = false;
+        state.Backlogged = false;
+        state.Idle.TrySetResult();
+        return state.Generation == target.Generation ? state : null;
+    }
+
+    private void Report(uint? partition, ulong generation, Exception error, PollingStrategy? skip = null)
+    {
+        lock (_gate)
+        {
+            // One error of the whole reader at a time, so a lasting outage does not fill the queue.
+            if (partition is null && _queue.Any(entry => entry.Error is { PartitionId: null }))
+            {
+                return;
+            }
+        }
+
+        Enqueue(partition, generation, new ReadException(partition, error, skip));
+    }
+
+    private void Enqueue(uint? partition, ulong generation, ReadException error)
+    {
+        lock (_gate)
+        {
+            Enqueue(new Entry(partition, generation, null, error, 0, 0));
+        }
+    }
+
+    private void Enqueue(Entry entry)
+    {
+        _queue.AddLast(entry);
+        _queuedMessages += entry.Messages;
+        _queuedBytes += entry.Bytes;
+        _arrival.TrySetResult();
+        _arrival = NewArrival();
+    }
+
+    private void Release(Entry entry)
+    {
+        _queuedMessages -= entry.Messages;
+        _queuedBytes -= entry.Bytes;
+    }
+
+    private bool IsCurrent(Entry entry)
+    {
+        return entry.PartitionId is not { } partition || Keeps(partition, entry.Generation);
+    }
+
+    /// <summary>
+    ///     Whether a read of a partition under a generation still counts. A seek or a new assignment of the partition
+    ///     drops it. A partition that left the assignment keeps the reads that the server stored under
+    ///     <see cref="CommitMode.OnRead" />.
+    /// </summary>
+    private bool Keeps(uint partition, ulong generation)
+    {
+        return (_states.TryGetValue(partition, out var state) && state.Generation == generation) ||
+               (OnRead && (!_sought.TryGetValue(partition, out var sought) || generation >= sought));
+    }
+
+    private PartitionState State(uint partition)
+    {
+        return _states.TryGetValue(partition, out var state)
+            ? state
+            : throw VsrError.Exception(VsrError.INVALID_COMMAND, $"The reader does not read partition {partition}.");
+    }
+
+    private void Wake()
+    {
+        Signal(_wake);
+    }
+
+    private static void Signal(SemaphoreSlim signal)
+    {
+        try
+        {
+            if (signal.CurrentCount == 0)
+            {
+                signal.Release();
+            }
+        }
+        catch (SemaphoreFullException)
+        {
+        }
+    }
+
+    private static bool SameHistory(PartitionContext sent, PartitionContext served)
+    {
+        return sent.Incarnation == served.Incarnation && sent.OwnerGeneration == served.OwnerGeneration;
+    }
+
+    private static bool IsTransient(Exception error)
+    {
+        return error is IggyInvalidStatusCodeException
+        {
+            StatusCode: VsrError.TRANSIENT_NOT_ACCEPTED or VsrError.TRANSIENT_NOT_COMMITTED or VsrError.NOT_PRIMARY
+            or VsrError.DEFERRED_POLL_CAPACITY_EXCEEDED or VsrError.LIFECYCLE_BUSY
+        } or VsrRequestOutcomeUnknownException;
+    }
+
+    /// <summary>A refusal that a retry soon after does not change.</summary>
+    private static bool IsLasting(int status)
+    {
+        return status is VsrError.UNAUTHORIZED or VsrError.FEATURE_UNAVAILABLE or VsrError.INVALID_COMMAND
+            or VsrError.PARTITION_NOT_FOUND or VsrError.STREAM_ID_NOT_FOUND or VsrError.STREAM_NAME_NOT_FOUND
+            or VsrError.TOPIC_ID_NOT_FOUND or VsrError.TOPIC_NAME_NOT_FOUND;
+    }
+
+    /// <summary>An error of the connection or the session, which no partition causes.</summary>
+    private static bool IsReaderWide(Exception error)
+    {
+        return error is NotConnectedException or ObjectDisposedException or IOException
+            or VsrSessionEvictedException
+            || error is IggyInvalidStatusCodeException
+            {
+                StatusCode: VsrError.UNAUTHENTICATED or VsrError.STALE_CLIENT
+            };
+    }
+
+    /// <summary>A read of one partition. Its claim tells it from a later read of the partition, and zero reads nothing.</summary>
+    private readonly record struct Target(uint Partition, PollingStrategy Sent, ulong Generation, ulong Claim = 0);
+
+    /// <summary>A batch or an error, with the generation of its partition when the reader read it.</summary>
+    private sealed record Entry(
+        uint? PartitionId,
+        ulong Generation,
+        PolledMessages? Batch,
+        ReadException? Error,
+        long Messages,
+        long Bytes);
+
+    private sealed class PartitionState(PollingStrategy start, ulong generation)
+    {
+        internal ulong? AloneUntil;
+
+        /// <summary>The last fetch section of the position ended before the head of the partition.</summary>
+        internal bool Backlogged;
+        internal ulong? CatchUpUntil;
+        internal PolledMessages? CatchUpBatch;
+        internal ulong Claim;
+        internal PollingStrategy? PendingStart;
+        internal Checkpoint? Counted;
+        internal uint CountedSinceStore;
+        internal (ulong Offset, PartitionContext Context)? Delivered;
+        internal ulong? FirstHanded;
+        internal ulong Generation = generation;
+        internal (ulong Offset, PartitionContext Context)? Handed;
+        internal TaskCompletionSource Idle = new(TaskCreationOptions.RunContinuationsAsynchronously);
+        internal bool InFlight;
+        internal int Lasting;
+
+        /// <summary>With an on read policy: a committing read of the partition lost its reply and may have stored.</summary>
+        internal bool Lost;
+        internal PollingStrategy Position = start;
+        internal long RetryAt;
+
+        /// <summary>When a read last served messages of the partition, so served partitions go last in a fetch.</summary>
+        internal ulong ServedAt;
+
+        internal ReadException? Stopped;
+        internal Checkpoint? Stored;
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK/Consumers/ReadException.cs b/foreign/csharp/Iggy_SDK/Consumers/ReadException.cs
new file mode 100644
index 0000000..1fa8295
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK/Consumers/ReadException.cs
@@ -0,0 +1,60 @@
+// 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.
+
+using Apache.Iggy.Exceptions;
+using Apache.Iggy.Kinds;
+
+namespace Apache.Iggy.Consumers;
+
+/// <summary>
+///     An error of a read of a <see cref="PartitionReader" />. The reader stays usable after it and retries by itself.
+///     <see cref="Exception.InnerException" /> is the error itself.
+/// </summary>
+public sealed class ReadException(uint? partitionId, Exception error, PollingStrategy? skip = null)
+    : Exception(partitionId is { } partition
+        ? $"Reading partition {partition} failed: {error.Message}"
+        : $"Reading failed: {error.Message}", error)
+{
+    /// <summary>The partition of the error, or null for an error of the whole reader.</summary>
+    public uint? PartitionId { get; } = partitionId;
+
+    /// <summary>
+    ///     For a message that does not decode: the continuation after it. The reader reads the message again until a
+    ///     <see cref="PartitionReader.SeekAsync" /> to this continuation skips it. The messages before it arrive in a
+    ///     batch before this error.
+    /// </summary>
+    public PollingStrategy? Skip { get; } = skip;
+
+    /// <summary>
+    ///     For a message that does not decode: its offset, known when its frame decoded and a later step failed, such
+    ///     as its decryption. Null when the framing of the reply is malformed, so no message can be told apart.
+    /// </summary>
+    public ulong? Offset { get; } = (error as MessageDecryptionException)?.Offset;
+
+    /// <summary>
+    ///     For a message that does not decode: the record as the reply carried it, its 48-byte frame header, payload
+    ///     and user headers, before any decryption. For malformed framing, the remaining bytes of the reply records
+    ///     from where decoding stopped. Null for an error that is not a decode failure, and for a message of a JSON
+    ///     reply over HTTP, which carries no records.
+    /// </summary>
+    public byte[]? Record { get; } = error switch
+    {
+        MessageDecryptionException decryption => decryption.Record,
+        MalformedResponseException malformed => malformed.Record,
+        _ => null
+    };
+}
diff --git a/foreign/csharp/Iggy_SDK/Consumers/ReaderBuilder.cs b/foreign/csharp/Iggy_SDK/Consumers/ReaderBuilder.cs
new file mode 100644
index 0000000..47b7aa6
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK/Consumers/ReaderBuilder.cs
@@ -0,0 +1,198 @@
+// 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.
+
+using Apache.Iggy.Contracts;
+using Apache.Iggy.Enums;
+using Apache.Iggy.Exceptions;
+using Apache.Iggy.IggyClient;
+using Apache.Iggy.Kinds;
+using Apache.Iggy.Vsr;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Apache.Iggy.Consumers;
+
+/// <summary>
+///     Builds a <see cref="PartitionReader" />. Create one with
+///     <see cref="Extensions.IggyClientExtension.Reader" />.
+/// </summary>
+public sealed class ReaderBuilder
+{
+    internal const uint DEFAULT_PREFETCH_BYTES = 16 * 1024 * 1024;
+    internal const uint DEFAULT_PREFETCH_MESSAGES = 16_000;
+
+    /// <summary>The wait of a reader read when the caller sets no options.</summary>
+    internal static readonly TimeSpan DefaultMaxWait = TimeSpan.FromSeconds(5);
+
+    private static readonly TimeSpan DefaultRetryInterval = TimeSpan.FromSeconds(1);
+
+    private readonly IIggyClient _client;
+    private readonly Consumer _consumer;
+    private readonly Identifier _streamId;
+    private readonly Identifier _topicId;
+    private ILoggerFactory _loggerFactory = NullLoggerFactory.Instance;
+    private PollOptions _options = new() { MaxWait = DefaultMaxWait };
+    private uint[] _partitions = [];
+    private uint _prefetchBytes = DEFAULT_PREFETCH_BYTES;
+    private uint _prefetchMessages = DEFAULT_PREFETCH_MESSAGES;
+    private TimeSpan _retryInterval = DefaultRetryInterval;
+    private PollingStrategy _start = PollingStrategy.Next();
+    private bool _countsMessages;
+
+    internal ReaderBuilder(IIggyClient client, Identifier streamId, Identifier topicId, Consumer consumer)
+    {
+        _client = client;
+        _streamId = streamId;
+        _topicId = topicId;
+        _consumer = consumer;
+    }
+
+    /// <summary>The partitions of a standalone consumer. A consumer group reads the assignment of its member.</summary>
+    public ReaderBuilder WithPartitions(params uint[] partitions)
+    {
+        _partitions = [.. partitions.Distinct()];
+        return this;
+    }
+
+    /// <summary>
+    ///     Where a partition without a position starts. <see cref="PollingStrategy.Next" /> by default, the stored
+    ///     offset of the consumer.
+    /// </summary>
+    public ReaderBuilder WithStart(PollingStrategy start)
+    {
+        _start = start;
+        return this;
+    }
+
+    /// <summary>
+    ///     The limits of each read and the commit policy. By default a read waits up to 5 seconds and nothing is
+    ///     stored.
+    /// </summary>
+    public ReaderBuilder WithOptions(PollOptions options)
+    {
+        _options = options;
+        return this;
+    }
+
+    /// <summary>
+    ///     The most bytes and messages that the reader holds read ahead of the application, 16 MiB and 16,000 by
+    ///     default. With <see cref="CommitPolicy.OnRead" /> the reader reads only when the application waits.
+    /// </summary>
+    public ReaderBuilder WithPrefetch(uint bytes, uint messages)
+    {
+        _prefetchBytes = bytes;
+        _prefetchMessages = messages;
+        return this;
+    }
+
+    /// <summary>The logger factory of the reader.</summary>
+    public ReaderBuilder WithLoggerFactory(ILoggerFactory loggerFactory)
+    {
+        _loggerFactory = loggerFactory;
+        return this;
+    }
+
+    /// <summary>
+    ///     A consumer hands over one message at a time, so it reports each delivered message and counts it itself
+    ///     instead of the reader counting whole batches.
+    /// </summary>
+    internal ReaderBuilder CountingMessages()
+    {
+        _countsMessages = true;
+        return this;
+    }
+
+    /// <summary>The wait before a refused partition reads again.</summary>
+    internal ReaderBuilder WithRetryInterval(TimeSpan retryInterval)
+    {
+        _retryInterval = retryInterval;
+        return this;
+    }
+
+    /// <summary>Checks the configuration, joins the group of a group reader and starts reading.</summary>
+    /// <exception cref="IggyInvalidStatusCodeException">
+    ///     Status 3 for partitions of a consumer group, a standalone consumer without partitions, or a continuation
+    ///     as the start. Status 2 for <see cref="CommitPolicy.OnRead" /> with an encryptor: the server would store
+    ///     the offset of a batch before the client decrypts it. The status of invalid options, and the error of the
+    ///     join.
+    /// </exception>
+    /// <exception cref="FeatureUnavailableException">A consumer group on a transport without its assignment, such as HTTP.</exception>
+    public async Task<PartitionReader> BuildAsync(CancellationToken token = default)
+    {
+        ArgumentNullException.ThrowIfNull(_options);
+        _options.Validate();
+        var group = _consumer.Type == ConsumerType.ConsumerGroup;
+        if (group && _partitions.Length > 0)
+        {
+            throw VsrError.Exception(VsrError.INVALID_COMMAND,
+                "A consumer group reads the partitions of its assignment, not partitions given to the reader.");
+        }
+
+        if (!group && _partitions.Length == 0)
+        {
+            throw VsrError.Exception(VsrError.INVALID_COMMAND, "A standalone reader needs partitions.");
+        }
+
+        if (_start.PartitionId is not null)
+        {
+            throw VsrError.Exception(VsrError.INVALID_COMMAND,
+                "A continuation belongs to one partition, so it cannot start a reader.");
+        }
+
+        if (_options.Commit.Mode == CommitMode.OnRead && _client.MessageEncryptor is not null)
+        {
+            throw VsrError.Exception(VsrError.INVALID_CONFIGURATION,
+                "Storing the offset on read with an encryptor loses a batch that does not decrypt.");
+        }
+
+        var assignment = _client as IConsumerGroupAssignmentProvider;
+        if (group && assignment is null)
+        {
+            throw new FeatureUnavailableException();
+        }
+
+        if (group)
+        {
+            await _client.JoinConsumerGroupAsync(_streamId, _topicId, _consumer.ConsumerId, token);
+        }
+
+        var settings = new ReaderSettings(_streamId, _topicId, _consumer, _partitions,
+            _start.Kind == MessagePolling.First ? PollingStrategy.Offset(0) : _start, _options, _prefetchBytes,
+            _prefetchMessages, _retryInterval)
+        {
+            CountsMessages = _countsMessages
+        };
+        return new PartitionReader(_client, group ? assignment : null, settings,
+            _loggerFactory.CreateLogger<PartitionReader>());
+    }
+}
+
+/// <summary>The fixed configuration of a reader.</summary>
+internal sealed record ReaderSettings(
+    Identifier StreamId,
+    Identifier TopicId,
+    Consumer Consumer,
+    IReadOnlyList<uint> Partitions,
+    PollingStrategy Start,
+    PollOptions Options,
+    uint PrefetchBytes,
+    uint PrefetchMessages,
+    TimeSpan RetryInterval)
+{
+    /// <summary>Whether the owner of the reader reports and counts each delivered message.</summary>
+    internal bool CountsMessages { get; init; }
+}
diff --git a/foreign/csharp/Iggy_SDK/Consumers/ReceivedMessage.cs b/foreign/csharp/Iggy_SDK/Consumers/ReceivedMessage.cs
index f355666..a6187da 100644
--- a/foreign/csharp/Iggy_SDK/Consumers/ReceivedMessage.cs
+++ b/foreign/csharp/Iggy_SDK/Consumers/ReceivedMessage.cs
@@ -24,7 +24,7 @@
 /// <summary>
 ///     Represents a message whose payload was deserialized directly from rented memory. The rented buffer has
 ///     already been returned to the pool by the time this message is yielded, so only the header, user headers,
-///     and the deserialized <see cref="Data" /> are available; the raw payload bytes are not retained.
+///     and the deserialized <see cref="Data" /> are available. The raw payload bytes are not retained.
 /// </summary>
 /// <typeparam name="T">The deserialized payload type.</typeparam>
 public sealed class ReceivedMessage<T>
@@ -55,12 +55,14 @@
     public uint PartitionId { get; init; }
 
     /// <summary>
-    ///     The partition incarnation and owner of the poll that delivered this message. Pass it to
-    ///     <see cref="IggyConsumer.StoreOffsetAsync(ulong, uint, PartitionContext, CancellationToken)" /> to commit
-    ///     the message after later polls.
+    ///     The partition incarnation and owner of the read that delivered this message. <see cref="Checkpoint" />
+    ///     carries it, so a commit after a change of the partition history fails.
     /// </summary>
     public PartitionContext Context { get; init; }
 
+    /// <summary>The position after this message, to commit it with <see cref="IggyConsumer.CommitAsync" />.</summary>
+    public Checkpoint? Checkpoint { get; init; }
+
     /// <summary>
     ///     The status of the message (Success, DeserializationFailed).
     /// </summary>
@@ -93,12 +95,14 @@
     public uint PartitionId { get; init; }
 
     /// <summary>
-    ///     The partition incarnation and owner of the poll that delivered this message. Pass it to
-    ///     <see cref="IggyConsumer.StoreOffsetAsync(ulong, uint, PartitionContext, CancellationToken)" /> to commit
-    ///     the message after later polls.
+    ///     The partition incarnation and owner of the read that delivered this message. <see cref="Checkpoint" />
+    ///     carries it, so a commit after a change of the partition history fails.
     /// </summary>
     public PartitionContext Context { get; init; }
 
+    /// <summary>The position after this message, to commit it with <see cref="IggyConsumer.CommitAsync" />.</summary>
+    public Checkpoint? Checkpoint { get; init; }
+
     /// <summary>
     ///     The status of the message (Success, DeserializationFailed)
     /// </summary>
diff --git a/foreign/csharp/Iggy_SDK/Consumers/ReceivedRentedMessage.cs b/foreign/csharp/Iggy_SDK/Consumers/ReceivedRentedMessage.cs
index 815c6e5..839cfc4 100644
--- a/foreign/csharp/Iggy_SDK/Consumers/ReceivedRentedMessage.cs
+++ b/foreign/csharp/Iggy_SDK/Consumers/ReceivedRentedMessage.cs
@@ -21,7 +21,7 @@
 
 /// <summary>
 ///     Represents a message received from the Iggy consumer whose payload and raw headers are backed by rented memory
-///     shared across a polled batch. The caller MUST dispose the message once processing is complete; the underlying
+///     shared across a polled batch. The caller MUST dispose the message once processing is complete. The underlying
 ///     pool buffer is returned only when the batch's last message is disposed (reference counted). There is no
 ///     finalizer: a batch left undisposed never returns its buffer to the pool - it is reclaimed by the GC as an
 ///     ordinary allocation, so forgetting to dispose leaks pool capacity rather than corrupting data.
@@ -47,12 +47,14 @@
     public uint PartitionId { get; init; }
 
     /// <summary>
-    ///     The partition incarnation and owner of the poll that delivered this message. Pass it to
-    ///     <see cref="IggyConsumer.StoreOffsetAsync(ulong, uint, PartitionContext, CancellationToken)" /> to commit
-    ///     the message after later polls.
+    ///     The partition incarnation and owner of the read that delivered this message. <see cref="Checkpoint" />
+    ///     carries it, so a commit after a change of the partition history fails.
     /// </summary>
     public PartitionContext Context { get; init; }
 
+    /// <summary>The position after this message, to commit it with <see cref="IggyConsumer.CommitAsync" />.</summary>
+    public Checkpoint? Checkpoint { get; init; }
+
     /// <summary>
     ///     The status of the message. The rented path performs no deserialization or decryption, so this is
     ///     always <see cref="MessageStatus.Success" />.
@@ -81,12 +83,15 @@
 
 /// <summary>
 ///     Reference-counted handle around a <see cref="PolledMessagesRental" />. Shared by all
-///     <see cref="ReceivedRentedMessage" /> instances produced from one poll; the rental is disposed
+///     <see cref="ReceivedRentedMessage" /> instances produced from one poll. The rental is disposed
 ///     when the reference count drops to zero.
 /// </summary>
 public sealed class RentedBatchHandle : IDisposable
 {
     private readonly PolledMessagesRental _rental;
+
+    /// <summary>The rented batch.</summary>
+    internal PolledMessagesRental Rental => _rental;
     private int _refCount = 1;
 
     /// <summary>
diff --git a/foreign/csharp/Iggy_SDK/Contracts/Auth/GlobalPermissions.cs b/foreign/csharp/Iggy_SDK/Contracts/Auth/GlobalPermissions.cs
index cab24df..692ca69 100644
--- a/foreign/csharp/Iggy_SDK/Contracts/Auth/GlobalPermissions.cs
+++ b/foreign/csharp/Iggy_SDK/Contracts/Auth/GlobalPermissions.cs
@@ -185,7 +185,8 @@
     ///                 cref="Apache.Iggy.IggyClient.IIggyOffset.StoreOffsetAsync(Consumer, Identifier, Identifier, ulong, uint, PartitionContext, CancellationToken)" />
     ///         </item>
     ///         <item>
-    ///             <see cref="Apache.Iggy.IggyClient.IIggyOffset.DeleteOffsetAsync" />
+    ///             <see
+    ///                 cref="Apache.Iggy.IggyClient.IIggyOffset.DeleteOffsetAsync(Consumer, Identifier, Identifier, uint?, CancellationToken)" />
     ///         </item>
     ///     </list>
     /// </summary>
diff --git a/foreign/csharp/Iggy_SDK/Contracts/Checkpoint.cs b/foreign/csharp/Iggy_SDK/Contracts/Checkpoint.cs
new file mode 100644
index 0000000..04852a8
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK/Contracts/Checkpoint.cs
@@ -0,0 +1,52 @@
+// 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.
+
+namespace Apache.Iggy.Contracts;
+
+/// <summary>
+///     The position after one delivered message. A commit stores its offset, so it covers all earlier messages of the
+///     partition. A checkpoint keeps the partition incarnation and owner that delivered the message, so a commit after
+///     a change of the partition history fails. Only a read creates one.
+/// </summary>
+public sealed record Checkpoint
+{
+    internal Checkpoint(uint partitionId, ulong offset, PartitionContext context, ReaderStamp? stamp = null)
+    {
+        PartitionId = partitionId;
+        Offset = offset;
+        Context = context;
+        Stamp = stamp;
+    }
+
+    /// <summary>The partition of the message.</summary>
+    public uint PartitionId { get; }
+
+    /// <summary>The offset of the message.</summary>
+    public ulong Offset { get; }
+
+    /// <summary>The partition history that delivered the message.</summary>
+    internal PartitionContext Context { get; }
+
+    /// <summary>The reader and the partition generation that delivered the message, null for a one-shot poll.</summary>
+    internal ReaderStamp? Stamp { get; }
+}
+
+/// <summary>
+///     The reader and the partition generation that delivered a message. A reader refuses a commit of a checkpoint
+///     that another reader delivered, and skips one of an older generation.
+/// </summary>
+internal readonly record struct ReaderStamp(long Reader, ulong Generation);
diff --git a/foreign/csharp/Iggy_SDK/Contracts/CommitPolicy.cs b/foreign/csharp/Iggy_SDK/Contracts/CommitPolicy.cs
new file mode 100644
index 0000000..a6b8f97
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK/Contracts/CommitPolicy.cs
@@ -0,0 +1,156 @@
+// 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.
+
+namespace Apache.Iggy.Contracts;
+
+/// <summary>Who stores the offsets of the messages that a read returns.</summary>
+public enum CommitMode
+{
+    /// <summary>Nothing is stored. The application commits checkpoints.</summary>
+    Manual,
+
+    /// <summary>
+    ///     The server stores the offset of the last message of each read as part of the read, in one round trip. While
+    ///     the primary stays up a message is delivered at most once. A failover before the store replicates can deliver
+    ///     the last batch again.
+    /// </summary>
+    OnRead,
+
+    /// <summary>The client stores the latest counted position, at least once with <see cref="CommitAfter.Processing" />.</summary>
+    Auto
+}
+
+/// <summary>
+///     When the offsets of the messages that a read returns are stored. A one-shot poll accepts
+///     <see cref="Manual" /> and <see cref="OnRead" />. A partition reader and a consumer accept every policy.
+/// </summary>
+public sealed record CommitPolicy
+{
+    private CommitPolicy(CommitMode mode, AutoCommit? settings = null)
+    {
+        Mode = mode;
+        Settings = settings;
+    }
+
+    /// <summary>Nothing is stored. The application commits checkpoints. The default.</summary>
+    public static CommitPolicy Manual { get; } = new(CommitMode.Manual);
+
+    /// <summary>The server stores the offset of the last message of each read as part of the read.</summary>
+    public static CommitPolicy OnRead { get; } = new(CommitMode.OnRead);
+
+    /// <summary>The mode of the policy.</summary>
+    public CommitMode Mode { get; }
+
+    /// <summary>When the client stores, set only for <see cref="CommitMode.Auto" />.</summary>
+    public AutoCommit? Settings { get; }
+
+    /// <summary>
+    ///     The client stores the latest counted position as <paramref name="settings" /> say, by default every 10,000
+    ///     messages or 5 seconds after processing. It also stores on close, on shutdown and before a rebalance takes a
+    ///     partition away.
+    /// </summary>
+    public static CommitPolicy Auto(AutoCommit? settings = null)
+    {
+        return new CommitPolicy(CommitMode.Auto, settings ?? AutoCommit.Default);
+    }
+}
+
+/// <summary>
+///     When <see cref="CommitPolicy.Auto" /> stores. It needs <see cref="Interval" /> or <see cref="Every" />. A
+///     store only moves forward, and a position of an older generation of its partition, after a seek or a new
+///     assignment, is dropped.
+/// </summary>
+public sealed record AutoCommit
+{
+    /// <summary>Every 10,000 messages or 5 seconds, whichever comes first, after <see cref="CommitAfter.Processing" />.</summary>
+    public static AutoCommit Default { get; } = new()
+    {
+        Interval = TimeSpan.FromSeconds(5),
+        Every = CommitEvery.Messages(10_000)
+    };
+
+    /// <summary>Store the latest counted position at this interval, when it moved.</summary>
+    public TimeSpan? Interval { get; init; }
+
+    /// <summary>Also store it on this event.</summary>
+    public CommitEvery? Every { get; init; }
+
+    /// <summary>When a message counts.</summary>
+    public CommitAfter After { get; init; } = CommitAfter.Processing;
+}
+
+/// <summary>The kind of a <see cref="CommitEvery" /> event.</summary>
+public enum CommitEveryKind
+{
+    /// <summary>Each counted message, or each counted batch of a partition reader.</summary>
+    Message,
+
+    /// <summary>A number of counted messages of a partition since its last store.</summary>
+    Messages,
+
+    /// <summary>The last message of each batch.</summary>
+    Batch
+}
+
+/// <summary>The event of <see cref="AutoCommit.Every" />.</summary>
+public sealed record CommitEvery
+{
+    private CommitEvery(CommitEveryKind kind, uint count)
+    {
+        Kind = kind;
+        Count = count;
+    }
+
+    /// <summary>Each counted message, or each counted batch of a partition reader.</summary>
+    public static CommitEvery Message { get; } = new(CommitEveryKind.Message, 1);
+
+    /// <summary>The last message of each batch.</summary>
+    public static CommitEvery Batch { get; } = new(CommitEveryKind.Batch, 0);
+
+    /// <summary>The kind of the event.</summary>
+    public CommitEveryKind Kind { get; }
+
+    /// <summary>The counted messages of a partition between stores, for <see cref="CommitEveryKind.Messages" />.</summary>
+    public uint Count { get; }
+
+    /// <summary>This many counted messages of a partition since its last store.</summary>
+    /// <exception cref="ArgumentOutOfRangeException"><paramref name="count" /> is zero.</exception>
+    public static CommitEvery Messages(uint count)
+    {
+        ArgumentOutOfRangeException.ThrowIfZero(count);
+        return new CommitEvery(CommitEveryKind.Messages, count);
+    }
+}
+
+/// <summary>When a message counts for <see cref="CommitPolicy.Auto" />.</summary>
+public enum CommitAfter
+{
+    /// <summary>
+    ///     Once the application asks for the next message or batch, returns from its handler with success, or closes
+    ///     the reader. A handler that fails leaves its message uncounted.
+    /// </summary>
+    Processing,
+
+    /// <summary>When the message is handed over.</summary>
+    Delivery,
+
+    /// <summary>
+    ///     Only when the application marks its checkpoint, so messages processed concurrently count once they are
+    ///     done.
+    /// </summary>
+    Marked
+}
diff --git a/foreign/csharp/Iggy_SDK/Contracts/DeferredPollOptions.cs b/foreign/csharp/Iggy_SDK/Contracts/DeferredPollOptions.cs
new file mode 100644
index 0000000..28c4471
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK/Contracts/DeferredPollOptions.cs
@@ -0,0 +1,179 @@
+// 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.
+
+using Apache.Iggy.Exceptions;
+using Apache.Iggy.Messages;
+using Apache.Iggy.Vsr;
+
+namespace Apache.Iggy.Contracts;
+
+/// <summary>
+///     Readiness and reply limits of a deferred (long) poll, independent of the maximum message count. The server
+///     replies once <see cref="MinCount" /> messages are readable, the reply reaches <see cref="MaxBytes" />, or
+///     <see cref="MaxWait" /> passes. It then returns what it has, which can be fewer messages or none.
+///     <see cref="RequestTimeout" /> bounds the whole call: routing, admission, the wait, the read and the reply.
+/// </summary>
+/// <remarks>
+///     <see cref="MaxWait" /> is at most 600 seconds, and the server can shorten it to its own maximum.
+///     <see cref="RequestTimeout" /> is 1 microsecond to 630 seconds, and a call that waits must leave one second
+///     between its wait and its timeout for the final read and the reply. <see cref="MaxBytes" /> covers the binary
+///     reply, its metadata and batch framing included, so it is at least 344 bytes: the reply header, one batch
+///     header and one message header. A record wider than <see cref="MaxBytes" /> fails with status 80
+///     (InvalidSizeBytes) and is not read.
+/// </remarks>
+internal sealed record DeferredPollOptions
+{
+    internal const ulong REPLY_MARGIN_US = 1_000_000;
+
+    /// <summary>
+    ///     Deferred polls and fetches one client keeps parked. The server holds at most as many per session
+    ///     and shard, so a call over the limit waits in the client for a free slot instead of being refused.
+    /// </summary>
+    internal const int MAX_PARKED_REQUESTS = 64;
+
+    private const ulong MaxWaitLimitUs = 600_000_000;
+    private const ulong RequestTimeoutHeadroomUs = 30_000_000;
+    private const ulong MaxRequestTimeoutUs = MaxWaitLimitUs + RequestTimeoutHeadroomUs;
+
+    /// <summary>The longest <see cref="RequestTimeout" /> a deferred poll accepts.</summary>
+    internal static readonly TimeSpan MaxRequestTimeout = FromMicros(MaxRequestTimeoutUs);
+
+    /// <summary>The header of a poll reply: the partition, its current offset, the count and the context.</summary>
+    internal const int POLL_REPLY_HEADER_SIZE =
+        sizeof(uint) + sizeof(ulong) + sizeof(uint) + PartitionContext.ENCODED_SIZE;
+
+    /// <summary>The smallest byte limit: a reply header, one batch header and one message header.</summary>
+    internal const uint MIN_MAX_BYTES =
+        POLL_REPLY_HEADER_SIZE + BatchWireFormat.BATCH_HEADER_SIZE + BatchWireFormat.FRAME_HEADER_SIZE;
+
+    /// <summary>How long the server waits for <see cref="MinCount" /> messages. One second by default.</summary>
+    public TimeSpan MaxWait { get; init; } = TimeSpan.FromSeconds(1);
+
+    /// <summary>How many messages make the server reply before <see cref="MaxWait" />. One by default.</summary>
+    public uint MinCount { get; init; } = 1;
+
+    /// <summary>The largest binary reply, in bytes. 1 MiB by default.</summary>
+    public uint MaxBytes { get; init; } = 1024 * 1024;
+
+    /// <summary>The budget of the whole call. Eleven seconds by default.</summary>
+    public TimeSpan RequestTimeout { get; init; } = TimeSpan.FromSeconds(11);
+
+    internal ulong MaxWaitUs => Micros(MaxWait);
+
+    internal ulong RequestTimeoutUs => Micros(RequestTimeout);
+
+    /// <summary>Applies the rules the server applies to a deferred poll of <paramref name="count" /> messages.</summary>
+    /// <exception cref="IggyInvalidStatusCodeException">
+    ///     Status 4009 for a minimum count outside 1 to <paramref name="count" />, 3 for a wait or request timeout
+    ///     out of range, 80 for a byte limit below one message.
+    /// </exception>
+    internal void Validate(uint count)
+    {
+        if (MinCount == 0 || MinCount > count)
+        {
+            throw VsrError.Exception(VsrError.INVALID_MESSAGES_COUNT,
+                $"The minimum count must be 1 to the poll count {count}, got {MinCount}.");
+        }
+
+        ValidateBudget();
+        if (MaxBytes < MIN_MAX_BYTES)
+        {
+            throw VsrError.Exception(VsrError.INVALID_SIZE_BYTES,
+                $"The byte limit must be at least {MIN_MAX_BYTES}, got {MaxBytes}.");
+        }
+    }
+
+    /// <summary>
+    ///     Applies the rules of <see cref="Validate" /> and the one rule a fetch (code 109) adds: its request timeout
+    ///     exceeds its wait by the reply margin also without a wait, with time left for client-side setup.
+    /// </summary>
+    /// <exception cref="IggyInvalidStatusCodeException">The status of <see cref="Validate" />, or 3.</exception>
+    internal void ValidateFetch(uint count)
+    {
+        Validate(count);
+        if (RequestTimeoutUs <= REPLY_MARGIN_US || RequestTimeoutUs < MaxWaitUs + REPLY_MARGIN_US)
+        {
+            throw Refused($"A fetch request timeout must exceed {REPLY_MARGIN_US} microseconds and cover its wait " +
+                          "plus that reply margin.");
+        }
+    }
+
+    /// <summary>The shared rules of the wait and the request timeout.</summary>
+    /// <exception cref="IggyInvalidStatusCodeException">Status 3 naming the broken rule.</exception>
+    internal void ValidateBudget()
+    {
+        // Compared as TimeSpan first: a negative span has no microsecond value on the wire.
+        if (MaxWait < TimeSpan.Zero || MaxWait > FromMicros(MaxWaitLimitUs))
+        {
+            throw Refused($"The wait must be 0 to {MaxWaitLimitUs} microseconds.");
+        }
+
+        if (RequestTimeoutUs == 0 || RequestTimeout > FromMicros(MaxRequestTimeoutUs))
+        {
+            throw Refused($"The request timeout must be 1 to {MaxRequestTimeoutUs} microseconds.");
+        }
+
+        if (MaxWaitUs > 0 && RequestTimeoutUs < MaxWaitUs + REPLY_MARGIN_US)
+        {
+            throw Refused($"A request timeout must exceed its wait by {REPLY_MARGIN_US} microseconds.");
+        }
+    }
+
+    /// <summary>
+    ///     The options of a call that already spent <paramref name="elapsed" />. The wait and the request timeout
+    ///     shrink by the same whole microseconds, so a wait keeps its reply margin on the wire.
+    /// </summary>
+    /// <remarks>
+    ///     Callers measure <paramref name="elapsed" /> with a <see cref="System.Diagnostics.Stopwatch" />. The tick
+    ///     count of <see cref="Environment.TickCount64" /> moves in steps of 1 to 16 ms, so it can count time that did
+    ///     not pass and end an idle wait before its max wait.
+    /// </remarks>
+    /// <exception cref="IggyInvalidStatusCodeException">
+    ///     Status 58 once the request timeout is spent: the request never left the client.
+    /// </exception>
+    internal DeferredPollOptions Remaining(TimeSpan elapsed)
+    {
+        elapsed = FromMicros(Micros(elapsed));
+        var requestTimeout = RequestTimeout - elapsed;
+        if (Micros(requestTimeout) == 0)
+        {
+            throw VsrError.Exception(VsrError.TRANSIENT_NOT_ACCEPTED,
+                "The deferred call spent its request timeout before it was sent.");
+        }
+
+        return this with
+        {
+            MaxWait = MaxWait > elapsed ? MaxWait - elapsed : TimeSpan.Zero,
+            RequestTimeout = requestTimeout
+        };
+    }
+
+    internal static ulong Micros(TimeSpan span)
+    {
+        return span <= TimeSpan.Zero ? 0 : (ulong)(span.Ticks / TimeSpan.TicksPerMicrosecond);
+    }
+
+    private static TimeSpan FromMicros(ulong micros)
+    {
+        return TimeSpan.FromTicks((long)micros * TimeSpan.TicksPerMicrosecond);
+    }
+
+    private static IggyInvalidStatusCodeException Refused(string message)
+    {
+        return VsrError.Exception(VsrError.INVALID_COMMAND, $"Deferred poll options refused: {message}");
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK/Contracts/FetchedMessages.cs b/foreign/csharp/Iggy_SDK/Contracts/FetchedMessages.cs
new file mode 100644
index 0000000..bf44870
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK/Contracts/FetchedMessages.cs
@@ -0,0 +1,207 @@
+// 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.
+
+using System.Buffers;
+using Apache.Iggy.Encryption;
+using Apache.Iggy.Exceptions;
+using Apache.Iggy.Kinds;
+using Apache.Iggy.Mappers;
+using Apache.Iggy.Vsr;
+
+namespace Apache.Iggy.Contracts;
+
+/// <summary>
+///     The reply of a fetch: one section per target that the server read or refused, in reply order. A target
+///     without a section keeps its position. The records stay undecoded until a section is decoded.
+/// </summary>
+/// <remarks>
+///     The reply holds one rented buffer. <see cref="FetchedSection.Records" /> and the rentals of
+///     <see cref="FetchedSection.DecodeRented" /> are slices of it, so the buffer goes back to the pool once the
+///     reply and every such rental are disposed.
+/// </remarks>
+internal sealed class FetchedMessages : IDisposable
+{
+    internal const int SECTIONS_COUNT_SIZE = sizeof(uint);
+    internal const int SECTION_HEADER_SIZE = 56;
+
+    private readonly IMemoryOwner<byte> _reply;
+    private int _references = 1;
+    private int _disposed;
+
+    internal FetchedMessages(IMemoryOwner<byte> reply)
+    {
+        _reply = reply;
+    }
+
+    /// <summary>The sections in reply order.</summary>
+    public IReadOnlyList<FetchedSection> Sections { get; internal set; } = [];
+
+    /// <summary>The bytes of a reply before its records: the section count and one header per section.</summary>
+    internal static int HeadSize(int sections)
+    {
+        return SECTIONS_COUNT_SIZE + sections * SECTION_HEADER_SIZE;
+    }
+
+    /// <summary>Releases the reference of the reply on its buffer.</summary>
+    public void Dispose()
+    {
+        if (Interlocked.Exchange(ref _disposed, 1) == 0)
+        {
+            Release();
+        }
+    }
+
+    /// <summary>A reference on the reply buffer for one rental, released when the rental is disposed.</summary>
+    /// <exception cref="ObjectDisposedException">The reply was disposed.</exception>
+    internal IMemoryOwner<byte> Acquire()
+    {
+        while (true)
+        {
+            var references = Volatile.Read(ref _references);
+            ObjectDisposedException.ThrowIf(references == 0 || Volatile.Read(ref _disposed) == 1, this);
+            if (Interlocked.CompareExchange(ref _references, references + 1, references) == references)
+            {
+                return new Reference(this);
+            }
+        }
+    }
+
+    private void Release()
+    {
+        if (Interlocked.Decrement(ref _references) == 0)
+        {
+            _reply.Dispose();
+        }
+    }
+
+    private sealed class Reference(FetchedMessages reply) : IMemoryOwner<byte>
+    {
+        private int _disposed;
+
+        public Memory<byte> Memory => reply._reply.Memory;
+
+        public void Dispose()
+        {
+            if (Interlocked.Exchange(ref _disposed, 1) == 0)
+            {
+                reply.Release();
+            }
+        }
+    }
+}
+
+/// <summary>What a fetch read from one target, or the status that refused it.</summary>
+internal sealed class FetchedSection
+{
+    private readonly IMessageEncryptor? _encryptor;
+    private readonly FetchedMessages _reply;
+
+    internal FetchedSection(FetchedMessages reply, IMessageEncryptor? encryptor)
+    {
+        _reply = reply;
+        _encryptor = encryptor;
+    }
+
+    /// <summary>The partition of the target.</summary>
+    public required uint PartitionId { get; init; }
+
+    /// <summary>
+    ///     Zero when the server read the partition. Otherwise the status code that refused the target, such as 89
+    ///     (NotPrimary) or 5009 (ConsumerGroupPartitionNotOwned). Local status 14010 carries <see cref="ProgressError" />
+    ///     and blocks the target until explicit abandonment. A refused section carries no records or position.
+    /// </summary>
+    public required uint Status { get; init; }
+
+    /// <summary>Local recovery for an uncertain committing section. Healthy sibling sections remain usable.</summary>
+    public VsrFetchProgressUncertainException? ProgressError { get; init; }
+
+    /// <summary>The current offset of the partition, as a poll reports it.</summary>
+    public required ulong CurrentOffset { get; init; }
+
+    /// <summary>
+    ///     Where the next fetch of the partition starts. Send the target next as <see cref="PollingStrategy.Offset" />
+    ///     of this value with <see cref="Context" />. A section of a <see cref="PollingStrategy.Next" /> target
+    ///     carries it also when it holds no messages.
+    /// </summary>
+    public required ulong NextOffset { get; init; }
+
+    /// <summary>The number of messages in <see cref="Records" />.</summary>
+    public required uint MessagesCount { get; init; }
+
+    /// <summary>The partition incarnation and owner that the server read the partition under.</summary>
+    public required PartitionContext Context { get; init; }
+
+    /// <summary>
+    ///     The undecoded batch records, as a poll reply carries them after its header. They are a slice of the
+    ///     reply buffer, valid until the reply is disposed.
+    /// </summary>
+    public required ReadOnlyMemory<byte> Records { get; init; }
+
+    /// <summary>Decodes the messages into copies that stay valid after the reply is disposed.</summary>
+    /// <exception cref="IggyInvalidStatusCodeException">The status of a refused section.</exception>
+    /// <exception cref="VsrFetchProgressUncertainException">The section requires explicit recovery.</exception>
+    /// <exception cref="MessageDecryptionException">A message does not decrypt with the client's encryptor.</exception>
+    /// <exception cref="ObjectDisposedException">The reply was disposed.</exception>
+    public PolledMessages Decode()
+    {
+        using var rental = DecodeRented();
+        return BinaryMapper.MaterializeMessages(rental);
+    }
+
+    /// <summary>
+    ///     Decodes the messages as slices of the reply buffer, decrypted into a buffer of their own when the client
+    ///     has an encryptor. The rental keeps the reply buffer rented until it is disposed.
+    /// </summary>
+    /// <exception cref="IggyInvalidStatusCodeException">The status of a refused section.</exception>
+    /// <exception cref="VsrFetchProgressUncertainException">The section requires explicit recovery.</exception>
+    /// <exception cref="MessageDecryptionException">A message does not decrypt with the client's encryptor.</exception>
+    /// <exception cref="ObjectDisposedException">The reply was disposed.</exception>
+    public PolledMessagesRental DecodeRented()
+    {
+        if (Status != 0)
+        {
+            throw Refusal();
+        }
+
+        var reference = _reply.Acquire();
+        try
+        {
+            return BinaryMapper.MapRentedRecords(Records, reference, PartitionId, CurrentOffset, MessagesCount,
+                Context, _encryptor);
+        }
+        catch
+        {
+            reference.Dispose();
+            throw;
+        }
+    }
+
+    /// <summary>The refusal of a section with a nonzero <see cref="Status" />, as the server reported it.</summary>
+    internal Exception Refusal()
+    {
+        return ProgressError ?? (Exception)VsrError.FromServer((int)Status, $"The fetch of partition {PartitionId} was refused.");
+    }
+}
+
+internal sealed class FetchProgressReply(IMemoryOwner<byte> reply, ulong operationId, IReadOnlySet<uint> partitions)
+    : IMemoryOwner<byte>
+{
+    internal ulong OperationId { get; } = operationId;
+    internal IReadOnlySet<uint> Partitions { get; } = partitions;
+    public Memory<byte> Memory => reply.Memory;
+    public void Dispose() => reply.Dispose();
+}
diff --git a/foreign/csharp/Iggy_SDK/Contracts/PollOptions.cs b/foreign/csharp/Iggy_SDK/Contracts/PollOptions.cs
new file mode 100644
index 0000000..7cf3d82
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK/Contracts/PollOptions.cs
@@ -0,0 +1,111 @@
+// 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.
+
+using Apache.Iggy.Exceptions;
+using Apache.Iggy.Kinds;
+using Apache.Iggy.Vsr;
+
+namespace Apache.Iggy.Contracts;
+
+/// <summary>
+///     The limits of one read and who stores its offsets. A read is a poll of one partition, or a fetch of several
+///     partitions of one primary that a reader sends, whose sections share the limits.
+/// </summary>
+/// <remarks>
+///     A read waits until <see cref="MinCount" /> messages are readable or <see cref="MaxWait" /> passes, and returns
+///     up to <see cref="Count" /> messages and <see cref="MaxBytes" /> bytes. A first message larger than
+///     <see cref="MaxBytes" /> comes alone in its read. A <see cref="Kinds.PollingStrategy.Last" /> read of more than
+///     one message that starts with such a message fails with status 80 (InvalidSizeBytes) instead, and stores
+///     nothing.
+/// </remarks>
+public sealed record PollOptions
+{
+    private static readonly TimeSpan RequestTimeoutMargin = TimeSpan.FromSeconds(10);
+
+    /// <summary>Most messages of one batch. 1000 by default.</summary>
+    public uint Count { get; init; } = 1000;
+
+    /// <summary>
+    ///     How long the server waits for <see cref="MinCount" /> messages. Zero answers at once and is the default of
+    ///     a poll. At most 600 seconds.
+    /// </summary>
+    public TimeSpan MaxWait { get; init; } = TimeSpan.Zero;
+
+    /// <summary>How many messages make the server answer before <see cref="MaxWait" />. 1 to <see cref="Count" />, 1 by default.</summary>
+    public uint MinCount { get; init; } = 1;
+
+    /// <summary>The largest reply, in bytes. 1 MiB by default.</summary>
+    public uint MaxBytes { get; init; } = 1024 * 1024;
+
+    /// <summary>Who stores the offsets. <see cref="CommitPolicy.Manual" /> by default.</summary>
+    public CommitPolicy Commit { get; init; } = CommitPolicy.Manual;
+
+    /// <summary>The limit of the whole call: <see cref="MaxWait" /> plus 10 seconds.</summary>
+    public TimeSpan RequestTimeout => MaxWait + RequestTimeoutMargin;
+
+    /// <summary>The wire limits of a read with these options.</summary>
+    internal DeferredPollOptions Wire => new()
+    {
+        MaxWait = MaxWait,
+        MinCount = MinCount,
+        MaxBytes = MaxBytes,
+        RequestTimeout = RequestTimeout
+    };
+
+    /// <summary>The defaults with a wait of up to <paramref name="maxWait" /> for one message.</summary>
+    public static PollOptions Wait(TimeSpan maxWait)
+    {
+        return new PollOptions { MaxWait = maxWait };
+    }
+
+    /// <summary>Applies the rules of the server and of the commit policy, so refused options are never sent.</summary>
+    /// <exception cref="IggyInvalidStatusCodeException">
+    ///     Status 4009 for a zero <see cref="Count" /> or a <see cref="MinCount" /> outside 1 to <see cref="Count" />, 3
+    ///     for a <see cref="MaxWait" /> above 600 seconds, 80 for a <see cref="MaxBytes" /> below one message, and 2
+    ///     (InvalidConfiguration) for <see cref="CommitPolicy.Auto" /> without an interval or an event, which would
+    ///     store only on close, or with an interval that is not positive.
+    /// </exception>
+    internal void Validate()
+    {
+        ArgumentNullException.ThrowIfNull(Commit);
+        if (Commit.Settings is { } auto && (auto.Interval is null && auto.Every is null
+                                            || auto.Interval <= TimeSpan.Zero))
+        {
+            throw VsrError.Exception(VsrError.INVALID_CONFIGURATION,
+                "An automatic commit needs a positive interval or an event.");
+        }
+
+        Wire.Validate(Count);
+    }
+
+    /// <summary>Applies <see cref="Validate" /> and the rules of a one-shot poll of <paramref name="partitionId" />.</summary>
+    /// <exception cref="IggyInvalidStatusCodeException">
+    ///     The status of <see cref="Validate" />, 2 for <see cref="CommitPolicy.Auto" />, which a one-shot poll has
+    ///     nothing to count for, and 3 for a continuation of another partition.
+    /// </exception>
+    internal void ValidatePoll(uint partitionId, PollingStrategy strategy)
+    {
+        Validate();
+        if (Commit.Mode == CommitMode.Auto)
+        {
+            throw VsrError.Exception(VsrError.INVALID_CONFIGURATION,
+                "A one-shot poll has nothing to count, so it cannot commit automatically. Use a partition reader.");
+        }
+
+        strategy.ValidatePartition(partitionId);
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK/Contracts/PolledMessages.cs b/foreign/csharp/Iggy_SDK/Contracts/PolledMessages.cs
index 323b2c9..cd69bfc 100644
--- a/foreign/csharp/Iggy_SDK/Contracts/PolledMessages.cs
+++ b/foreign/csharp/Iggy_SDK/Contracts/PolledMessages.cs
@@ -15,6 +15,10 @@
 // specific language governing permissions and limitations
 // under the License.
 
+using System.Text.Json.Serialization;
+using Apache.Iggy.Enums;
+using Apache.Iggy.Kinds;
+
 namespace Apache.Iggy.Contracts;
 
 /// <summary>
@@ -47,6 +51,62 @@
     /// </summary>
     public required IReadOnlyList<MessageResponse> Messages { get; set; }
 
+    /// <summary>The start of the read that returned this batch, which the continuation of an empty batch keeps.</summary>
+    internal PollingStrategy? RequestStart { get; set; }
+
+    /// <summary>The position after the last message, to commit the whole batch. Null for an empty batch.</summary>
+    [JsonIgnore]
+    public Checkpoint? Checkpoint => CheckpointAt(Messages.Count - 1);
+
+    /// <summary>
+    ///     The position after the next read of the partition. After the last message it is
+    ///     <see cref="PollingStrategy.Offset" /> of the next offset. An empty batch keeps the start of its read:
+    ///     <see cref="PollingStrategy.First" /> becomes offset 0, and <see cref="PollingStrategy.Next" /> stays Next, so
+    ///     the next read resolves it again from the stored offset. The continuation belongs to the partition of the
+    ///     batch, and a read of another partition refuses it. It carries the context of the batch, so a read after a
+    ///     change of the partition history fails with status 87 (HistoryUnavailable).
+    /// </summary>
+    /// <remarks>
+    ///     An empty batch with a zero context names no partition history, so its continuation is the start of the
+    ///     read as it was.
+    /// </remarks>
+    [JsonIgnore]
+    public PollingStrategy Continuation
+    {
+        get
+        {
+            if (Messages.Count > 0)
+            {
+                return PollingStrategy.Continuation(PartitionId, MessagePolling.Offset,
+                    Messages[^1].Header.Offset + 1, Context);
+            }
+
+            var start = RequestStart ?? PollingStrategy.Next();
+            if (Context == default)
+            {
+                return start;
+            }
+
+            return start.Kind == MessagePolling.First
+                ? PollingStrategy.Continuation(PartitionId, MessagePolling.Offset, 0, Context)
+                : PollingStrategy.Continuation(PartitionId, start.Kind, start.Value, Context);
+        }
+    }
+
+    /// <summary>
+    ///     The position after <see cref="Messages" /> at <paramref name="index" />, to commit a processed part of the
+    ///     batch. Null for an index out of range.
+    /// </summary>
+    public Checkpoint? CheckpointAt(int index)
+    {
+        return index >= 0 && index < Messages.Count
+            ? new Checkpoint(PartitionId, Messages[index].Header.Offset, Context, Stamp)
+            : null;
+    }
+
+    /// <summary>The reader and partition generation that delivered the batch, null for a one-shot poll.</summary>
+    internal ReaderStamp? Stamp { get; set; }
+
     /// <summary>
     ///     Empty polled messages.
     /// </summary>
diff --git a/foreign/csharp/Iggy_SDK/Contracts/StreamPermissions.cs b/foreign/csharp/Iggy_SDK/Contracts/StreamPermissions.cs
index 6821fb6..42cd940 100644
--- a/foreign/csharp/Iggy_SDK/Contracts/StreamPermissions.cs
+++ b/foreign/csharp/Iggy_SDK/Contracts/StreamPermissions.cs
@@ -126,7 +126,8 @@
     ///                 cref="Apache.Iggy.IggyClient.IIggyOffset.StoreOffsetAsync(Consumer, Identifier, Identifier, ulong, uint, PartitionContext, CancellationToken)" />
     ///         </item>
     ///         <item>
-    ///             <see cref="Apache.Iggy.IggyClient.IIggyOffset.DeleteOffsetAsync" />
+    ///             <see
+    ///                 cref="Apache.Iggy.IggyClient.IIggyOffset.DeleteOffsetAsync(Consumer, Identifier, Identifier, uint?, CancellationToken)" />
     ///         </item>
     ///     </list>
     /// </summary>
diff --git a/foreign/csharp/Iggy_SDK/Contracts/Tcp/TcpContracts.cs b/foreign/csharp/Iggy_SDK/Contracts/Tcp/TcpContracts.cs
index daaa1a4..2ade361 100644
--- a/foreign/csharp/Iggy_SDK/Contracts/Tcp/TcpContracts.cs
+++ b/foreign/csharp/Iggy_SDK/Contracts/Tcp/TcpContracts.cs
@@ -42,6 +42,55 @@
     /// <summary>Offset mutations always request quorum acknowledgement.</summary>
     private const byte AckQuorum = 1;
 
+    /// <summary>The bytes a deferred poll (code 106) appends to the body of code 100.</summary>
+    internal const int DEFERRED_POLL_TAIL_SIZE = sizeof(ulong) + sizeof(uint) + sizeof(uint) + sizeof(ulong);
+
+    /// <summary>The most partitions one fetch names.</summary>
+    internal const int MAX_PARTITIONS_PER_REQUEST = 1000;
+
+    /// <summary>
+    ///     The largest record of one message in a read reply: the batch header, the message header, the largest
+    ///     payload and the largest user headers. A read returns a first record up to this size even above its byte
+    ///     limit, so a reply frame limit below <see cref="MAX_RECORD_REPLY_SIZE" /> cannot read every message.
+    /// </summary>
+    internal const int MAX_RECORD_SIZE = BatchWireFormat.BATCH_HEADER_SIZE + BatchWireFormat.FRAME_HEADER_SIZE
+                                         + 64_000_000 + 100_000;
+
+    /// <summary>The largest reply frame of a read of one such record: the frame header, the largest fetch table and the record.</summary>
+    internal const int MAX_RECORD_REPLY_SIZE = VsrHeader.HEADER_SIZE + FetchedMessages.SECTIONS_COUNT_SIZE
+                                               + MAX_PARTITIONS_PER_REQUEST * FetchedMessages.SECTION_HEADER_SIZE
+                                               + MAX_RECORD_SIZE;
+
+    /// <summary>The wait and the request timeout that follow the fetch topic.</summary>
+    private const int WaitBudgetSize = sizeof(ulong) + sizeof(ulong);
+
+    private const int ConsumerKindSize = 1;
+    private const int IdentifierPrefixSize = 2;
+    private const int PollingStrategySize = 1 + sizeof(ulong);
+    private const int PartitionStrategySize = sizeof(uint) + PollingStrategySize;
+
+    private const int FetchMinCountOffset = WaitBudgetSize;
+    private const int FetchCountOffset = FetchMinCountOffset + sizeof(uint);
+    private const int FetchMaxBytesOffset = FetchCountOffset + sizeof(uint);
+    private const int FetchAutoCommitOffset = FetchMaxBytesOffset + sizeof(uint);
+    private const int FetchTargetsCountOffset = FetchAutoCommitOffset + 1;
+
+    /// <summary>
+    ///     What a fetch (code 109) carries between its topic and its targets: the wait, the request timeout, the
+    ///     minimum count, the count, the byte limit, the commit flag and the target count.
+    /// </summary>
+    private const int FetchFieldsSize = FetchTargetsCountOffset + sizeof(uint);
+
+    /// <summary>
+    ///     A fetch target: the partition id, the strategy, its own byte limit, then its fence, the incarnation and
+    ///     the owner generation that a poll carries in its request header.
+    /// </summary>
+    private const int FetchTargetSize = PartitionStrategySize + sizeof(uint) + sizeof(ulong) + sizeof(ulong);
+
+    private const int FetchTargetStrategyOffset = sizeof(uint);
+    private const int FetchTargetMaxBytesOffset = PartitionStrategySize;
+    private const int FetchTargetFenceOffset = FetchTargetMaxBytesOffset + sizeof(uint);
+
     internal static byte[] LoginWithPersonalAccessToken(string token)
     {
         var tokenLength = WireName.ByteCount(token, nameof(token));
@@ -264,15 +313,15 @@
         writer.Advance(1);
     }
 
+    /// <summary>
+    ///     Encodes a PollMessages body (code 100), or with <paramref name="deferred" /> a PollMessagesDeferred body
+    ///     (code 106): the same body followed by the wait, the minimum count, the byte limit and the request timeout.
+    /// </summary>
     internal static void GetMessages(Span<byte> bytes, Consumer consumer, Identifier streamId, Identifier topicId,
         PollingStrategy pollingStrategy,
-        uint count, bool autoCommit, uint? partitionId)
+        uint count, bool autoCommit, uint? partitionId, DeferredPollOptions? deferred = null)
     {
-        bytes[0] = GetConsumerTypeByte(consumer.Type);
-        bytes.WriteBytesFromIdentifier(consumer.ConsumerId, 1);
-        var position = 1 + consumer.ConsumerId.Length + 2;
-        bytes.WriteBytesFromStreamAndTopicIdentifiers(streamId, topicId, position);
-        position += 2 + streamId.Length + 2 + topicId.Length;
+        var position = WriteConsumerStreamAndTopic(bytes, consumer, streamId, topicId);
 
         // Encode partition_id with a flag byte: 1 = Some, 0 = None
         if (partitionId.HasValue)
@@ -286,17 +335,152 @@
             BinaryPrimitives.WriteUInt32LittleEndian(bytes[(position + 1)..(position + 5)], 0); // Padding
         }
 
-        bytes[position + 5] = GetPollingStrategyByte(pollingStrategy.Kind);
-        BinaryPrimitives.WriteUInt64LittleEndian(bytes[(position + 6)..(position + 14)], pollingStrategy.Value);
+        WritePollingStrategy(bytes[(position + 5)..], pollingStrategy);
         BinaryPrimitives.WriteUInt32LittleEndian(bytes[(position + 14)..(position + 18)], count);
 
         bytes[position + 18] = autoCommit ? (byte)1 : (byte)0;
+        if (deferred is not null)
+        {
+            var tail = bytes[(position + 19)..];
+            BinaryPrimitives.WriteUInt64LittleEndian(tail, deferred.MaxWaitUs);
+            BinaryPrimitives.WriteUInt32LittleEndian(tail[8..], deferred.MinCount);
+            BinaryPrimitives.WriteUInt32LittleEndian(tail[12..], deferred.MaxBytes);
+            BinaryPrimitives.WriteUInt64LittleEndian(tail[16..], deferred.RequestTimeoutUs);
+        }
+    }
+
+    /// <summary>
+    ///     Encodes a FetchMessages body (code 109): the consumer, stream and topic as code 100 carries them, the
+    ///     budget and the limits of the whole reply, then each target with its position, the byte limit
+    ///     <paramref name="targetMaxBytes" /> and the fence of <see cref="PollingStrategy.Context" />.
+    /// </summary>
+    internal static byte[] FetchMessages(Consumer consumer, Identifier streamId, Identifier topicId,
+        DeferredPollOptions options, uint count, bool autoCommit, uint targetMaxBytes,
+        IReadOnlyList<(uint PartitionId, PollingStrategy Strategy)> targets)
+    {
+        var fieldsOffset = ConsumerStreamAndTopicSize(consumer, streamId, topicId);
+        var bytes = new byte[fieldsOffset + FetchFieldsSize + targets.Count * FetchTargetSize];
+        WriteConsumerStreamAndTopic(bytes, consumer, streamId, topicId);
+        var fields = bytes.AsSpan(fieldsOffset);
+        BinaryPrimitives.WriteUInt64LittleEndian(fields, options.MaxWaitUs);
+        BinaryPrimitives.WriteUInt64LittleEndian(fields[sizeof(ulong)..], options.RequestTimeoutUs);
+        BinaryPrimitives.WriteUInt32LittleEndian(fields[FetchMinCountOffset..], options.MinCount);
+        BinaryPrimitives.WriteUInt32LittleEndian(fields[FetchCountOffset..], count);
+        BinaryPrimitives.WriteUInt32LittleEndian(fields[FetchMaxBytesOffset..], options.MaxBytes);
+        fields[FetchAutoCommitOffset] = autoCommit ? (byte)1 : (byte)0;
+        BinaryPrimitives.WriteUInt32LittleEndian(fields[FetchTargetsCountOffset..], (uint)targets.Count);
+        var target = fields[FetchFieldsSize..];
+        foreach (var (partitionId, strategy) in targets)
+        {
+            var fence = strategy.Context ?? default;
+            BinaryPrimitives.WriteUInt32LittleEndian(target, partitionId);
+            WritePollingStrategy(target[FetchTargetStrategyOffset..], strategy);
+            BinaryPrimitives.WriteUInt32LittleEndian(target[FetchTargetMaxBytesOffset..], targetMaxBytes);
+            BinaryPrimitives.WriteUInt64LittleEndian(target[FetchTargetFenceOffset..], fence.Incarnation);
+            BinaryPrimitives.WriteUInt64LittleEndian(target[(FetchTargetFenceOffset + sizeof(ulong))..],
+                fence.OwnerGeneration);
+            target = target[FetchTargetSize..];
+        }
+
+        return bytes;
+    }
+
+    /// <summary>Whether a FetchMessages body commits the offsets of what it reads.</summary>
+    internal static bool FetchAutoCommits(ReadOnlySpan<byte> body)
+    {
+        return body[FetchFieldsOffset(body) + FetchAutoCommitOffset] != 0;
+    }
+
+    /// <summary>Whether a PollMessages, PollMessagesDeferred or FetchMessages body commits what it reads.</summary>
+    internal static bool ReadAutoCommits(int code, ReadOnlySpan<byte> body)
+    {
+        return code switch
+        {
+            CommandCodes.FETCH_MESSAGES_CODE => FetchAutoCommits(body),
+            CommandCodes.POLL_MESSAGES_DEFERRED_CODE => body.Length > DEFERRED_POLL_TAIL_SIZE
+                                                        && body[^(DEFERRED_POLL_TAIL_SIZE + 1)] != 0,
+            CommandCodes.POLL_MESSAGES_CODE => body.Length > 0 && body[^1] != 0,
+            _ => false
+        };
+    }
+
+    /// <summary>Whether a FetchMessages body moves its consumer: it commits, or a target starts at Next.</summary>
+    internal static bool FetchMovesConsumer(ReadOnlySpan<byte> body)
+    {
+        if (FetchAutoCommits(body))
+        {
+            return true;
+        }
+
+        var next = GetPollingStrategyByte(MessagePolling.Next);
+        for (var targets = FetchTargets(body); !targets.IsEmpty; targets = targets[FetchTargetSize..])
+        {
+            if (targets[FetchTargetStrategyOffset] == next)
+            {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    /// <summary>The partition and the incarnation of each target of a FetchMessages body, in body order.</summary>
+    internal static (uint PartitionId, ulong Incarnation)[] FetchTargetFences(ReadOnlySpan<byte> body)
+    {
+        var targets = FetchTargets(body);
+        var fences = new (uint PartitionId, ulong Incarnation)[targets.Length / FetchTargetSize];
+        for (var index = 0; index < fences.Length; index++)
+        {
+            var target = targets[(index * FetchTargetSize)..];
+            fences[index] = (BinaryPrimitives.ReadUInt32LittleEndian(target),
+                BinaryPrimitives.ReadUInt64LittleEndian(target[FetchTargetFenceOffset..]));
+        }
+
+        return fences;
+    }
+
+    private static ReadOnlySpan<byte> FetchTargets(ReadOnlySpan<byte> body)
+    {
+        return body[(FetchFieldsOffset(body) + FetchFieldsSize)..];
+    }
+
+    private static int FetchFieldsOffset(ReadOnlySpan<byte> body)
+    {
+        var streamId = ConsumerKindSize + IdentifierSize(body, ConsumerKindSize);
+        var topicId = streamId + IdentifierSize(body, streamId);
+        return topicId + IdentifierSize(body, topicId);
+    }
+
+    private static int IdentifierSize(ReadOnlySpan<byte> body, int position)
+    {
+        return IdentifierPrefixSize + body[position + 1];
+    }
+
+    private static int ConsumerStreamAndTopicSize(Consumer consumer, Identifier streamId, Identifier topicId)
+    {
+        return 1 + 2 + consumer.ConsumerId.Length + 2 + streamId.Length + 2 + topicId.Length;
+    }
+
+    private static int WriteConsumerStreamAndTopic(Span<byte> bytes, Consumer consumer, Identifier streamId,
+        Identifier topicId)
+    {
+        bytes[0] = GetConsumerTypeByte(consumer.Type);
+        bytes.WriteBytesFromIdentifier(consumer.ConsumerId, 1);
+        var position = 1 + consumer.ConsumerId.Length + 2;
+        bytes.WriteBytesFromStreamAndTopicIdentifiers(streamId, topicId, position);
+        return position + 2 + streamId.Length + 2 + topicId.Length;
+    }
+
+    private static void WritePollingStrategy(Span<byte> bytes, PollingStrategy pollingStrategy)
+    {
+        bytes[0] = GetPollingStrategyByte(pollingStrategy.Kind);
+        BinaryPrimitives.WriteUInt64LittleEndian(bytes[1..PollingStrategySize], pollingStrategy.Value);
     }
 
     /// <summary>
     ///     Encodes a SendMessages body: routing metadata followed by one canonical batch record
     ///     (a 256-byte batch header plus per-message frames). The server stamps
-    ///     <c>partition_id</c>, <c>base_offset</c>, and <c>base_timestamp</c>; they stay zero here.
+    ///     <c>partition_id</c>, <c>base_offset</c>, and <c>base_timestamp</c>. They stay zero here.
     /// </summary>
     internal static int CreateMessage(Span<byte> bytes, Identifier streamId, Identifier topicId,
         Partitioning partitioning, ReadOnlySpan<Message> messages, IMessageEncryptor? encryptor = null)
@@ -334,7 +518,7 @@
         var blobStart = position;
         var offsetDelta = 0u;
 
-        // One scratch buffer reused across the batch (grown on demand); holds plaintext headers, so it is
+        // One scratch buffer reused across the batch (grown on demand). Holds plaintext headers, so it is
         // returned cleared on every path, including a mid-batch throw from the encryptor or header serialization.
         byte[]? headerScratch = null;
         try
@@ -421,7 +605,7 @@
                     (uint)timestampDelta);
                 BinaryPrimitives.WriteInt32LittleEndian(bytes[(frameStart + 32)..(frameStart + 36)], headersLength);
                 BinaryPrimitives.WriteInt32LittleEndian(bytes[(frameStart + 36)..(frameStart + 40)], payloadLength);
-                // Reserved must be zero on the wire; the server rejects non-zero values.
+                // Reserved must be zero on the wire. The server rejects non-zero values.
                 BinaryPrimitives.WriteUInt64LittleEndian(bytes[(frameStart + 40)..(frameStart + 48)], 0);
 
                 position = headersStart + headersLength;
@@ -701,13 +885,8 @@
     internal static byte[] UpdateOffset(Identifier streamId, Identifier topicId, Consumer consumer, ulong offset,
         uint? partitionId)
     {
-        Span<byte> bytes =
-            stackalloc byte[2 + streamId.Length + 2 + topicId.Length + 14 + 1 + 2 + consumer.ConsumerId.Length];
-        bytes[0] = GetConsumerTypeByte(consumer.Type);
-        bytes.WriteBytesFromIdentifier(consumer.ConsumerId, 1);
-        var position = 1 + consumer.ConsumerId.Length + 2;
-        bytes.WriteBytesFromStreamAndTopicIdentifiers(streamId, topicId, position);
-        position += 2 + streamId.Length + 2 + topicId.Length;
+        Span<byte> bytes = stackalloc byte[ConsumerStreamAndTopicSize(consumer, streamId, topicId) + 14];
+        var position = WriteConsumerStreamAndTopic(bytes, consumer, streamId, topicId);
 
         // Encode partition_id with a flag byte: 1 = Some, 0 = None
         if (partitionId.HasValue)
@@ -728,13 +907,8 @@
 
     internal static byte[] GetOffset(Identifier streamId, Identifier topicId, Consumer consumer, uint? partitionId)
     {
-        Span<byte> bytes =
-            stackalloc byte[2 + streamId.Length + 2 + topicId.Length + 5 + 1 + 2 + consumer.ConsumerId.Length];
-        bytes[0] = GetConsumerTypeByte(consumer.Type);
-        bytes.WriteBytesFromIdentifier(consumer.ConsumerId, 1);
-        var position = 1 + consumer.ConsumerId.Length + 2;
-        bytes.WriteBytesFromStreamAndTopicIdentifiers(streamId, topicId, position);
-        position += 2 + streamId.Length + 2 + topicId.Length;
+        Span<byte> bytes = stackalloc byte[ConsumerStreamAndTopicSize(consumer, streamId, topicId) + 5];
+        var position = WriteConsumerStreamAndTopic(bytes, consumer, streamId, topicId);
 
         // Encode partition_id with a flag byte: 1 = Some, 0 = None
         if (partitionId.HasValue)
@@ -865,13 +1039,8 @@
 
     internal static byte[] DeleteOffset(Identifier streamId, Identifier topicId, Consumer consumer, uint? partitionId)
     {
-        Span<byte> bytes =
-            stackalloc byte[2 + streamId.Length + 2 + topicId.Length + 6 + 1 + 2 + consumer.ConsumerId.Length];
-        bytes[0] = GetConsumerTypeByte(consumer.Type);
-        bytes.WriteBytesFromIdentifier(consumer.ConsumerId, 1);
-        var position = 1 + consumer.ConsumerId.Length + 2;
-        bytes.WriteBytesFromStreamAndTopicIdentifiers(streamId, topicId, position);
-        position += 2 + streamId.Length + 2 + topicId.Length;
+        Span<byte> bytes = stackalloc byte[ConsumerStreamAndTopicSize(consumer, streamId, topicId) + 6];
+        var position = WriteConsumerStreamAndTopic(bytes, consumer, streamId, topicId);
 
         // Encode partition_id with a flag byte: 1 = Some, 0 = None
         if (partitionId.HasValue)
diff --git a/foreign/csharp/Iggy_SDK/Contracts/TopicPermissions.cs b/foreign/csharp/Iggy_SDK/Contracts/TopicPermissions.cs
index f255622..09933a1 100644
--- a/foreign/csharp/Iggy_SDK/Contracts/TopicPermissions.cs
+++ b/foreign/csharp/Iggy_SDK/Contracts/TopicPermissions.cs
@@ -94,7 +94,8 @@
     ///                 cref="Apache.Iggy.IggyClient.IIggyOffset.StoreOffsetAsync(Consumer, Identifier, Identifier, ulong, uint, PartitionContext, CancellationToken)" />
     ///         </item>
     ///         <item>
-    ///             <see cref="Apache.Iggy.IggyClient.IIggyOffset.DeleteOffsetAsync" />
+    ///             <see
+    ///                 cref="Apache.Iggy.IggyClient.IIggyOffset.DeleteOffsetAsync(Consumer, Identifier, Identifier, uint?, CancellationToken)" />
     ///         </item>
     ///     </list>
     /// </summary>
diff --git a/foreign/csharp/Iggy_SDK/Exceptions/MalformedResponseException.cs b/foreign/csharp/Iggy_SDK/Exceptions/MalformedResponseException.cs
index f22246e..eea4a78 100644
--- a/foreign/csharp/Iggy_SDK/Exceptions/MalformedResponseException.cs
+++ b/foreign/csharp/Iggy_SDK/Exceptions/MalformedResponseException.cs
@@ -28,4 +28,15 @@
     internal MalformedResponseException(string message) : base(message)
     {
     }
+
+    internal MalformedResponseException(string message, ReadOnlySpan<byte> record) : base(message)
+    {
+        Record = record.ToArray();
+    }
+
+    /// <summary>
+    ///     For reply records that do not decode: the remaining bytes of the records from where decoding stopped,
+    ///     as received. Null for a malformed reply that is not records.
+    /// </summary>
+    public byte[]? Record { get; }
 }
diff --git a/foreign/csharp/Iggy_SDK/Exceptions/MessageDecryptionException.cs b/foreign/csharp/Iggy_SDK/Exceptions/MessageDecryptionException.cs
index 3ce3103..0db20f8 100644
--- a/foreign/csharp/Iggy_SDK/Exceptions/MessageDecryptionException.cs
+++ b/foreign/csharp/Iggy_SDK/Exceptions/MessageDecryptionException.cs
@@ -39,15 +39,24 @@
     public uint PartitionId { get; }
 
     /// <summary>
+    ///     The record of the message as the reply carried it: its 48-byte frame header, payload and user headers
+    ///     before decryption. Null for a message of a JSON reply over HTTP, which carries no records.
+    /// </summary>
+    public byte[]? Record { get; }
+
+    /// <summary>
     ///     Initializes a new instance of the <see cref="MessageDecryptionException" /> class.
     /// </summary>
     /// <param name="offset">Offset of the message that failed to decrypt.</param>
     /// <param name="partitionId">Partition the failing message was polled from.</param>
     /// <param name="innerException">The underlying cryptographic failure.</param>
-    public MessageDecryptionException(ulong offset, uint partitionId, Exception innerException)
+    /// <param name="record">The record of the message as the reply carried it, when the reply carried records.</param>
+    public MessageDecryptionException(ulong offset, uint partitionId, Exception innerException,
+        byte[]? record = null)
         : base($"Failed to decrypt message at offset {offset} in partition {partitionId}.", innerException)
     {
         Offset = offset;
         PartitionId = partitionId;
+        Record = record;
     }
 }
diff --git a/foreign/csharp/Iggy_SDK/Exceptions/VsrFetchProgressUncertainException.cs b/foreign/csharp/Iggy_SDK/Exceptions/VsrFetchProgressUncertainException.cs
new file mode 100644
index 0000000..d69c16c
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK/Exceptions/VsrFetchProgressUncertainException.cs
@@ -0,0 +1,35 @@
+// 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.
+
+using Apache.Iggy.Vsr;
+
+namespace Apache.Iggy.Exceptions;
+
+/// <summary>
+///     A committing poll or fetch with an unknown outcome retains local progress blocks. Its operation id is a
+///     recovery key, not a replayable server receipt. Explicit abandonment accepts that the old read may still commit.
+/// </summary>
+public sealed class VsrFetchProgressUncertainException(ulong operationId, Exception? cause = null)
+    : VsrRequestOutcomeUnknownException(cause ?? new InvalidOperationException(
+        $"Read {operationId} has unknown progress and requires explicit abandonment."), operationId)
+{
+    /// <summary>The SDK-local uncertain-fetch status.</summary>
+    public const int STATUS_CODE = VsrError.FETCH_PROGRESS_UNCERTAIN;
+
+    /// <summary>The SDK-local uncertain-fetch status.</summary>
+    public int StatusCode => STATUS_CODE;
+}
diff --git a/foreign/csharp/Iggy_SDK/Exceptions/VsrLaneBlockedException.cs b/foreign/csharp/Iggy_SDK/Exceptions/VsrLaneBlockedException.cs
index 6d63293..79ed343 100644
--- a/foreign/csharp/Iggy_SDK/Exceptions/VsrLaneBlockedException.cs
+++ b/foreign/csharp/Iggy_SDK/Exceptions/VsrLaneBlockedException.cs
@@ -15,11 +15,43 @@
 // specific language governing permissions and limitations
 // under the License.
 
+using Apache.Iggy.Vsr;
+
 namespace Apache.Iggy.Exceptions;
 
 /// <summary>A retained operation blocks this mutation lane until it is resolved or abandoned.</summary>
-public sealed class VsrLaneBlockedException(ulong operationId) : Exception("A retained operation blocks the mutation lane.")
+public class VsrLaneBlockedException : Exception
 {
+    /// <summary>The SDK-local lane blocked status.</summary>
+    public const int STATUS_CODE = VsrError.LANE_BLOCKED;
+
+    /// <summary>A retained operation blocks the lane.</summary>
+    public VsrLaneBlockedException(ulong operationId)
+        : this(operationId, null, $"Lane blocked by operation {operationId} with an unknown outcome.")
+    {
+    }
+
+    /// <summary>A retained operation blocks a particular fetch target.</summary>
+    public VsrLaneBlockedException(ulong operationId, uint partitionId)
+        : this(operationId, partitionId, $"Lane blocked by operation {operationId} with an unknown outcome.")
+    {
+    }
+
+    private protected VsrLaneBlockedException(ulong operationId, uint? partitionId, string message) : base(message)
+    {
+        OperationId = operationId;
+        PartitionId = partitionId;
+    }
+
     /// <summary>The request id of the operation.</summary>
-    public ulong OperationId { get; } = operationId;
+    public ulong OperationId { get; }
+
+    /// <summary>The blocked target of a fetch, otherwise null.</summary>
+    public uint? PartitionId { get; }
+
+    /// <summary>The blocker is a committing fetch without a replayable server receipt.</summary>
+    public virtual bool RequiresExplicitRecovery => false;
+
+    /// <summary>The SDK-local status of the block.</summary>
+    public virtual int StatusCode => STATUS_CODE;
 }
diff --git a/foreign/csharp/Iggy_SDK/Exceptions/VsrPartitionLaneBlockedException.cs b/foreign/csharp/Iggy_SDK/Exceptions/VsrPartitionLaneBlockedException.cs
new file mode 100644
index 0000000..e0340bd
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK/Exceptions/VsrPartitionLaneBlockedException.cs
@@ -0,0 +1,38 @@
+// 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.
+
+using Apache.Iggy.Vsr;
+
+namespace Apache.Iggy.Exceptions;
+
+/// <summary>
+///     A committing poll or fetch with an unknown outcome blocks the partition until the application abandons its
+///     operation. Polls, committing fetches, offset stores and deletes of the partition fail with this until then.
+/// </summary>
+public sealed class VsrPartitionLaneBlockedException(ulong operationId, uint partitionId)
+    : VsrLaneBlockedException(operationId, partitionId,
+        $"Lane of partition {partitionId} blocked by operation {operationId} with an unknown outcome.")
+{
+    /// <summary>The SDK-local partition lane blocked status.</summary>
+    public new const int STATUS_CODE = VsrError.PARTITION_LANE_BLOCKED;
+
+    /// <inheritdoc />
+    public override bool RequiresExplicitRecovery => true;
+
+    /// <inheritdoc />
+    public override int StatusCode => STATUS_CODE;
+}
diff --git a/foreign/csharp/Iggy_SDK/Exceptions/VsrRequestOutcomeUnknownException.cs b/foreign/csharp/Iggy_SDK/Exceptions/VsrRequestOutcomeUnknownException.cs
index eea2bbb..fee1ae2 100644
--- a/foreign/csharp/Iggy_SDK/Exceptions/VsrRequestOutcomeUnknownException.cs
+++ b/foreign/csharp/Iggy_SDK/Exceptions/VsrRequestOutcomeUnknownException.cs
@@ -29,10 +29,10 @@
 ///     and either retried or swallowed, which are the two responses this type exists to prevent. The triggering
 ///     exception is preserved as <see cref="Exception.InnerException" />.
 /// </remarks>
-public sealed class VsrRequestOutcomeUnknownException(Exception innerException, ulong? operationId = null)
+public class VsrRequestOutcomeUnknownException(Exception innerException, ulong? operationId = null)
     : Exception("The VSR request outcome is unknown because the server did not confirm whether it committed the request.",
         innerException)
 {
-    /// <summary>The retained operation to resolve or abandon; null for weak polls and session control.</summary>
+    /// <summary>The local operation to resolve or abandon. An uncertain committing poll or fetch can only be abandoned.</summary>
     public ulong? OperationId { get; } = operationId;
 }
diff --git a/foreign/csharp/Iggy_SDK/Extensions/Extensions.cs b/foreign/csharp/Iggy_SDK/Extensions/Extensions.cs
index 137279b..bb8a282 100644
--- a/foreign/csharp/Iggy_SDK/Extensions/Extensions.cs
+++ b/foreign/csharp/Iggy_SDK/Extensions/Extensions.cs
@@ -104,11 +104,11 @@
 {
     internal static DateTimeOffset FromUnixTimeMicroSeconds(ulong microSeconds)
     {
-        return DateTimeOffset.FromUnixTimeMilliseconds((long)(microSeconds / 1000));
+        return DateTimeOffset.UnixEpoch.AddTicks((long)microSeconds * TimeSpan.TicksPerMicrosecond);
     }
 
     internal static ulong ToUnixTimeMicroSeconds(DateTimeOffset date)
     {
-        return (ulong)(date.ToUnixTimeMilliseconds() * 1000);
+        return (ulong)((date - DateTimeOffset.UnixEpoch).Ticks / TimeSpan.TicksPerMicrosecond);
     }
 }
diff --git a/foreign/csharp/Iggy_SDK/Extensions/IggyClientExtension.cs b/foreign/csharp/Iggy_SDK/Extensions/IggyClientExtension.cs
index ffa5afc..9ea6991 100644
--- a/foreign/csharp/Iggy_SDK/Extensions/IggyClientExtension.cs
+++ b/foreign/csharp/Iggy_SDK/Extensions/IggyClientExtension.cs
@@ -59,6 +59,21 @@
     }
 
     /// <summary>
+    ///     Creates a <see cref="ReaderBuilder" /> for a <see cref="PartitionReader" /> of the topic. A standalone
+    ///     consumer names its partitions with <see cref="ReaderBuilder.WithPartitions" />, and a consumer group reads
+    ///     the assignment of its member.
+    /// </summary>
+    /// <param name="client">Existing iggy client</param>
+    /// <param name="streamId">Stream identifier from which to read</param>
+    /// <param name="topicId">Topic identifier from which to read</param>
+    /// <param name="consumer">The consumer whose offsets the reader reads and stores</param>
+    public static ReaderBuilder Reader(this IIggyClient client, Identifier streamId, Identifier topicId,
+        Consumer consumer)
+    {
+        return new ReaderBuilder(client, streamId, topicId, consumer);
+    }
+
+    /// <summary>
     ///     Creates a new <see cref="IggyPublisherBuilder" /> from <see cref="IIggyClient" /> for the specified stream and
     ///     topic.
     /// </summary>
diff --git a/foreign/csharp/Iggy_SDK/Factory/IggyClientFactory.cs b/foreign/csharp/Iggy_SDK/Factory/IggyClientFactory.cs
index 4cb493a..829952d 100644
--- a/foreign/csharp/Iggy_SDK/Factory/IggyClientFactory.cs
+++ b/foreign/csharp/Iggy_SDK/Factory/IggyClientFactory.cs
@@ -17,6 +17,7 @@
 
 using System.ComponentModel;
 using Apache.Iggy.Configuration;
+using Apache.Iggy.Contracts;
 using Apache.Iggy.Enums;
 using Apache.Iggy.IggyClient;
 using Apache.Iggy.IggyClient.Implementations;
@@ -33,6 +34,8 @@
 /// </remarks>
 public static class IggyClientFactory
 {
+    private static readonly TimeSpan HttpTimeoutMargin = TimeSpan.FromSeconds(10);
+
     /// <summary>
     ///     Creates and returns an instance of <see cref="IIggyClient" /> based on the provided configuration options.
     /// </summary>
@@ -122,10 +125,13 @@
             options.AllowAutoCommitWithEncryptor);
     }
 
-    private static HttpClient CreateHttpClient(IggyClientConfigurator options)
+    internal static HttpClient CreateHttpClient(IggyClientConfigurator options)
     {
-        var client = new HttpClient(new TransientHttpRetryHandler(new HttpClientHandler()));
-        client.BaseAddress = new Uri(options.BaseAddress);
-        return client;
+        // A deferred poll ends itself at its request timeout, so the client timeout must not end it first.
+        return new HttpClient(new TransientHttpRetryHandler(new HttpClientHandler()))
+        {
+            BaseAddress = new Uri(options.BaseAddress),
+            Timeout = DeferredPollOptions.MaxRequestTimeout + HttpTimeoutMargin
+        };
     }
 }
diff --git a/foreign/csharp/Iggy_SDK/IggyClient/IIggyClient.cs b/foreign/csharp/Iggy_SDK/IggyClient/IIggyClient.cs
index 81d14cf..6772774 100644
--- a/foreign/csharp/Iggy_SDK/IggyClient/IIggyClient.cs
+++ b/foreign/csharp/Iggy_SDK/IggyClient/IIggyClient.cs
@@ -64,7 +64,9 @@
     ///     Sends a command code with a payload and returns the raw response bytes.
     /// </summary>
     /// <remarks>
-    ///     Session-control codes are rejected with an invalid-command error.
+    ///     Session-control codes and the deferred codes (PollMessagesDeferred, CancelDeferredRequest and FetchMessages)
+    ///     are rejected with an invalid-command error. A deferred poll or a fetch
+    ///     goes through the typed calls of <see cref="IIggyConsumer" />, which own its deadline and its cancel.
     ///     HTTP clients report that this operation is unavailable.
     /// </remarks>
     /// <param name="code">The numeric command code to send.</param>
@@ -79,8 +81,8 @@
     /// <remarks>
     ///     The replica id is the one that cluster metadata names for the node. Every node answers non-replicated
     ///     commands such as GetStats from its own state, so this reads the state of the chosen node. The request carries
-    ///     the metadata watermark of the session. Replicated commands, PollMessages, GetConsumerOffset and session-control
-    ///     codes are rejected with an invalid-command error before anything is sent. An id that cluster metadata does not name
+    ///     the metadata watermark of the session. Replicated commands, PollMessages, GetConsumerOffset, session-control
+    ///     and deferred codes are rejected with an invalid-command error before anything is sent. An id that cluster metadata does not name
     ///     after one read of it fails with an invalid-identifier error. A replica that cannot be reached, or that answers
     ///     with TransientNotAccepted, is asked again until the request timeout. Any other refusal, NotPrimary included, is
     ///     returned and never sent to another replica. HTTP clients report that this operation is unavailable.
@@ -105,6 +107,8 @@
     ///     send or another caller of this client replayed it first. <see cref="VsrOperationNotRetainedException" />
     ///     means that the outcome is unknown and this client can no longer recover it: a session reset or an abandon
     ///     dropped the operation before a replay proved its outcome, or that outcome aged out.
+    ///     An uncertain committing poll or fetch instead throws <see cref="VsrFetchProgressUncertainException" />
+    ///     without I/O. Its recovery id cannot prove completion by replay or offset inspection.
     /// </remarks>
     Task ResolveOperationAsync(ulong operationId, TimeSpan timeout, CancellationToken token = default) =>
         Task.FromException(new FeatureUnavailableException());
@@ -115,6 +119,8 @@
     ///     operation that is already running is not canceled. It keeps the lane until it ends, so later mutations of
     ///     the lane still wait behind it, and the client keeps the outcome it proves for
     ///     <see cref="ResolveOperationAsync" />.
+    ///     For an uncertain committing poll or fetch, this releases every affected partition locally and accepts
+    ///     that the old server request may still commit. It does not cancel or fence that request.
     /// </summary>
     Task AbandonOperationAsync(ulong operationId) => Task.FromException(new FeatureUnavailableException());
 }
diff --git a/foreign/csharp/Iggy_SDK/IggyClient/IIggyConsumer.cs b/foreign/csharp/Iggy_SDK/IggyClient/IIggyConsumer.cs
index e734268..d7e2819 100644
--- a/foreign/csharp/Iggy_SDK/IggyClient/IIggyConsumer.cs
+++ b/foreign/csharp/Iggy_SDK/IggyClient/IIggyConsumer.cs
@@ -28,6 +28,58 @@
 public interface IIggyConsumer
 {
     /// <summary>
+    ///     Reads one batch of one partition: waits until <see cref="PollOptions.MinCount" /> messages are readable or
+    ///     <see cref="PollOptions.MaxWait" /> passes, and returns up to <see cref="PollOptions.Count" /> messages.
+    /// </summary>
+    /// <remarks>
+    ///     <para>
+    ///         <see cref="PollOptions.Commit" /> decides who stores the offset. With
+    ///         <see cref="CommitPolicy.Manual" /> nothing is stored: commit <see cref="PolledMessages.Checkpoint" />
+    ///         with <see cref="IIggyOffset.StoreConsumerPositionAsync" />.
+    ///         With <see cref="CommitPolicy.OnRead" /> the server stores the offset of the last message as part of the
+    ///         read. A one-shot poll has nothing to count, so it refuses <see cref="CommitPolicy.Auto" />.
+    ///     </para>
+    ///     <para>
+    ///         Continue the partition with <see cref="PolledMessages.Continuation" />, which a poll of another
+    ///         partition refuses. A poll that can move an offset (<see cref="CommitPolicy.OnRead" /> or
+    ///         <see cref="PollingStrategy.Next" />) is not sent again after a lost reply. A canceled poll is also
+    ///         canceled on the server. With <see cref="CommitPolicy.OnRead" /> the call then waits for the one answer
+    ///         of the server: it ends as canceled when nothing was stored, or returns the messages that the read
+    ///         stored before the cancel arrived.
+    ///     </para>
+    ///     <para>
+    ///         The default implementation throws <see cref="FeatureUnavailableException" />: a client must opt in, so
+    ///         a wait never turns into an immediate poll without notice.
+    ///     </para>
+    /// </remarks>
+    /// <param name="streamId">The identifier of the stream containing the topic (numeric ID or name).</param>
+    /// <param name="topicId">The identifier of the topic to consume from (numeric ID or name).</param>
+    /// <param name="partitionId">The partition to read.</param>
+    /// <param name="consumer">The consumer whose position <see cref="PollingStrategy.Next" /> means.</param>
+    /// <param name="strategy">Where the read starts, such as the continuation of the previous batch.</param>
+    /// <param name="options">The limits of the read and the commit policy.</param>
+    /// <param name="token">The cancellation token to cancel the operation.</param>
+    /// <exception cref="IggyInvalidStatusCodeException">
+    ///     Status 4009, 3, 80 or 2 when <paramref name="options" /> break their rules, 2 for
+    ///     <see cref="CommitPolicy.Auto" />, and 3 for a continuation of another partition. Nothing is sent then.
+    /// </exception>
+    Task<PolledMessages> PollMessagesAsync(Identifier streamId, Identifier topicId, uint partitionId,
+        Consumer consumer, PollingStrategy strategy, PollOptions options, CancellationToken token = default)
+    {
+        return Task.FromException<PolledMessages>(new FeatureUnavailableException());
+    }
+
+    /// <summary>
+    ///     <see cref="PollMessagesAsync(Identifier, Identifier, uint, Consumer, PollingStrategy, PollOptions, CancellationToken)" />
+    ///     with the payload buffers rented from a shared pool. The caller disposes the rental.
+    /// </summary>
+    Task<PolledMessagesRental> PollMessagesRentedAsync(Identifier streamId, Identifier topicId, uint partitionId,
+        Consumer consumer, PollingStrategy strategy, PollOptions options, CancellationToken token = default)
+    {
+        return Task.FromException<PolledMessagesRental>(new FeatureUnavailableException());
+    }
+
+    /// <summary>
     ///     Polls messages from a specified topic and partition with a given polling strategy.
     /// </summary>
     /// <remarks>
@@ -52,7 +104,7 @@
     /// <returns>
     ///     A task that represents the asynchronous operation and returns the polled messages. A consumer-group
     ///     poll whose member currently owns no partition returns an empty batch whose
-    ///     <see cref="PolledMessages.PartitionId" /> is <see cref="PolledMessages.NoAssignedPartition" />; do
+    ///     <see cref="PolledMessages.PartitionId" /> is <see cref="PolledMessages.NoAssignedPartition" />. Do
     ///     not key offsets on it, back off and poll again.
     /// </returns>
     Task<PolledMessages> PollMessagesAsync(Identifier streamId, Identifier topicId, uint? partitionId,
diff --git a/foreign/csharp/Iggy_SDK/IggyClient/IIggyOffset.cs b/foreign/csharp/Iggy_SDK/IggyClient/IIggyOffset.cs
index 07448f0..6bf7da5 100644
--- a/foreign/csharp/Iggy_SDK/IggyClient/IIggyOffset.cs
+++ b/foreign/csharp/Iggy_SDK/IggyClient/IIggyOffset.cs
@@ -16,6 +16,7 @@
 // under the License.
 
 using Apache.Iggy.Contracts;
+using Apache.Iggy.Exceptions;
 using Apache.Iggy.Kinds;
 
 namespace Apache.Iggy.IggyClient;
@@ -65,6 +66,24 @@
         PartitionContext context, CancellationToken token = default);
 
     /// <summary>
+    ///     Commits a checkpoint: stores its offset under the partition history that delivered its message. The server
+    ///     refuses the store with status 87 when the partition was deleted and recreated since, and with status 5009
+    ///     when the partition moved to another member of the consumer group.
+    /// </summary>
+    /// <param name="consumer">The consumer identifier (group ID or member ID).</param>
+    /// <param name="streamId">The identifier of the stream containing the topic (numeric ID or name).</param>
+    /// <param name="topicId">The identifier of the topic (numeric ID or name).</param>
+    /// <param name="checkpoint">The checkpoint of a read, such as <see cref="PolledMessages.Checkpoint" />.</param>
+    /// <param name="token">The cancellation token to cancel the operation.</param>
+    Task StoreConsumerPositionAsync(Consumer consumer, Identifier streamId, Identifier topicId,
+        Checkpoint checkpoint, CancellationToken token = default)
+    {
+        ArgumentNullException.ThrowIfNull(checkpoint);
+        return StoreOffsetAsync(consumer, streamId, topicId, checkpoint.Offset, checkpoint.PartitionId,
+            checkpoint.Context, token);
+    }
+
+    /// <summary>
     ///     Retrieves the current offset for a consumer in a specified topic.
     /// </summary>
     /// <param name="consumer">The consumer identifier (group ID or member ID).</param>
@@ -99,4 +118,39 @@
     /// <returns>A task representing the asynchronous operation.</returns>
     Task DeleteOffsetAsync(Consumer consumer, Identifier streamId, Identifier topicId, uint? partitionId,
         CancellationToken token = default);
+
+    /// <summary>
+    ///     Deletes a stored offset under the context of the poll that delivered the partition. The server refuses the
+    ///     delete with status 87 when the partition was deleted and recreated since that poll, and with status 5009
+    ///     when the partition moved to another member of the consumer group. The default implementation is for
+    ///     clients without this fence, such as HTTP: it throws <see cref="FeatureUnavailableException" />.
+    /// </summary>
+    /// <param name="consumer">The consumer identifier (group ID or member ID).</param>
+    /// <param name="streamId">The identifier of the stream containing the topic (numeric ID or name).</param>
+    /// <param name="topicId">The identifier of the topic (numeric ID or name).</param>
+    /// <param name="partitionId">The partition that delivered the offset.</param>
+    /// <param name="context">The <see cref="PolledMessages.Context" /> of the poll that delivered the partition.</param>
+    /// <param name="token">The cancellation token to cancel the operation.</param>
+    /// <returns>A task representing the asynchronous operation.</returns>
+    Task DeleteOffsetAsync(Consumer consumer, Identifier streamId, Identifier topicId, uint partitionId,
+        PartitionContext context, CancellationToken token = default)
+    {
+        return Task.FromException(new FeatureUnavailableException());
+    }
+
+    /// <summary>
+    ///     Deletes the stored offset of the partition of a checkpoint under the partition history that delivered its
+    ///     message. The offset of the checkpoint is not used.
+    /// </summary>
+    /// <param name="consumer">The consumer identifier (group ID or member ID).</param>
+    /// <param name="streamId">The identifier of the stream containing the topic (numeric ID or name).</param>
+    /// <param name="topicId">The identifier of the topic (numeric ID or name).</param>
+    /// <param name="checkpoint">A checkpoint of the partition.</param>
+    /// <param name="token">The cancellation token to cancel the operation.</param>
+    Task DeleteConsumerPositionAsync(Consumer consumer, Identifier streamId, Identifier topicId,
+        Checkpoint checkpoint, CancellationToken token = default)
+    {
+        ArgumentNullException.ThrowIfNull(checkpoint);
+        return DeleteOffsetAsync(consumer, streamId, topicId, checkpoint.PartitionId, checkpoint.Context, token);
+    }
 }
diff --git a/foreign/csharp/Iggy_SDK/IggyClient/Implementations/HttpMessageStream.cs b/foreign/csharp/Iggy_SDK/IggyClient/Implementations/HttpMessageStream.cs
index 3bc8a50..da7acc4 100644
--- a/foreign/csharp/Iggy_SDK/IggyClient/Implementations/HttpMessageStream.cs
+++ b/foreign/csharp/Iggy_SDK/IggyClient/Implementations/HttpMessageStream.cs
@@ -17,6 +17,7 @@
 
 using System.Buffers;
 using System.Buffers.Binary;
+using System.Diagnostics;
 using System.IO.Hashing;
 using System.Net;
 using System.Net.Http.Headers;
@@ -50,10 +51,33 @@
 public class HttpMessageStream : IIggyClient
 {
     private const string Context = "csharp-sdk";
+    private const int DeferredRetryIntervalMs = 50;
+    private const int DeferredRetryMaxIntervalMs = 1_000;
+
+    /// <summary>A plain poll that stores its offset: no wait and no byte limit, as the plain poll has none.</summary>
+    private static readonly DeferredPollOptions CommittingPlainPoll = new()
+    {
+        MaxWait = TimeSpan.Zero,
+        MaxBytes = uint.MaxValue
+    };
 
     private readonly bool _allowAutoCommitWithEncryptor;
+    private readonly SemaphoreSlim _deferredRequests =
+        new(DeferredPollOptions.MAX_PARKED_REQUESTS, DeferredPollOptions.MAX_PARKED_REQUESTS);
     private readonly ConsumerGroupClientState _groupState = new();
     private readonly HttpClient _httpClient;
+    private readonly object _progressGate = new();
+
+    /// <summary>
+    ///     The partitions blocked by a committing poll whose outcome is unknown, by the local operation that
+    ///     <see cref="AbandonOperationAsync" /> takes.
+    /// </summary>
+    private readonly Dictionary<ulong, ProgressKey> _progressBlocks = [];
+
+    /// <summary>The progress polls of each spelled partition run one at a time, so a block check and its arm are one step.</summary>
+    private readonly Dictionary<ProgressKey, (SemaphoreSlim Lane, int Holders)> _progressLanes = [];
+
+    private long _progressOperations;
 
     //TODO - create mechanism for refreshing jwt token
     //TODO - replace the HttpClient with IHttpClientFactory, when implementing support for ASP.NET Core DI
@@ -284,10 +308,58 @@
     }
 
     /// <inheritdoc />
-    public async Task<PolledMessages> PollMessagesAsync(Identifier streamId, Identifier topicId, uint? partitionId,
+    public async Task<PolledMessages> PollMessagesAsync(Identifier streamId, Identifier topicId, uint partitionId,
+        Consumer consumer, PollingStrategy strategy, PollOptions options, CancellationToken token = default)
+    {
+        ArgumentNullException.ThrowIfNull(options);
+        options.ValidatePoll(partitionId, strategy);
+        var polled = await PollAsync(streamId, topicId, partitionId, consumer, strategy, options.Count,
+            options.Commit.Mode == CommitMode.OnRead, options.Wire, token);
+        polled.RequestStart = strategy;
+        return polled;
+    }
+
+    /// <inheritdoc />
+    public async Task<PolledMessagesRental> PollMessagesRentedAsync(Identifier streamId, Identifier topicId,
+        uint partitionId, Consumer consumer, PollingStrategy strategy, PollOptions options,
+        CancellationToken token = default)
+    {
+        var messages = await PollMessagesAsync(streamId, topicId, partitionId, consumer, strategy, options, token);
+        return BinaryMapper.ToRentedMessages(messages);
+    }
+
+    /// <inheritdoc />
+    public Task<PolledMessages> PollMessagesAsync(Identifier streamId, Identifier topicId, uint? partitionId,
         Consumer consumer,
         PollingStrategy pollingStrategy, uint count, bool autoCommit, CancellationToken token = default)
     {
+        // The server refuses a GET that stores an offset, so a committing poll is a deferred POST without a wait.
+        return PollAsync(streamId, topicId, partitionId, consumer, pollingStrategy, count, autoCommit,
+            autoCommit ? CommittingPlainPoll : null, token);
+    }
+
+    internal Task<PolledMessages> PollMessagesAsync(Identifier streamId, Identifier topicId, uint? partitionId,
+        Consumer consumer, PollingStrategy pollingStrategy, uint count, bool autoCommit, DeferredPollOptions options,
+        CancellationToken token = default)
+    {
+        ArgumentNullException.ThrowIfNull(options);
+        options.Validate(count);
+        return PollAsync(streamId, topicId, partitionId, consumer, pollingStrategy, count, autoCommit, options, token);
+    }
+
+    internal async Task<PolledMessagesRental> PollMessagesRentedAsync(Identifier streamId, Identifier topicId,
+        uint? partitionId, Consumer consumer, PollingStrategy pollingStrategy, uint count, bool autoCommit,
+        DeferredPollOptions options, CancellationToken token = default)
+    {
+        var messages = await PollMessagesAsync(streamId, topicId, partitionId, consumer, pollingStrategy, count,
+            autoCommit, options, token);
+        return BinaryMapper.ToRentedMessages(messages);
+    }
+
+    private async Task<PolledMessages> PollAsync(Identifier streamId, Identifier topicId, uint? partitionId,
+        Consumer consumer, PollingStrategy pollingStrategy, uint count, bool autoCommit,
+        DeferredPollOptions? deferred, CancellationToken token)
+    {
         if (autoCommit && MessageEncryptor is not null && !_allowAutoCommitWithEncryptor)
         {
             // Server-side autoCommit commits the batch offset before the client decrypts, so a decryption failure
@@ -297,17 +369,49 @@
                 "AutoCommit with a message encryptor risks silent message loss: the offset is committed before decryption. Poll with autoCommit false, or set AllowAutoCommitWithEncryptor.");
         }
 
+        if (partitionId is { } partition)
+        {
+            pollingStrategy.ValidatePartition(partition);
+        }
+
         var partitionIdParam = partitionId.HasValue ? $"&partition_id={partitionId.Value}" : string.Empty;
         var contextParam = pollingStrategy.Context is { } context
             ? $"&context={Uri.EscapeDataString(JsonSerializer.Serialize(context, _jsonSerializerOptions))}"
             : string.Empty;
-        var url = CreateUrl($"/streams/{streamId}/topics/{topicId}/messages?consumer_id={consumer.ConsumerId}" +
-                            $"{partitionIdParam}&kind={pollingStrategy.Kind}&value={pollingStrategy.Value}&count={count}&auto_commit={autoCommit}{contextParam}");
+        var deferredPath = deferred is null ? string.Empty : "/deferred";
+        var url = CreateUrl($"/streams/{streamId}/topics/{topicId}/messages{deferredPath}?consumer_id={consumer.ConsumerId}" +
+                            $"{partitionIdParam}&kind={pollingStrategy.Kind}&value={pollingStrategy.Value}&count={count}&auto_commit={(autoCommit ? "true" : "false")}{contextParam}");
 
-        var response = await _httpClient.GetAsync(url, token);
+        var progress = autoCommit || pollingStrategy.Kind == MessagePolling.Next;
+        var key = new ProgressKey(streamId.ToString(), topicId.ToString(), partitionId ?? 0,
+            pollingStrategy.Context?.Incarnation);
+        HttpResponseMessage response;
+        if (deferred is null)
+        {
+            response = await _httpClient.GetAsync(url, token);
+        }
+        else if (progress)
+        {
+            var lane = await EnterProgressLaneAsync(key, token);
+            try
+            {
+                response = await SendDeferredPollAsync(url, deferred, key, autoCommit, token);
+            }
+            finally
+            {
+                LeaveProgressLane(key, lane);
+            }
+        }
+        else
+        {
+            response = await SendDeferredPollAsync(url, deferred, null, false, token);
+        }
         if (response.IsSuccessStatusCode)
         {
-            var pollMessages = await response.Content.ReadFromJsonAsync<PolledMessages>(_jsonSerializerOptions, token)
+            // SendAsync buffered a deferred reply, so its decode does not wait on the server. If the poll moved an
+            // offset, a cancel here hides the batch that it moved past.
+            var pollMessages = await response.Content.ReadFromJsonAsync<PolledMessages>(_jsonSerializerOptions,
+                                   deferred is null ? token : CancellationToken.None)
                                ?? PolledMessages.Empty;
 
             if (MessageEncryptor is not null)
@@ -333,6 +437,220 @@
         return BinaryMapper.ToRentedMessages(messages);
     }
 
+    /// <summary>
+    ///     Sends a deferred poll until the server admits it or its request timeout passes. Every attempt asks for
+    ///     what is left of the wait and the request timeout, so the server always answers before the client gives
+    ///     up. Only a refusal that admitted nothing (58 or 90) is sent again.
+    /// </summary>
+    /// <remarks>
+    ///     A poll that stores its offset is a POST, which no client or proxy sends again. The server refuses a GET
+    ///     that asks for it.
+    /// </remarks>
+    /// <exception cref="VsrFetchProgressUncertainException">
+    ///     A committing poll whose outcome is unknown: the caller canceled or the request timeout passed while it was
+    ///     on the wire, the transport failed after the connect, or the server answered with an error that does not
+    ///     prove that it stored nothing. Its partition stays blocked until <see cref="AbandonOperationAsync" />.
+    /// </exception>
+    /// <exception cref="VsrPartitionLaneBlockedException">A committing poll of the partition has an unknown outcome.</exception>
+    /// <exception cref="VsrRequestOutcomeUnknownException">
+    ///     The caller canceled, or the request timeout passed, while a <see cref="MessagePolling.Next" /> poll was on
+    ///     the wire.
+    /// </exception>
+    private async Task<HttpResponseMessage> SendDeferredPollAsync(string url, DeferredPollOptions options,
+        ProgressKey? progress, bool commits, CancellationToken token)
+    {
+        // Before the lease wait, so a client full of waiting polls cannot hide the block behind status 58.
+        if (progress is { } partition)
+        {
+            ThrowIfProgressBlocked(partition);
+        }
+
+        var started = Stopwatch.GetTimestamp();
+        using var budget = CancellationTokenSource.CreateLinkedTokenSource(token);
+        budget.CancelAfter(options.RequestTimeout);
+        var sending = false;
+        try
+        {
+            await _deferredRequests.WaitAsync(budget.Token);
+            try
+            {
+                for (var retryDelayMs = DeferredRetryIntervalMs; ;
+                     retryDelayMs = Math.Min(retryDelayMs * 2, DeferredRetryMaxIntervalMs))
+                {
+                    // Another spelling of the partition can block it during the lease wait or a retry pause.
+                    if (progress is { } blocked)
+                    {
+                        ThrowIfProgressBlocked(blocked);
+                    }
+
+                    var remaining = options.Remaining(Stopwatch.GetElapsedTime(started));
+                    using var request = new HttpRequestMessage(commits ? HttpMethod.Post : HttpMethod.Get, CreateUrl(
+                        $"{url}&wait_us={remaining.MaxWaitUs}&min_count={remaining.MinCount}&max_bytes={remaining.MaxBytes}&request_timeout_us={remaining.RequestTimeoutUs}"));
+                    request.Options.Set(TransientHttpRetryHandler.SingleAttempt, true);
+                    sending = true;
+                    HttpResponseMessage response;
+                    try
+                    {
+                        response = await _httpClient.SendAsync(request, budget.Token);
+                    }
+                    catch (HttpRequestException error) when (commits
+                                                             && error.HttpRequestError != HttpRequestError.ConnectionError)
+                    {
+                        throw Lose(progress!.Value, error);
+                    }
+
+                    sending = false;
+                    try
+                    {
+                        await HandleResponseAsync(response, true);
+                        return response;
+                    }
+                    catch (IggyInvalidStatusCodeException error) when ((!commits || ProvesNothingStored(response, error))
+                        && error.StatusCode is VsrError.TRANSIENT_NOT_ACCEPTED or VsrError.DEFERRED_POLL_CAPACITY_EXCEEDED
+                        && Stopwatch.GetElapsedTime(started).TotalMilliseconds + retryDelayMs
+                        < options.RequestTimeout.TotalMilliseconds)
+                    {
+                        response.Dispose();
+                    }
+                    catch (IggyInvalidStatusCodeException error) when (commits && !ProvesNothingStored(response, error))
+                    {
+                        // The server stores the offset before it answers, so the poll may have moved past a batch it
+                        // never sent.
+                        response.Dispose();
+                        throw Lose(progress!.Value, error);
+                    }
+                    catch (VsrRequestOutcomeUnknownException error) when (commits)
+                    {
+                        response.Dispose();
+                        throw Lose(progress!.Value, error.InnerException ?? error);
+                    }
+
+                    await Task.Delay(retryDelayMs, budget.Token);
+                }
+            }
+            finally
+            {
+                _deferredRequests.Release();
+            }
+        }
+        catch (OperationCanceledException error) when (sending && commits)
+        {
+            // The server can store the offset before it sees that the client gave up.
+            throw Lose(progress!.Value, error);
+        }
+        catch (OperationCanceledException error) when (sending && progress is not null)
+        {
+            // The server can move the offset before it sees that the client gave up.
+            throw new VsrRequestOutcomeUnknownException(error);
+        }
+        catch (OperationCanceledException) when (!token.IsCancellationRequested)
+        {
+            throw VsrError.Exception(VsrError.TRANSIENT_NOT_ACCEPTED, "The deferred poll spent its request timeout.");
+        }
+    }
+
+    /// <summary>
+    ///     A 503 refuses a poll before the server takes it, and the server sends a 404 only before the read. Another
+    ///     4xx proves that nothing was stored only with an error that the binary protocol takes as proof.
+    /// </summary>
+    private static bool ProvesNothingStored(HttpResponseMessage response, IggyInvalidStatusCodeException error)
+    {
+        return response.StatusCode is HttpStatusCode.ServiceUnavailable or HttpStatusCode.NotFound
+               || (int)response.StatusCode is >= 400 and < 500
+               && CommitErrorScope.Request.ProvesNothingStored(error.StatusCode);
+    }
+
+    /// <summary>Blocks the partition of a committing poll whose outcome is unknown until the caller abandons it.</summary>
+    private VsrFetchProgressUncertainException Lose(ProgressKey partition, Exception cause)
+    {
+        var operation = (ulong)Interlocked.Increment(ref _progressOperations);
+        lock (_progressGate)
+        {
+            _progressBlocks[operation] = partition;
+        }
+
+        return new VsrFetchProgressUncertainException(operation, cause);
+    }
+
+    private void ThrowIfProgressBlocked(ProgressKey call)
+    {
+        lock (_progressGate)
+        {
+            foreach (var (operation, blocked) in _progressBlocks)
+            {
+                if (blocked.Partition == call.Partition
+                    && (blocked.Stream == call.Stream && blocked.Topic == call.Topic
+                        || blocked.Incarnation is { } incarnation && incarnation == call.Incarnation))
+                {
+                    throw new VsrPartitionLaneBlockedException(operation, call.Partition);
+                }
+            }
+        }
+    }
+
+    private async Task<SemaphoreSlim> EnterProgressLaneAsync(ProgressKey partition, CancellationToken token)
+    {
+        var spelled = partition with { Incarnation = null };
+        SemaphoreSlim lane;
+        lock (_progressGate)
+        {
+            var (held, holders) = _progressLanes.TryGetValue(spelled, out var entry) ? entry : (new SemaphoreSlim(1, 1), 0);
+            lane = held;
+            _progressLanes[spelled] = (lane, holders + 1);
+        }
+
+        try
+        {
+            await lane.WaitAsync(token);
+            return lane;
+        }
+        catch
+        {
+            LeaveProgressLane(spelled, lane, false);
+            throw;
+        }
+    }
+
+    private void LeaveProgressLane(ProgressKey partition, SemaphoreSlim lane, bool entered = true)
+    {
+        if (entered)
+        {
+            lane.Release();
+        }
+
+        var spelled = partition with { Incarnation = null };
+        lock (_progressGate)
+        {
+            var (_, holders) = _progressLanes[spelled];
+            if (holders == 1)
+            {
+                _progressLanes.Remove(spelled);
+            }
+            else
+            {
+                _progressLanes[spelled] = (lane, holders - 1);
+            }
+        }
+    }
+
+    /// <inheritdoc />
+    /// <remarks>
+    ///     Over HTTP the client keeps only committing polls with an unknown outcome. The abandon unblocks the partition
+    ///     of one, and the server may still store its offset.
+    /// </remarks>
+    public Task AbandonOperationAsync(ulong operationId)
+    {
+        lock (_progressGate)
+        {
+            if (!_progressBlocks.Remove(operationId))
+            {
+                return Task.FromException(new VsrOperationNotRetainedException(operationId));
+            }
+        }
+
+        return Task.CompletedTask;
+    }
+
     /// <inheritdoc />
     public Task StoreOffsetAsync(Consumer consumer, Identifier streamId, Identifier topicId, ulong offset,
         uint? partitionId, CancellationToken token = default)
@@ -348,9 +666,20 @@
             new StoreOffsetRequest(consumer, partitionId, offset) { Context = context }, token);
     }
 
+    /// <inheritdoc />
+    public Task StoreConsumerPositionAsync(Consumer consumer, Identifier streamId, Identifier topicId,
+        Checkpoint checkpoint, CancellationToken token = default)
+    {
+        ArgumentNullException.ThrowIfNull(checkpoint);
+        return StoreOffsetAsync(consumer, streamId, topicId, checkpoint.Offset, checkpoint.PartitionId,
+            checkpoint.Context, token);
+    }
+
     private async Task PutOffsetAsync(Identifier streamId, Identifier topicId, StoreOffsetRequest request,
         CancellationToken token)
     {
+        ThrowIfProgressBlocked(new ProgressKey(streamId.ToString(), topicId.ToString(), request.PartitionId ?? 0,
+            request.Context?.Incarnation));
         var json = JsonSerializer.Serialize(request, _jsonSerializerOptions);
         var data = new StringContent(json, Encoding.UTF8, "application/json");
 
@@ -383,6 +712,7 @@
     public async Task DeleteOffsetAsync(Consumer consumer, Identifier streamId, Identifier topicId, uint? partitionId,
         CancellationToken token = default)
     {
+        ThrowIfProgressBlocked(new ProgressKey(streamId.ToString(), topicId.ToString(), partitionId ?? 0, null));
         var partitionIdParam = partitionId.HasValue ? $"?partition_id={partitionId.Value}" : string.Empty;
         var response = await _httpClient.DeleteAsync(
             $"/streams/{streamId}/topics/{topicId}/consumer-offsets/{consumer}{partitionIdParam}", token);
@@ -390,6 +720,15 @@
     }
 
     /// <inheritdoc />
+    /// <remarks>HTTP sends no context with a delete, so the server deletes the offset of the history that it holds.</remarks>
+    public Task DeleteConsumerPositionAsync(Consumer consumer, Identifier streamId, Identifier topicId,
+        Checkpoint checkpoint, CancellationToken token = default)
+    {
+        ArgumentNullException.ThrowIfNull(checkpoint);
+        return DeleteOffsetAsync(consumer, streamId, topicId, checkpoint.PartitionId, token);
+    }
+
+    /// <inheritdoc />
     public async Task<IReadOnlyList<ConsumerGroupResponse>> GetConsumerGroupsAsync(Identifier streamId,
         Identifier topicId, CancellationToken token = default)
     {
@@ -1157,7 +1496,7 @@
         }
         catch (JsonException)
         {
-            // A gateway or proxy error body is not the server's JSON schema; the raw text still travels in
+            // A gateway or proxy error body is not the server's JSON schema. The raw text still travels in
             // the exception message.
         }
 
@@ -1175,4 +1514,11 @@
     {
         return message.ToString();
     }
+
+    /// <summary>
+    ///     A partition as a call spells its stream and topic, with the incarnation of its context when it has one.
+    ///     HTTP has no id independent of the spelling, so a block covers a later call with the same spelling, or one
+    ///     whose context names the same incarnation.
+    /// </summary>
+    private readonly record struct ProgressKey(string Stream, string Topic, uint Partition, ulong? Incarnation);
 }
diff --git a/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.PollRouting.cs b/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.PollRouting.cs
index 18e859a..e9e5f32 100644
--- a/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.PollRouting.cs
+++ b/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.PollRouting.cs
@@ -17,14 +17,17 @@
 
 using System.Buffers;
 using System.Buffers.Binary;
+using System.Diagnostics;
 using System.Net.Sockets;
 using System.Runtime.ExceptionServices;
 using System.Text;
 using Apache.Iggy.Configuration;
 using Apache.Iggy.Contracts;
+using Apache.Iggy.Contracts.Tcp;
 using Apache.Iggy.Enums;
 using Apache.Iggy.Extensions;
 using Apache.Iggy.Exceptions;
+using Apache.Iggy.Kinds;
 using Apache.Iggy.Mappers;
 using Apache.Iggy.Utils;
 using Apache.Iggy.Vsr;
@@ -43,6 +46,10 @@
     private const byte ConsumerGroupWireKind = 2;
     private const byte StringKind = 2;
 
+    private const int MaxRouteLookupsInFlight = 16;
+
+    private readonly SemaphoreSlim _deferredRequests =
+        new(DeferredPollOptions.MAX_PARKED_REQUESTS, DeferredPollOptions.MAX_PARKED_REQUESTS);
     private readonly object _pollRoutingGate = new();
     private readonly Dictionary<PartitionRouteKey, CachedRoute> _partitionRoutes = [];
     private readonly Dictionary<byte, string> _replicaEndpoints = [];
@@ -57,6 +64,71 @@
     private long _routeSequence;
     private readonly record struct SendTarget(PartitionRouteKey Key, byte[] Query);
 
+    /// <summary>
+    ///     Where the wait and the request timeout of a deferred request sit in its body, and the values the caller
+    ///     encoded there. Every attempt sends what is left of both: a non-replicated read has no replay receipt,
+    ///     so its budget shrinks without a new request identity. A fetch keeps the reply margin also without a wait.
+    /// </summary>
+    private readonly record struct DeferredBudget(int WaitOffset, int TimeoutOffset, ulong WaitUs, ulong TimeoutUs,
+        bool KeepsMargin)
+    {
+        internal static DeferredBudget? Read(int code, ReadOnlySpan<byte> body)
+        {
+            int waitOffset;
+            int timeoutOffset;
+            if (code == CommandCodes.POLL_MESSAGES_DEFERRED_CODE)
+            {
+                waitOffset = body.Length - TcpContracts.DEFERRED_POLL_TAIL_SIZE;
+                timeoutOffset = body.Length - sizeof(ulong);
+            }
+            else if (code == CommandCodes.FETCH_MESSAGES_CODE)
+            {
+                waitOffset = 1;
+                _ = ReadIdentifier(body, ref waitOffset);
+                _ = ReadIdentifier(body, ref waitOffset);
+                _ = ReadIdentifier(body, ref waitOffset);
+                timeoutOffset = waitOffset + sizeof(ulong);
+            }
+            else
+            {
+                return null;
+            }
+
+            if (waitOffset < 0 || timeoutOffset + sizeof(ulong) > body.Length)
+            {
+                throw VsrError.Exception(VsrError.INVALID_COMMAND, "The deferred request body is truncated.");
+            }
+
+            return new DeferredBudget(waitOffset, timeoutOffset,
+                BinaryPrimitives.ReadUInt64LittleEndian(body[waitOffset..]),
+                BinaryPrimitives.ReadUInt64LittleEndian(body[timeoutOffset..]),
+                code == CommandCodes.FETCH_MESSAGES_CODE);
+        }
+
+        /// <summary>
+        ///     Writes into <paramref name="body" /> what is left after <paramref name="spent" />, measured with a
+        ///     stopwatch as for <see cref="DeferredPollOptions.Remaining" />. The timeout also ends within
+        ///     <paramref name="budget" />, the time this client gives the call. The wait keeps its reply margin, so an
+        ///     idle wait still answers before the client gives up.
+        /// </summary>
+        /// <exception cref="IggyInvalidStatusCodeException">Status 58 once nothing is left to send.</exception>
+        internal void Spend(Span<byte> body, TimeSpan budget, TimeSpan spent)
+        {
+            var spentUs = DeferredPollOptions.Micros(spent);
+            var timeoutUs = Math.Min(TimeoutUs > spentUs ? TimeoutUs - spentUs : 0,
+                DeferredPollOptions.Micros(budget - spent));
+            if (timeoutUs == 0 || KeepsMargin && timeoutUs < DeferredPollOptions.REPLY_MARGIN_US)
+            {
+                throw PollNotAccepted();
+            }
+
+            var waitUs = Math.Min(WaitUs > spentUs ? WaitUs - spentUs : 0,
+                timeoutUs > DeferredPollOptions.REPLY_MARGIN_US ? timeoutUs - DeferredPollOptions.REPLY_MARGIN_US : 0);
+            BinaryPrimitives.WriteUInt64LittleEndian(body[WaitOffset..], waitUs);
+            BinaryPrimitives.WriteUInt64LittleEndian(body[TimeoutOffset..], timeoutUs);
+        }
+    }
+
     internal bool RetainsOperation(ulong operationId)
     {
         try
@@ -151,7 +223,7 @@
         }
         var present = body[position++] != 0;
         if (!present && (code is CommandCodes.STORE_CONSUMER_OFFSET_CODE or CommandCodes.DELETE_CONSUMER_OFFSET_CODE
-            || code == CommandCodes.POLL_MESSAGES_CODE && group))
+            || code is CommandCodes.POLL_MESSAGES_CODE or CommandCodes.POLL_MESSAGES_DEFERRED_CODE && group))
         {
             throw VsrError.Exception(VsrError.INVALID_COMMAND, "This partition request requires an explicit partition id.");
         }
@@ -185,6 +257,7 @@
         {
             throw new ArgumentOutOfRangeException(nameof(timeout));
         }
+        var started = Stopwatch.GetTimestamp();
         cancellation.CancelAfter(budget);
         var deadline = Environment.TickCount64 + (long)budget.TotalMilliseconds;
         var captured = retained?.Context ?? context;
@@ -195,6 +268,18 @@
         var generation = retained?.Generation ?? startedUnder ?? _consensusSession.Generation;
         var retainable = VsrOperations.ForCode(code) is not (VsrOperation.NonReplicated or VsrOperation.Register or VsrOperation.Logout);
         var progress = IsProgressPoll(code, payload.Span);
+        var fetchLanes = progress && code == CommandCodes.FETCH_MESSAGES_CODE ? FetchProgressLanes(payload.Span) : null;
+        var commits = code == CommandCodes.FETCH_MESSAGES_CODE && TcpContracts.FetchAutoCommits(payload.Span);
+        var committing = TcpContracts.ReadAutoCommits(code, payload.Span);
+        var deferred = DeferredBudget.Read(code, payload.Span);
+        // A progress poll spends its budget in the frame of its lane. Any other deferred call spends it in a copy,
+        // so the caller's buffer keeps what the caller encoded.
+        var deferredBody = deferred is not null && !progress ? payload.ToArray() : null;
+        if (deferredBody is not null)
+        {
+            payload = deferredBody;
+        }
+        var leased = false;
         var refreshHistory = code == CommandCodes.SEND_MESSAGES_CODE && context is null && retained is null;
         var retryDelay = LifecycleRetryIntervalMs;
         var routingRetries = 0;
@@ -203,12 +288,18 @@
         var rosterRead = false;
         var terminal = false;
         var frameAcquired = false;
+        var mayHaveCommitted = false;
         var startOver = false;
         IggyInvalidStatusCodeException? refusal = null;
         IggyInvalidStatusCodeException? rejection = null;
         CancellationTokenRegistration operationCancellation = default;
         try
         {
+            if (deferred is not null)
+            {
+                await _deferredRequests.WaitAsync(cancellation.Token);
+                leased = true;
+            }
             while (true)
             {
                 CachedRoute? route = null;
@@ -251,7 +342,7 @@
                     }
                     if (target is { } partition)
                     {
-                        if (operation is null && (retainable || progress))
+                        if (operation is null && fetchLanes is null && (retainable || progress))
                         {
                             PartitionContext? known = captured;
                             lock (_pollRoutingGate)
@@ -264,7 +355,8 @@
                             }
                             if (known is { } knownContext)
                             {
-                                _mutations.ThrowIfRetained(new MutationLane(knownContext.Incarnation, partition.Key.PartitionId));
+                                _mutations.ThrowIfRetained(new MutationLane(knownContext.Incarnation,
+                                    partition.Key.PartitionId, progress));
                             }
                         }
                         route = await GetPartitionRouteAsync(partition.Key, partition.Query, deadline, cancellation.Token,
@@ -279,10 +371,17 @@
                     EnsureOperationSession(code, generation);
                     if (operation is null && (retainable || progress))
                     {
+                        // A caller's zero wire fence must not bypass the resolved history's local recovery block.
+                        var laneContext = captured.Value.Incarnation == 0 && route is not null
+                            ? route.Route.Context : captured.Value;
                         var lane = target is { } selected
-                            ? new MutationLane(captured.Value.Incarnation, selected.Key.PartitionId) : MutationLane.Metadata;
-                        operation = await _mutations.EnterAsync(lane, code, payload, captured.Value, retainable, generation,
-                            cancellation.Token);
+                            ? new MutationLane(laneContext.Incarnation, selected.Key.PartitionId, progress)
+                            : MutationLane.Metadata;
+                        operation = await (fetchLanes is null
+                            ? _mutations.EnterAsync(lane, code, payload, captured.Value, retainable, generation,
+                                cancellation.Token)
+                            : _mutations.EnterAsync(fetchLanes, code, payload, captured.Value, retainable, generation,
+                                cancellation.Token));
                         _mutations.Acquire(operation);
                         frameAcquired = true;
                         operationCancellation = operation.Lifetime.Token.Register(cancellation.Cancel);
@@ -354,11 +453,47 @@
                         ExceptionDispatchInfo.Throw(refusal);
                     }
                     refusal = null;
+                    if (deferred is { } deferredBudget)
+                    {
+                        deferredBudget.Spend(
+                            operation is null ? deferredBody : operation.Frame.Bytes.AsSpan(VsrHeader.HEADER_SIZE),
+                            budget, Stopwatch.GetElapsedTime(started));
+                    }
                     sending = true;
+                    // A read that commits can store its offset before a cancel reaches the server, so once it goes
+                    // out the caller's cancel only asks the server to cancel it. Its reply, its deadline, or the end
+                    // of the client or the lane ends the call.
+                    using var unattended = committing && operation is not null
+                        ? CancellationTokenSource.CreateLinkedTokenSource(_clientLifetime.Token,
+                            operation.Lifetime.Token)
+                        : null;
                     var attempt = operation is null
                         ? await connection.SendAttemptAsync(code, payload, deadline, deadline, HasSensitiveReply(code),
                             cancellation.Token, retryTransient: false, context: captured.Value, retainedFrame: frame)
-                        : await connection.SendFrameAsync(operation, deadline, HasSensitiveReply(code), cancellation.Token);
+                        : await connection.SendFrameAsync(operation, deadline, HasSensitiveReply(code),
+                            unattended?.Token ?? cancellation.Token, unattended is null ? default : token);
+                    if (unattended is not null && token.IsCancellationRequested
+                        && attempt.Error is IggyInvalidStatusCodeException
+                        {
+                            FromServer: true, StatusCode: VsrError.TRANSIENT_NOT_ACCEPTED
+                        })
+                    {
+                        // The cancel won before the read stored anything.
+                        throw new OperationCanceledException(token);
+                    }
+                    if (committing && attempt.RequestStarted)
+                    {
+                        mayHaveCommitted = attempt.Error is not IggyInvalidStatusCodeException { FromServer: true } refusalStatus
+                            || !CommitErrorScope.Request.ProvesNothingStored(refusalStatus.StatusCode);
+                    }
+                    if (mayHaveCommitted && attempt.Error is not null)
+                    {
+                        if (IsSessionFailure(attempt.Error))
+                        {
+                            ScheduleSessionRecovery(generation);
+                        }
+                        throw new VsrFetchProgressUncertainException(operation!.Identity.RequestId, attempt.Error);
+                    }
                     if (attempt.Error is null)
                     {
                         if (operation is { Uncertain: true } && !attempt.Committed)
@@ -368,9 +503,40 @@
                                 VsrError.Exception(VsrError.TRANSIENT_NOT_COMMITTED, "The reply does not prove commit."),
                                 operation.Identity.RequestId);
                         }
+                        var response = attempt.Response!;
+                        if (commits && operation is not null)
+                        {
+                            try
+                            {
+                                var sections = BinaryMapper.ReadFetchSections(response.Memory.Span);
+                                var requested = fetchLanes!.Select(lane => lane.PartitionId).ToHashSet();
+                                if (sections.Any(section => !requested.Contains(section.PartitionId)))
+                                {
+                                    throw new MalformedResponseException("Fetch reply names an unrequested partition.");
+                                }
+                                var uncertain = sections.Where(section => section.Status != 0
+                                    && !CommitErrorScope.Section.ProvesNothingStored((int)section.Status)).Select(section => section.PartitionId).ToHashSet();
+                                if (uncertain.Count > 0)
+                                {
+                                    _mutations.RetainFetch(operation, uncertain);
+                                    response = new FetchProgressReply(response, operation.Identity.RequestId, uncertain);
+                                }
+                                mayHaveCommitted = false;
+                                if (_consensusSession.Ended(generation) is { } replySessionEnded)
+                                {
+                                    ExceptionDispatchInfo.Throw(replySessionEnded);
+                                }
+                            }
+                            catch
+                            {
+                                response.Dispose();
+                                throw;
+                            }
+                        }
+                        mayHaveCommitted = false;
                         terminal = true;
                         RefreshCommittedCredentials(code, (operation?.Body ?? payload).Span, generation);
-                        return attempt.Response!;
+                        return response;
                     }
                     if (IsSessionFailure(attempt.Error))
                     {
@@ -410,7 +576,8 @@
                             terminal = true;
                             rejection = status;
                         }
-                        if (status.StatusCode == VsrError.HISTORY_UNAVAILABLE && target is { } staleHistory)
+                        if (status.StatusCode is VsrError.HISTORY_UNAVAILABLE or VsrError.CONSUMER_GROUP_PARTITION_NOT_OWNED
+                            && target is { } staleHistory)
                         {
                             RemovePartitionRoute(staleHistory.Key);
                         }
@@ -449,9 +616,13 @@
                     lastWasNotPrimary = false;
                     await Task.Delay(PollRoutingRetryDelay(routingRetries++, Random.Shared.NextDouble()), cancellation.Token);
                 }
+                // A fetch names several partitions and its refusal as not primary does not say
+                // which one moved, so the caller looks up every route of the call again.
                 catch (IggyInvalidStatusCodeException error) when (error.StatusCode is
                     VsrError.TRANSIENT_NOT_ACCEPTED or VsrError.TRANSIENT_NOT_COMMITTED
-                    || error.StatusCode == VsrError.NOT_PRIMARY && replica is null)
+                    or VsrError.DEFERRED_POLL_CAPACITY_EXCEEDED
+                    || error.StatusCode == VsrError.NOT_PRIMARY && replica is null
+                    && code != CommandCodes.FETCH_MESSAGES_CODE)
                 {
                     if (generation != _consensusSession.Generation && retained is null
                         && operation is not { Uncertain: true } && ResumesUnderNewSession(generation))
@@ -486,7 +657,8 @@
                         }
                         throw;
                     }
-                    if (target is { } failed)
+                    // A full server refuses a deferred request on the right route, so the route is kept.
+                    if (target is { } failed && error.StatusCode != VsrError.DEFERRED_POLL_CAPACITY_EXCEEDED)
                     {
                         RemovePartitionRoute(failed.Key, error.StatusCode == VsrError.NOT_PRIMARY ? route : null);
                     }
@@ -563,6 +735,12 @@
                 }
             }
         }
+        catch (Exception error) when (operation?.FetchProgress is not null && mayHaveCommitted)
+        {
+            _mutations.RetainFetch(operation);
+            throw error is VsrFetchProgressUncertainException ? error
+                : new VsrFetchProgressUncertainException(operation.Identity.RequestId, error);
+        }
         catch (Exception error) when (operation is { Retainable: true, Uncertain: true }
             && error is not IggyInvalidStatusCodeException { Committed: true })
         {
@@ -590,6 +768,10 @@
         }
         finally
         {
+            if (leased)
+            {
+                _deferredRequests.Release();
+            }
             operationCancellation.Dispose();
             if (terminal && retained is not null)
             {
@@ -689,8 +871,152 @@
     {
         const int PollTailSize = 14;
         const byte NextStrategy = 5;
-        return code == CommandCodes.POLL_MESSAGES_CODE && body.Length >= PollTailSize
-            && (body[^1] != 0 || body[^PollTailSize] == NextStrategy);
+        if (code == CommandCodes.FETCH_MESSAGES_CODE)
+        {
+            return TcpContracts.FetchMovesConsumer(body);
+        }
+
+        if (code == CommandCodes.POLL_MESSAGES_DEFERRED_CODE && body.Length >= TcpContracts.DEFERRED_POLL_TAIL_SIZE)
+        {
+            body = body[..^TcpContracts.DEFERRED_POLL_TAIL_SIZE];
+        }
+        else if (code != CommandCodes.POLL_MESSAGES_CODE)
+        {
+            return false;
+        }
+
+        return body.Length >= PollTailSize && (body[^1] != 0 || body[^PollTailSize] == NextStrategy);
+    }
+
+    // Two fetches that share partitions take their lanes in the same order, so neither waits for the other in a
+    // cycle.
+    private static MutationLane[] FetchProgressLanes(ReadOnlySpan<byte> body) =>
+    [
+        .. TcpContracts.FetchTargetFences(body).OrderBy(target => target.PartitionId)
+            .Select(target => new MutationLane(target.Incarnation, target.PartitionId, Progress: true))
+    ];
+
+    /// <summary>
+    ///     Sends one fetch to the primary of the first target. A target without a fence, or with incarnation zero as
+    ///     the Rust SDK reads it, takes the context of its route, and each target gets the whole byte limit. A
+    ///     section or a refusal as not primary, or with a lost history, marks the routes of its partitions stale, so
+    ///     the next grouping looks them up again. So does a refusal as not owned, whose route names an old owner
+    ///     generation. A refusal of the whole fetch does not name its partition, so it marks all of them.
+    /// </summary>
+    private async Task<FetchedMessages> FetchMessagesCoreAsync(Identifier streamId, Identifier topicId,
+        IReadOnlyList<(uint PartitionId, PollingStrategy Strategy)> targets, Consumer consumer, uint count,
+        bool autoCommit, DeferredPollOptions options, CancellationToken token, ulong generation)
+    {
+        var started = Stopwatch.GetTimestamp();
+        var deadline = Environment.TickCount64 + (long)options.RequestTimeout.TotalMilliseconds;
+        // A link drops a reply wider than it reads, so the records get what the frame leaves after the table.
+        var records = _configuration.MaxResponseFrameSize - VsrHeader.HEADER_SIZE - FetchedMessages.HeadSize(targets.Count);
+        if (records < DeferredPollOptions.MIN_MAX_BYTES)
+        {
+            throw VsrError.Exception(VsrError.INVALID_SIZE_BYTES,
+                $"A reply frame of {_configuration.MaxResponseFrameSize} bytes cannot hold a fetch of {targets.Count} partitions.");
+        }
+
+        var fenced = new (uint PartitionId, PollingStrategy Strategy)[targets.Count];
+        for (var index = 0; index < fenced.Length; index++)
+        {
+            var (partitionId, strategy) = targets[index];
+            if (strategy.Context is not { Incarnation: not 0 })
+            {
+                var key = new PartitionRouteKey(streamId, topicId, partitionId);
+                var route = await GetPartitionRouteAsync(key, RouteQuery(key), deadline, token);
+                strategy = strategy.WithContext(route.Route.Context);
+            }
+
+            fenced[index] = (partitionId, strategy);
+        }
+
+        var left = options.Remaining(Stopwatch.GetElapsedTime(started)) with
+        {
+            MaxBytes = Math.Min(options.MaxBytes, (uint)records)
+        };
+        var first = new PartitionRouteKey(streamId, topicId, targets[0].PartitionId);
+        IMemoryOwner<byte> reply;
+        try
+        {
+            reply = await SendRoutedAsync(CommandCodes.FETCH_MESSAGES_CODE, new SendTarget(first, RouteQuery(first)),
+                TcpContracts.FetchMessages(consumer, streamId, topicId, left, count, autoCommit, left.MaxBytes, fenced),
+                default(PartitionContext), token, left.RequestTimeout, generation);
+        }
+        catch (IggyInvalidStatusCodeException error) when (error.StatusCode is VsrError.NOT_PRIMARY
+                                                               or VsrError.HISTORY_UNAVAILABLE
+                                                               or VsrError.CONSUMER_GROUP_PARTITION_NOT_OWNED)
+        {
+            foreach (var (partitionId, _) in targets)
+            {
+                RemovePartitionRoute(new PartitionRouteKey(streamId, topicId, partitionId));
+            }
+
+            throw;
+        }
+
+        FetchedMessages fetched;
+        try
+        {
+            fetched = BinaryMapper.MapFetchedMessages(reply, _configuration.MessageEncryptor);
+        }
+        catch
+        {
+            reply.Dispose();
+            throw;
+        }
+
+        foreach (var section in fetched.Sections)
+        {
+            if (section.Status is (uint)VsrError.NOT_PRIMARY or (uint)VsrError.HISTORY_UNAVAILABLE
+                or (uint)VsrError.CONSUMER_GROUP_PARTITION_NOT_OWNED)
+            {
+                RemovePartitionRoute(new PartitionRouteKey(streamId, topicId, section.PartitionId));
+            }
+        }
+
+        return fetched;
+    }
+
+    /// <summary>
+    ///     <paramref name="targets" /> grouped by the primary of <paramref name="partitionOf" /> and split to fit one
+    ///     fetch each, keeping their order. A lookup that no replica answers is retried until
+    ///     <paramref name="deadline" />.
+    /// </summary>
+    private async Task<List<T[]>> ReadinessGroupsAsync<T>(Identifier streamId, Identifier topicId,
+        IReadOnlyList<T> targets, Func<T, uint> partitionOf, long deadline, CancellationToken token)
+    {
+        var primaries = new byte[targets.Count];
+        var lookups = new ParallelOptions { MaxDegreeOfParallelism = MaxRouteLookupsInFlight, CancellationToken = token };
+        for (var retries = 0; ; retries++)
+        {
+            try
+            {
+                await Parallel.ForEachAsync(Enumerable.Range(0, targets.Count), lookups, async (index, lookup) =>
+                {
+                    var key = new PartitionRouteKey(streamId, topicId, partitionOf(targets[index]));
+                    var route = await GetPartitionRouteAsync(key, RouteQuery(key), deadline, lookup);
+                    primaries[index] = route.Route.PrimaryReplica;
+                });
+                return
+                [
+                    .. targets.Select((target, index) => (Primary: primaries[index], Target: target))
+                        .GroupBy(entry => entry.Primary, entry => entry.Target)
+                        .OrderBy(group => group.Key)
+                        .SelectMany(group => group.Chunk(TcpContracts.MAX_PARTITIONS_PER_REQUEST))
+                ];
+            }
+            catch (Exception error) when (VsrConnection.IsConnectionException(error)
+                || error is IggyInvalidStatusCodeException { StatusCode: VsrError.TRANSIENT_NOT_ACCEPTED or VsrError.NOT_PRIMARY })
+            {
+                var pause = PollRoutingRetryDelay(retries, Random.Shared.NextDouble());
+                if (Environment.TickCount64 + (long)pause.TotalMilliseconds >= deadline)
+                {
+                    throw PollNotAccepted();
+                }
+                await Task.Delay(pause, token);
+            }
+        }
     }
 
     private async Task<CachedRoute> GetPartitionRouteAsync(PartitionRouteKey key, ReadOnlyMemory<byte> query,
diff --git a/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.Vsr.cs b/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.Vsr.cs
index 98bbd12..9da586e 100644
--- a/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.Vsr.cs
+++ b/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.Vsr.cs
@@ -40,7 +40,7 @@
 ///     assignment the broker does not
 ///     resolve server-side. The command surface lives in <see cref="TcpMessageStream" />.
 /// </summary>
-public sealed partial class TcpMessageStream : ISessionGenerationProvider
+public sealed partial class TcpMessageStream : ISessionGenerationProvider, IConsumerGroupAssignmentProvider
 {
     /// <summary>
     ///     Admission, routing, and retries share one call deadline. Each link also bounds incomplete frame
@@ -519,6 +519,25 @@
         return NoAssignedPartitionPolledMessages;
     }
 
+    /// <inheritdoc />
+    async Task<IReadOnlyList<uint>> IConsumerGroupAssignmentProvider.GetAssignedPartitionsAsync(Identifier streamId,
+        Identifier topicId, Identifier groupId, bool resync, CancellationToken token)
+    {
+        var generation = _consensusSession.Generation;
+        var key = new GroupKey(streamId, topicId, groupId);
+        if (resync)
+        {
+            _groupState.InvalidateAssignment(key);
+        }
+
+        if (!_groupState.HasAssignment(key))
+        {
+            await SyncGroupAssignmentAsync(streamId, topicId, groupId, token, startedUnder: generation);
+        }
+
+        return _groupState.AssignedPartitions(key);
+    }
+
     /// <summary>
     ///     Pulls the requesting member's assignment from the coordinator into the cache. An empty reply means the
     ///     client is not a member: the coordinator answers with an assignment header for any member, including
@@ -540,7 +559,7 @@
             if (responseBuffer.Memory.Length == 0)
             {
                 // Deregistering is the only thing that observes a server-side removal (group deleted, member
-                // evicted); without it membership latches true and every later poll returns empty.
+                // evicted). Without it membership latches true and every later poll returns empty.
                 _groupState.DeregisterGroup(key);
 
                 return;
@@ -554,7 +573,7 @@
 
     /// <summary>
     ///     Re-syncs every joined group so a widened assignment (a partition-count change, say) is picked up
-    ///     without first hitting an ownership fence. Failed syncs are logged; all groups share the refresh
+    ///     without first hitting an ownership fence. Failed syncs are logged. All groups share the refresh
     ///     budget. A sync that outlasts <paramref name="deadline" /> fails as not accepted rather than canceled,
     ///     so it is logged like any other failure, and the groups after it wait for the next refresh. Only
     ///     <paramref name="token" /> cancels the refresh.
diff --git a/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.cs b/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.cs
index 592517f..ddb3f6f 100644
--- a/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.cs
+++ b/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.cs
@@ -44,7 +44,7 @@
 /// <summary>
 ///     A TCP client with one logical consensus session and independently managed, multiplexed replica links.
 /// </summary>
-public sealed partial class TcpMessageStream : IIggyClient
+public sealed partial class TcpMessageStream : IIggyClient, IPartitionFetchClient
 {
     private static readonly HashSet<uint> SessionControlCodes =
     [
@@ -55,6 +55,16 @@
         CommandCodes.LOGIN_REGISTER_WITH_PAT_CODE
     ];
 
+    /// <summary>
+    ///     Refused as raw requests: the typed deferred poll and fetch own their deadline, their lease and their cancel.
+    /// </summary>
+    private static readonly HashSet<uint> DeferredCodes =
+    [
+        CommandCodes.POLL_MESSAGES_DEFERRED_CODE,
+        CommandCodes.CANCEL_DEFERRED_REQUEST_CODE,
+        CommandCodes.FETCH_MESSAGES_CODE
+    ];
+
     private readonly IggyClientConfigurator _configuration;
 
     // The configurator stays settable after construction, so the endpoint the client was built for is pinned here:
@@ -85,7 +95,7 @@
     // The credentials a sign-in succeeded with, so a reconnect - on this node or, after a failover, another one -
     // can re-establish the session instead of leaving every later request unauthenticated. A caller that signs in
     // by hand is otherwise less reconnectable than one that configures auto login, which is a surprising
-    // difference between two ways of doing the same thing. Cleared on sign-out and on Dispose; a server-side
+    // difference between two ways of doing the same thing. Cleared on sign-out and on Dispose. A server-side
     // eviction is not caller intent and leaves them in place.
     private AutoLoginSettings? _rememberedLogin;
     private bool _signInWithdrawn;
@@ -317,6 +327,28 @@
     }
 
     /// <inheritdoc />
+    public async Task<PolledMessages> PollMessagesAsync(Identifier streamId, Identifier topicId, uint partitionId,
+        Consumer consumer, PollingStrategy strategy, PollOptions options, CancellationToken token = default)
+    {
+        using var rental = await PollMessagesRentedAsync(streamId, topicId, partitionId, consumer, strategy, options,
+            token);
+        var polled = BinaryMapper.MaterializeMessages(rental);
+        polled.RequestStart = strategy;
+        return polled;
+    }
+
+    /// <inheritdoc />
+    public Task<PolledMessagesRental> PollMessagesRentedAsync(Identifier streamId, Identifier topicId,
+        uint partitionId, Consumer consumer, PollingStrategy strategy, PollOptions options,
+        CancellationToken token = default)
+    {
+        ArgumentNullException.ThrowIfNull(options);
+        options.ValidatePoll(partitionId, strategy);
+        return PollRentedAsync(streamId, topicId, partitionId, consumer, _ => strategy, options.Count,
+            options.Commit.Mode == CommitMode.OnRead, options.Wire, token);
+    }
+
+    /// <inheritdoc />
     public async Task<PolledMessages> PollMessagesAsync(Identifier streamId, Identifier topicId, uint? partitionId,
         Consumer consumer,
         PollingStrategy pollingStrategy, uint count, bool autoCommit, CancellationToken token = default)
@@ -351,19 +383,123 @@
         uint? partitionId, Consumer consumer, Func<uint, PollingStrategy> pollingStrategyFor, uint count,
         bool autoCommit, CancellationToken token = default)
     {
+        return PollRentedAsync(streamId, topicId, partitionId, consumer, pollingStrategyFor, count, autoCommit, null,
+            token);
+    }
+
+    internal async Task<PolledMessages> PollMessagesAsync(Identifier streamId, Identifier topicId, uint? partitionId,
+        Consumer consumer, PollingStrategy pollingStrategy, uint count, bool autoCommit, DeferredPollOptions options,
+        CancellationToken token = default)
+    {
+        using var rental = await PollMessagesRentedAsync(streamId, topicId, partitionId, consumer, pollingStrategy,
+            count, autoCommit, options, token);
+        return BinaryMapper.MaterializeMessages(rental);
+    }
+
+    internal Task<PolledMessagesRental> PollMessagesRentedAsync(Identifier streamId, Identifier topicId,
+        uint? partitionId, Consumer consumer, PollingStrategy pollingStrategy, uint count, bool autoCommit,
+        DeferredPollOptions options, CancellationToken token = default)
+    {
+        return PollMessagesRentedAsync(streamId, topicId, partitionId, consumer, _ => pollingStrategy, count,
+            autoCommit, options, token);
+    }
+
+    internal async Task<PolledMessages> PollMessagesAsync(Identifier streamId, Identifier topicId, uint? partitionId,
+        Consumer consumer, Func<uint, PollingStrategy> pollingStrategyFor, uint count, bool autoCommit,
+        DeferredPollOptions options, CancellationToken token = default)
+    {
+        using var rental = await PollMessagesRentedAsync(streamId, topicId, partitionId, consumer, pollingStrategyFor,
+            count, autoCommit, options, token);
+        return BinaryMapper.MaterializeMessages(rental);
+    }
+
+    internal Task<PolledMessagesRental> PollMessagesRentedAsync(Identifier streamId, Identifier topicId,
+        uint? partitionId, Consumer consumer, Func<uint, PollingStrategy> pollingStrategyFor, uint count,
+        bool autoCommit, DeferredPollOptions options, CancellationToken token = default)
+    {
+        ArgumentNullException.ThrowIfNull(options);
+        options.Validate(count);
+        return PollRentedAsync(streamId, topicId, partitionId, consumer, pollingStrategyFor, count, autoCommit,
+            options, token);
+    }
+
+    internal async Task<FetchedMessages> FetchMessagesAsync(Identifier streamId, Identifier topicId,
+        IReadOnlyList<(uint PartitionId, PollingStrategy Strategy)> targets, Consumer consumer, uint count,
+        bool autoCommit, DeferredPollOptions options, CancellationToken token = default)
+    {
+        ArgumentNullException.ThrowIfNull(targets);
+        ArgumentNullException.ThrowIfNull(options);
+        options.ValidateFetch(count);
+        if (targets.Count is 0 or > TcpContracts.MAX_PARTITIONS_PER_REQUEST)
+        {
+            throw VsrError.Exception(VsrError.INVALID_COMMAND,
+                $"A fetch names 1 to {TcpContracts.MAX_PARTITIONS_PER_REQUEST} partitions, got {targets.Count}.");
+        }
+
+        if (targets.DistinctBy(target => target.PartitionId).Count() != targets.Count)
+        {
+            throw VsrError.Exception(VsrError.INVALID_COMMAND, "A fetch names each partition once.");
+        }
+
+        if (targets.Any(target => target.Strategy.Kind is not (MessagePolling.Offset or MessagePolling.Next)))
+        {
+            throw VsrError.Exception(VsrError.INVALID_COMMAND, "A fetch starts each partition at Offset or Next.");
+        }
+
+        ThrowIfAutoCommitWithEncryptor(autoCommit);
+        return await FetchMessagesCoreAsync(streamId, topicId, targets, consumer, count, autoCommit, options, token,
+            _consensusSession.Generation);
+    }
+
+    internal async Task<IReadOnlyList<IReadOnlyList<uint>>> ReadinessGroupsAsync(Identifier streamId,
+        Identifier topicId, IReadOnlyList<uint> partitions, CancellationToken token = default)
+    {
+        ArgumentNullException.ThrowIfNull(partitions);
+        return await ReadinessGroupsAsync(streamId, topicId, partitions, partition => partition,
+            Environment.TickCount64 + VsrRequestTimeoutMs, token);
+    }
+
+    Task<FetchedMessages> IPartitionFetchClient.FetchMessagesAsync(Identifier streamId, Identifier topicId,
+        IReadOnlyList<(uint PartitionId, PollingStrategy Strategy)> targets, Consumer consumer, uint count,
+        bool autoCommit, DeferredPollOptions options, CancellationToken token)
+    {
+        return FetchMessagesAsync(streamId, topicId, targets, consumer, count, autoCommit, options, token);
+    }
+
+    Task<IReadOnlyList<IReadOnlyList<uint>>> IPartitionFetchClient.ReadinessGroupsAsync(Identifier streamId,
+        Identifier topicId, IReadOnlyList<uint> partitions, CancellationToken token)
+    {
+        return ReadinessGroupsAsync(streamId, topicId, partitions, token);
+    }
+
+    private Task<PolledMessagesRental> PollRentedAsync(Identifier streamId, Identifier topicId, uint? partitionId,
+        Consumer consumer, Func<uint, PollingStrategy> pollingStrategyFor, uint count, bool autoCommit,
+        DeferredPollOptions? options, CancellationToken token)
+    {
         var generation = _consensusSession.Generation;
         ThrowIfAutoCommitWithEncryptor(autoCommit);
 
         // The broker routes explicit partitions only, so a group poll picks one of the member's assigned
-        // partitions client-side.
+        // partitions client-side. A deferred one would wait on a single partition while others hold messages, so a
+        // member fetches its partitions instead.
         if (consumer.Type == ConsumerType.ConsumerGroup && partitionId is null)
         {
-            return PollGroupMessagesRentedAsync(streamId, topicId, consumer, pollingStrategyFor, count, autoCommit,
-                token, generation);
+            return options is null
+                ? PollGroupMessagesRentedAsync(streamId, topicId, consumer, pollingStrategyFor, count, autoCommit,
+                    token, generation)
+                : Task.FromException<PolledMessagesRental>(VsrError.Exception(VsrError.INVALID_COMMAND,
+                    "A deferred consumer group poll needs a partition. Fetch the member's partitions with " +
+                    "FetchMessagesAsync, one fetch per readiness group."));
         }
 
-        return PollPartitionMessagesRentedAsync(streamId, topicId, partitionId, consumer,
-            pollingStrategyFor(partitionId ?? 0), count, autoCommit, token, generation);
+        var strategy = pollingStrategyFor(partitionId ?? 0);
+        if (partitionId is { } partition)
+        {
+            strategy.ValidatePartition(partition);
+        }
+
+        return PollPartitionMessagesRentedAsync(streamId, topicId, partitionId, consumer, strategy, count,
+            autoCommit, token, generation, options);
     }
 
     /// <inheritdoc />
@@ -384,6 +520,15 @@
     }
 
     /// <inheritdoc />
+    public Task StoreConsumerPositionAsync(Consumer consumer, Identifier streamId, Identifier topicId,
+        Checkpoint checkpoint, CancellationToken token = default)
+    {
+        ArgumentNullException.ThrowIfNull(checkpoint);
+        return StoreOffsetAsync(consumer, streamId, topicId, checkpoint.Offset, checkpoint.PartitionId,
+            checkpoint.Context, token);
+    }
+
+    /// <inheritdoc />
     public async Task<OffsetResponse?> GetOffsetAsync(Consumer consumer, Identifier streamId, Identifier topicId,
         uint? partitionId, CancellationToken token = default)
     {
@@ -408,6 +553,23 @@
     }
 
     /// <inheritdoc />
+    public async Task DeleteOffsetAsync(Consumer consumer, Identifier streamId, Identifier topicId, uint partitionId,
+        PartitionContext context, CancellationToken token = default)
+    {
+        var message = TcpContracts.DeleteOffset(streamId, topicId, consumer, partitionId);
+        using var response = await SendWithResponseAsync(CommandCodes.DELETE_CONSUMER_OFFSET_CODE, message,
+            token: token, capturedContext: context);
+    }
+
+    /// <inheritdoc />
+    public Task DeleteConsumerPositionAsync(Consumer consumer, Identifier streamId, Identifier topicId,
+        Checkpoint checkpoint, CancellationToken token = default)
+    {
+        ArgumentNullException.ThrowIfNull(checkpoint);
+        return DeleteOffsetAsync(consumer, streamId, topicId, checkpoint.PartitionId, checkpoint.Context, token);
+    }
+
+    /// <inheritdoc />
     public async Task<IReadOnlyList<ConsumerGroupResponse>> GetConsumerGroupsAsync(Identifier streamId,
         Identifier topicId,
         CancellationToken token = default)
@@ -609,7 +771,7 @@
     /// <inheritdoc />
     public async Task<byte[]> SendBinaryRequestAsync(uint code, byte[] payload, CancellationToken token = default)
     {
-        if (SessionControlCodes.Contains(code))
+        if (SessionControlCodes.Contains(code) || DeferredCodes.Contains(code))
         {
             throw VsrError.Exception(VsrError.INVALID_COMMAND,
                 $"Command {code} cannot be sent as a raw binary request.");
@@ -624,7 +786,7 @@
     public async Task<byte[]> SendBinaryRequestToReplicaAsync(uint code, byte[] payload, byte replicaId,
         CancellationToken token = default)
     {
-        if (SessionControlCodes.Contains(code) || code == CommandCodes.POLL_MESSAGES_CODE
+        if (SessionControlCodes.Contains(code) || DeferredCodes.Contains(code) || code == CommandCodes.POLL_MESSAGES_CODE
             || code == CommandCodes.GET_CONSUMER_OFFSET_CODE
             || VsrOperations.ForCode((int)code) != VsrOperation.NonReplicated)
         {
@@ -650,6 +812,17 @@
         return ConnectAsync(true, token);
     }
 
+    /// <summary>
+    ///     Closes the links of the client without a sign-out, as a lost network does, so the server keeps the session
+    ///     until it expires. <see cref="ConnectAsync(CancellationToken)" /> and a sign-in open the client again. A
+    ///     committing read in flight ends with an unknown outcome and keeps its partition blocked.
+    /// </summary>
+    internal Task DisconnectAsync()
+    {
+        CancelSessionRecovery();
+        return ResetConsensusSessionAsync(stops: true, interrupt: true).AsTask();
+    }
+
     /// <inheritdoc />
     public async Task<IReadOnlyList<ClientResponse>> GetClientsAsync(CancellationToken token = default)
     {
@@ -925,22 +1098,40 @@
         }
     }
 
+    /// <summary>
+    ///     Polls one partition, deferred when <paramref name="deferred" /> is given. A deferred poll ends at its own
+    ///     request timeout, and asks for no reply wider than a frame this client reads: a wider one would fail the
+    ///     whole link instead of the poll.
+    /// </summary>
     private async Task<PolledMessagesRental> PollPartitionMessagesRentedAsync(Identifier streamId, Identifier topicId,
         uint? partitionId, Consumer consumer, PollingStrategy pollingStrategy, uint count, bool autoCommit,
-        CancellationToken token, ulong generation)
+        CancellationToken token, ulong generation, DeferredPollOptions? deferred = null)
     {
-        var messageBufferSize = CalculateMessageBufferSize(streamId, topicId, consumer);
+        var messageBufferSize = CalculateMessageBufferSize(streamId, topicId, consumer)
+                                + (deferred is null ? 0 : TcpContracts.DEFERRED_POLL_TAIL_SIZE);
         var payload = ArrayPool<byte>.Shared.Rent(messageBufferSize);
         IMemoryOwner<byte>? responseBuffer = null;
 
         try
         {
+            long? deadline = null;
+            if (deferred is not null)
+            {
+                deadline = Environment.TickCount64 + (long)deferred.RequestTimeout.TotalMilliseconds;
+                deferred = deferred with
+                {
+                    MaxBytes = Math.Min(deferred.MaxBytes,
+                        (uint)Math.Max(0, _configuration.MaxResponseFrameSize - VsrHeader.HEADER_SIZE))
+                };
+            }
+
             TcpContracts.GetMessages(payload.AsSpan(0, messageBufferSize), consumer, streamId,
-                topicId, pollingStrategy, count, autoCommit, partitionId ?? 0);
+                topicId, pollingStrategy, count, autoCommit, partitionId ?? 0, deferred);
 
             var body = payload.AsMemory(0, messageBufferSize);
-            responseBuffer = await SendWithResponseAsync(CommandCodes.POLL_MESSAGES_CODE, body,
-                token: token, capturedContext: pollingStrategy.Context, startedUnder: generation);
+            responseBuffer = await SendWithResponseAsync(
+                deferred is null ? CommandCodes.POLL_MESSAGES_CODE : CommandCodes.POLL_MESSAGES_DEFERRED_CODE, body,
+                token: token, capturedContext: pollingStrategy.Context, deadline: deadline, startedUnder: generation);
             if (responseBuffer.Memory.Length == 0)
             {
                 responseBuffer.Dispose();
@@ -979,7 +1170,7 @@
     {
         var encryptor = _configuration.MessageEncryptor;
 
-        // With an encryptor this is an upper bound; the fill reports the size actually written and only that
+        // With an encryptor this is an upper bound. The fill reports the size actually written and only that
         // prefix is sent.
         var metadataLength = 2 + streamId.Length + 2 + topicId.Length
                              + 2 + partitioning.Length + 4 + 4;
@@ -1239,6 +1430,7 @@
             throw PollNotAccepted();
         }
         if (code is CommandCodes.SEND_MESSAGES_CODE or CommandCodes.POLL_MESSAGES_CODE
+            or CommandCodes.POLL_MESSAGES_DEFERRED_CODE
             or CommandCodes.GET_CONSUMER_OFFSET_CODE or CommandCodes.STORE_CONSUMER_OFFSET_CODE
             or CommandCodes.DELETE_CONSUMER_OFFSET_CODE)
         {
diff --git a/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TransientHttpRetryHandler.cs b/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TransientHttpRetryHandler.cs
index 014ee53..b1e8dab 100644
--- a/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TransientHttpRetryHandler.cs
+++ b/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TransientHttpRetryHandler.cs
@@ -16,18 +16,28 @@
 // under the License.
 
 using System.Net;
+using System.Web;
 
 namespace Apache.Iggy.IggyClient.Implementations;
 
 /// <summary>
 ///     Replays requests the server answered with a retryable status: 503 (no caught-up primary, full
 ///     pipeline, view-change cancel, shard write budget) and 429 (session write cap). The server signals
-///     both as transient with a Retry-After; the binary transports absorb the equivalent frames in their
+///     both as transient with a Retry-After. The binary transports absorb the equivalent frames in their
 ///     in-client replay loop, so HTTP replays here for transport parity. A write 504 stays terminal: the
-///     request may still commit, so its outcome is unknown and must surface to the caller.
+///     request may still commit, so its outcome is unknown and must surface to the caller. A poll with auto commit
+///     is a GET that writes, because the server stores its offset before it answers.
 /// </summary>
 internal sealed class TransientHttpRetryHandler : DelegatingHandler
 {
+    /// <summary>
+    ///     Marks a request whose caller sends it again itself. A deferred poll does: each attempt asks for what is
+    ///     left of its wait, so a copy of an earlier attempt would wait past the request timeout.
+    /// </summary>
+    internal static readonly HttpRequestOptionsKey<bool> SingleAttempt = new(nameof(SingleAttempt));
+
+    private const string AutoCommitQueryKey = "auto_commit";
+
     private static readonly TimeSpan RetryDeadline = TimeSpan.FromSeconds(30);
     private static readonly TimeSpan RetryInterval = TimeSpan.FromMilliseconds(50);
 
@@ -38,6 +48,11 @@
     protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
         CancellationToken cancellationToken)
     {
+        if (request.Options.TryGetValue(SingleAttempt, out var single) && single)
+        {
+            return await base.SendAsync(request, cancellationToken);
+        }
+
         var deadline = Environment.TickCount64 + (long)RetryDeadline.TotalMilliseconds;
         byte[]? bufferedContent = null;
         if (request.Content is not null)
@@ -49,7 +64,7 @@
         {
             HttpResponseMessage response = await base.SendAsync(CloneRequest(request, bufferedContent),
                 cancellationToken);
-            if (!IsRetryable(request.Method, response.StatusCode) || Environment.TickCount64 >= deadline)
+            if (!IsRetryable(request, response.StatusCode) || Environment.TickCount64 >= deadline)
             {
                 return response;
             }
@@ -60,7 +75,7 @@
         }
     }
 
-    private static bool IsRetryable(HttpMethod method, HttpStatusCode statusCode)
+    private static bool IsRetryable(HttpRequestMessage request, HttpStatusCode statusCode)
     {
         if (statusCode is HttpStatusCode.ServiceUnavailable or HttpStatusCode.TooManyRequests)
         {
@@ -70,7 +85,15 @@
         // A read that timed out in the partition plane (e.g. a poll racing the reconciler that has not yet
         // materialised a fresh topic's partition group) has no side effects, so it replays. A write 504 is an
         // unknown outcome and stays terminal.
-        return statusCode == HttpStatusCode.GatewayTimeout && method == HttpMethod.Get;
+        return statusCode == HttpStatusCode.GatewayTimeout && request.Method == HttpMethod.Get
+                                                           && !AutoCommits(request.RequestUri);
+    }
+
+    private static bool AutoCommits(Uri? uri)
+    {
+        return uri is not null
+               && bool.TryParse(HttpUtility.ParseQueryString(uri.Query)[AutoCommitQueryKey], out var autoCommit)
+               && autoCommit;
     }
 
     /// <summary>
diff --git a/foreign/csharp/Iggy_SDK/Iggy_SDK.csproj b/foreign/csharp/Iggy_SDK/Iggy_SDK.csproj
index 6676f5b..fe043fc 100644
--- a/foreign/csharp/Iggy_SDK/Iggy_SDK.csproj
+++ b/foreign/csharp/Iggy_SDK/Iggy_SDK.csproj
@@ -26,7 +26,7 @@
         <TargetFrameworks>net8.0;net10.0</TargetFrameworks>
         <AssemblyName>Apache.Iggy</AssemblyName>
         <RootNamespace>Apache.Iggy</RootNamespace>
-        <Version>0.9.1-edge.5</Version>
+        <Version>0.9.1-edge.6</Version>
         <GenerateDocumentationFile>true</GenerateDocumentationFile>
     </PropertyGroup>
 
@@ -74,6 +74,7 @@
     <ItemGroup>
         <InternalsVisibleTo Include="Apache.Iggy.Tests" />
         <InternalsVisibleTo Include="Apache.Iggy.Tests.Integrations" />
+        <InternalsVisibleTo Include="Apache.Iggy.Tests.BDD" />
     </ItemGroup>
 
 </Project>
diff --git a/foreign/csharp/Iggy_SDK/Kinds/PollingStrategy.cs b/foreign/csharp/Iggy_SDK/Kinds/PollingStrategy.cs
index d71c528..dd9e476 100644
--- a/foreign/csharp/Iggy_SDK/Kinds/PollingStrategy.cs
+++ b/foreign/csharp/Iggy_SDK/Kinds/PollingStrategy.cs
@@ -17,6 +17,8 @@
 
 using Apache.Iggy.Contracts;
 using Apache.Iggy.Enums;
+using Apache.Iggy.Exceptions;
+using Apache.Iggy.Vsr;
 
 namespace Apache.Iggy.Kinds;
 
@@ -48,6 +50,12 @@
     public PartitionContext? Context { get; init; }
 
     /// <summary>
+    ///     The partition of a continuation, which a read of another partition refuses. Null for a strategy that
+    ///     fits every partition.
+    /// </summary>
+    internal uint? PartitionId { get; init; }
+
+    /// <summary>
     ///     Pins the poll to a context, such as <see cref="PolledMessages.Context" /> of an earlier poll, so it never
     ///     reads a newer incarnation of the partition.
     /// </summary>
@@ -58,6 +66,30 @@
         return this with { Context = context };
     }
 
+    /// <summary>A continuation of <paramref name="partitionId" /> under <paramref name="context" />.</summary>
+    internal static PollingStrategy Continuation(uint partitionId, MessagePolling kind, ulong value,
+        PartitionContext context)
+    {
+        return new PollingStrategy
+        {
+            Kind = kind,
+            Value = value,
+            Context = context,
+            PartitionId = partitionId
+        };
+    }
+
+    /// <summary>Refuses a continuation of another partition than <paramref name="partitionId" />.</summary>
+    /// <exception cref="IggyInvalidStatusCodeException">Status 3 (InvalidCommand) for a continuation of another partition.</exception>
+    internal void ValidatePartition(uint partitionId)
+    {
+        if (PartitionId is { } bound && bound != partitionId)
+        {
+            throw VsrError.Exception(VsrError.INVALID_COMMAND,
+                $"A continuation of partition {bound} cannot read partition {partitionId}.");
+        }
+    }
+
     /// <summary>
     ///     Creates a polling strategy that starts from a specific message offset.
     /// </summary>
diff --git a/foreign/csharp/Iggy_SDK/Mappers/BinaryMapper.cs b/foreign/csharp/Iggy_SDK/Mappers/BinaryMapper.cs
index 5027742..c66c916 100644
--- a/foreign/csharp/Iggy_SDK/Mappers/BinaryMapper.cs
+++ b/foreign/csharp/Iggy_SDK/Mappers/BinaryMapper.cs
@@ -20,6 +20,7 @@
 using System.Text;
 using Apache.Iggy.Contracts;
 using Apache.Iggy.Contracts.Auth;
+using Apache.Iggy.Contracts.Tcp;
 using Apache.Iggy.Encryption;
 using Apache.Iggy.Enums;
 using Apache.Iggy.Exceptions;
@@ -280,20 +281,34 @@
         IMemoryOwner<byte> payloadOwner, IMessageEncryptor? encryptor = null)
     {
         ReadOnlySpan<byte> span = payload.Span;
-        var length = payload.Length;
         var partitionId = BinaryPrimitives.ReadUInt32LittleEndian(span[..4]);
         var currentOffset = BinaryPrimitives.ReadUInt64LittleEndian(span[4..12]);
         var messagesCount = BinaryPrimitives.ReadUInt32LittleEndian(span[12..16]);
         var context = MapPartitionContext(span[16..]);
-        var position = 16 + PartitionContext.ENCODED_SIZE;
-        if (position >= length)
+        return MapRentedRecords(payload[DeferredPollOptions.POLL_REPLY_HEADER_SIZE..], payloadOwner, partitionId,
+            currentOffset, messagesCount, context, encryptor);
+    }
+
+    /// <summary>
+    ///     Maps whole batch records, each a batch header and its message frames, as a poll reply carries them after
+    ///     its header and a fetch reply in each section. The messages are slices of <paramref name="records" />,
+    ///     which <paramref name="recordsOwner" /> keeps valid until the rental is disposed.
+    /// </summary>
+    internal static PolledMessagesRental MapRentedRecords(ReadOnlyMemory<byte> records,
+        IMemoryOwner<byte> recordsOwner, uint partitionId, ulong currentOffset, uint messagesCount,
+        PartitionContext context, IMessageEncryptor? encryptor)
+    {
+        ReadOnlySpan<byte> span = records.Span;
+        var length = records.Length;
+        var position = 0;
+        if (length == 0)
         {
             if (messagesCount != 0)
             {
-                throw new MalformedResponseException("Poll count does not match its messages.");
+                throw new MalformedResponseException("Poll count does not match its messages.", span);
             }
 
-            return new PolledMessagesRental(payloadOwner)
+            return new PolledMessagesRental(recordsOwner)
             {
                 PartitionId = partitionId,
                 Context = context,
@@ -327,7 +342,7 @@
             {
                 var batchEnd = ReadBatchExtent(span, length, position, out var baseOffset, out var baseTimestamp,
                     out var batchOriginTimestamp);
-                // Broker append time is stamped once per batch record; the per-frame delta applies to the
+                // Broker append time is stamped once per batch record. The per-frame delta applies to the
                 // origin timestamp only.
                 var timestamp = DateTimeOffsetUtils.FromUnixTimeMicroSeconds(baseTimestamp);
                 var cursor = position + BatchWireFormat.BATCH_HEADER_SIZE;
@@ -344,13 +359,13 @@
                     var payloadRangeStart = cursor + BatchWireFormat.FRAME_HEADER_SIZE;
                     var headersRangeStart = payloadRangeStart + payloadLength;
 
-                    ReadOnlyMemory<byte> payloadSlice = payload.Slice(payloadRangeStart, payloadLength);
+                    ReadOnlyMemory<byte> payloadSlice = records.Slice(payloadRangeStart, payloadLength);
                     ReadOnlyMemory<byte> rawHeaders = headersLength > 0
-                        ? payload.Slice(headersRangeStart, headersLength)
+                        ? records.Slice(headersRangeStart, headersLength)
                         : ReadOnlyMemory<byte>.Empty;
 
                     // Decrypt into the shared buffer so the message looks like plaintext downstream. Wire lengths
-                    // still drive the cursor advance; only the decrypted lengths land on the header.
+                    // still drive the cursor advance. Only the decrypted lengths land on the header.
                     var storedPayloadLength = payloadLength;
                     var storedHeadersLength = headersLength;
                     if (encryptor is not null)
@@ -378,7 +393,9 @@
                         }
                         catch (Exception ex)
                         {
-                            throw new MessageDecryptionException(offset, partitionId, ex);
+                            throw new MessageDecryptionException(offset, partitionId, ex, records
+                                .Slice(cursor, BatchWireFormat.FRAME_HEADER_SIZE + payloadLength + headersLength)
+                                .ToArray());
                         }
                     }
 
@@ -407,10 +424,10 @@
 
             if (messages.Count != messagesCount)
             {
-                throw new MalformedResponseException("Poll count does not match its messages.");
+                throw new MalformedResponseException("Poll count does not match its messages.", span);
             }
 
-            return new PolledMessagesRental(payloadOwner, plaintextOwner)
+            return new PolledMessagesRental(recordsOwner, plaintextOwner)
             {
                 PartitionId = partitionId,
                 Context = context,
@@ -433,7 +450,8 @@
         if (position + BatchWireFormat.BATCH_HEADER_SIZE > length)
         {
             throw new MalformedResponseException(
-                $"Malformed batch record at byte {position}: {length - position} bytes cannot hold a batch header.");
+                $"Malformed batch record at byte {position}: {length - position} bytes cannot hold a batch header.",
+                span[position..]);
         }
 
         baseOffset = BinaryPrimitives.ReadUInt64LittleEndian(span[(position + 8)..(position + 16)]);
@@ -443,7 +461,8 @@
         if (batchLength < BatchWireFormat.BATCH_HEADER_SIZE || (ulong)position + batchLength > (ulong)length)
         {
             throw new MalformedResponseException(
-                $"Malformed batch record at byte {position}: batch length {batchLength} does not fit the response.");
+                $"Malformed batch record at byte {position}: batch length {batchLength} does not fit the response.",
+                span[position..]);
         }
 
         return position + (int)batchLength;
@@ -455,7 +474,8 @@
         if (cursor + BatchWireFormat.FRAME_HEADER_SIZE > batchEnd)
         {
             throw new MalformedResponseException(
-                $"Malformed message frame at byte {cursor}: {batchEnd - cursor} bytes cannot hold a frame header.");
+                $"Malformed message frame at byte {cursor}: {batchEnd - cursor} bytes cannot hold a frame header.",
+                span[cursor..]);
         }
 
         headersLength = BinaryPrimitives.ReadInt32LittleEndian(span[(cursor + 32)..(cursor + 36)]);
@@ -464,20 +484,20 @@
         {
             throw new MalformedResponseException(
                 $"Malformed message frame at byte {cursor}: negative payload ({payloadLength}) or header " +
-                $"({headersLength}) length.");
+                $"({headersLength}) length.", span[cursor..]);
         }
 
         if (BinaryPrimitives.ReadUInt64LittleEndian(span[(cursor + 40)..(cursor + 48)]) != 0)
         {
             throw new MalformedResponseException(
-                $"Malformed message frame at byte {cursor}: reserved bytes must be zero.");
+                $"Malformed message frame at byte {cursor}: reserved bytes must be zero.", span[cursor..]);
         }
 
         // Overflow-safe: server-controlled lengths can approach int.MaxValue, so compute the bound in long.
         if ((long)cursor + BatchWireFormat.FRAME_HEADER_SIZE + payloadLength + headersLength > batchEnd)
         {
             throw new MalformedResponseException(
-                $"Malformed message frame at byte {cursor}: frame runs past its batch record.");
+                $"Malformed message frame at byte {cursor}: frame runs past its batch record.", span[cursor..]);
         }
     }
 
@@ -485,7 +505,7 @@
     // and frame walk as the main loop, so both agree on which messages are included.
     private static int SumMaxDecryptedLength(ReadOnlySpan<byte> span, int length, IMessageEncryptor encryptor)
     {
-        var position = 16 + PartitionContext.ENCODED_SIZE;
+        var position = 0;
         var total = 0;
         while (position < length)
         {
@@ -536,13 +556,20 @@
         var rentedMessages = new List<RentedMessageResponse>(messages.Messages.Count);
         foreach (var message in messages.Messages)
         {
-            rentedMessages.Add(new RentedMessageResponse
-            {
-                Header = message.Header,
-                Payload = message.Payload,
-                RawUserHeaders = message.RawUserHeaders ?? ReadOnlyMemory<byte>.Empty,
-                UserHeaders = message.UserHeaders
-            });
+            // Raw headers decode on first access, so only headers that came decoded are copied.
+            rentedMessages.Add(message.RawUserHeaders is { } raw
+                ? new RentedMessageResponse
+                {
+                    Header = message.Header,
+                    Payload = message.Payload,
+                    RawUserHeaders = raw
+                }
+                : new RentedMessageResponse
+                {
+                    Header = message.Header,
+                    Payload = message.Payload,
+                    UserHeaders = message.UserHeaders
+                });
         }
 
         return new PolledMessagesRental(EmptyMemoryOwner.Instance)
@@ -1350,6 +1377,96 @@
         };
     }
 
+    /// <summary>
+    ///     Maps a FetchMessages reply (code 109): the section count, the section headers, then the records of each
+    ///     section in table order. The sections take the reply buffer, and decode with <paramref name="encryptor" />.
+    /// </summary>
+    internal static FetchedMessages MapFetchedMessages(IMemoryOwner<byte> reply, IMessageEncryptor? encryptor)
+    {
+        var body = reply.Memory;
+        var span = body.Span;
+        var headers = ReadFetchSections(span);
+        var fetched = new FetchedMessages(reply);
+        var sections = new FetchedSection[headers.Length];
+        for (var index = 0; index < sections.Length; index++)
+        {
+            var (partitionId, status, records, recordsLength) = headers[index];
+            var header = span[(FetchedMessages.SECTIONS_COUNT_SIZE + index * FetchedMessages.SECTION_HEADER_SIZE)..];
+            var progressError = reply is FetchProgressReply recovery && recovery.Partitions.Contains(partitionId)
+                ? new VsrFetchProgressUncertainException(recovery.OperationId, VsrError.FromServer((int)status,
+                    $"The committing fetch of partition {partitionId} has an unknown outcome."))
+                : null;
+            sections[index] = new FetchedSection(fetched, encryptor)
+            {
+                PartitionId = partitionId,
+                Status = progressError is null ? status : VsrFetchProgressUncertainException.STATUS_CODE,
+                CurrentOffset = BinaryPrimitives.ReadUInt64LittleEndian(header[8..]),
+                MessagesCount = BinaryPrimitives.ReadUInt32LittleEndian(header[16..]),
+                Context = MapPartitionContext(header[20..]),
+                NextOffset = BinaryPrimitives.ReadUInt64LittleEndian(header[44..]),
+                Records = body.Slice(records, recordsLength),
+                ProgressError = progressError
+            };
+        }
+        fetched.Sections = sections;
+        return fetched;
+    }
+
+    internal static (uint PartitionId, uint Status, int RecordsAt, int RecordsLength)[] ReadFetchSections(
+        ReadOnlySpan<byte> span)
+    {
+        if (span.Length < FetchedMessages.SECTIONS_COUNT_SIZE)
+        {
+            throw new MalformedResponseException("Fetch reply is shorter than its section count.");
+        }
+
+        var count = BinaryPrimitives.ReadUInt32LittleEndian(span);
+        if (count > TcpContracts.MAX_PARTITIONS_PER_REQUEST)
+        {
+            throw new MalformedResponseException(
+                $"Fetch reply holds {count} sections, more than {TcpContracts.MAX_PARTITIONS_PER_REQUEST}.");
+        }
+
+        var records = FetchedMessages.HeadSize((int)count);
+        if (span.Length < records)
+        {
+            throw new MalformedResponseException("Fetch reply section table does not fit its body.");
+        }
+
+        var sections = new (uint PartitionId, uint Status, int RecordsAt, int RecordsLength)[count];
+        var seen = new HashSet<uint>();
+        for (var index = 0; index < sections.Length; index++)
+        {
+            var header = span[(FetchedMessages.SECTIONS_COUNT_SIZE + index * FetchedMessages.SECTION_HEADER_SIZE)..];
+            var partitionId = BinaryPrimitives.ReadUInt32LittleEndian(header);
+            if (!seen.Add(partitionId))
+            {
+                throw new MalformedResponseException($"Fetch reply repeats partition {partitionId}.");
+            }
+            var status = BinaryPrimitives.ReadUInt32LittleEndian(header[4..]);
+            var recordsLength = BinaryPrimitives.ReadUInt32LittleEndian(header[52..]);
+            if (status != 0 && recordsLength != 0)
+            {
+                throw new MalformedResponseException($"Fetch section {index} has status {status} and records.");
+            }
+
+            if ((ulong)records + recordsLength > (ulong)span.Length)
+            {
+                throw new MalformedResponseException($"Fetch section {index} runs past the reply.");
+            }
+
+            sections[index] = (partitionId, status, records, (int)recordsLength);
+            records += (int)recordsLength;
+        }
+
+        if (records != span.Length)
+        {
+            throw new MalformedResponseException("Fetch reply holds bytes after its last section.");
+        }
+
+        return sections;
+    }
+
     internal static PartitionRoute MapPartitionRoute(ReadOnlySpan<byte> payload)
     {
         if (payload.Length != PartitionRoute.ENCODED_SIZE)
diff --git a/foreign/csharp/Iggy_SDK/Utils/CommandCodes.cs b/foreign/csharp/Iggy_SDK/Utils/CommandCodes.cs
index db6cc92..9f9f796 100644
--- a/foreign/csharp/Iggy_SDK/Utils/CommandCodes.cs
+++ b/foreign/csharp/Iggy_SDK/Utils/CommandCodes.cs
@@ -50,8 +50,8 @@
     // 103 and 104 are retired and must never be reused.
     internal const int GET_PARTITION_ROUTE_CODE = 105;
     internal const int POLL_MESSAGES_DEFERRED_CODE = 106;
-    internal const int WAIT_FOR_MESSAGES_CODE = 107;
     internal const int CANCEL_DEFERRED_REQUEST_CODE = 108;
+    internal const int FETCH_MESSAGES_CODE = 109;
     internal const int GET_CONSUMER_OFFSET_CODE = 120;
     internal const int STORE_CONSUMER_OFFSET_CODE = 121;
     internal const int DELETE_CONSUMER_OFFSET_CODE = 122;
diff --git a/foreign/csharp/Iggy_SDK/Vsr/CommitOutcome.cs b/foreign/csharp/Iggy_SDK/Vsr/CommitOutcome.cs
new file mode 100644
index 0000000..06a7a15
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK/Vsr/CommitOutcome.cs
@@ -0,0 +1,53 @@
+// 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.
+
+namespace Apache.Iggy.Vsr;
+
+/// <summary>Where a committing deferred poll (106) or fetch (109) reports an error.</summary>
+internal enum CommitErrorScope
+{
+    /// <summary>The whole reply is the error.</summary>
+    Request,
+
+    /// <summary>One section of a fetch reply carries the error status.</summary>
+    Section
+}
+
+internal static class CommitOutcome
+{
+    /// <summary>
+    ///     Whether <paramref name="status" />, reported in <paramref name="scope" />, proves that the server stored no
+    ///     offset. Any other status, a lost reply and a transport failure after the send leave the outcome unknown.
+    /// </summary>
+    internal static bool ProvesNothingStored(this CommitErrorScope scope, int status)
+    {
+        return scope switch
+        {
+            // A first message above the byte limit and a new offset key above the limit are refused before the store.
+            CommitErrorScope.Request => status is VsrError.FEATURE_UNAVAILABLE or VsrError.INVALID_IDENTIFIER
+                or VsrError.TRANSIENT_NOT_ACCEPTED or VsrError.INVALID_SIZE_BYTES or VsrError.HISTORY_UNAVAILABLE
+                or VsrError.NOT_PRIMARY or VsrError.DEFERRED_POLL_CAPACITY_EXCEEDED or VsrError.STREAM_ID_NOT_FOUND
+                or VsrError.STREAM_NAME_NOT_FOUND or VsrError.TOPIC_ID_NOT_FOUND or VsrError.TOPIC_NAME_NOT_FOUND
+                or VsrError.PARTITION_NOT_FOUND or VsrError.TOO_MANY_CONSUMER_OFFSETS or VsrError.INVALID_OFFSET
+                or VsrError.CONSUMER_GROUP_PARTITION_NOT_OWNED,
+            // A section whose read may have stored never carries one of these.
+            _ => status is VsrError.TRANSIENT_NOT_ACCEPTED or VsrError.INVALID_SIZE_BYTES
+                or VsrError.HISTORY_UNAVAILABLE or VsrError.NOT_PRIMARY or VsrError.PARTITION_NOT_FOUND
+                or VsrError.TOO_MANY_CONSUMER_OFFSETS or VsrError.CONSUMER_GROUP_PARTITION_NOT_OWNED
+        };
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK/Vsr/ConsumerGroupClientState.cs b/foreign/csharp/Iggy_SDK/Vsr/ConsumerGroupClientState.cs
index 54a48b4..cc5da1f 100644
--- a/foreign/csharp/Iggy_SDK/Vsr/ConsumerGroupClientState.cs
+++ b/foreign/csharp/Iggy_SDK/Vsr/ConsumerGroupClientState.cs
@@ -122,6 +122,17 @@
         }
     }
 
+    /// <summary>
+    ///     The assigned partitions of a group, in assignment order, or an empty list when nothing is cached.
+    /// </summary>
+    internal IReadOnlyList<uint> AssignedPartitions(GroupKey key)
+    {
+        lock (_gate)
+        {
+            return _assignments.TryGetValue(key, out var assignment) ? assignment.Partitions : [];
+        }
+    }
+
     /// <summary>The next balanced produce partition for a topic, advancing the cursor.</summary>
     internal uint NextBalancedPartition(TopicKey key, uint partitionCount)
     {
diff --git a/foreign/csharp/Iggy_SDK/Vsr/IConsumerGroupAssignmentProvider.cs b/foreign/csharp/Iggy_SDK/Vsr/IConsumerGroupAssignmentProvider.cs
new file mode 100644
index 0000000..be1ad2c
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK/Vsr/IConsumerGroupAssignmentProvider.cs
@@ -0,0 +1,40 @@
+// 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.
+
+using Apache.Iggy.IggyClient;
+
+namespace Apache.Iggy.Vsr;
+
+/// <summary>
+///     Exposes the partitions that the consumer-group member of a transport owns, so a group reader can read them. A
+///     transport without it, such as HTTP, cannot serve a group reader.
+/// </summary>
+internal interface IConsumerGroupAssignmentProvider
+{
+    /// <summary>
+    ///     The partitions the member owns, in assignment order, or an empty list while it owns none. The client asks
+    ///     the coordinator again when it holds no assignment, when the one it holds is older than its refresh
+    ///     interval, or when <paramref name="resync" /> is set.
+    /// </summary>
+    /// <param name="streamId">The identifier of the stream containing the topic (numeric ID or name).</param>
+    /// <param name="topicId">The identifier of the topic (numeric ID or name).</param>
+    /// <param name="groupId">The identifier of the consumer group (numeric ID or name).</param>
+    /// <param name="resync">Whether to ask the coordinator even for a fresh assignment, after a fence.</param>
+    /// <param name="token">The cancellation token to cancel the operation.</param>
+    Task<IReadOnlyList<uint>> GetAssignedPartitionsAsync(Identifier streamId, Identifier topicId, Identifier groupId,
+        bool resync, CancellationToken token = default);
+}
diff --git a/foreign/csharp/Iggy_SDK/Vsr/IPartitionFetchClient.cs b/foreign/csharp/Iggy_SDK/Vsr/IPartitionFetchClient.cs
new file mode 100644
index 0000000..378be29
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK/Vsr/IPartitionFetchClient.cs
@@ -0,0 +1,35 @@
+// 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.
+
+using Apache.Iggy.Contracts;
+using Apache.Iggy.Kinds;
+
+namespace Apache.Iggy.Vsr;
+
+/// <summary>
+///     A transport that reads several partitions of one primary in one request (code 109). A partition reader groups
+///     its partitions with it, and reads each partition with its own poll over a transport without it.
+/// </summary>
+internal interface IPartitionFetchClient
+{
+    Task<FetchedMessages> FetchMessagesAsync(Identifier streamId, Identifier topicId,
+        IReadOnlyList<(uint PartitionId, PollingStrategy Strategy)> targets, Consumer consumer, uint count,
+        bool autoCommit, DeferredPollOptions options, CancellationToken token = default);
+
+    Task<IReadOnlyList<IReadOnlyList<uint>>> ReadinessGroupsAsync(Identifier streamId, Identifier topicId,
+        IReadOnlyList<uint> partitions, CancellationToken token = default);
+}
diff --git a/foreign/csharp/Iggy_SDK/Vsr/LoginRegister.cs b/foreign/csharp/Iggy_SDK/Vsr/LoginRegister.cs
index ba2091b..448f4a2 100644
--- a/foreign/csharp/Iggy_SDK/Vsr/LoginRegister.cs
+++ b/foreign/csharp/Iggy_SDK/Vsr/LoginRegister.cs
@@ -35,7 +35,7 @@
     internal const int PROTOCOL_VERSION_MAJOR = 0;
 
     internal const int PROTOCOL_VERSION_MINOR = 12;
-    internal const int PROTOCOL_VERSION_PATCH = 0;
+    internal const int PROTOCOL_VERSION_PATCH = 1;
 
     /// <summary>Packed protocol version: <c>major &lt;&lt; 20 | minor &lt;&lt; 10 | patch</c>, 10 bits each.</summary>
     internal const uint PROTOCOL_VERSION =
diff --git a/foreign/csharp/Iggy_SDK/Vsr/MutationLanes.cs b/foreign/csharp/Iggy_SDK/Vsr/MutationLanes.cs
index 8ce188e..4d90641 100644
--- a/foreign/csharp/Iggy_SDK/Vsr/MutationLanes.cs
+++ b/foreign/csharp/Iggy_SDK/Vsr/MutationLanes.cs
@@ -18,11 +18,18 @@
 using System.Runtime.ExceptionServices;
 using Apache.Iggy.Configuration;
 using Apache.Iggy.Contracts;
+using Apache.Iggy.Contracts.Tcp;
 using Apache.Iggy.Exceptions;
+using Apache.Iggy.Utils;
 
 namespace Apache.Iggy.Vsr;
 
-internal readonly record struct MutationLane(ulong Incarnation, uint PartitionId)
+/// <summary>
+///     A partition has two lanes. Sends take its mutation lane, and polls that move a consumer take its progress lane,
+///     so a poll that waits on the server never delays a send. Offset stores and deletes take both, and a fetch that
+///     moves a consumer takes the progress lanes of all its partitions.
+/// </summary>
+internal readonly record struct MutationLane(ulong Incarnation, uint PartitionId, bool Progress = false)
 {
     internal static MutationLane Metadata => default;
 }
@@ -44,13 +51,14 @@
 internal sealed class MutationLanes(ConsensusSession session, IggyClientConfigurator configuration,
     Func<Task?>? signInRecovery = null)
 {
-    private sealed class Waiter(MutationLane lane, int size, bool retainable, bool restart)
+    private sealed class Waiter(MutationLane[] lanes, int size, bool retainable, bool restart, bool commits = false)
     {
-        internal readonly MutationLane Lane = lane;
+        internal readonly MutationLane[] Lanes = lanes;
+        internal readonly LinkedListNode<Waiter>?[] LaneNodes = new LinkedListNode<Waiter>?[lanes.Length];
         internal readonly int Size = size;
         internal readonly bool Retainable = retainable;
         internal readonly bool Restart = restart;
-        internal LinkedListNode<Waiter>? LaneNode;
+        internal readonly bool Commits = commits;
         internal LinkedListNode<Waiter>? BudgetNode;
         private TaskCompletionSource? _signal;
 
@@ -72,9 +80,13 @@
     private readonly LinkedList<Waiter> _budgetWaiters = new();
     private readonly Dictionary<MutationLane, ClientOperation> _lanes = [];
     private readonly Dictionary<ulong, ClientOperation> _operations = [];
+    private readonly Dictionary<ulong, FetchProgress> _fetchProgress = [];
+    private readonly Dictionary<MutationLane, FetchProgress> _blockedProgress = [];
     private readonly Queue<(ulong OperationId, IggyInvalidStatusCodeException? Rejection)> _proved = new();
     private int _admitted;
     private long _bytes;
+    private int _fetchAdmitted;
+    private long _fetchBytes;
 
     /// <summary>Rejects pending limits that admit no mutation, so the client factory can fail before construction.</summary>
     internal static IggyClientConfigurator ValidateLimits(IggyClientConfigurator configuration)
@@ -118,10 +130,22 @@
         }
     }
 
-    internal async Task<ClientOperation> EnterAsync(MutationLane lane, int code, ReadOnlyMemory<byte> body,
+    internal Task<ClientOperation> EnterAsync(MutationLane lane, int code, ReadOnlyMemory<byte> body,
         PartitionContext context, bool retainable, ulong generation, CancellationToken token)
     {
-        var waiter = new Waiter(lane, FrameSize(body, retainable), retainable, restart: false);
+        MutationLane[] lanes = ProgressLaneOf(lane, code) is { } progress ? [progress, lane] : [lane];
+        return EnterAsync(lanes, code, body, context, retainable, generation, token);
+    }
+
+    /// <summary>
+    ///     Enters every lane of <paramref name="lanes" /> in the given order. A waiter joins the queue of a lane only
+    ///     once it heads the free lanes before it, so callers that list shared lanes in one order never deadlock.
+    /// </summary>
+    internal async Task<ClientOperation> EnterAsync(MutationLane[] lanes, int code, ReadOnlyMemory<byte> body,
+        PartitionContext context, bool retainable, ulong generation, CancellationToken token)
+    {
+        var commits = TcpContracts.ReadAutoCommits(code, body.Span);
+        var waiter = new Waiter(lanes, FrameSize(body, retainable || commits), retainable, restart: false, commits);
         try
         {
             while (true)
@@ -131,16 +155,12 @@
                 SessionFrame? identity = null;
                 lock (_gate)
                 {
-                    waiter.LaneNode ??= JoinLane(waiter);
+                    waiter.LaneNodes[0] ??= JoinLane(lanes[0], waiter);
                     token.ThrowIfCancellationRequested();
                     if (AdmissionGeneration(generation, out recovery) is { } admission)
                     {
                         generation = admission;
-                        if (_lanes.TryGetValue(lane, out var active) && active.Retained)
-                        {
-                            throw new VsrLaneBlockedException(active.Identity.RequestId);
-                        }
-                        if (active is null && waiter.LaneNode.Previous is null)
+                        if (IsTurn(waiter, code))
                         {
                             identity = TryReserve(waiter, code);
                         }
@@ -154,8 +174,15 @@
                     {
                         if (Keep(waiter, operation, token))
                         {
-                            _lanes.Add(lane, operation);
+                            foreach (var held in lanes)
+                            {
+                                _lanes.Add(held, operation);
+                            }
                             _operations.Add(reserved.RequestId, operation);
+                            if (operation.FetchProgress is { } fetch)
+                            {
+                                _fetchProgress.Add(fetch.OperationId, fetch);
+                            }
                             Leave(waiter);
                             return operation;
                         }
@@ -192,8 +219,8 @@
     internal async Task<ClientOperation> RestartAsync(ClientOperation previous, ReadOnlyMemory<byte> body,
         CancellationToken token)
     {
-        var waiter = new Waiter(previous.Lane, FrameSize(body, previous.Retainable), previous.Retainable,
-            restart: true);
+        var waiter = new Waiter(previous.Lanes, FrameSize(body, previous.Retainable), previous.Retainable,
+            restart: true, previous.FetchProgress is not null);
         lock (_gate)
         {
             if (!Owns(previous))
@@ -244,7 +271,15 @@
                             }
                             _operations.Remove(previous.Identity.RequestId);
                             _operations.Add(reserved.RequestId, operation);
-                            _lanes[previous.Lane] = operation;
+                            if (operation.FetchProgress is { } fetch)
+                            {
+                                _fetchProgress.Remove(previous.Identity.RequestId);
+                                _fetchProgress.Add(fetch.OperationId, fetch);
+                            }
+                            foreach (var held in previous.Lanes)
+                            {
+                                _lanes[held] = operation;
+                            }
                             return operation;
                         }
                     }
@@ -276,6 +311,7 @@
     {
         lock (_gate)
         {
+            ThrowIfFetchRetained(operationId);
             if (!_operations.TryGetValue(operationId, out var operation) || !operation.Retained)
             {
                 return null;
@@ -338,7 +374,33 @@
                 && operation.Generation == session.Generation)
             {
                 operation.Retained = true;
-                WakeLane(operation.Lane);
+                WakeLanes(operation);
+            }
+        }
+    }
+
+    internal void RetainFetch(ClientOperation operation, IReadOnlySet<uint>? partitions = null)
+    {
+        lock (_gate)
+        {
+            if (operation.FetchProgress is not { } fetch || !_fetchProgress.ContainsKey(fetch.OperationId))
+            {
+                return;
+            }
+            fetch.Retained = true;
+            foreach (var lane in fetch.Lanes)
+            {
+                if (partitions is null || partitions.Contains(lane.PartitionId))
+                {
+                    _blockedProgress[lane] = fetch;
+                    if (_laneWaiters.TryGetValue(lane, out var waiters))
+                    {
+                        foreach (var waiter in waiters)
+                        {
+                            waiter.Wake();
+                        }
+                    }
+                }
             }
         }
     }
@@ -384,6 +446,11 @@
         ClientOperation operation;
         lock (_gate)
         {
+            if (_fetchProgress.TryGetValue(operationId, out var fetch) && fetch.Retained)
+            {
+                ReleaseFetch(fetch);
+                return;
+            }
             operation = Retained(operationId);
             operation.Retained = false;
             if (operation.Replaying)
@@ -422,15 +489,22 @@
                 return;
             }
             _operations.Remove(operation.Identity.RequestId);
-            _lanes.Remove(operation.Lane);
+            foreach (var held in operation.Lanes)
+            {
+                _lanes.Remove(held);
+            }
             ReleaseSlot(operation);
-            WakeLane(operation.Lane);
+            if (operation.FetchProgress is { Retained: false } fetch)
+            {
+                ReleaseFetch(fetch);
+            }
+            WakeLanes(operation);
         }
         operation.Lifetime.Cancel();
         operation.ReleaseRegistryFrame();
     }
 
-    internal void Reset() => CompleteWhere(_ => true);
+    internal void Reset() => CompleteWhere(operation => operation.FetchProgress is null);
 
     /// <summary>
     ///     Drops the retained operations of a replaced session, which can never replay. The live operations and the
@@ -488,17 +562,57 @@
         return (int)size;
     }
 
-    private LinkedListNode<Waiter> JoinLane(Waiter waiter)
+    // Offset stores and deletes move a consumer, so they keep their order with the polls that move it.
+    private static MutationLane? ProgressLaneOf(MutationLane lane, int code) =>
+        code is CommandCodes.STORE_CONSUMER_OFFSET_CODE or CommandCodes.DELETE_CONSUMER_OFFSET_CODE
+            ? lane with { Progress = true } : null;
+
+    private LinkedListNode<Waiter> JoinLane(MutationLane lane, Waiter waiter)
     {
-        if (!_laneWaiters.TryGetValue(waiter.Lane, out var queue))
+        if (!_laneWaiters.TryGetValue(lane, out var queue))
         {
             queue = new LinkedList<Waiter>();
-            _laneWaiters.Add(waiter.Lane, queue);
+            _laneWaiters.Add(lane, queue);
         }
         return queue.AddLast(waiter);
     }
 
     /// <summary>
+    ///     Whether the waiter heads the queue of each free lane it needs. It joins the queue of a lane only once it
+    ///     heads the free lanes before it. So an offset store or delete, which needs the progress lane first, never
+    ///     holds back the sends of the partition while it waits behind a poll, and the polls that queue after it wait
+    ///     for it.
+    /// </summary>
+    private bool IsTurn(Waiter waiter, int code)
+    {
+        for (var index = 0; index < waiter.Lanes.Length; index++)
+        {
+            waiter.LaneNodes[index] ??= JoinLane(waiter.Lanes[index], waiter);
+            if (!Heads(waiter.Lanes[index], waiter.LaneNodes[index]!, code))
+            {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    // A retained lane fails its waiters at once.
+    private bool Heads(MutationLane lane, LinkedListNode<Waiter> node, int code)
+    {
+        if (code != CommandCodes.GET_CONSUMER_OFFSET_CODE && _blockedProgress.TryGetValue(lane, out var fetch))
+        {
+            throw new VsrPartitionLaneBlockedException(fetch.OperationId, lane.PartitionId);
+        }
+        if (_lanes.TryGetValue(lane, out var active) && active.Retained)
+        {
+            throw code == CommandCodes.FETCH_MESSAGES_CODE
+                ? new VsrLaneBlockedException(active.Identity.RequestId, lane.PartitionId)
+                : new VsrLaneBlockedException(active.Identity.RequestId);
+        }
+        return active is null && node.Previous is null;
+    }
+
+    /// <summary>
     ///     Reserves the budget of a waiter whose lane is ready and takes its request id, or queues the waiter for the
     ///     budget. A waiter reserves only at the head of its queue, and an enter only while no restart waits.
     /// </summary>
@@ -509,6 +623,15 @@
     /// </remarks>
     private SessionFrame? TryReserve(Waiter waiter, int code)
     {
+        if (waiter.Commits && !waiter.Restart)
+        {
+            if (_fetchAdmitted >= _maxOperations || waiter.Size > _maxBytes - _fetchBytes)
+            {
+                throw VsrError.Exception(VsrError.TRANSIENT_NOT_ACCEPTED, "Committing fetch recovery capacity is full.");
+            }
+            _fetchAdmitted++;
+            _fetchBytes += waiter.Size;
+        }
         if (waiter.Retainable)
         {
             var queue = waiter.Restart ? _restartWaiters : _budgetWaiters;
@@ -552,7 +675,8 @@
             VsrHeader.EncodeRequestHeader(bytes, session, code, body.Span, context, identity);
             body.CopyTo(bytes.AsMemory(VsrHeader.HEADER_SIZE));
             var frame = new SharedRequestFrame(bytes, waiter.Retainable ? () => ReleaseBudget(size) : null);
-            return new ClientOperation(waiter.Lane, code, identity, context, generation, frame, waiter.Retainable);
+            return new ClientOperation(waiter.Lanes, code, identity, context, generation, frame, waiter.Retainable,
+                waiter.Commits ? new FetchProgress(identity.RequestId, waiter.Lanes, size) : null);
         }
         catch
         {
@@ -581,6 +705,11 @@
 
     private void Unreserve(Waiter waiter)
     {
+        if (waiter.Commits && !waiter.Restart)
+        {
+            _fetchAdmitted--;
+            _fetchBytes -= waiter.Size;
+        }
         if (!waiter.Retainable)
         {
             return;
@@ -605,20 +734,36 @@
                 WakeBudget();
             }
         }
-        if (waiter.LaneNode is { } node)
+        for (var index = 0; index < waiter.Lanes.Length; index++)
         {
-            var queue = _laneWaiters[waiter.Lane];
-            var first = node.Previous is null;
-            queue.Remove(node);
-            waiter.LaneNode = null;
-            if (queue.Count == 0)
+            if (waiter.LaneNodes[index] is { } node)
             {
-                _laneWaiters.Remove(waiter.Lane);
+                LeaveLane(waiter.Lanes[index], node);
+                waiter.LaneNodes[index] = null;
             }
-            else if (first)
-            {
-                WakeLane(waiter.Lane);
-            }
+        }
+    }
+
+    private void LeaveLane(MutationLane lane, LinkedListNode<Waiter> node)
+    {
+        var queue = _laneWaiters[lane];
+        var first = node.Previous is null;
+        queue.Remove(node);
+        if (queue.Count == 0)
+        {
+            _laneWaiters.Remove(lane);
+        }
+        else if (first)
+        {
+            WakeLane(lane);
+        }
+    }
+
+    private void WakeLanes(ClientOperation operation)
+    {
+        foreach (var held in operation.Lanes)
+        {
+            WakeLane(held);
         }
     }
 
@@ -679,20 +824,62 @@
         }
     }
 
+    private void ThrowIfFetchRetained(ulong operationId)
+    {
+        if (_fetchProgress.TryGetValue(operationId, out var fetch) && fetch.Retained)
+        {
+            throw new VsrFetchProgressUncertainException(operationId);
+        }
+    }
+
+    private void ReleaseFetch(FetchProgress fetch)
+    {
+        if (!_fetchProgress.Remove(fetch.OperationId))
+        {
+            return;
+        }
+        _fetchAdmitted--;
+        _fetchBytes -= fetch.Size;
+        foreach (var lane in fetch.Lanes)
+        {
+            if (_blockedProgress.TryGetValue(lane, out var blocked) && ReferenceEquals(blocked, fetch))
+            {
+                _blockedProgress.Remove(lane);
+            }
+        }
+    }
+
     private static TaskCompletionSource NewSignal() => new(TaskCreationOptions.RunContinuationsAsynchronously);
 }
 
-internal sealed class ClientOperation(MutationLane lane, int code, SessionFrame identity, PartitionContext context,
-    ulong generation, SharedRequestFrame frame, bool retainable)
+internal sealed class FetchProgress(ulong operationId, MutationLane[] lanes, int size)
+{
+    internal readonly ulong OperationId = operationId;
+    internal readonly MutationLane[] Lanes = lanes;
+    internal readonly int Size = size;
+    internal bool Retained;
+}
+
+internal sealed class ClientOperation(MutationLane[] lanes, int code, SessionFrame identity,
+    PartitionContext context, ulong generation, SharedRequestFrame frame, bool retainable, FetchProgress? fetchProgress = null)
 {
     private int _registryReference = 1;
-    internal readonly MutationLane Lane = lane;
+
+    /// <summary>
+    ///     The lanes the operation holds, in the order it entered them. An offset store or delete holds the progress
+    ///     lane of its partition before its own lane.
+    /// </summary>
+    internal readonly MutationLane[] Lanes = lanes;
+
+    internal MutationLane Lane => Lanes[^1];
+
     internal readonly int Code = code;
     internal readonly SessionFrame Identity = identity;
     internal readonly PartitionContext Context = context;
     internal readonly ulong Generation = generation;
     internal readonly SharedRequestFrame Frame = frame;
     internal readonly bool Retainable = retainable;
+    internal readonly FetchProgress? FetchProgress = fetchProgress;
     internal readonly SemaphoreSlim Resolution = new(1, 1);
     internal readonly CancellationTokenSource Lifetime = new();
     internal bool Retained;
diff --git a/foreign/csharp/Iggy_SDK/Vsr/VsrConnection.cs b/foreign/csharp/Iggy_SDK/Vsr/VsrConnection.cs
index e559bfa..40d185a 100644
--- a/foreign/csharp/Iggy_SDK/Vsr/VsrConnection.cs
+++ b/foreign/csharp/Iggy_SDK/Vsr/VsrConnection.cs
@@ -27,8 +27,10 @@
 namespace Apache.Iggy.Vsr;
 
 /// <summary>
-///     A bounded multiplexed connection. The writer owns frame boundaries; the reader correlates replies
-///     by request id. Canceling one exchange never interrupts either task or another request.
+///     A bounded multiplexed connection. The writer owns frame boundaries. The reader correlates replies
+///     by request id. Canceling one exchange never interrupts either task or another request. A canceled deferred
+///     request that already went out is also canceled on the server, with a header-only frame that the server
+///     never answers.
 /// </summary>
 internal sealed class VsrConnection : IDisposable
 {
@@ -49,13 +51,20 @@
         internal readonly UInt128 RequestChecksum = BinaryPrimitives.ReadUInt128LittleEndian(frame.AsSpan(VsrHeader.REQUEST_CHECKSUM_OFFSET));
         internal readonly ulong Incarnation = BinaryPrimitives.ReadUInt64LittleEndian(frame.AsSpan(VsrHeader.REQUEST_INCARNATION_OFFSET));
         internal bool Started;
+        internal bool CancelSent;
     }
 
     private readonly object _gate = new();
     private readonly Dictionary<ulong, PendingRequest> _pending = [];
     private readonly SemaphoreSlim _pendingCapacity = new(MAX_PENDING_REQUESTS, MAX_PENDING_REQUESTS);
-    private readonly Channel<PendingRequest> _writes = Channel.CreateBounded<PendingRequest>(
-        new BoundedChannelOptions(WRITE_QUEUE_CAPACITY) { SingleReader = true, FullMode = BoundedChannelFullMode.Wait });
+
+    /// <summary>
+    ///     Bounds the queued request frames instead of the channel: a cancel is queued from a cancellation callback,
+    ///     which cannot wait for room.
+    /// </summary>
+    private readonly SemaphoreSlim _writeSlots = new(WRITE_QUEUE_CAPACITY, WRITE_QUEUE_CAPACITY);
+    private readonly Channel<PendingRequest> _writes = Channel.CreateUnbounded<PendingRequest>(
+        new UnboundedChannelOptions { SingleReader = true });
     private readonly CancellationTokenSource _lifetime = new();
     private readonly Stream _stream;
     private readonly ConsensusSession _session;
@@ -97,10 +106,15 @@
         Reason: EvictionReason.NoSession or EvictionReason.StaleClient
     };
 
+    /// <summary>
+    ///     Sends a retained frame. <paramref name="serverCancellation" /> ends the request while it has not gone out.
+    ///     After that it only asks the server to cancel it and the request keeps its reply slot, because a read that
+    ///     commits answers once either way: refused when the cancel won, or with what it stored.
+    /// </summary>
     internal Task<VsrAttempt> SendFrameAsync(ClientOperation operation, long deadline, bool clearSensitiveReply,
-        CancellationToken token) =>
+        CancellationToken token, CancellationToken serverCancellation = default) =>
         ExchangeAsync(operation.Code, operation.Body, operation.Identity, operation.Context, deadline, clearSensitiveReply, token,
-            operation.Frame);
+            operation.Frame, serverCancellation);
 
     internal async ValueTask<VsrAttempt> SendAttemptAsync(int code, ReadOnlyMemory<byte> body,
         long transientDeadline, long readDeadline, bool clearSensitiveReply, CancellationToken token,
@@ -158,15 +172,19 @@
 
     private async Task<VsrAttempt> ExchangeAsync(int code, ReadOnlyMemory<byte> body, SessionFrame frame,
         PartitionContext context, long deadline, bool clearSensitiveReply, CancellationToken token,
-        SharedRequestFrame? owner = null)
+        SharedRequestFrame? owner = null, CancellationToken serverCancellation = default)
     {
         using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(token, _lifetime.Token);
         cancellation.CancelAfter((int)Math.Clamp(deadline - Environment.TickCount64, 0, int.MaxValue));
+        using var unsent = serverCancellation.CanBeCanceled
+            ? CancellationTokenSource.CreateLinkedTokenSource(cancellation.Token, serverCancellation)
+            : null;
+        var sendToken = unsent?.Token ?? cancellation.Token;
         var admitted = false;
         PendingRequest? request = null;
         try
         {
-            await _pendingCapacity.WaitAsync(cancellation.Token);
+            await _pendingCapacity.WaitAsync(sendToken);
             admitted = true;
             var encoded = owner?.Bytes ?? new byte[checked(VsrHeader.HEADER_SIZE + body.Length)];
             if (owner is null)
@@ -194,34 +212,40 @@
                 }
             }
             using var registration = cancellation.Token.Register(() => Cancel(request, token));
+            using var serverRegistration = serverCancellation.Register(() => CancelOnServer(request, serverCancellation));
             try
             {
-                while (await _writes.Writer.WaitToWriteAsync(cancellation.Token))
+                await _writeSlots.WaitAsync(sendToken);
+                lock (_gate)
                 {
-                    lock (_gate)
+                    // Fail completes every pending request under this lock before it completes the channel, so an
+                    // open request always finds the channel open.
+                    if (request.Completion.Task.IsCompleted)
                     {
-                        if (request.Completion.Task.IsCompleted)
-                        {
-                            break;
-                        }
+                        _writeSlots.Release();
+                    }
+                    else
+                    {
                         owner?.Acquire();
                         request.Started = true;
-                        if (_writes.Writer.TryWrite(request))
+                        _writes.Writer.TryWrite(request);
+                        if (_replySilenceDeadline == 0)
                         {
-                            if (_replySilenceDeadline == 0)
-                            {
-                                _replySilenceDeadline = Environment.TickCount64 + _requestTimeoutMs;
-                            }
-                            break;
+                            _replySilenceDeadline = Environment.TickCount64 + _requestTimeoutMs;
                         }
-                        owner?.Release();
-                        request.Started = false;
                     }
                 }
             }
             catch (OperationCanceledException)
             {
-                Cancel(request, token);
+                if (serverCancellation.IsCancellationRequested)
+                {
+                    CancelOnServer(request, serverCancellation);
+                }
+                else
+                {
+                    Cancel(request, token);
+                }
             }
             return await request.Completion.Task;
         }
@@ -237,7 +261,8 @@
                     }
                 }
             }
-            if (error is OperationCanceledException && !token.IsCancellationRequested)
+            if (error is OperationCanceledException && !token.IsCancellationRequested
+                && !serverCancellation.IsCancellationRequested)
             {
                 error = _failure ?? new IOException($"Timed out after {_requestTimeoutMs} ms waiting for a consensus reply.");
             }
@@ -262,10 +287,61 @@
             }
             var error = token.IsCancellationRequested ? new OperationCanceledException(token)
                 : _failure ?? new IOException($"Timed out after {_requestTimeoutMs} ms waiting for a consensus reply.");
-            request.Completion.TrySetResult(VsrAttempt.Failed(true, error, request.Started));
+            if (request.Completion.TrySetResult(VsrAttempt.Failed(true, error, request.Started)) && request.Started
+                && IsDeferred(request.Code) && !request.CancelSent)
+            {
+                // A parked request would hold its server slot until its wait ends. A frame the writer skips gets
+                // the cancel too, and the server ignores a cancel for a request it does not hold.
+                _writes.Writer.TryWrite(DeferredCancellation(request));
+                // The server answers neither the cancel nor the canceled request, so the silence watchdog must not
+                // wait for a reply that never comes. An expired deadline keeps it: a missing reply is the symptom.
+                if (token.IsCancellationRequested && _pending.Count == 0)
+                {
+                    _replySilenceDeadline = 0;
+                    _waitingForActiveDeadlines = false;
+                }
+            }
         }
     }
 
+    private void CancelOnServer(PendingRequest request, CancellationToken serverCancellation)
+    {
+        lock (_gate)
+        {
+            if (request.Completion.Task.IsCompleted || request.CancelSent)
+            {
+                return;
+            }
+            if (!request.Started)
+            {
+                if (_pending.TryGetValue(request.RequestId, out var active) && ReferenceEquals(active, request))
+                {
+                    _pending.Remove(request.RequestId);
+                }
+                request.Completion.TrySetResult(VsrAttempt.Failed(true,
+                    new OperationCanceledException(serverCancellation), false));
+                return;
+            }
+            if (IsDeferred(request.Code))
+            {
+                request.CancelSent = true;
+                _writes.Writer.TryWrite(DeferredCancellation(request));
+            }
+        }
+    }
+
+    private static bool IsDeferred(int code) =>
+        code is CommandCodes.POLL_MESSAGES_DEFERRED_CODE or CommandCodes.FETCH_MESSAGES_CODE;
+
+    private PendingRequest DeferredCancellation(PendingRequest request)
+    {
+        var frame = new byte[VsrHeader.HEADER_SIZE];
+        var session = BinaryPrimitives.ReadUInt64LittleEndian(request.Frame.AsSpan(VsrHeader.REQUEST_SESSION_OFFSET));
+        VsrHeader.EncodeRequestHeader(frame, _session, CommandCodes.CANCEL_DEFERRED_REQUEST_CODE,
+            ReadOnlySpan<byte>.Empty, retainedFrame: new SessionFrame(request.ClientId, request.RequestId, session));
+        return new PendingRequest(frame, CommandCodes.CANCEL_DEFERRED_REQUEST_CODE, request.Deadline, false, null);
+    }
+
     private async Task WriteFramesAsync()
     {
         try
@@ -274,6 +350,11 @@
             {
                 try
                 {
+                    // A cancel took no slot. Its completion never ends, so it is always written.
+                    if (request.Code != CommandCodes.CANCEL_DEFERRED_REQUEST_CODE)
+                    {
+                        _writeSlots.Release();
+                    }
                     if (!request.Completion.Task.IsCompleted)
                     {
                         using var writeCancellation = CancellationTokenSource.CreateLinkedTokenSource(_lifetime.Token);
@@ -510,7 +591,7 @@
     }
 }
 
-/// <summary>A correlated attempt outcome; Committed is independent of a successful weak acknowledgement.</summary>
+/// <summary>A correlated attempt outcome. Committed is independent of a successful weak acknowledgement.</summary>
 internal readonly record struct VsrAttempt(
     bool Encoded,
     IMemoryOwner<byte>? Response,
@@ -525,7 +606,7 @@
         new(encoded, null, error, requestStarted, error is IggyInvalidStatusCodeException { Committed: true });
 }
 
-/// <summary>Shared empty reply payload; disposes to nothing, so it is safe to hand out repeatedly.</summary>
+/// <summary>Shared empty reply payload. Disposes to nothing, so it is safe to hand out repeatedly.</summary>
 internal sealed class EmptyMemoryOwner : IMemoryOwner<byte>
 {
     public static readonly EmptyMemoryOwner Instance = new();
diff --git a/foreign/csharp/Iggy_SDK/Vsr/VsrError.cs b/foreign/csharp/Iggy_SDK/Vsr/VsrError.cs
index b2c88eb..a14ab2d 100644
--- a/foreign/csharp/Iggy_SDK/Vsr/VsrError.cs
+++ b/foreign/csharp/Iggy_SDK/Vsr/VsrError.cs
@@ -21,11 +21,12 @@
 
 /// <summary>
 ///     Server error codes the VSR paths raise locally or surface from the wire. Values match
-///     <c>core/common/src/error/iggy_error.rs</c>; the server shares this code space across the reply
+///     <c>core/common/src/error/iggy_error.rs</c>. The server shares this code space across the reply
 ///     status word, the committed result section and the eviction mapping.
 /// </summary>
 internal static class VsrError
 {
+    internal const int INVALID_CONFIGURATION = 2;
     internal const int INVALID_COMMAND = 3;
     internal const int INVALID_FORMAT = 4;
     internal const int FEATURE_UNAVAILABLE = 5;
@@ -42,12 +43,14 @@
     internal const int PERSONAL_ACCESS_TOKEN_EXPIRED = 54;
     internal const int TRANSIENT_NOT_COMMITTED = 57;
     internal const int TRANSIENT_NOT_ACCEPTED = 58;
+    internal const int REQUEST_ALREADY_APPLIED = 59;
     internal const int INVALID_ENCRYPTION_KEY = 70;
     internal const int INVALID_SIZE_BYTES = 80;
     internal const int REQUEST_TOO_OLD = 85;
     internal const int HISTORY_UNAVAILABLE = 87;
     internal const int LIFECYCLE_BUSY = 88;
     internal const int NOT_PRIMARY = 89;
+    internal const int DEFERRED_POLL_CAPACITY_EXCEEDED = 90;
     internal const int EMPTY_RESPONSE = 304;
     internal const int STREAM_ID_NOT_FOUND = 1009;
     internal const int STREAM_NAME_NOT_FOUND = 1010;
@@ -57,9 +60,12 @@
     internal const int INVALID_TOPIC_NAME = 2014;
     internal const int CONSUMER_GROUP_MEMBER_NOT_FOUND = 5006;
     internal const int PARTITION_NOT_FOUND = 3007;
+    internal const int TOO_MANY_CONSUMER_OFFSETS = 3024;
     internal const int CONSUMER_GROUP_PARTITION_NOT_OWNED = 5009;
     internal const int TOO_MANY_CONSUMER_GROUPS = 5010;
+    internal const int INVALID_MESSAGES_COUNT = 4009;
     internal const int UNSUPPORTED_OPTION_KEY = 4041;
+    internal const int INVALID_OFFSET = 4100;
     internal const int OPTION_NOT_UPDATABLE = 4103;
     internal const int OPTION_NOT_RESETTABLE = 4104;
     internal const int READ_ONLY_OPTION = 4105;
@@ -67,6 +73,9 @@
     internal const int INCOMPATIBLE_PROTOCOL_VERSION = 14003;
     internal const int SESSION_MISMATCH = 14004;
     internal const int REQUEST_ID_EXHAUSTED = 14005;
+    internal const int LANE_BLOCKED = 14007;
+    internal const int PARTITION_LANE_BLOCKED = 14009;
+    internal const int FETCH_PROGRESS_UNCERTAIN = 14010;
 
     /// <summary>A failure the client raised itself, before or instead of a server verdict.</summary>
     internal static IggyInvalidStatusCodeException Exception(int code, string message, Exception? cause = null)
diff --git a/foreign/csharp/Iggy_SDK/Vsr/VsrOperation.cs b/foreign/csharp/Iggy_SDK/Vsr/VsrOperation.cs
index 7431743..60d2fbc 100644
--- a/foreign/csharp/Iggy_SDK/Vsr/VsrOperation.cs
+++ b/foreign/csharp/Iggy_SDK/Vsr/VsrOperation.cs
@@ -15,6 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
+using Apache.Iggy.Contracts.Tcp;
 using Apache.Iggy.Utils;
 
 namespace Apache.Iggy.Vsr;
@@ -163,10 +164,17 @@
         }
 
         // A poll that auto-commits moves the consumer offset server-side, so a reply lost after the commit
-        // would make the replay start past a batch the caller never saw. auto_commit is the last body byte.
-        if (code is CommandCodes.POLL_MESSAGES_CODE)
+        // would make the replay start past a batch the caller never saw. auto_commit is the last byte of a
+        // PollMessages body, which a deferred poll extends.
+        if (code is CommandCodes.POLL_MESSAGES_CODE or CommandCodes.POLL_MESSAGES_DEFERRED_CODE)
         {
-            return body.Length > 0 && body[^1] == 0;
+            var tail = code == CommandCodes.POLL_MESSAGES_DEFERRED_CODE ? TcpContracts.DEFERRED_POLL_TAIL_SIZE : 0;
+            return body.Length > tail && body[^(tail + 1)] == 0;
+        }
+
+        if (code == CommandCodes.FETCH_MESSAGES_CODE)
+        {
+            return !TcpContracts.FetchAutoCommits(body);
         }
 
         // Everything else non-replicated is replay-safe only if this build knows it to be a read. An unmapped
diff --git a/foreign/csharp/Iggy_SDK_Tests/ClientTests/HttpCommittingPollTests.cs b/foreign/csharp/Iggy_SDK_Tests/ClientTests/HttpCommittingPollTests.cs
new file mode 100644
index 0000000..8a8b117
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK_Tests/ClientTests/HttpCommittingPollTests.cs
@@ -0,0 +1,234 @@
+// 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.
+
+using System.Net;
+using System.Text;
+using Apache.Iggy.Contracts;
+using Apache.Iggy.Exceptions;
+using Apache.Iggy.IggyClient;
+using Apache.Iggy.IggyClient.Implementations;
+using Apache.Iggy.Kinds;
+
+namespace Apache.Iggy.Tests.ClientTests;
+
+/// <summary>
+///     The server stores the offset of a poll with auto commit before it answers, so a 504 can hide a batch it
+///     already moved past. Such a poll is never sent again and its outcome is reported as unknown. A poll without
+///     auto commit moves nothing and is sent again.
+/// </summary>
+public sealed class HttpCommittingPollTests
+{
+    private const string PollPath = "/streams/1/topics/2/messages";
+
+    private const string TimedOut = """{"id":57,"code":"transient_not_committed","reason":"timed out"}""";
+
+    /// <summary>A reply with one message at offset 42.</summary>
+    private const string Batch = """
+        {
+          "partition_id": 1,
+          "current_offset": 42,
+          "messages": [
+            {
+              "header": {
+                "checksum": 0, "id": 1, "offset": 42, "timestamp": 0, "origin_timestamp": 0,
+                "user_headers_length": 0, "payload_length": 5, "reserved": 0
+              },
+              "payload": "aGVsbG8="
+            }
+          ]
+        }
+        """;
+
+    private static readonly Identifier Stream = Identifier.Numeric(1);
+    private static readonly Identifier Topic = Identifier.Numeric(2);
+
+    [Fact]
+    public async Task given_a_committing_poll_when_the_server_times_out_should_not_send_it_again_and_report_an_unknown_outcome()
+    {
+        var server = new ScriptedHandler((HttpStatusCode.GatewayTimeout, TimedOut), (HttpStatusCode.OK, Batch));
+
+        var error = await Assert.ThrowsAsync<VsrFetchProgressUncertainException>(() =>
+            PollAsync(Client(server), true));
+
+        Assert.Equal(57, Assert.IsType<IggyInvalidStatusCodeException>(error.InnerException).StatusCode);
+        Assert.NotNull(error.OperationId);
+        Assert.Equal(1, server.Requests);
+    }
+
+    /// <summary>
+    ///     A committing poll whose outcome is unknown may still have stored its offset. Its partition takes no
+    ///     committing poll and no offset store of the same topic until the caller abandons it, while the same partition
+    ///     id of another topic goes on.
+    /// </summary>
+    [Fact]
+    public async Task given_a_committing_poll_with_an_unknown_outcome_when_using_its_partition_should_block_it_until_abandoned()
+    {
+        var server = new RoutedHandler(TimedOut);
+        var client = Client(server);
+        IIggyClient recovery = client;
+        var token = TestContext.Current.CancellationToken;
+
+        var uncertain = await Assert.ThrowsAsync<VsrFetchProgressUncertainException>(() => PollAsync(client, true));
+        var poll = await Assert.ThrowsAsync<VsrPartitionLaneBlockedException>(() => PollAsync(client, true));
+        var store = await Assert.ThrowsAsync<VsrPartitionLaneBlockedException>(() =>
+            client.StoreOffsetAsync(Consumer.New(1), Stream, Topic, 41, 1, token));
+        var otherTopic = await client.PollMessagesAsync(Stream, Identifier.Numeric(3), 1, Consumer.New(1),
+            PollingStrategy.Next(), 10, true, token);
+        await recovery.AbandonOperationAsync(uncertain.OperationId!.Value);
+        var polled = await PollAsync(client, true);
+        await client.StoreOffsetAsync(Consumer.New(1), Stream, Topic, 42, 1, token);
+
+        Assert.Equal((uncertain.OperationId.Value, 1u), (poll.OperationId, poll.PartitionId));
+        Assert.Equal((uncertain.OperationId.Value, 1u), (store.OperationId, store.PartitionId));
+        Assert.Single(otherTopic.Messages);
+        Assert.Single(polled.Messages);
+        Assert.Equal(3, server.Polls);
+        Assert.Equal(1, server.Stores);
+        await Assert.ThrowsAsync<VsrOperationNotRetainedException>(() =>
+            recovery.AbandonOperationAsync(uncertain.OperationId.Value));
+    }
+
+    [Fact]
+    public async Task given_a_poll_without_auto_commit_when_the_server_times_out_should_send_it_again()
+    {
+        var server = new ScriptedHandler((HttpStatusCode.GatewayTimeout, TimedOut), (HttpStatusCode.OK, Batch));
+
+        var polled = await PollAsync(Client(server), false);
+
+        Assert.Equal(42ul, Assert.Single(polled.Messages).Header.Offset);
+        Assert.Equal(2, server.Requests);
+    }
+
+    private static HttpMessageStream Client(HttpMessageHandler server)
+    {
+        return new HttpMessageStream(new HttpClient(new TransientHttpRetryHandler(server))
+        {
+            BaseAddress = new Uri("http://localhost")
+        });
+    }
+
+    private static Task<PolledMessages> PollAsync(HttpMessageStream client, bool autoCommit)
+    {
+        return client.PollMessagesAsync(Stream, Topic, 1, Consumer.New(1), PollingStrategy.Next(), 10, autoCommit,
+            TestContext.Current.CancellationToken);
+    }
+
+    /// <summary>
+    ///     Two committing polls of one partition run at once. The second waits for the first, whose outcome turns out
+    ///     unknown, and then fails as blocked by it without going out.
+    /// </summary>
+    [Fact]
+    public async Task given_a_committing_poll_in_flight_when_another_committing_poll_of_its_partition_starts_should_wait_and_see_its_block()
+    {
+        var server = new GatedHandler(TimedOut);
+        var client = Client(server);
+        var token = TestContext.Current.CancellationToken;
+        var first = PollAsync(client, true);
+        await server.Held.Task.WaitAsync(TimeSpan.FromSeconds(5), token);
+
+        var second = PollAsync(client, true);
+        // The second poll would reach the server now.
+        await Task.Delay(TimeSpan.FromMilliseconds(200), token);
+        var sentWhileFirstRan = server.Polls;
+        server.Release.SetResult();
+        var uncertain = await Assert.ThrowsAsync<VsrFetchProgressUncertainException>(() => first);
+        var blocked = await Assert.ThrowsAsync<VsrPartitionLaneBlockedException>(() => second);
+
+        Assert.Equal(1, sentWhileFirstRan);
+        Assert.Equal((uncertain.OperationId!.Value, 1u), (blocked.OperationId, blocked.PartitionId));
+        Assert.Equal(1, server.Polls);
+    }
+
+    /// <summary>Holds the first poll until <see cref="Release" />, then answers it with a gateway timeout.</summary>
+    private sealed class GatedHandler(string timedOut) : HttpMessageHandler
+    {
+        private int _polls;
+
+        internal TaskCompletionSource Held { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+        internal TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+        internal int Polls => Volatile.Read(ref _polls);
+
+        protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
+        {
+            var first = Interlocked.Increment(ref _polls) == 1;
+            if (first)
+            {
+                Held.SetResult();
+                await Release.Task.WaitAsync(ct);
+            }
+
+            var (status, body) = first ? (HttpStatusCode.GatewayTimeout, timedOut) : (HttpStatusCode.OK, Batch);
+            return new HttpResponseMessage(status)
+            {
+                RequestMessage = request,
+                Content = new StringContent(body, Encoding.UTF8, "application/json")
+            };
+        }
+    }
+
+    /// <summary>
+    ///     Answers the first poll of topic 2 with a gateway timeout and every other poll with the batch, and stores
+    ///     each offset.
+    /// </summary>
+    private sealed class RoutedHandler(string timedOut) : HttpMessageHandler
+    {
+        private int _polls;
+        private int _stores;
+
+        internal int Polls => Volatile.Read(ref _polls);
+
+        internal int Stores => Volatile.Read(ref _stores);
+
+        protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
+        {
+            if (request.RequestUri!.AbsolutePath.EndsWith("/consumer-offsets"))
+            {
+                Interlocked.Increment(ref _stores);
+                return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NoContent) { RequestMessage = request });
+            }
+
+            var first = Interlocked.Increment(ref _polls) == 1;
+            var (status, body) = first ? (HttpStatusCode.GatewayTimeout, timedOut) : (HttpStatusCode.OK, Batch);
+            return Task.FromResult(new HttpResponseMessage(status)
+            {
+                RequestMessage = request,
+                Content = new StringContent(body, Encoding.UTF8, "application/json")
+            });
+        }
+    }
+
+    /// <summary>Answers each poll with the next scripted status and body.</summary>
+    private sealed class ScriptedHandler(params (HttpStatusCode Status, string Body)[] replies) : HttpMessageHandler
+    {
+        private int _requests;
+
+        internal int Requests => Volatile.Read(ref _requests);
+
+        protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
+        {
+            Assert.StartsWith(PollPath, request.RequestUri!.AbsolutePath);
+            var (status, body) = replies[Interlocked.Increment(ref _requests) - 1];
+            return Task.FromResult(new HttpResponseMessage(status)
+            {
+                RequestMessage = request,
+                Content = new StringContent(body, Encoding.UTF8, "application/json")
+            });
+        }
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK_Tests/ClientTests/HttpDeferredPollTests.cs b/foreign/csharp/Iggy_SDK_Tests/ClientTests/HttpDeferredPollTests.cs
new file mode 100644
index 0000000..bce4d1d
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK_Tests/ClientTests/HttpDeferredPollTests.cs
@@ -0,0 +1,241 @@
+// 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.
+
+using System.Net;
+using System.Net.Http.Headers;
+using System.Text;
+using Apache.Iggy.Contracts;
+using Apache.Iggy.Enums;
+using Apache.Iggy.Exceptions;
+using Apache.Iggy.IggyClient.Implementations;
+using Apache.Iggy.Kinds;
+
+namespace Apache.Iggy.Tests.ClientTests;
+
+/// <summary>
+///     A deferred poll over HTTP that the caller cancels while the server holds it, as the TCP client reports it: a
+///     poll that can move an offset has an unknown outcome, because the server can move the offset before it sees
+///     that the client gave up. Any other canceled poll ends as canceled. A cancel after the reply arrived does not
+///     hide the batch.
+/// </summary>
+public sealed class HttpDeferredPollTests
+{
+    private const string DeferredPollPath = "/streams/1/topics/2/messages/deferred";
+
+    /// <summary>A reply with one message at offset 42.</summary>
+    private const string Batch = """
+        {
+          "partition_id": 1,
+          "current_offset": 42,
+          "messages": [
+            {
+              "header": {
+                "checksum": 0, "id": 1, "offset": 42, "timestamp": 0, "origin_timestamp": 0,
+                "user_headers_length": 0, "payload_length": 5, "reserved": 0
+              },
+              "payload": "aGVsbG8="
+            }
+          ]
+        }
+        """;
+
+    private static readonly Identifier Stream = Identifier.Numeric(1);
+    private static readonly Identifier Topic = Identifier.Numeric(2);
+
+    /// <summary>Longer than any test, so a poll the handler holds stays held until the test cancels it.</summary>
+    private static readonly DeferredPollOptions LongWait = new()
+    {
+        MaxWait = TimeSpan.FromSeconds(30),
+        RequestTimeout = TimeSpan.FromSeconds(31)
+    };
+
+    /// <summary>How long a negative check waits for a request that must not come.</summary>
+    private static readonly TimeSpan SettleDelay = TimeSpan.FromMilliseconds(200);
+
+    [Theory]
+    [InlineData(MessagePolling.Next, false)]
+    [InlineData(MessagePolling.Offset, true)]
+    public async Task given_a_held_progress_poll_when_the_caller_cancels_should_report_an_unknown_outcome(
+        MessagePolling kind, bool autoCommit)
+    {
+        var handler = new HoldingHandler();
+        var client = Client(handler);
+        using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+        var poll = PollAsync(client, kind, autoCommit, cancellation.Token);
+        await handler.Held.Task.WaitAsync(TestContext.Current.CancellationToken);
+
+        await cancellation.CancelAsync();
+
+        var error = await Assert.ThrowsAnyAsync<VsrRequestOutcomeUnknownException>(() => poll);
+        Assert.IsAssignableFrom<OperationCanceledException>(error.InnerException);
+        // A poll that commits may have stored its offset, so it names the operation that blocks its partition.
+        Assert.Equal(autoCommit, error is VsrFetchProgressUncertainException { OperationId: not null });
+    }
+
+    [Fact]
+    public async Task given_a_held_poll_that_moves_no_offset_when_the_caller_cancels_should_end_as_canceled()
+    {
+        var handler = new HoldingHandler();
+        var client = Client(handler);
+        using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+        var poll = PollAsync(client, MessagePolling.Offset, false, cancellation.Token);
+        await handler.Held.Task.WaitAsync(TestContext.Current.CancellationToken);
+
+        await cancellation.CancelAsync();
+
+        await Assert.ThrowsAnyAsync<OperationCanceledException>(() => poll);
+    }
+
+    /// <summary>
+    ///     A poll that waits for a free parked slot was never sent, so even one that can move an offset ends as
+    ///     canceled.
+    /// </summary>
+    [Fact]
+    public async Task given_a_progress_poll_waiting_for_a_parked_slot_when_the_caller_cancels_should_end_as_canceled_unsent()
+    {
+        const int Limit = DeferredPollOptions.MAX_PARKED_REQUESTS;
+        var handler = new HoldingHandler();
+        var client = Client(handler);
+        using var held = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+        using var waiting = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+        var polls = Enumerable.Range(0, Limit)
+            .Select(_ => PollAsync(client, MessagePolling.Offset, false, held.Token)).ToArray();
+        await UntilAsync(() => handler.Polls == Limit);
+        var queued = PollAsync(client, MessagePolling.Next, false, waiting.Token);
+        await Task.Delay(SettleDelay, TestContext.Current.CancellationToken);
+
+        await waiting.CancelAsync();
+
+        await Assert.ThrowsAnyAsync<OperationCanceledException>(() => queued);
+        Assert.Equal(Limit, handler.Polls);
+        await held.CancelAsync();
+        foreach (var poll in polls)
+        {
+            await Assert.ThrowsAnyAsync<OperationCanceledException>(() => poll);
+        }
+    }
+
+    /// <summary>
+    ///     The caller cancels after the reply of a progress poll arrived, before the client decodes it. The server
+    ///     already moved the offset, so the client returns the batch or reports an unknown outcome, never a plain
+    ///     cancel that hides the batch.
+    /// </summary>
+    [Fact]
+    public async Task given_the_reply_of_a_progress_poll_when_the_caller_cancels_before_the_decode_should_not_hide_the_batch()
+    {
+        using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+        var client = Client(new ReplyHandler(new CancelingContent(Batch, cancellation)));
+
+        var poll = PollAsync(client, MessagePolling.Next, false, cancellation.Token);
+        var error = await Record.ExceptionAsync(() => poll);
+
+        Assert.True(cancellation.IsCancellationRequested);
+        if (error is null)
+        {
+            Assert.Equal(42ul, Assert.Single((await poll).Messages).Header.Offset);
+        }
+        else
+        {
+            Assert.IsType<VsrRequestOutcomeUnknownException>(error);
+        }
+    }
+
+    private static HttpMessageStream Client(HttpMessageHandler handler)
+    {
+        return new HttpMessageStream(new HttpClient(handler) { BaseAddress = new Uri("http://localhost") });
+    }
+
+    private static Task<PolledMessages> PollAsync(HttpMessageStream client, MessagePolling kind, bool autoCommit,
+        CancellationToken token)
+    {
+        return client.PollMessagesAsync(Stream, Topic, 1, Consumer.New(1),
+            new PollingStrategy { Kind = kind, Value = 0 }, 10, autoCommit, LongWait, token);
+    }
+
+    private static async Task UntilAsync(Func<bool> predicate)
+    {
+        using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+        timeout.CancelAfter(TimeSpan.FromSeconds(10));
+        while (!predicate())
+        {
+            await Task.Delay(5, timeout.Token);
+        }
+    }
+
+    /// <summary>Holds every deferred poll until its request is canceled, as a server holds a parked poll.</summary>
+    private sealed class HoldingHandler : HttpMessageHandler
+    {
+        private int _polls;
+
+        internal TaskCompletionSource Held { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+        internal int Polls => Volatile.Read(ref _polls);
+
+        protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
+            CancellationToken ct)
+        {
+            Assert.Equal(DeferredPollPath, request.RequestUri!.AbsolutePath);
+            Interlocked.Increment(ref _polls);
+            Held.TrySetResult();
+            await Task.Delay(Timeout.Infinite, ct);
+            throw new OperationCanceledException(ct);
+        }
+    }
+
+    /// <summary>Answers every deferred poll at once with <paramref name="content" />.</summary>
+    private sealed class ReplyHandler(HttpContent content) : HttpMessageHandler
+    {
+        protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
+        {
+            Assert.Equal(DeferredPollPath, request.RequestUri!.AbsolutePath);
+            return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+            {
+                RequestMessage = request,
+                Content = content
+            });
+        }
+    }
+
+    /// <summary>
+    ///     A reply body that cancels the caller once it is written. HttpClient reads the whole body before SendAsync
+    ///     returns, so the cancel lands after the reply arrived and before the client decodes it.
+    /// </summary>
+    private sealed class CancelingContent : HttpContent
+    {
+        private readonly byte[] _body;
+        private readonly CancellationTokenSource _caller;
+
+        internal CancelingContent(string json, CancellationTokenSource caller)
+        {
+            _body = Encoding.UTF8.GetBytes(json);
+            _caller = caller;
+            Headers.ContentType = new MediaTypeHeaderValue("application/json") { CharSet = "utf-8" };
+        }
+
+        protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context)
+        {
+            await stream.WriteAsync(_body);
+            await _caller.CancelAsync();
+        }
+
+        protected override bool TryComputeLength(out long length)
+        {
+            length = _body.Length;
+            return true;
+        }
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK_Tests/ClientTests/HttpPartitionPollTests.cs b/foreign/csharp/Iggy_SDK_Tests/ClientTests/HttpPartitionPollTests.cs
new file mode 100644
index 0000000..b128ef0
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK_Tests/ClientTests/HttpPartitionPollTests.cs
@@ -0,0 +1,166 @@
+// 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.
+
+using System.Collections.Concurrent;
+using System.Net;
+using System.Text;
+using System.Web;
+using Apache.Iggy.Contracts;
+using Apache.Iggy.Enums;
+using Apache.Iggy.Extensions;
+using Apache.Iggy.Exceptions;
+using Apache.Iggy.IggyClient.Implementations;
+using Apache.Iggy.Kinds;
+using Apache.Iggy.Vsr;
+
+namespace Apache.Iggy.Tests.ClientTests;
+
+/// <summary>
+///     A poll that stores its offset changes state, so it is a POST that no client or proxy sends again. The server
+///     refuses a GET that asks for auto commit.
+/// </summary>
+public sealed class HttpPartitionPollTests
+{
+    private const string DeferredPollPath = "/streams/1/topics/2/messages/deferred";
+
+    /// <summary>A reply with one message at offset 42 under the context 17, 9, 52.</summary>
+    private const string Batch = """
+        {
+          "partition_id": 1,
+          "current_offset": 42,
+          "context": { "incarnation": 17, "owner_generation": 9, "metadata_op": 52 },
+          "messages": [
+            {
+              "header": {
+                "checksum": 0, "id": 1, "offset": 42, "timestamp": 0, "origin_timestamp": 0,
+                "user_headers_length": 0, "payload_length": 5, "reserved": 0
+              },
+              "payload": "aGVsbG8="
+            }
+          ]
+        }
+        """;
+
+    private static readonly Identifier Stream = Identifier.Numeric(1);
+    private static readonly Identifier Topic = Identifier.Numeric(2);
+
+    [Theory]
+    [InlineData(false)]
+    [InlineData(true)]
+    public async Task given_poll_options_when_polling_should_post_only_a_poll_that_commits(bool onRead)
+    {
+        var server = new RecordingHandler();
+        var options = new PollOptions
+        {
+            MaxWait = TimeSpan.FromSeconds(2),
+            Count = 5,
+            Commit = onRead ? CommitPolicy.OnRead : CommitPolicy.Manual
+        };
+
+        var polled = await Client(server).PollMessagesAsync(Stream, Topic, 1, Consumer.New(1), PollingStrategy.Next(),
+            options, TestContext.Current.CancellationToken);
+
+        var request = Assert.Single(server.Requests);
+        Assert.Equal((onRead ? HttpMethod.Post : HttpMethod.Get, DeferredPollPath), (request.Method, request.Path));
+        Assert.Equal(onRead ? "true" : "false", request.Query["auto_commit"]);
+        Assert.Equal("5", request.Query["count"]);
+        Assert.Equal(PollingStrategy.Continuation(1, MessagePolling.Offset, 43, new PartitionContext(17, 9, 52)),
+            polled.Continuation);
+    }
+
+    [Theory]
+    [InlineData(false)]
+    [InlineData(true)]
+    public async Task given_a_plain_poll_when_it_commits_should_post_a_deferred_poll_without_a_wait(bool autoCommit)
+    {
+        var server = new RecordingHandler();
+
+        await Client(server).PollMessagesAsync(Stream, Topic, 1, Consumer.New(1), PollingStrategy.Next(), 10,
+            autoCommit, TestContext.Current.CancellationToken);
+
+        var request = Assert.Single(server.Requests);
+        if (autoCommit)
+        {
+            Assert.Equal((HttpMethod.Post, DeferredPollPath), (request.Method, request.Path));
+            Assert.Equal("0", request.Query["wait_us"]);
+        }
+        else
+        {
+            Assert.Equal((HttpMethod.Get, "/streams/1/topics/2/messages"), (request.Method, request.Path));
+        }
+    }
+
+    [Fact]
+    public async Task given_auto_commit_when_polling_should_refuse_it_before_sending()
+    {
+        var server = new RecordingHandler();
+
+        var refused = await Assert.ThrowsAsync<IggyInvalidStatusCodeException>(() => Client(server).PollMessagesAsync(
+            Stream, Topic, 1, Consumer.New(1), PollingStrategy.Next(), new PollOptions { Commit = CommitPolicy.Auto() },
+            TestContext.Current.CancellationToken));
+
+        Assert.Equal(VsrError.INVALID_CONFIGURATION, refused.StatusCode);
+        Assert.Empty(server.Requests);
+    }
+
+    [Fact]
+    public async Task given_a_standalone_reader_over_http_when_reading_should_poll_each_partition_on_its_own()
+    {
+        var server = new RecordingHandler();
+        await using var reader = await Client(server).Reader(Stream, Topic, Consumer.New(1))
+            .WithPartitions(1, 2)
+            .WithOptions(new PollOptions { MaxWait = TimeSpan.FromMilliseconds(10) })
+            .BuildAsync(TestContext.Current.CancellationToken);
+
+        var seen = new HashSet<uint>();
+        while (seen.Count < 2)
+        {
+            seen.Add((await reader.NextAsync(TestContext.Current.CancellationToken)).PartitionId);
+        }
+
+        Assert.Equal([1u, 2u], seen.Order());
+        Assert.All(server.Requests, request =>
+            Assert.Equal((HttpMethod.Get, DeferredPollPath), (request.Method, request.Path)));
+        Assert.Equal(["1", "2"], server.Requests.Select(request => request.Query["partition_id"]).Distinct().Order());
+    }
+
+    private static HttpMessageStream Client(HttpMessageHandler handler)
+    {
+        return new HttpMessageStream(new HttpClient(handler) { BaseAddress = new Uri("http://localhost") });
+    }
+
+    private sealed record RecordedRequest(HttpMethod Method, string Path, System.Collections.Specialized.NameValueCollection Query);
+
+    /// <summary>Answers every poll with <see cref="Batch" /> and records its method, path and query.</summary>
+    private sealed class RecordingHandler : HttpMessageHandler
+    {
+        internal ConcurrentQueue<RecordedRequest> Requests { get; } = new();
+
+        protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
+        {
+            var query = HttpUtility.ParseQueryString(request.RequestUri!.Query);
+            Requests.Enqueue(new RecordedRequest(request.Method, request.RequestUri.AbsolutePath, query));
+            var partition = query["partition_id"] ?? "1";
+            return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+            {
+                RequestMessage = request,
+                Content = new StringContent(Batch.Replace("\"partition_id\": 1", $"\"partition_id\": {partition}"),
+                    Encoding.UTF8, "application/json")
+            });
+        }
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK_Tests/ClientTests/IggyClientFactoryTests.cs b/foreign/csharp/Iggy_SDK_Tests/ClientTests/IggyClientFactoryTests.cs
index b023008..f2f6c95 100644
--- a/foreign/csharp/Iggy_SDK_Tests/ClientTests/IggyClientFactoryTests.cs
+++ b/foreign/csharp/Iggy_SDK_Tests/ClientTests/IggyClientFactoryTests.cs
@@ -111,6 +111,21 @@
         Assert.Throws<ArgumentOutOfRangeException>(() => IggyClientFactory.CreateClient(options));
     }
 
+    /// <summary>A deferred poll may keep its request open for up to 630 seconds and ends it itself.</summary>
+    [Fact]
+    public void CreateHttpClient_OutlastsTheLongestDeferredPollRequest()
+    {
+        var options = new IggyClientConfigurator
+        {
+            BaseAddress = "http://127.0.0.1:3000",
+            Protocol = Protocol.Http
+        };
+
+        using var client = IggyClientFactory.CreateHttpClient(options);
+
+        Assert.True(client.Timeout > TimeSpan.FromSeconds(630), $"the client times out after {client.Timeout}");
+    }
+
     [Fact]
     public void CreateClient_TcpClientDisposesTwice()
     {
diff --git a/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/ConsumerOnReaderTests.cs b/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/ConsumerOnReaderTests.cs
new file mode 100644
index 0000000..eb1b018
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/ConsumerOnReaderTests.cs
@@ -0,0 +1,387 @@
+// 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.
+
+using System.Text;
+using Apache.Iggy.Consumers;
+using Apache.Iggy.Contracts;
+using Apache.Iggy.Enums;
+using Apache.Iggy.IggyClient.Implementations;
+using Apache.Iggy.Kinds;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Apache.Iggy.Tests.ConsumerTests;
+
+/// <summary>
+///     The consumer hands over one message at a time on a partition reader, and counts each message for an
+///     automatic commit policy where its <see cref="CommitAfter" /> says.
+/// </summary>
+public sealed class ConsumerOnReaderTests
+{
+    private static readonly Identifier Stream = Identifier.Numeric(1);
+    private static readonly Identifier Topic = Identifier.Numeric(2);
+    private static readonly TimeSpan PromptLimit = TimeSpan.FromSeconds(5);
+
+    private static CancellationToken Token => TestContext.Current.CancellationToken;
+
+    [Fact]
+    public async Task given_a_standalone_consumer_of_two_partitions_when_receiving_should_hand_over_every_message()
+    {
+        using var cluster = new ReaderCluster(2);
+        using var client = await cluster.ConnectAsync();
+        await using var consumer = await StartAsync(client, CommitPolicy.Manual, 1, 2);
+
+        var received = await ReceiveAsync(consumer, 4);
+
+        Assert.Equal([(1u, 0ul), (1u, 1ul), (2u, 0ul), (2u, 1ul)],
+            received.Select(message => (message.PartitionId, message.CurrentOffset)).Order());
+        Assert.All(received, message => Assert.Equal(message.CurrentOffset, message.Checkpoint!.Offset));
+    }
+
+    [Fact]
+    public async Task given_manual_commits_when_committing_a_received_message_should_store_its_offset()
+    {
+        using var cluster = new ReaderCluster(1);
+        using var client = await cluster.ConnectAsync();
+        await using var consumer = await StartAsync(client, CommitPolicy.Manual, 1);
+        var received = await ReceiveAsync(consumer, 1);
+
+        await consumer.CommitAsync(received[0].Checkpoint!, Token);
+
+        Assert.Equal(0ul, cluster.Stored[1]);
+    }
+
+    /// <summary>A message counts once the application reads the stream again, and the last one on shutdown.</summary>
+    [Fact]
+    public async Task given_auto_commit_after_processing_when_reading_the_stream_again_should_count_the_previous_message()
+    {
+        using var cluster = new ReaderCluster(1);
+        using var client = await cluster.ConnectAsync();
+        var consumer = await StartAsync(client,
+            CommitPolicy.Auto(new AutoCommit { Every = CommitEvery.Message, After = CommitAfter.Processing }), 1);
+        await using var messages = consumer.ReceiveAsync(Token).GetAsyncEnumerator(Token);
+
+        Assert.True(await messages.MoveNextAsync());
+        await Task.Delay(TimeSpan.FromMilliseconds(200), Token);
+        Assert.False(cluster.Stored.ContainsKey(1));
+        Assert.True(await messages.MoveNextAsync());
+        await UntilAsync(() => cluster.Stored.TryGetValue(1, out var stored) && stored == 0);
+        await consumer.ShutdownAsync();
+
+        Assert.Equal(1ul, cluster.Stored[1]);
+    }
+
+    [Fact]
+    public async Task given_auto_commit_after_delivery_when_a_message_is_handed_over_should_count_it_at_once()
+    {
+        using var cluster = new ReaderCluster(1);
+        using var client = await cluster.ConnectAsync();
+        await using var consumer = await StartAsync(client,
+            CommitPolicy.Auto(new AutoCommit { Every = CommitEvery.Message, After = CommitAfter.Delivery }), 1);
+        await using var messages = consumer.ReceiveAsync(Token).GetAsyncEnumerator(Token);
+
+        Assert.True(await messages.MoveNextAsync());
+
+        await UntilAsync(() => cluster.Stored.TryGetValue(1, out var stored) && stored == 0);
+    }
+
+    /// <summary>A handler that fails ends the loop with its error, and its message never counts.</summary>
+    [Fact]
+    public async Task given_auto_commit_after_processing_when_a_handler_fails_should_end_the_loop_without_counting_it()
+    {
+        using var cluster = new ReaderCluster(1);
+        using var client = await cluster.ConnectAsync();
+        var consumer = await StartAsync(client,
+            CommitPolicy.Auto(new AutoCommit { Interval = TimeSpan.FromHours(1), After = CommitAfter.Processing }), 1);
+        var failure = new InvalidOperationException("The handler failed.");
+
+        var error = await Assert.ThrowsAsync<InvalidOperationException>(() => consumer.ConsumeAsync(
+            (message, _) => message.CurrentOffset == 1 ? Task.FromException(failure) : Task.CompletedTask, Token));
+        await consumer.ShutdownAsync();
+
+        Assert.Same(failure, error);
+        Assert.Equal(0ul, cluster.Stored[1]);
+    }
+
+    /// <summary>
+    ///     A shutdown that starts while a handler runs waits for it, because the message counts only once its handler
+    ///     returns with success. This handler fails, so only the message before it is stored.
+    /// </summary>
+    [Fact]
+    public async Task given_auto_commit_after_processing_when_shutting_down_during_a_handler_should_not_count_its_message()
+    {
+        using var cluster = new ReaderCluster(1);
+        using var client = await cluster.ConnectAsync();
+        var consumer = await StartAsync(client,
+            CommitPolicy.Auto(new AutoCommit { Interval = TimeSpan.FromHours(1), After = CommitAfter.Processing }), 1);
+        var failure = new InvalidOperationException("The handler failed.");
+        var handling = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+        var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+        var consuming = consumer.ConsumeAsync(async (message, _) =>
+        {
+            if (message.CurrentOffset == 1)
+            {
+                handling.SetResult();
+                await release.Task;
+                throw failure;
+            }
+        }, Token);
+        await handling.Task.WaitAsync(PromptLimit, Token);
+
+        var shutdown = consumer.ShutdownAsync();
+        await Task.WhenAny(shutdown, Task.Delay(TimeSpan.FromMilliseconds(200), Token));
+        var waited = !shutdown.IsCompleted;
+        release.SetResult();
+        await shutdown;
+
+        Assert.Same(failure, await Assert.ThrowsAsync<InvalidOperationException>(() => consuming));
+        Assert.True(waited);
+        Assert.Equal(0ul, cluster.Stored[1]);
+    }
+
+    /// <summary>
+    ///     The first shutdown waits for the handler that runs. A second shutdown waits for the first instead of
+    ///     returning while it still runs.
+    /// </summary>
+    [Fact]
+    public async Task given_a_shutdown_in_progress_when_shutting_down_again_should_wait_for_the_first()
+    {
+        using var cluster = new ReaderCluster(1);
+        using var client = await cluster.ConnectAsync();
+        var consumer = await StartAsync(client,
+            CommitPolicy.Auto(new AutoCommit { Interval = TimeSpan.FromHours(1), After = CommitAfter.Processing }), 1);
+        var handling = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+        var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+        var consuming = consumer.ConsumeAsync(async (message, _) =>
+        {
+            if (message.CurrentOffset == 1)
+            {
+                handling.SetResult();
+                await release.Task;
+            }
+        }, Token);
+        await handling.Task.WaitAsync(PromptLimit, Token);
+        var first = consumer.ShutdownAsync();
+
+        var second = consumer.ShutdownAsync();
+        await Task.WhenAny(second, Task.Delay(TimeSpan.FromMilliseconds(200), Token));
+        var waited = !second.IsCompleted;
+        release.SetResult();
+        await first.WaitAsync(PromptLimit, Token);
+        await second.WaitAsync(PromptLimit, Token);
+        await consuming.WaitAsync(PromptLimit, Token);
+
+        Assert.True(waited);
+        Assert.Equal(1ul, cluster.Stored[1]);
+    }
+
+    /// <summary>
+    ///     With on read commits the server stored both messages of the read, but the application took only the
+    ///     first, so shutdown moves the offset back to it.
+    /// </summary>
+    [Fact]
+    public async Task given_on_read_when_shutting_down_after_part_of_a_batch_should_move_the_offset_back_to_the_last_message_taken()
+    {
+        using var cluster = new ReaderCluster(1);
+        using var client = await cluster.ConnectAsync();
+        var consumer = await StartAsync(client, CommitPolicy.OnRead, 1);
+        await using var messages = consumer.ReceiveAsync(Token).GetAsyncEnumerator(Token);
+        Assert.True(await messages.MoveNextAsync());
+        Assert.Equal(1ul, cluster.Stored[1]);
+
+        await consumer.ShutdownAsync();
+
+        Assert.Equal(0ul, cluster.Stored[1]);
+    }
+
+    [Fact]
+    public async Task given_a_message_that_does_not_decode_when_receiving_should_end_the_stream_with_its_skip()
+    {
+        using var cluster = new ReaderCluster(1) { Limit = 5 };
+        cluster.Undecodable.Add(2);
+        using var client = await cluster.ConnectAsync();
+        await using var consumer = await StartAsync(client, CommitPolicy.Manual, 1);
+        using var timeout = CancellationTokenSource.CreateLinkedTokenSource(Token);
+        timeout.CancelAfter(PromptLimit);
+        List<ulong> offsets = [];
+
+        var error = await Assert.ThrowsAsync<ReadException>(async () =>
+        {
+            await foreach (var message in consumer.ReceiveAsync(timeout.Token))
+            {
+                offsets.Add(message.CurrentOffset);
+            }
+        });
+
+        Assert.Equal([0ul, 1ul], offsets);
+        Assert.Equal((MessagePolling.Offset, 3ul), (error.Skip!.Value.Kind, error.Skip.Value.Value));
+    }
+
+    [Fact]
+    public async Task given_a_cancel_after_a_message_when_receiving_should_stop_without_an_error()
+    {
+        using var cluster = new ReaderCluster(1) { Limit = 4 };
+        using var client = await cluster.ConnectAsync();
+        await using var consumer = await StartAsync(client, CommitPolicy.Manual, 1);
+        using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(Token);
+        var received = 0;
+
+        await foreach (var _ in consumer.ReceiveAsync(cancellation.Token))
+        {
+            received++;
+            await cancellation.CancelAsync();
+        }
+
+        Assert.Equal(1, received);
+    }
+
+    [Theory]
+    [InlineData(false)]
+    [InlineData(true)]
+    public async Task given_a_handler_that_stops_consuming_when_it_returns_should_not_receive_another_message(bool shutdown)
+    {
+        using var cluster = new ReaderCluster(1) { Limit = 4 };
+        using var client = await cluster.ConnectAsync();
+        await using var consumer = await StartAsync(client, CommitPolicy.OnRead, 1);
+        using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(Token);
+        List<ulong> received = [];
+
+        await consumer.ConsumeAsync(async (message, _) =>
+        {
+            received.Add(message.CurrentOffset);
+            if (shutdown)
+            {
+                await consumer.ShutdownAsync();
+            }
+            else
+            {
+                await cancellation.CancelAsync();
+            }
+        }, cancellation.Token);
+        await consumer.ShutdownAsync();
+
+        Assert.Equal([0ul], received);
+        Assert.Equal(0ul, cluster.Stored[1]);
+    }
+
+    [Fact]
+    public async Task given_a_typed_consumer_when_receiving_should_deserialize_each_payload()
+    {
+        using var cluster = new ReaderCluster(1);
+        using var client = await cluster.ConnectAsync();
+        await using var consumer = new IggyConsumer<string>(client, new IggyConsumerConfig<string>
+        {
+            StreamId = Stream,
+            TopicId = Topic,
+            Consumer = Consumer.New(4),
+            Partitions = [1],
+            PollOptions = new PollOptions { MaxWait = TimeSpan.FromMilliseconds(100) },
+            Deserializer = new Utf8Deserializer()
+        }, NullLoggerFactory.Instance);
+        await consumer.InitAsync(Token);
+        using var timeout = CancellationTokenSource.CreateLinkedTokenSource(Token);
+        timeout.CancelAfter(PromptLimit);
+
+        List<string?> payloads = [];
+        await foreach (var message in consumer.ReceiveDeserializedAsync(timeout.Token))
+        {
+            payloads.Add(message.Data);
+            if (payloads.Count == 2)
+            {
+                break;
+            }
+        }
+
+        Assert.Equal(["first-payload", "second-payload"], payloads);
+    }
+
+    [Fact]
+    public async Task given_rented_messages_when_receiving_should_keep_the_batch_until_each_message_is_disposed()
+    {
+        using var cluster = new ReaderCluster(1);
+        using var client = await cluster.ConnectAsync();
+        await using var consumer = await StartAsync(client, CommitPolicy.Manual, 1);
+        using var timeout = CancellationTokenSource.CreateLinkedTokenSource(Token);
+        timeout.CancelAfter(PromptLimit);
+        List<ReceivedRentedMessage> received = [];
+
+        await foreach (var message in consumer.ReceiveRentedAsync(timeout.Token))
+        {
+            received.Add(message);
+            if (received.Count == 2)
+            {
+                break;
+            }
+        }
+
+        Assert.Equal("second-payload", Encoding.UTF8.GetString(received[1].Message.Payload.Span));
+        Assert.Equal([0ul, 1ul], received.Select(message => message.Checkpoint!.Offset));
+        foreach (var message in received)
+        {
+            message.Dispose();
+        }
+    }
+
+    private static async Task<IggyConsumer> StartAsync(TcpMessageStream client, CommitPolicy commit,
+        params uint[] partitions)
+    {
+        var consumer = new IggyConsumer(client, new IggyConsumerConfig
+        {
+            StreamId = Stream,
+            TopicId = Topic,
+            Consumer = Consumer.New(4),
+            Partitions = partitions,
+            PollOptions = new PollOptions { MaxWait = TimeSpan.FromMilliseconds(100), Commit = commit }
+        }, NullLoggerFactory.Instance);
+        await consumer.InitAsync(Token);
+        return consumer;
+    }
+
+    private static async Task<List<ReceivedMessage>> ReceiveAsync(IggyConsumer consumer, int count)
+    {
+        using var timeout = CancellationTokenSource.CreateLinkedTokenSource(Token);
+        timeout.CancelAfter(PromptLimit);
+        List<ReceivedMessage> received = [];
+        await foreach (var message in consumer.ReceiveAsync(timeout.Token))
+        {
+            received.Add(message);
+            if (received.Count == count)
+            {
+                break;
+            }
+        }
+
+        return received;
+    }
+
+    private static async Task UntilAsync(Func<bool> predicate)
+    {
+        using var timeout = CancellationTokenSource.CreateLinkedTokenSource(Token);
+        timeout.CancelAfter(PromptLimit);
+        while (!predicate())
+        {
+            await Task.Delay(5, timeout.Token);
+        }
+    }
+
+    private sealed class Utf8Deserializer : IDeserializer<string>
+    {
+        public string Deserialize(ReadOnlyMemory<byte> data)
+        {
+            return Encoding.UTF8.GetString(data.Span);
+        }
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/ConsumerSessionGenerationTests.cs b/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/ConsumerSessionGenerationTests.cs
deleted file mode 100644
index 60c1435..0000000
--- a/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/ConsumerSessionGenerationTests.cs
+++ /dev/null
@@ -1,245 +0,0 @@
-// 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.
-
-using Apache.Iggy.Consumers;
-using Apache.Iggy.Contracts;
-using Apache.Iggy.Enums;
-using Apache.Iggy.IggyClient;
-using Apache.Iggy.Kinds;
-using Apache.Iggy.Messages;
-using Apache.Iggy.Vsr;
-using Microsoft.Extensions.Logging.Abstractions;
-using Moq;
-
-namespace Apache.Iggy.Tests.ConsumerTests;
-
-/// <summary>
-///     Group membership on a transport that exposes a consensus session is keyed off the session generation
-///     rather than off connection-state edges, so these cover both arms of that branch.
-/// </summary>
-public sealed class ConsumerSessionGenerationTests
-{
-    [Fact]
-    public async Task
-        given_group_consumer_when_reauthenticated_on_the_same_session_generation_should_not_rejoin_the_group()
-    {
-        var client = new GenerationClient();
-        var consumer = new IggyConsumer(client.Object, BuildGroupConfig(), NullLoggerFactory.Instance);
-        await consumer.InitAsync(TestContext.Current.CancellationToken);
-        Assert.Equal(1, client.JoinCount);
-
-        await client.RaiseAsync(ConnectionState.Connected, ConnectionState.Authenticated);
-
-        Assert.Equal(1, client.JoinCount);
-        await consumer.DisposeAsync();
-    }
-
-    [Fact]
-    public async Task given_group_consumer_when_the_session_generation_moved_should_rejoin_the_group()
-    {
-        var client = new GenerationClient();
-        var consumer = new IggyConsumer(client.Object, BuildGroupConfig(), NullLoggerFactory.Instance);
-        await consumer.InitAsync(TestContext.Current.CancellationToken);
-        Assert.Equal(1, client.JoinCount);
-
-        // The session the membership was established under is gone.
-        client.SessionGeneration++;
-        await client.RaiseAsync(ConnectionState.Connected, ConnectionState.Authenticated);
-
-        Assert.Equal(2, client.JoinCount);
-
-        // The rejoin must re-stamp the membership generation: without it every later event would rejoin
-        // again, triggering a group-wide rebalance per reconnect.
-        await client.RaiseAsync(ConnectionState.Connected, ConnectionState.Authenticated);
-
-        Assert.Equal(2, client.JoinCount);
-        await consumer.DisposeAsync();
-    }
-
-    /// <summary>
-    ///     A disconnect re-arms the transport's session, so the generation moves and the reconnect rejoins. The
-    ///     membership is not cleared on the Disconnected event itself: the poll gate compares generations
-    ///     instead, which survives state events arriving out of order.
-    /// </summary>
-    [Fact]
-    public async Task given_group_consumer_when_disconnected_should_surrender_membership_and_rejoin_on_reconnect()
-    {
-        var client = new GenerationClient();
-        var consumer = new IggyConsumer(client.Object, BuildGroupConfig(), NullLoggerFactory.Instance);
-        await consumer.InitAsync(TestContext.Current.CancellationToken);
-        Assert.Equal(1, client.JoinCount);
-
-        await client.RaiseAsync(ConnectionState.Authenticated, ConnectionState.Disconnected);
-        // The transport re-arms the consensus session when the connection drops.
-        client.SessionGeneration++;
-        await client.RaiseAsync(ConnectionState.Connecting, ConnectionState.Authenticated);
-
-        Assert.Equal(2, client.JoinCount);
-        await consumer.DisposeAsync();
-    }
-
-    /// <summary>
-    ///     The event-time rejoin swallows its failures and the transport never republishes an unchanged state,
-    ///     so the poll loop is the only place left that can restore a membership the event handler failed to.
-    /// </summary>
-    [Fact]
-    public async Task given_group_consumer_when_the_event_time_rejoin_fails_should_rejoin_from_the_poll_loop()
-    {
-        var client = new GenerationClient();
-        var consumer = new IggyConsumer(client.Object, BuildGroupConfig(), NullLoggerFactory.Instance);
-        await consumer.InitAsync(TestContext.Current.CancellationToken);
-        Assert.Equal(1, client.JoinCount);
-
-        client.SessionGeneration++;
-        client.FailNextJoin = true;
-        await client.RaiseAsync(ConnectionState.Connected, ConnectionState.Authenticated);
-        Assert.Equal(1, client.JoinCount);
-
-        var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
-        await using IAsyncEnumerator<ReceivedMessage> messages = consumer.ReceiveAsync(cts.Token)
-            .GetAsyncEnumerator(TestContext.Current.CancellationToken);
-
-        Assert.True(await messages.MoveNextAsync());
-        Assert.Equal(2, client.JoinCount);
-        await consumer.DisposeAsync();
-    }
-
-    /// <summary>
-    ///     A plain consumer holds no membership, so nothing may clear its joined flag: no code path would ever
-    ///     set it again and every later poll would be skipped.
-    /// </summary>
-    [Fact]
-    public async Task given_plain_consumer_when_disconnected_should_keep_polling_after_reconnect()
-    {
-        var client = new GenerationClient();
-        var config = BuildGroupConfig();
-        config.Consumer = Consumer.New(1);
-        var consumer = new IggyConsumer(client.Object, config, NullLoggerFactory.Instance);
-        await consumer.InitAsync(TestContext.Current.CancellationToken);
-        Assert.Equal(0, client.JoinCount);
-
-        await client.RaiseAsync(ConnectionState.Authenticated, ConnectionState.Disconnected);
-        client.SessionGeneration++;
-        await client.RaiseAsync(ConnectionState.Connecting, ConnectionState.Authenticated);
-
-        var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
-        await using IAsyncEnumerator<ReceivedMessage> messages = consumer.ReceiveAsync(cts.Token)
-            .GetAsyncEnumerator(TestContext.Current.CancellationToken);
-
-        Assert.True(await messages.MoveNextAsync());
-        Assert.Equal(0, client.JoinCount);
-        await consumer.DisposeAsync();
-    }
-
-    private static IggyConsumerConfig BuildGroupConfig()
-    {
-        return new IggyConsumerConfig
-        {
-            StreamId = Identifier.Numeric(1),
-            TopicId = Identifier.Numeric(1),
-            Consumer = Consumer.Group("group-1"),
-            PollingStrategy = PollingStrategy.Next(),
-            BatchSize = 10,
-            AutoCommitMode = AutoCommitMode.Disabled,
-            AutoCommit = false,
-            PollingIntervalMs = 0
-        };
-    }
-
-    /// <summary>
-    ///     A client that carries a consensus session, counts group joins, and replays connection-state events on
-    ///     demand so a test can drive the reconnection handler without a socket.
-    /// </summary>
-    private sealed class GenerationClient
-    {
-        private readonly List<Func<ConnectionStateChangedEventArgs, Task>> _subscribers = [];
-
-        public IIggyClient Object { get; }
-
-        public ulong SessionGeneration { get; set; }
-
-        public int JoinCount { get; private set; }
-
-        /// <summary>The next join attempt throws instead of counting, then the flag disarms itself.</summary>
-        public bool FailNextJoin { get; set; }
-
-        public GenerationClient()
-        {
-            var mock = new Mock<IIggyClient>(MockBehavior.Loose);
-            mock.As<ISessionGenerationProvider>().SetupGet(c => c.SessionGeneration)
-                .Returns(() => SessionGeneration);
-            mock.Setup(c => c.ConnectAsync(It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
-            mock.Setup(c => c.SubscribeConnectionEvents(It.IsAny<Func<ConnectionStateChangedEventArgs, Task>>()))
-                .Callback<Func<ConnectionStateChangedEventArgs, Task>>(_subscribers.Add);
-            mock.Setup(c => c.UnsubscribeConnectionEvents(It.IsAny<Func<ConnectionStateChangedEventArgs, Task>>()))
-                .Callback<Func<ConnectionStateChangedEventArgs, Task>>(callback => _subscribers.Remove(callback));
-            mock.Setup(c => c.GetConsumerGroupByIdAsync(It.IsAny<Identifier>(), It.IsAny<Identifier>(),
-                    It.IsAny<Identifier>(), It.IsAny<CancellationToken>()))
-                .ReturnsAsync(new ConsumerGroupResponse
-                {
-                    Id = 1,
-                    Name = "group-1",
-                    MembersCount = 1,
-                    PartitionsCount = 1
-                });
-            mock.Setup(c => c.JoinConsumerGroupAsync(It.IsAny<Identifier>(), It.IsAny<Identifier>(),
-                    It.IsAny<Identifier>(), It.IsAny<CancellationToken>()))
-                .Callback(() =>
-                {
-                    if (FailNextJoin)
-                    {
-                        FailNextJoin = false;
-                        throw new InvalidOperationException("Injected join failure.");
-                    }
-
-                    JoinCount++;
-                })
-                .Returns(Task.CompletedTask);
-            mock.Setup(c => c.PollMessagesAsync(It.IsAny<Identifier>(), It.IsAny<Identifier>(), It.IsAny<uint?>(),
-                    It.IsAny<Consumer>(), It.IsAny<Func<uint, PollingStrategy>>(), It.IsAny<uint>(), It.IsAny<bool>(),
-                    It.IsAny<CancellationToken>()))
-                .ReturnsAsync(() => new PolledMessages
-                {
-                    PartitionId = 1,
-                    CurrentOffset = 0,
-                    Messages =
-                    [
-                        new MessageResponse
-                        {
-                            Header = new MessageHeader
-                            {
-                                Offset = 0,
-                                PayloadLength = 1
-                            },
-                            Payload = new byte[] { 1 },
-                            UserHeaders = null
-                        }
-                    ]
-                });
-
-            Object = mock.Object;
-        }
-
-        public async Task RaiseAsync(ConnectionState previousState, ConnectionState currentState)
-        {
-            foreach (Func<ConnectionStateChangedEventArgs, Task> subscriber in _subscribers.ToArray())
-            {
-                await subscriber(new ConnectionStateChangedEventArgs(previousState, currentState));
-            }
-        }
-    }
-}
diff --git a/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/IggyConsumerBuilderTests.cs b/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/IggyConsumerBuilderTests.cs
index baa18b0..2578adb 100644
--- a/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/IggyConsumerBuilderTests.cs
+++ b/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/IggyConsumerBuilderTests.cs
@@ -17,6 +17,7 @@
 
 using System.Text;
 using Apache.Iggy.Consumers;
+using Apache.Iggy.Contracts;
 using Apache.Iggy.Encryption;
 using Apache.Iggy.Enums;
 using Apache.Iggy.IggyClient;
@@ -32,10 +33,33 @@
     private static readonly Identifier TopicId = Identifier.Numeric(1);
 
     [Fact]
+    public void Build_WithAutoCommitWithoutAnIntervalOrAnEvent_Throws()
+    {
+        var client = new Mock<IIggyClient>();
+        var builder = IggyConsumerBuilder.Create(client.Object, StreamId, TopicId, Consumer.Group("group"))
+            .WithPollOptions(new PollOptions { Commit = CommitPolicy.Auto(new AutoCommit()) });
+
+        var error = Assert.Throws<InvalidOperationException>(() => builder.Build());
+
+        Assert.Contains("PollOptions", error.Message);
+    }
+
+    [Fact]
+    public void Build_WithAStandaloneConsumerWithoutPartitions_Throws()
+    {
+        var builder = IggyConsumerBuilder.Create(new Mock<IIggyClient>().Object, StreamId, TopicId, Consumer.New(1));
+
+        var error = Assert.Throws<InvalidOperationException>(() => builder.Build());
+
+        Assert.Contains("partitions", error.Message);
+    }
+
+    [Fact]
     public void Build_WithDefaultSocketBufferSizes_CreatesTheClient()
     {
         var builder = IggyConsumerBuilder
             .Create(StreamId, TopicId, Consumer.New(1))
+            .WithPartitions(1)
             .WithConnection(Protocol.Tcp, "127.0.0.1:8090", "user", "pass");
 
         Assert.Null(builder.Config.ReceiveBufferSize);
@@ -52,6 +76,7 @@
     {
         var builder = IggyConsumerBuilder
             .Create(StreamId, TopicId, Consumer.New(1))
+            .WithPartitions(1)
             .WithConnection(Protocol.Tcp, "127.0.0.1:8090", "user", "pass",
                 receiveBufferSize: receiveBufferSize, sendBufferSize: sendBufferSize);
 
@@ -59,20 +84,21 @@
     }
 
     [Fact]
-    public void Build_WithEncryptorAndAutoCommit_Throws()
+    public void Build_WithEncryptorAndOnReadCommit_Throws()
     {
         var builder = IggyConsumerBuilder
             .Create(StreamId, TopicId, Consumer.New(1))
+            .WithPartitions(1)
             .WithConnection(Protocol.Tcp, "127.0.0.1:8090", "user", "pass")
             .WithEncryptor(new AesMessageEncryptor(AesMessageEncryptor.GenerateKey()))
-            .WithAutoCommitMode(AutoCommitMode.Auto);
+            .WithPollOptions(new PollOptions { Commit = CommitPolicy.OnRead });
 
         var ex = Assert.Throws<InvalidOperationException>(() => builder.Build());
-        Assert.Contains("AutoCommitMode.Auto", ex.Message);
+        Assert.Contains("on read", ex.Message);
     }
 
     [Fact]
-    public void Build_WithExternalClientEncryptorAndAutoCommit_Throws()
+    public void Build_WithExternalClientEncryptorAndOnReadCommit_Throws()
     {
         var client = new Mock<IIggyClient>();
         client.SetupGet(c => c.MessageEncryptor)
@@ -80,20 +106,22 @@
 
         var builder = IggyConsumerBuilder
             .Create(client.Object, StreamId, TopicId, Consumer.New(1))
-            .WithAutoCommitMode(AutoCommitMode.Auto);
+            .WithPartitions(1)
+            .WithPollOptions(new PollOptions { Commit = CommitPolicy.OnRead });
 
         var ex = Assert.Throws<InvalidOperationException>(() => builder.Build());
-        Assert.Contains("AutoCommitMode.Auto", ex.Message);
+        Assert.Contains("on read", ex.Message);
     }
 
     [Fact]
-    public void Build_WithEncryptorAndAfterReceiveCommit_DoesNotThrow()
+    public void Build_WithEncryptorAndClientCommit_DoesNotThrow()
     {
         var consumer = IggyConsumerBuilder
             .Create(StreamId, TopicId, Consumer.New(1))
+            .WithPartitions(1)
             .WithConnection(Protocol.Tcp, "127.0.0.1:8090", "user", "pass")
             .WithEncryptor(new AesMessageEncryptor(AesMessageEncryptor.GenerateKey()))
-            .WithAutoCommitMode(AutoCommitMode.AfterReceive)
+            .WithPollOptions(new PollOptions { Commit = CommitPolicy.Auto() })
             .Build();
 
         Assert.NotNull(consumer);
@@ -104,6 +132,7 @@
     {
         var consumer = IggyConsumerBuilder
             .Create(StreamId, TopicId, Consumer.New(1))
+            .WithPartitions(1)
             .WithConnection(Protocol.Tcp, "127.0.0.1:8090", "token")
             .Build();
 
@@ -115,6 +144,7 @@
     {
         var builder = IggyConsumerBuilder
             .Create(StreamId, TopicId, Consumer.New(1))
+            .WithPartitions(1)
             .WithConnection(Protocol.Tcp, "127.0.0.1:8090", string.Empty);
 
         var ex = Assert.Throws<InvalidOperationException>(() => builder.Build());
@@ -126,6 +156,7 @@
     {
         IggyConsumerBuilder<string> builder = IggyConsumerBuilder<string>
             .Create(StreamId, TopicId, Consumer.New(1), new StringDeserializer());
+        builder.WithPartitions(1);
         builder.WithConnection(Protocol.Tcp, "127.0.0.1:8090", "user", "pass");
 
         Assert.NotNull(builder.Build());
@@ -141,12 +172,18 @@
         var client = new Mock<IIggyClient>();
         client.Setup(value => value.LogoutUserAsync(It.IsAny<CancellationToken>()))
             .ThrowsAsync(new IOException("Connection lost during logout."));
+        client.Setup(value => value.PollMessagesAsync(It.IsAny<Identifier>(), It.IsAny<Identifier>(),
+                It.IsAny<uint>(), It.IsAny<Consumer>(), It.IsAny<PollingStrategy>(), It.IsAny<PollOptions>(),
+                It.IsAny<CancellationToken>()))
+            .Returns((Identifier _, Identifier _, uint _, Consumer _, PollingStrategy _, PollOptions _,
+                CancellationToken token) => ParkedPollAsync(token));
         var consumer = new IggyConsumer(client.Object, new IggyConsumerConfig
         {
             CreateIggyClient = ownsClient,
             StreamId = StreamId,
             TopicId = TopicId,
-            Consumer = Consumer.New(1)
+            Consumer = Consumer.New(1),
+            Partitions = [1]
         }, NullLoggerFactory.Instance);
 
         if (initialized)
@@ -168,6 +205,13 @@
             ownsClient && initialized ? Times.Once() : Times.Never());
     }
 
+    /// <summary>A poll that the server holds until the caller cancels it.</summary>
+    private static async Task<PolledMessages> ParkedPollAsync(CancellationToken token)
+    {
+        await Task.Delay(Timeout.Infinite, token);
+        return PolledMessages.Empty;
+    }
+
     private sealed class StringDeserializer : IDeserializer<string>
     {
         public string Deserialize(ReadOnlyMemory<byte> data)
diff --git a/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/NoAssignedPartitionBackoffTests.cs b/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/NoAssignedPartitionBackoffTests.cs
deleted file mode 100644
index 2465508..0000000
--- a/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/NoAssignedPartitionBackoffTests.cs
+++ /dev/null
@@ -1,193 +0,0 @@
-// 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.
-
-using System.Diagnostics;
-using Apache.Iggy.Consumers;
-using Apache.Iggy.Contracts;
-using Apache.Iggy.Enums;
-using Apache.Iggy.IggyClient;
-using Apache.Iggy.Kinds;
-using Apache.Iggy.Messages;
-using Apache.Iggy.Vsr;
-using Microsoft.Extensions.Logging.Abstractions;
-using Moq;
-
-namespace Apache.Iggy.Tests.ConsumerTests;
-
-/// <summary>
-///     A group poll that reports <see cref="PolledMessages.NoAssignedPartition" /> must not be re-issued at once:
-///     with <c>PollingIntervalMs</c> at zero that would spin against the coordinator until a partition arrives.
-/// </summary>
-public sealed class NoAssignedPartitionBackoffTests
-{
-    private const int MinimumGapMs = 80;
-
-    [Fact]
-    public async Task given_no_assigned_partition_when_receiving_should_back_off_before_polling_again()
-    {
-        var polls = new List<long>();
-        var stopwatch = Stopwatch.StartNew();
-        var mock = new Mock<IIggyClient>(MockBehavior.Loose);
-        mock.Setup(c => c.PollMessagesAsync(It.IsAny<Identifier>(), It.IsAny<Identifier>(), It.IsAny<uint?>(),
-                It.IsAny<Consumer>(), It.IsAny<Func<uint, PollingStrategy>>(), It.IsAny<uint>(), It.IsAny<bool>(),
-                It.IsAny<CancellationToken>()))
-            .ReturnsAsync(() =>
-            {
-                polls.Add(stopwatch.ElapsedMilliseconds);
-
-                return polls.Count < 3 ? NoAssignment() : OneMessage();
-            });
-        var consumer = new IggyConsumer(mock.Object, BuildConfig(), NullLoggerFactory.Instance);
-        await consumer.InitAsync(TestContext.Current.CancellationToken);
-
-        using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
-        await using var messages = consumer.ReceiveAsync(cts.Token)
-            .GetAsyncEnumerator(TestContext.Current.CancellationToken);
-        Assert.True(await messages.MoveNextAsync());
-
-        Assert.True(polls.Count >= 3);
-        Assert.True(polls[1] - polls[0] >= MinimumGapMs, $"second poll came {polls[1] - polls[0]}ms after the first");
-        Assert.True(polls[2] - polls[1] >= MinimumGapMs, $"third poll came {polls[2] - polls[1]}ms after the second");
-        await consumer.DisposeAsync();
-    }
-
-    [Fact]
-    public async Task given_no_assigned_partition_when_receiving_rented_should_back_off_before_polling_again()
-    {
-        var polls = new List<long>();
-        var stopwatch = Stopwatch.StartNew();
-        var mock = new Mock<IIggyClient>(MockBehavior.Loose);
-        mock.Setup(c => c.PollMessagesRentedAsync(It.IsAny<Identifier>(), It.IsAny<Identifier>(),
-                It.IsAny<uint?>(), It.IsAny<Consumer>(), It.IsAny<Func<uint, PollingStrategy>>(), It.IsAny<uint>(),
-                It.IsAny<bool>(), It.IsAny<CancellationToken>()))
-            .ReturnsAsync(() =>
-            {
-                polls.Add(stopwatch.ElapsedMilliseconds);
-
-                return polls.Count < 3 ? NoAssignmentRental() : OneMessageRental();
-            });
-        var consumer = new IggyConsumer(mock.Object, BuildConfig(), NullLoggerFactory.Instance);
-        await consumer.InitAsync(TestContext.Current.CancellationToken);
-
-        using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
-        await using var messages = consumer.ReceiveRentedAsync(cts.Token)
-            .GetAsyncEnumerator(TestContext.Current.CancellationToken);
-        Assert.True(await messages.MoveNextAsync());
-        messages.Current.Dispose();
-
-        Assert.True(polls.Count >= 3);
-        Assert.True(polls[1] - polls[0] >= MinimumGapMs, $"second poll came {polls[1] - polls[0]}ms after the first");
-        Assert.True(polls[2] - polls[1] >= MinimumGapMs, $"third poll came {polls[2] - polls[1]}ms after the second");
-        await consumer.DisposeAsync();
-    }
-
-    [Fact]
-    public async Task given_no_assigned_partition_when_cancelled_mid_backoff_should_not_publish_an_error()
-    {
-        var errors = 0;
-        var mock = new Mock<IIggyClient>(MockBehavior.Loose);
-        mock.Setup(c => c.PollMessagesAsync(It.IsAny<Identifier>(), It.IsAny<Identifier>(), It.IsAny<uint?>(),
-                It.IsAny<Consumer>(), It.IsAny<Func<uint, PollingStrategy>>(), It.IsAny<uint>(), It.IsAny<bool>(),
-                It.IsAny<CancellationToken>()))
-            .ReturnsAsync(NoAssignment);
-        var consumer = new IggyConsumer(mock.Object, BuildConfig(), NullLoggerFactory.Instance);
-        consumer.SubscribeToErrorEvents(_ =>
-        {
-            Interlocked.Increment(ref errors);
-            return Task.CompletedTask;
-        });
-        await consumer.InitAsync(TestContext.Current.CancellationToken);
-
-        using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(30));
-        await using var messages = consumer.ReceiveAsync(cts.Token)
-            .GetAsyncEnumerator(TestContext.Current.CancellationToken);
-        await Assert.ThrowsAnyAsync<OperationCanceledException>(async () => await messages.MoveNextAsync());
-
-        Assert.Equal(0, Volatile.Read(ref errors));
-        await consumer.DisposeAsync();
-    }
-
-    private static IggyConsumerConfig BuildConfig()
-    {
-        return new IggyConsumerConfig
-        {
-            StreamId = Identifier.Numeric(1),
-            TopicId = Identifier.Numeric(1),
-            Consumer = Consumer.Group("group-1"),
-            PollingStrategy = PollingStrategy.Next(),
-            BatchSize = 10,
-            PartitionId = null,
-            AutoCommitMode = AutoCommitMode.Disabled,
-            AutoCommit = false,
-            PollingIntervalMs = 0
-        };
-    }
-
-    private static PolledMessages NoAssignment()
-    {
-        return new PolledMessages
-        {
-            PartitionId = PolledMessages.NoAssignedPartition,
-            CurrentOffset = 0,
-            Messages = []
-        };
-    }
-
-    private static PolledMessages OneMessage()
-    {
-        return new PolledMessages
-        {
-            PartitionId = 1,
-            CurrentOffset = 0,
-            Messages =
-            [
-                new MessageResponse
-                {
-                    Header = new MessageHeader
-                    {
-                        Offset = 0,
-                        PayloadLength = 1
-                    },
-                    Payload = [1],
-                    UserHeaders = null
-                }
-            ]
-        };
-    }
-
-    private static PolledMessagesRental NoAssignmentRental()
-    {
-        return new PolledMessagesRental(EmptyMemoryOwner.Instance)
-        {
-            PartitionId = PolledMessages.NoAssignedPartition,
-            CurrentOffset = 0,
-            Messages = []
-        };
-    }
-
-    private static PolledMessagesRental OneMessageRental()
-    {
-        var owner = new RentedConsumerTests.TrackingMemoryOwner(16);
-
-        return new PolledMessagesRental(owner)
-        {
-            PartitionId = 1,
-            CurrentOffset = 0,
-            Messages = RentedConsumerTests.BuildMessages(owner, 1)
-        };
-    }
-}
diff --git a/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/OffsetContinuationTests.cs b/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/OffsetContinuationTests.cs
deleted file mode 100644
index 3578b78..0000000
--- a/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/OffsetContinuationTests.cs
+++ /dev/null
@@ -1,381 +0,0 @@
-// 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.
-
-using System.Buffers.Binary;
-using System.Collections.Concurrent;
-using Apache.Iggy.Configuration;
-using Apache.Iggy.Consumers;
-using Apache.Iggy.Contracts;
-using Apache.Iggy.Enums;
-using Apache.Iggy.Exceptions;
-using Apache.Iggy.IggyClient.Implementations;
-using Apache.Iggy.Kinds;
-using Apache.Iggy.Tests.ContractsTests;
-using Apache.Iggy.Tests.VsrTests;
-using Apache.Iggy.Utils;
-using Apache.Iggy.Vsr;
-using Microsoft.Extensions.Logging.Abstractions;
-using static Apache.Iggy.Tests.VsrTests.MockFrames;
-
-namespace Apache.Iggy.Tests.ConsumerTests;
-
-/// <summary>
-///     The configured polling strategy only says where reading a partition starts. Every later poll of the partition
-///     continues after the last message it delivered, under the context of that batch, so a recreated partition
-///     refuses the poll instead of serving a later offset of its new incarnation. Mirrors the next offsets of
-///     <c>core/sdk/src/clients/consumer.rs</c>.
-/// </summary>
-public sealed class OffsetContinuationTests
-{
-    private const uint PartitionId = 3;
-    private const uint OtherPartitionId = 4;
-    private static readonly PartitionContext Original = new(17, 9, 52);
-    private static readonly PartitionContext Recreated = new(18, 9, 60);
-    private static readonly PartitionContext Reassigned = new(17, 10, 60);
-    private static readonly PartitionContext Other = new(21, 9, 52);
-
-    [Theory]
-    [InlineData(MessagePolling.Offset, false)]
-    [InlineData(MessagePolling.Offset, true)]
-    [InlineData(MessagePolling.First, false)]
-    [InlineData(MessagePolling.First, true)]
-    public async Task given_a_start_strategy_when_a_batch_arrives_should_continue_after_it_under_its_context(
-        MessagePolling kind, bool rented)
-    {
-        using var server = new PartitionServer(new Dictionary<uint, PartitionContext> { [PartitionId] = Original });
-        using var client = Client(server);
-        await using var consumer = await StartAsync(client, Consumer.New(1), PartitionId,
-            kind == MessagePolling.First ? PollingStrategy.First() : PollingStrategy.Offset(0));
-
-        var received = await ReceiveAsync(consumer, rented, 4);
-
-        Assert.Equal([(PartitionId, 0ul, Original), (PartitionId, 1ul, Original), (PartitionId, 2ul, Original),
-            (PartitionId, 3ul, Original)], received);
-        Assert.Equal([new Poll(PartitionId, kind, 0, PartitionServer.Route(Original)),
-            new Poll(PartitionId, MessagePolling.Offset, 2, Original)], server.Polls.Take(2));
-    }
-
-    [Fact]
-    public async Task given_the_next_strategy_when_a_batch_arrives_should_leave_the_position_to_the_server()
-    {
-        using var server = new PartitionServer(new Dictionary<uint, PartitionContext> { [PartitionId] = Original });
-        using var client = Client(server);
-        await using var consumer = await StartAsync(client, Consumer.New(1), PartitionId, PollingStrategy.Next());
-
-        var received = await ReceiveAsync(consumer, false, 4);
-
-        Assert.Equal([0ul, 1ul, 2ul, 3ul], received.Select(message => message.Offset));
-        Assert.Equal([new Poll(PartitionId, MessagePolling.Next, 0, PartitionServer.Route(Original)),
-            new Poll(PartitionId, MessagePolling.Next, 0, PartitionServer.Route(Original))], server.Polls.Take(2));
-    }
-
-    [Theory]
-    [InlineData(false)]
-    [InlineData(true)]
-    public async Task given_a_recreated_partition_when_its_continuation_is_refused_should_report_it_and_start_over(
-        bool rented)
-    {
-        using var server = new PartitionServer(new Dictionary<uint, PartitionContext> { [PartitionId] = Original });
-        server.ReplaceAfterNextBatch(PartitionId, Recreated);
-        using var client = Client(server);
-        await using var consumer = await StartAsync(client, Consumer.New(1), PartitionId, PollingStrategy.Offset(0));
-        var errors = new ConcurrentQueue<Exception>();
-        consumer.SubscribeToErrorEvents(error =>
-        {
-            errors.Enqueue(error.Exception);
-            return Task.CompletedTask;
-        });
-
-        var received = await ReceiveAsync(consumer, rented, 4);
-
-        Assert.Equal([(PartitionId, 0ul, Original), (PartitionId, 1ul, Original), (PartitionId, 0ul, Recreated),
-            (PartitionId, 1ul, Recreated)], received);
-        Assert.Equal([new Poll(PartitionId, MessagePolling.Offset, 2, Original),
-                new Poll(PartitionId, MessagePolling.Offset, 0, PartitionServer.Route(Recreated))],
-            server.Polls.Skip(1).Take(2));
-        Assert.Equal(VsrError.HISTORY_UNAVAILABLE,
-            Assert.IsType<IggyInvalidStatusCodeException>(Assert.Single(errors)).StatusCode);
-    }
-
-    /// <summary>
-    ///     Another member may have read and committed the partition while it was away, so the member starts it over
-    ///     from the configured strategy, as the Rust consumer does.
-    /// </summary>
-    [Theory]
-    [InlineData(false)]
-    [InlineData(true)]
-    public async Task given_a_reassigned_partition_when_its_continuation_is_fenced_should_report_it_and_start_over(
-        bool rented)
-    {
-        using var server = new PartitionServer(new Dictionary<uint, PartitionContext> { [PartitionId] = Original });
-        server.ReplaceAfterNextBatch(PartitionId, Reassigned);
-        using var client = Client(server);
-        await using var consumer = await StartAsync(client, Consumer.Group("group"), null, PollingStrategy.Offset(0));
-        var errors = new ConcurrentQueue<Exception>();
-        consumer.SubscribeToErrorEvents(error =>
-        {
-            errors.Enqueue(error.Exception);
-            return Task.CompletedTask;
-        });
-
-        var received = await ReceiveAsync(consumer, rented, 4);
-
-        Assert.Equal([(PartitionId, 0ul, Original), (PartitionId, 1ul, Original), (PartitionId, 0ul, Reassigned),
-            (PartitionId, 1ul, Reassigned)], received);
-        Assert.Equal([new Poll(PartitionId, MessagePolling.Offset, 2, Original),
-                new Poll(PartitionId, MessagePolling.Offset, 0, PartitionServer.Route(Reassigned))],
-            server.Polls.Skip(1).Take(2));
-        Assert.Equal(VsrError.CONSUMER_GROUP_PARTITION_NOT_OWNED,
-            Assert.IsType<IggyInvalidStatusCodeException>(Assert.Single(errors)).StatusCode);
-    }
-
-    [Theory]
-    [InlineData(false)]
-    [InlineData(true)]
-    public async Task given_a_group_member_when_continuing_should_keep_each_partition_on_its_own_offset(bool rented)
-    {
-        using var server = new PartitionServer(new Dictionary<uint, PartitionContext>
-        {
-            [PartitionId] = Original,
-            [OtherPartitionId] = Other
-        });
-        using var client = Client(server);
-        await using var consumer = await StartAsync(client, Consumer.Group("group"), null, PollingStrategy.Offset(0));
-
-        var received = await ReceiveAsync(consumer, rented, 8);
-
-        Assert.Equal([(PartitionId, 0ul), (PartitionId, 1ul), (OtherPartitionId, 0ul), (OtherPartitionId, 1ul),
-                (PartitionId, 2ul), (PartitionId, 3ul), (OtherPartitionId, 2ul), (OtherPartitionId, 3ul)],
-            received.Select(message => (message.PartitionId, message.Offset)));
-        Assert.Equal([new Poll(PartitionId, MessagePolling.Offset, 0, PartitionServer.Route(Original)),
-            new Poll(OtherPartitionId, MessagePolling.Offset, 0, PartitionServer.Route(Other)),
-            new Poll(PartitionId, MessagePolling.Offset, 2, Original),
-            new Poll(OtherPartitionId, MessagePolling.Offset, 2, Other)], server.Polls.Take(4));
-    }
-
-    private static TcpMessageStream Client(PartitionServer server)
-    {
-        return new TcpMessageStream(new IggyClientConfigurator
-        {
-            BaseAddress = $"127.0.0.1:{server.Port}",
-            Protocol = Protocol.Tcp,
-            HeartbeatInterval = TimeSpan.FromHours(1)
-        }, NullLoggerFactory.Instance);
-    }
-
-    private static async Task<IggyConsumer> StartAsync(TcpMessageStream client, Consumer consumer, uint? partitionId,
-        PollingStrategy strategy)
-    {
-        var started = new IggyConsumer(client, new IggyConsumerConfig
-        {
-            StreamId = Identifier.Numeric(1),
-            TopicId = Identifier.Numeric(2),
-            PartitionId = partitionId,
-            Consumer = consumer,
-            PollingStrategy = strategy,
-            BatchSize = 10,
-            AutoCommitMode = AutoCommitMode.Disabled,
-            PollingIntervalMs = 0,
-            Login = "iggy",
-            Password = "iggy"
-        }, NullLoggerFactory.Instance);
-        await started.InitAsync(TestContext.Current.CancellationToken);
-
-        return started;
-    }
-
-    /// <summary>
-    ///     The first <paramref name="count" /> messages, or fewer when the consumer stalls until the timeout.
-    /// </summary>
-    private static async Task<List<(uint PartitionId, ulong Offset, PartitionContext Context)>> ReceiveAsync(
-        IggyConsumer consumer, bool rented, int count)
-    {
-        using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
-        cancellation.CancelAfter(TimeSpan.FromSeconds(5));
-        var received = new List<(uint PartitionId, ulong Offset, PartitionContext Context)>();
-        try
-        {
-            if (rented)
-            {
-                await foreach (var message in consumer.ReceiveRentedAsync(cancellation.Token))
-                {
-                    using (message)
-                    {
-                        received.Add((message.PartitionId, message.CurrentOffset, message.Context));
-                    }
-
-                    if (received.Count == count)
-                    {
-                        break;
-                    }
-                }
-            }
-            else
-            {
-                await foreach (var message in consumer.ReceiveAsync(cancellation.Token))
-                {
-                    received.Add((message.PartitionId, message.CurrentOffset, message.Context));
-                    if (received.Count == count)
-                    {
-                        break;
-                    }
-                }
-            }
-        }
-        catch (OperationCanceledException) when (!TestContext.Current.CancellationToken.IsCancellationRequested)
-        {
-            // The timeout ends a stalled consumer, and the assertions then show what it delivered.
-        }
-
-        return received;
-    }
-
-    /// <summary>A poll as the node saw it: the partition, the strategy and the context in the request header.</summary>
-    private readonly record struct Poll(uint PartitionId, MessagePolling Kind, ulong Value, PartitionContext Context);
-
-    /// <summary>
-    ///     A standalone node that serves two messages of a partition from where a poll asks, under the partition's
-    ///     live context. It refuses a poll stamped with another incarnation with status 87 and one stamped with
-    ///     another owner with status 5009. A route reports the live context at an older metadata operation, so a poll
-    ///     stamped with the context of its batch tells itself apart from one that took the context of its route.
-    /// </summary>
-    private sealed class PartitionServer : IDisposable
-    {
-        private const ulong RouteMetadataOp = 50;
-
-        /// <summary>
-        ///     The poll body ends with the partition id, the strategy kind and value, the count and the auto commit
-        ///     flag.
-        /// </summary>
-        private const int PartitionFromEnd = 18;
-
-        private const int KindFromEnd = 14;
-        private const int ValueFromEnd = 13;
-
-        /// <summary>The wire numbers the polling kinds from 1, in the order of <see cref="MessagePolling" />.</summary>
-        private const int FirstWireKind = 1;
-
-        private const int PollContextPosition = 16;
-        private const int BatchBaseOffsetPosition = PollContextPosition + PartitionContext.ENCODED_SIZE + sizeof(ulong);
-
-        /// <summary>How many messages the golden poll body holds.</summary>
-        private const ulong MessagesPerBatch = 2;
-
-        private readonly ConcurrentDictionary<uint, ulong> _cursors = new();
-        private readonly ConcurrentDictionary<uint, PartitionContext> _live;
-        private readonly MockNode _node = new();
-        private readonly ConcurrentDictionary<uint, PartitionContext> _replacements = new();
-
-        internal PartitionServer(Dictionary<uint, PartitionContext> live)
-        {
-            _live = new ConcurrentDictionary<uint, PartitionContext>(live);
-            _node.Serve(Handle);
-        }
-
-        internal ushort Port => _node.Port;
-
-        internal ConcurrentQueue<Poll> Polls { get; } = new();
-
-        public void Dispose()
-        {
-            _node.Dispose();
-        }
-
-        internal static PartitionContext Route(PartitionContext live)
-        {
-            return live with { OwnerGeneration = 0, MetadataOp = RouteMetadataOp };
-        }
-
-        /// <summary>
-        ///     Replaces the context of the partition once its next batch is served, as a recreation or a reassignment
-        ///     would.
-        /// </summary>
-        internal void ReplaceAfterNextBatch(uint partitionId, PartitionContext replacement)
-        {
-            _replacements[partitionId] = replacement;
-        }
-
-        private byte[] Handle(MockRequest request)
-        {
-            return request.Code switch
-            {
-                GET_CLUSTER_METADATA_CODE => StandaloneRoster(_node.Port),
-                CommandCodes.GET_PARTITION_ROUTE_CODE => RouteReply(request,
-                    Route(_live[BinaryPrimitives.ReadUInt32LittleEndian(request.Body.AsSpan(request.Body.Length - sizeof(uint)))]),
-                    0, (uint)_live.Count),
-                CommandCodes.SYNC_CONSUMER_GROUP_CODE => Reply(OPERATION_NON_REPLICATED,
-                    AssignmentBody(1, [.. _live.Keys.Order()])),
-                CommandCodes.POLL_MESSAGES_CODE => Serve(request),
-                _ => request.Operation >= (byte)VsrOperation.CreateStream
-                    ? Reply(request.Operation, new byte[4])
-                    : Answer(request)
-            };
-        }
-
-        private byte[] Serve(MockRequest request)
-        {
-            var poll = new Poll(Partition(request), (MessagePolling)(request.Body[^KindFromEnd] - FirstWireKind),
-                BinaryPrimitives.ReadUInt64LittleEndian(request.Body.AsSpan(request.Body.Length - ValueFromEnd)),
-                request.Context);
-            Polls.Enqueue(poll);
-            var live = _live[poll.PartitionId];
-            if (poll.Context.Incarnation != live.Incarnation)
-            {
-                return Reply(request.Operation, [], VsrError.HISTORY_UNAVAILABLE);
-            }
-
-            if (poll.Context.OwnerGeneration != 0 && poll.Context.OwnerGeneration != live.OwnerGeneration)
-            {
-                return Reply(request.Operation, [], VsrError.CONSUMER_GROUP_PARTITION_NOT_OWNED);
-            }
-
-            var first = poll.Kind switch
-            {
-                MessagePolling.Offset => poll.Value,
-                MessagePolling.Next => _cursors.GetValueOrDefault(poll.PartitionId),
-                _ => 0ul
-            };
-            _cursors[poll.PartitionId] = first + MessagesPerBatch;
-            if (_replacements.TryRemove(poll.PartitionId, out var replacement))
-            {
-                _live[poll.PartitionId] = replacement;
-            }
-
-            return Reply(request.Operation, Batch(poll.PartitionId, first, live));
-        }
-
-        private static uint Partition(MockRequest request)
-        {
-            return BinaryPrimitives.ReadUInt32LittleEndian(request.Body.AsSpan(request.Body.Length - PartitionFromEnd));
-        }
-
-        /// <summary>The golden poll body moved to another partition, first offset and context.</summary>
-        private static byte[] Batch(uint partitionId, ulong firstOffset, PartitionContext context)
-        {
-            var body = Convert.FromHexString(MessageBatchGoldenVectorTests.POLL_BODY);
-            BinaryPrimitives.WriteUInt32LittleEndian(body, partitionId);
-            BinaryPrimitives.WriteUInt64LittleEndian(body.AsSpan(PollContextPosition), context.Incarnation);
-            BinaryPrimitives.WriteUInt64LittleEndian(body.AsSpan(PollContextPosition + sizeof(ulong)),
-                context.OwnerGeneration);
-            BinaryPrimitives.WriteUInt64LittleEndian(body.AsSpan(PollContextPosition + 2 * sizeof(ulong)),
-                context.MetadataOp);
-            BinaryPrimitives.WriteUInt64LittleEndian(body.AsSpan(BatchBaseOffsetPosition), firstOffset);
-
-            return body;
-        }
-    }
-}
diff --git a/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/PartitionIncarnationTests.Disposal.cs b/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/PartitionIncarnationTests.Disposal.cs
deleted file mode 100644
index f401f09..0000000
--- a/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/PartitionIncarnationTests.Disposal.cs
+++ /dev/null
@@ -1,287 +0,0 @@
-// 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.
-
-using System.Collections.Concurrent;
-using Apache.Iggy.Consumers;
-using Apache.Iggy.Contracts;
-using Apache.Iggy.Exceptions;
-using Apache.Iggy.IggyClient;
-using Apache.Iggy.Kinds;
-using Microsoft.Extensions.Logging.Abstractions;
-using Moq;
-
-namespace Apache.Iggy.Tests.ConsumerTests;
-
-public sealed partial class PartitionIncarnationTests
-{
-    [Theory]
-    [InlineData("plain", AutoCommitMode.AfterReceive)]
-    [InlineData("rented", AutoCommitMode.AfterReceive)]
-    [InlineData("typed", AutoCommitMode.AfterReceive)]
-    [InlineData("plain", AutoCommitMode.Auto)]
-    [InlineData("rented", AutoCommitMode.Auto)]
-    [InlineData("typed", AutoCommitMode.Auto)]
-    [InlineData("plain", AutoCommitMode.Disabled)]
-    [InlineData("rented", AutoCommitMode.Disabled)]
-    [InlineData("typed", AutoCommitMode.Disabled)]
-    public async Task given_buffered_messages_when_disposed_inside_receive_should_checkpoint_only_delivered_positions_before_leaving(
-        string receive, AutoCommitMode mode)
-    {
-        var stores = new ConcurrentQueue<(ulong Offset, PartitionContext Context)>();
-        var events = new ConcurrentQueue<string>();
-        var client = ClientMock(stores);
-        SetupDisposalGroup(client, events);
-        client.Setup(mock => mock.StoreOffsetAsync(It.IsAny<Consumer>(), It.IsAny<Identifier>(),
-                It.IsAny<Identifier>(), It.IsAny<ulong>(), It.IsAny<uint>(), It.IsAny<PartitionContext>(), It.IsAny<CancellationToken>()))
-            .Callback((Consumer _, Identifier _, Identifier _, ulong offset, uint _, PartitionContext context, CancellationToken _) =>
-            {
-                stores.Enqueue((offset, context));
-                events.Enqueue("store");
-            }).Returns(Task.CompletedTask);
-        var owner = new RentedConsumerTests.TrackingMemoryOwner(1024);
-        var rental = new PolledMessagesRental(owner)
-        {
-            Context = Original,
-            PartitionId = PartitionId,
-            CurrentOffset = 2,
-            Messages = RentedConsumerTests.BuildMessages(owner, 3)
-        };
-        var polled = new PolledMessages
-        {
-            Context = Original,
-            PartitionId = PartitionId,
-            CurrentOffset = 2,
-            Messages = [.. Batch(0, Original).Messages, .. Batch(1, Original).Messages, .. Batch(2, Original).Messages]
-        };
-        client.Setup(mock => mock.PollMessagesAsync(It.IsAny<Identifier>(), It.IsAny<Identifier>(),
-                It.IsAny<uint?>(), It.IsAny<Consumer>(), It.IsAny<Func<uint, PollingStrategy>>(), It.IsAny<uint>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
-            .ReturnsAsync(polled);
-        client.Setup(mock => mock.PollMessagesRentedAsync(It.IsAny<Identifier>(), It.IsAny<Identifier>(),
-                It.IsAny<uint?>(), It.IsAny<Consumer>(), It.IsAny<Func<uint, PollingStrategy>>(), It.IsAny<uint>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
-            .ReturnsAsync(rental);
-        var config = new IggyConsumerConfig<string>
-        {
-            StreamId = Identifier.Numeric(1),
-            TopicId = Identifier.Numeric(2),
-            Consumer = Consumer.Group("group"),
-            PollingStrategy = PollingStrategy.Next(),
-            BatchSize = 10,
-            AutoCommitMode = mode,
-            PollingIntervalMs = 0,
-            Deserializer = new DisposalDeserializer(),
-            CreateIggyClient = true
-        };
-        await using var consumer = receive == "typed"
-            ? new IggyConsumer<string>(client.Object, config, NullLoggerFactory.Instance)
-            : new IggyConsumer(client.Object, config, NullLoggerFactory.Instance);
-        using var timeout = Timeout();
-        await consumer.InitAsync(timeout.Token);
-        if (receive == "plain")
-        {
-            await using var messages = consumer.ReceiveAsync(timeout.Token).GetAsyncEnumerator(timeout.Token);
-            Assert.True(await messages.MoveNextAsync());
-            Assert.Equal(0ul, messages.Current.CurrentOffset);
-            await consumer.DisposeAsync().AsTask().WaitAsync(timeout.Token);
-            Assert.False(await messages.MoveNextAsync());
-        }
-        else if (receive == "rented")
-        {
-            await using var messages = consumer.ReceiveRentedAsync(timeout.Token).GetAsyncEnumerator(timeout.Token);
-            Assert.True(await messages.MoveNextAsync());
-            Assert.Equal(0ul, messages.Current.CurrentOffset);
-            await consumer.DisposeAsync().AsTask().WaitAsync(timeout.Token);
-            Assert.Equal(0, owner.DisposeCount);
-            messages.Current.Dispose();
-            Assert.Equal(1, owner.DisposeCount);
-            Assert.False(await messages.MoveNextAsync());
-        }
-        else
-        {
-            await using var messages = ((IggyConsumer<string>)consumer).ReceiveDeserializedAsync(timeout.Token).GetAsyncEnumerator(timeout.Token);
-            Assert.True(await messages.MoveNextAsync());
-            Assert.Equal(0ul, messages.Current.CurrentOffset);
-            await consumer.DisposeAsync().AsTask().WaitAsync(timeout.Token);
-            Assert.False(await messages.MoveNextAsync());
-            Assert.Equal(1, owner.DisposeCount);
-        }
-        await consumer.DisposeAsync();
-        Assert.Equal(mode == AutoCommitMode.Auto ? [(0ul, Original)] : [], stores);
-        Assert.Equal(mode == AutoCommitMode.Auto ? ["store", "leave", "logout", "dispose"] : ["leave", "logout", "dispose"], events);
-        if (receive == "plain")
-        {
-            rental.Dispose();
-        }
-    }
-
-    [Fact]
-    public async Task given_after_receive_when_a_loop_body_throws_should_not_commit_its_message_on_dispose()
-    {
-        var stores = new ConcurrentQueue<(ulong Offset, PartitionContext Context)>();
-        var client = ClientMock(stores);
-        client.Setup(mock => mock.PollMessagesAsync(It.IsAny<Identifier>(), It.IsAny<Identifier>(),
-                It.IsAny<uint?>(), It.IsAny<Consumer>(), It.IsAny<Func<uint, PollingStrategy>>(), It.IsAny<uint>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
-            .ReturnsAsync(new PolledMessages
-            {
-                Context = Original,
-                PartitionId = PartitionId,
-                CurrentOffset = 1,
-                Messages = [.. Batch(0, Original).Messages, .. Batch(1, Original).Messages]
-            });
-        await using var consumer = new IggyConsumer(client.Object, Config(), NullLoggerFactory.Instance);
-        using var timeout = Timeout();
-        await consumer.InitAsync(timeout.Token);
-
-        await Assert.ThrowsAsync<InvalidOperationException>(async () =>
-        {
-            await foreach (var message in consumer.ReceiveAsync(timeout.Token))
-            {
-                if (message.CurrentOffset == 1)
-                {
-                    throw new InvalidOperationException("Processing failed.");
-                }
-            }
-        });
-        await consumer.DisposeAsync();
-
-        Assert.Equal([(0ul, Original)], stores);
-    }
-
-    [Theory]
-    [InlineData(false)]
-    [InlineData(true)]
-    public async Task given_a_consumed_message_when_a_final_checkpoint_fails_or_would_rewind_should_still_leave_once(bool alreadyStored)
-    {
-        var events = new ConcurrentQueue<string>();
-        var client = ClientMock(new());
-        SetupDisposalGroup(client, events);
-        client.Setup(mock => mock.PollMessagesAsync(It.IsAny<Identifier>(), It.IsAny<Identifier>(),
-                It.IsAny<uint?>(), It.IsAny<Consumer>(), It.IsAny<Func<uint, PollingStrategy>>(), It.IsAny<uint>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
-            .ReturnsAsync(Batch(OriginalOffset, Original));
-        var stores = new ConcurrentQueue<ulong>();
-        client.Setup(mock => mock.StoreOffsetAsync(It.IsAny<Consumer>(), It.IsAny<Identifier>(), It.IsAny<Identifier>(),
-                It.IsAny<ulong>(), It.IsAny<uint>(), It.IsAny<PartitionContext>(), It.IsAny<CancellationToken>()))
-            .Returns((Consumer _, Identifier _, Identifier _, ulong offset, uint _, PartitionContext _, CancellationToken _) =>
-            {
-                stores.Enqueue(offset);
-                events.Enqueue("store");
-                return alreadyStored ? Task.CompletedTask : Task.FromException(new IOException("Checkpoint failed."));
-            });
-        var config = Config(AutoCommitMode.Auto);
-        config.Consumer = Consumer.Group("group");
-        await using var consumer = new IggyConsumer(client.Object, config, NullLoggerFactory.Instance);
-        using var timeout = Timeout();
-        await consumer.InitAsync(timeout.Token);
-        await using var messages = consumer.ReceiveAsync(timeout.Token).GetAsyncEnumerator(timeout.Token);
-        Assert.True(await messages.MoveNextAsync());
-        if (alreadyStored)
-        {
-            await consumer.StoreOffsetAsync(OriginalOffset + 1, PartitionId, Original, timeout.Token);
-        }
-        await consumer.DisposeAsync();
-        await consumer.DisposeAsync();
-        Assert.False(await messages.MoveNextAsync());
-        Assert.Equal([alreadyStored ? OriginalOffset + 1 : OriginalOffset], stores);
-        Assert.Equal(["store", "leave"], events);
-    }
-
-    [Theory]
-    [InlineData(false)]
-    [InlineData(true)]
-    public async Task given_an_after_receive_store_in_flight_when_disposed_should_drain_it_before_checkpointing_and_leaving(bool sent)
-    {
-        var events = new ConcurrentQueue<string>();
-        var client = ClientMock(new());
-        SetupDisposalGroup(client, events);
-        client.Setup(mock => mock.PollMessagesAsync(It.IsAny<Identifier>(), It.IsAny<Identifier>(),
-                It.IsAny<uint?>(), It.IsAny<Consumer>(), It.IsAny<Func<uint, PollingStrategy>>(), It.IsAny<uint>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
-            .ReturnsAsync(Batch(OriginalOffset, Original));
-        var storing = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
-        var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
-        var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
-        var attempts = 0;
-        client.Setup(mock => mock.ResolveOperationAsync(1, It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
-            .Callback(() => events.Enqueue("resolve")).Returns(Task.CompletedTask);
-        client.Setup(mock => mock.StoreOffsetAsync(It.IsAny<Consumer>(), It.IsAny<Identifier>(), It.IsAny<Identifier>(),
-                It.IsAny<ulong>(), It.IsAny<uint>(), It.IsAny<PartitionContext>(), It.IsAny<CancellationToken>()))
-            .Returns(async (Consumer _, Identifier _, Identifier _, ulong _, uint _, PartitionContext _, CancellationToken token) =>
-            {
-                var attempt = Interlocked.Increment(ref attempts);
-                if (attempt == 1)
-                {
-                    storing.TrySetResult();
-                    using var registration = token.Register(() => cancelled.TrySetResult());
-                    await release.Task;
-                    events.Enqueue("drained");
-                    if (sent)
-                    {
-                        throw new VsrRequestOutcomeUnknownException(new OperationCanceledException(token), 1);
-                    }
-                    token.ThrowIfCancellationRequested();
-                }
-                else if (sent && attempt == 2)
-                {
-                    throw new VsrLaneBlockedException(1);
-                }
-                else
-                {
-                    events.Enqueue("checkpoint");
-                }
-            });
-        var config = Config();
-        config.Consumer = Consumer.Group("group");
-        await using var consumer = new IggyConsumer(client.Object, config, NullLoggerFactory.Instance);
-        using var timeout = Timeout();
-        await consumer.InitAsync(timeout.Token);
-        await using var messages = consumer.ReceiveAsync(timeout.Token).GetAsyncEnumerator(timeout.Token);
-        Assert.True(await messages.MoveNextAsync());
-        var receive = messages.MoveNextAsync().AsTask();
-        await storing.Task.WaitAsync(timeout.Token);
-        var dispose = consumer.DisposeAsync().AsTask();
-        try
-        {
-            Assert.False(dispose.IsCompleted);
-            await cancelled.Task.WaitAsync(timeout.Token);
-            Assert.Empty(events);
-        }
-        finally
-        {
-            release.TrySetResult();
-            await receive;
-        }
-        await dispose.WaitAsync(timeout.Token);
-        Assert.False(await receive);
-        Assert.Equal(sent ? ["drained", "resolve", "checkpoint", "leave"] : ["drained", "checkpoint", "leave"], events);
-    }
-
-    private static void SetupDisposalGroup(Mock<IIggyClient> client, ConcurrentQueue<string> events)
-    {
-        client.Setup(mock => mock.GetConsumerGroupByIdAsync(It.IsAny<Identifier>(), It.IsAny<Identifier>(),
-                It.IsAny<Identifier>(), It.IsAny<CancellationToken>()))
-            .ReturnsAsync(new ConsumerGroupResponse { Id = 1, Name = "group", MembersCount = 1, PartitionsCount = 1 });
-        client.Setup(mock => mock.LeaveConsumerGroupAsync(It.IsAny<Identifier>(), It.IsAny<Identifier>(),
-                It.IsAny<Identifier>(), It.IsAny<CancellationToken>()))
-            .Callback(() => events.Enqueue("leave")).Returns(Task.CompletedTask);
-        client.Setup(mock => mock.LogoutUserAsync(It.IsAny<CancellationToken>()))
-            .Callback(() => events.Enqueue("logout")).Returns(Task.CompletedTask);
-        client.Setup(mock => mock.Dispose()).Callback(() => events.Enqueue("dispose"));
-    }
-
-    private sealed class DisposalDeserializer : IDeserializer<string>
-    {
-        public string Deserialize(ReadOnlyMemory<byte> data) => System.Text.Encoding.UTF8.GetString(data.Span);
-    }
-}
diff --git a/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/PartitionIncarnationTests.cs b/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/PartitionIncarnationTests.cs
deleted file mode 100644
index ed9972e..0000000
--- a/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/PartitionIncarnationTests.cs
+++ /dev/null
@@ -1,539 +0,0 @@
-// 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.
-
-using System.Collections.Concurrent;
-using Apache.Iggy.Consumers;
-using Apache.Iggy.Contracts;
-using Apache.Iggy.Exceptions;
-using Apache.Iggy.IggyClient;
-using Apache.Iggy.Kinds;
-using Apache.Iggy.Messages;
-using Apache.Iggy.Vsr;
-using Microsoft.Extensions.Logging.Abstractions;
-using Moq;
-
-namespace Apache.Iggy.Tests.ConsumerTests;
-
-/// <summary>
-///     Offsets compare only within one partition incarnation and owner. A recreated partition restarts at offset
-///     0, so the consumer has to deliver its messages even below the last offset the old incarnation delivered,
-///     and must never commit an offset of the old incarnation into it.
-/// </summary>
-public sealed partial class PartitionIncarnationTests
-{
-    private const uint PartitionId = 3;
-    private const ulong OriginalOffset = 100;
-    private const ulong BlockingOperationId = 73;
-    private static readonly PartitionContext Original = new(17, 0, 52);
-    private static readonly PartitionContext Recreated = new(18, 0, 60);
-
-    [Fact]
-    public async Task given_a_recreated_partition_when_receiving_should_deliver_it_and_not_commit_the_old_offset()
-    {
-        var polls = new ConcurrentQueue<PolledMessages>([Batch(OriginalOffset, Original), Batch(0, Recreated)]);
-        var stores = new ConcurrentQueue<(ulong Offset, PartitionContext Context)>();
-        var client = ClientMock(stores);
-        client.Setup(mock => mock.PollMessagesAsync(It.IsAny<Identifier>(), It.IsAny<Identifier>(),
-                It.IsAny<uint?>(), It.IsAny<Consumer>(), It.IsAny<Func<uint, PollingStrategy>>(), It.IsAny<uint>(),
-                It.IsAny<bool>(), It.IsAny<CancellationToken>()))
-            .Returns((Identifier _, Identifier _, uint? _, Consumer _, Func<uint, PollingStrategy> _, uint _, bool _,
-                CancellationToken token) => polls.TryDequeue(out var polled) ? Task.FromResult(polled) : Idle<PolledMessages>(token));
-        await using var consumer = new IggyConsumer(client.Object, Config(), NullLoggerFactory.Instance);
-        await consumer.InitAsync(TestContext.Current.CancellationToken);
-        using var cancellation = Timeout();
-
-        await using var messages = consumer.ReceiveAsync(cancellation.Token).GetAsyncEnumerator(cancellation.Token);
-        Assert.True(await messages.MoveNextAsync());
-        Assert.Equal(OriginalOffset, messages.Current.CurrentOffset);
-        var delivered = await NextAsync(messages);
-
-        Assert.DoesNotContain((OriginalOffset, Recreated), stores);
-        Assert.Equal(0ul, Assert.IsType<ReceivedMessage>(delivered).CurrentOffset);
-        Assert.Equal([(OriginalOffset, Original)], stores.ToArray());
-    }
-
-    [Fact]
-    public async Task given_a_recreated_partition_when_receiving_rented_should_deliver_it_and_not_commit_the_old_offset()
-    {
-        var polls = new ConcurrentQueue<PolledMessagesRental>([Rental(OriginalOffset, Original), Rental(0, Recreated)]);
-        var stores = new ConcurrentQueue<(ulong Offset, PartitionContext Context)>();
-        var client = ClientMock(stores);
-        client.Setup(mock => mock.PollMessagesRentedAsync(It.IsAny<Identifier>(), It.IsAny<Identifier>(),
-                It.IsAny<uint?>(), It.IsAny<Consumer>(), It.IsAny<Func<uint, PollingStrategy>>(), It.IsAny<uint>(),
-                It.IsAny<bool>(), It.IsAny<CancellationToken>()))
-            .Returns((Identifier _, Identifier _, uint? _, Consumer _, Func<uint, PollingStrategy> _, uint _, bool _,
-                CancellationToken token) => polls.TryDequeue(out var polled) ? Task.FromResult(polled) : Idle<PolledMessagesRental>(token));
-        await using var consumer = new IggyConsumer(client.Object, Config(), NullLoggerFactory.Instance);
-        await consumer.InitAsync(TestContext.Current.CancellationToken);
-        using var cancellation = Timeout();
-
-        await using var messages = consumer.ReceiveRentedAsync(cancellation.Token)
-            .GetAsyncEnumerator(cancellation.Token);
-        Assert.True(await messages.MoveNextAsync());
-        Assert.Equal(OriginalOffset, messages.Current.CurrentOffset);
-        messages.Current.Dispose();
-        using var delivered = await NextAsync(messages);
-
-        Assert.DoesNotContain((OriginalOffset, Recreated), stores);
-        Assert.Equal(0ul, Assert.IsType<ReceivedRentedMessage>(delivered).CurrentOffset);
-        Assert.Equal([(OriginalOffset, Original)], stores.ToArray());
-    }
-
-    [Fact]
-    public async Task given_a_message_of_an_earlier_poll_when_stored_late_should_store_it_under_its_own_context()
-    {
-        var polls = new ConcurrentQueue<PolledMessages>([Batch(OriginalOffset, Original), Batch(0, Recreated)]);
-        var stores = new ConcurrentQueue<(ulong Offset, PartitionContext Context)>();
-        var client = ClientMock(stores);
-        client.Setup(mock => mock.PollMessagesAsync(It.IsAny<Identifier>(), It.IsAny<Identifier>(),
-                It.IsAny<uint?>(), It.IsAny<Consumer>(), It.IsAny<Func<uint, PollingStrategy>>(), It.IsAny<uint>(),
-                It.IsAny<bool>(), It.IsAny<CancellationToken>()))
-            .Returns((Identifier _, Identifier _, uint? _, Consumer _, Func<uint, PollingStrategy> _, uint _, bool _,
-                CancellationToken token) => polls.TryDequeue(out var polled) ? Task.FromResult(polled) : Idle<PolledMessages>(token));
-        await using var consumer = new IggyConsumer(client.Object, Config(AutoCommitMode.Disabled),
-            NullLoggerFactory.Instance);
-        await consumer.InitAsync(TestContext.Current.CancellationToken);
-        using var cancellation = Timeout();
-        await using var messages = consumer.ReceiveAsync(cancellation.Token).GetAsyncEnumerator(cancellation.Token);
-        Assert.True(await messages.MoveNextAsync());
-        var early = messages.Current;
-        Assert.True(await messages.MoveNextAsync());
-        Assert.Equal(Recreated, messages.Current.Context);
-
-        await consumer.StoreOffsetAsync(early.CurrentOffset, early.PartitionId, early.Context,
-            TestContext.Current.CancellationToken);
-
-        Assert.Equal([(OriginalOffset, Original)], stores.ToArray());
-    }
-
-    public enum OffsetMutation
-    {
-        Store,
-        StoreWithContext,
-        Delete
-    }
-
-    public enum Resolution
-    {
-        Success,
-        NotRetained,
-        CommittedFailure
-    }
-
-    [Theory]
-    [InlineData(OffsetMutation.Store, Resolution.Success)]
-    [InlineData(OffsetMutation.StoreWithContext, Resolution.Success)]
-    [InlineData(OffsetMutation.Delete, Resolution.Success)]
-    [InlineData(OffsetMutation.Store, Resolution.NotRetained)]
-    [InlineData(OffsetMutation.StoreWithContext, Resolution.NotRetained)]
-    [InlineData(OffsetMutation.Delete, Resolution.NotRetained)]
-    [InlineData(OffsetMutation.Store, Resolution.CommittedFailure)]
-    [InlineData(OffsetMutation.StoreWithContext, Resolution.CommittedFailure)]
-    [InlineData(OffsetMutation.Delete, Resolution.CommittedFailure)]
-    public async Task given_a_blocked_offset_mutation_when_its_blocker_resolves_should_retry_once_with_the_same_scope(
-        OffsetMutation mutation, Resolution resolution)
-    {
-        var attempts = new List<CancellationToken>();
-        var client = MutationClient(token =>
-        {
-            attempts.Add(token);
-            return attempts.Count == 1
-                ? Task.FromException(new VsrLaneBlockedException(BlockingOperationId))
-                : Task.CompletedTask;
-        });
-        client.Setup(mock => mock.ResolveOperationAsync(BlockingOperationId, It.IsAny<TimeSpan>(),
-                It.IsAny<CancellationToken>()))
-            .Returns((ulong _, TimeSpan timeout, CancellationToken token) =>
-            {
-                Assert.Equal(attempts[0], token);
-                Assert.InRange(timeout.TotalMilliseconds, 1, 30_000);
-                return resolution switch
-                {
-                    Resolution.NotRetained => Task.FromException(new VsrOperationNotRetainedException(BlockingOperationId)),
-                    Resolution.CommittedFailure => Task.FromException(new IggyInvalidStatusCodeException(5009,
-                        "The blocking operation was refused.", true)
-                    { Committed = true }),
-                    _ => Task.CompletedTask
-                };
-            });
-        await using var consumer = new IggyConsumer(client.Object, Config(), NullLoggerFactory.Instance);
-        using var cancellation = Timeout();
-
-        await Mutate(consumer, mutation, cancellation.Token);
-
-        Assert.Equal(2, attempts.Count);
-        Assert.Equal(attempts[0], attempts[1]);
-        client.Verify(mock => mock.ResolveOperationAsync(BlockingOperationId, It.IsAny<TimeSpan>(),
-            It.IsAny<CancellationToken>()), Times.Once);
-        client.Verify(mock => mock.AbandonOperationAsync(It.IsAny<ulong>()), Times.Never);
-    }
-
-    [Theory]
-    [InlineData(OffsetMutation.Store)]
-    [InlineData(OffsetMutation.StoreWithContext)]
-    [InlineData(OffsetMutation.Delete)]
-    public async Task given_a_blocked_offset_mutation_when_resolution_is_unknown_should_leave_it_unsent(
-        OffsetMutation mutation)
-    {
-        var attempts = 0;
-        var client = MutationClient(_ =>
-        {
-            attempts++;
-            return Task.FromException(new VsrLaneBlockedException(BlockingOperationId));
-        });
-        client.Setup(mock => mock.ResolveOperationAsync(BlockingOperationId, It.IsAny<TimeSpan>(),
-                It.IsAny<CancellationToken>()))
-            .ThrowsAsync(new VsrRequestOutcomeUnknownException(new IOException(), BlockingOperationId));
-        await using var consumer = new IggyConsumer(client.Object, Config(), NullLoggerFactory.Instance);
-
-        var error = await Assert.ThrowsAsync<VsrLaneBlockedException>(() =>
-            Mutate(consumer, mutation, TestContext.Current.CancellationToken));
-
-        Assert.Equal(BlockingOperationId, error.OperationId);
-        Assert.Equal(1, attempts);
-        client.Verify(mock => mock.ResolveOperationAsync(BlockingOperationId, It.IsAny<TimeSpan>(),
-            It.IsAny<CancellationToken>()), Times.Once);
-        client.Verify(mock => mock.AbandonOperationAsync(It.IsAny<ulong>()), Times.Never);
-    }
-
-    [Fact]
-    public async Task given_a_recovered_lane_when_another_blocker_wins_should_not_resolve_again()
-    {
-        var attempts = 0;
-        var client = MutationClient(_ => Task.FromException(
-            new VsrLaneBlockedException(BlockingOperationId + (ulong)attempts++)));
-        client.Setup(mock => mock.ResolveOperationAsync(BlockingOperationId, It.IsAny<TimeSpan>(),
-            It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
-        await using var consumer = new IggyConsumer(client.Object, Config(), NullLoggerFactory.Instance);
-
-        var error = await Assert.ThrowsAsync<VsrLaneBlockedException>(() =>
-            consumer.StoreOffsetAsync(OriginalOffset, PartitionId, ct: TestContext.Current.CancellationToken));
-
-        Assert.Equal(BlockingOperationId + 1, error.OperationId);
-        Assert.Equal(2, attempts);
-        client.Verify(mock => mock.ResolveOperationAsync(It.IsAny<ulong>(), It.IsAny<TimeSpan>(),
-            It.IsAny<CancellationToken>()), Times.Once);
-    }
-
-    [Fact]
-    public async Task given_a_blocked_offset_mutation_when_cancelled_during_resolution_should_not_retry()
-    {
-        var resolving = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
-        var attempts = 0;
-        var client = MutationClient(_ =>
-        {
-            attempts++;
-            return Task.FromException(new VsrLaneBlockedException(BlockingOperationId));
-        });
-        client.Setup(mock => mock.ResolveOperationAsync(BlockingOperationId, It.IsAny<TimeSpan>(),
-                It.IsAny<CancellationToken>()))
-            .Returns(async (ulong _, TimeSpan _, CancellationToken token) =>
-            {
-                resolving.SetResult();
-                await Task.Delay(System.Threading.Timeout.Infinite, token);
-            });
-        await using var consumer = new IggyConsumer(client.Object, Config(), NullLoggerFactory.Instance);
-        using var cancellation = Timeout();
-
-        var mutation = consumer.StoreOffsetAsync(OriginalOffset, PartitionId, ct: cancellation.Token);
-        await resolving.Task.WaitAsync(cancellation.Token);
-        await cancellation.CancelAsync();
-
-        await Assert.ThrowsAnyAsync<OperationCanceledException>(() => mutation);
-        Assert.Equal(1, attempts);
-    }
-
-    private enum DeadlineStage
-    {
-        InitialAttempt,
-        BlockerResolution,
-        NewAttempt
-    }
-
-    [Fact]
-    public async Task given_a_consumer_operation_when_its_internal_deadline_expires_should_preserve_non_admission_or_blocker()
-    {
-        var outcomes = await Task.WhenAll(Enum.GetValues<DeadlineStage>().Select(ExpireAsync));
-
-        Assert.All(outcomes, outcome =>
-        {
-            Assert.False(outcome.CallerCancelled);
-            if (outcome.Stage == DeadlineStage.BlockerResolution)
-            {
-                Assert.Equal(BlockingOperationId, Assert.IsType<VsrLaneBlockedException>(outcome.Error).OperationId);
-            }
-            else
-            {
-                Assert.Equal(VsrError.TRANSIENT_NOT_ACCEPTED,
-                    Assert.IsType<IggyInvalidStatusCodeException>(outcome.Error).StatusCode);
-            }
-
-            Assert.Equal(outcome.Stage == DeadlineStage.NewAttempt ? 2 : 1, outcome.Attempts);
-            Assert.Equal(outcome.Stage == DeadlineStage.InitialAttempt ? 0 : 1, outcome.Resolutions);
-        });
-        static async Task<(DeadlineStage Stage, Exception? Error, int Attempts, int Resolutions, bool CallerCancelled)>
-            ExpireAsync(DeadlineStage stage)
-        {
-            var attempts = 0;
-            var resolutions = 0;
-            var client = MutationClient(async token =>
-            {
-                if (++attempts == 1 && stage != DeadlineStage.InitialAttempt)
-                {
-                    throw new VsrLaneBlockedException(BlockingOperationId);
-                }
-
-                await Task.Delay(System.Threading.Timeout.Infinite, token);
-            });
-            client.Setup(mock => mock.ResolveOperationAsync(BlockingOperationId, It.IsAny<TimeSpan>(),
-                    It.IsAny<CancellationToken>()))
-                .Returns(async (ulong _, TimeSpan _, CancellationToken token) =>
-                {
-                    resolutions++;
-                    if (stage == DeadlineStage.BlockerResolution)
-                    {
-                        await Task.Delay(System.Threading.Timeout.Infinite, token);
-                    }
-                });
-            await using var consumer = new IggyConsumer(client.Object, Config(), NullLoggerFactory.Instance);
-            using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
-            cancellation.CancelAfter(TimeSpan.FromSeconds(40));
-
-            var error = await Record.ExceptionAsync(() =>
-                consumer.StoreOffsetAsync(OriginalOffset, PartitionId, ct: cancellation.Token));
-
-            client.Verify(mock => mock.AbandonOperationAsync(It.IsAny<ulong>()), Times.Never);
-            return (stage, error, attempts, resolutions, cancellation.IsCancellationRequested);
-        }
-    }
-
-    [Fact]
-    public async Task given_a_delayed_lane_refusal_when_resolving_should_use_the_remaining_operation_budget()
-    {
-        var attempts = 0;
-        var client = MutationClient(async token =>
-        {
-            if (++attempts == 1)
-            {
-                await Task.Delay(50, token);
-                throw new VsrLaneBlockedException(BlockingOperationId);
-            }
-        });
-        TimeSpan? resolutionTimeout = null;
-        client.Setup(mock => mock.ResolveOperationAsync(BlockingOperationId, It.IsAny<TimeSpan>(),
-                It.IsAny<CancellationToken>()))
-            .Callback((ulong _, TimeSpan timeout, CancellationToken _) => resolutionTimeout = timeout)
-            .Returns(Task.CompletedTask);
-        await using var consumer = new IggyConsumer(client.Object, Config(), NullLoggerFactory.Instance);
-
-        await consumer.StoreOffsetAsync(OriginalOffset, PartitionId, ct: TestContext.Current.CancellationToken);
-
-        Assert.NotNull(resolutionTimeout);
-        Assert.InRange(resolutionTimeout.Value.TotalMilliseconds, 1, 29_975);
-        Assert.Equal(2, attempts);
-    }
-
-    [Fact]
-    public async Task given_a_progress_poll_behind_a_retained_mutation_when_receiving_should_stop_without_resolving_it()
-    {
-        var client = ClientMock(new ConcurrentQueue<(ulong, PartitionContext)>());
-        var attempts = 0;
-        client.Setup(mock => mock.PollMessagesAsync(It.IsAny<Identifier>(), It.IsAny<Identifier>(),
-                It.IsAny<uint?>(), It.IsAny<Consumer>(), It.IsAny<Func<uint, PollingStrategy>>(), It.IsAny<uint>(),
-                It.IsAny<bool>(), It.IsAny<CancellationToken>()))
-            .Returns((Identifier _, Identifier _, uint? _, Consumer _, Func<uint, PollingStrategy> strategy, uint _,
-                bool _, CancellationToken token) =>
-            {
-                strategy(PartitionId);
-                return ++attempts > 1
-                    ? Idle<PolledMessages>(token)
-                    : Task.FromException<PolledMessages>(new VsrLaneBlockedException(BlockingOperationId));
-            });
-        await using var consumer = new IggyConsumer(client.Object, Config(), NullLoggerFactory.Instance);
-        await consumer.InitAsync(TestContext.Current.CancellationToken);
-        using var cancellation = Timeout();
-        await using var messages = consumer.ReceiveAsync(cancellation.Token).GetAsyncEnumerator(cancellation.Token);
-
-        var error = await Assert.ThrowsAsync<VsrLaneBlockedException>(() => messages.MoveNextAsync().AsTask());
-
-        Assert.Equal(BlockingOperationId, error.OperationId);
-        Assert.Equal(1, attempts);
-        client.Verify(mock => mock.ResolveOperationAsync(It.IsAny<ulong>(), It.IsAny<TimeSpan>(),
-            It.IsAny<CancellationToken>()), Times.Never);
-    }
-
-    [Fact]
-    public async Task given_an_uncertain_progress_poll_when_receiving_should_report_it_and_poll_again()
-    {
-        var client = ClientMock(new ConcurrentQueue<(ulong, PartitionContext)>());
-        var attempts = 0;
-        var unknown = new VsrRequestOutcomeUnknownException(new IOException());
-        client.Setup(mock => mock.PollMessagesAsync(It.IsAny<Identifier>(), It.IsAny<Identifier>(),
-                It.IsAny<uint?>(), It.IsAny<Consumer>(), It.IsAny<Func<uint, PollingStrategy>>(), It.IsAny<uint>(),
-                It.IsAny<bool>(), It.IsAny<CancellationToken>()))
-            .Returns((Identifier _, Identifier _, uint? _, Consumer _, Func<uint, PollingStrategy> strategy, uint _,
-                bool _, CancellationToken _) =>
-            {
-                strategy(PartitionId);
-                return ++attempts > 1
-                    ? Task.FromResult(Batch(OriginalOffset, Original))
-                    : Task.FromException<PolledMessages>(unknown);
-            });
-        await using var consumer = new IggyConsumer(client.Object, Config(), NullLoggerFactory.Instance);
-        var errors = new ConcurrentQueue<Exception>();
-        consumer.SubscribeToErrorEvents(error =>
-        {
-            errors.Enqueue(error.Exception);
-            return Task.CompletedTask;
-        });
-        await consumer.InitAsync(TestContext.Current.CancellationToken);
-        using var cancellation = Timeout();
-        await using var messages = consumer.ReceiveAsync(cancellation.Token).GetAsyncEnumerator(cancellation.Token);
-
-        Assert.True(await messages.MoveNextAsync());
-
-        Assert.Equal(OriginalOffset, messages.Current.CurrentOffset);
-        Assert.Same(unknown, Assert.Single(errors));
-        Assert.Equal(2, attempts);
-        client.Verify(mock => mock.ResolveOperationAsync(It.IsAny<ulong>(), It.IsAny<TimeSpan>(),
-            It.IsAny<CancellationToken>()), Times.Never);
-    }
-
-    private static Mock<IIggyClient> MutationClient(Func<CancellationToken, Task> mutation)
-    {
-        var client = new Mock<IIggyClient>(MockBehavior.Loose);
-        client.Setup(mock => mock.StoreOffsetAsync(It.IsAny<Consumer>(), It.IsAny<Identifier>(),
-                It.IsAny<Identifier>(), OriginalOffset, (uint?)PartitionId, It.IsAny<CancellationToken>()))
-            .Returns((Consumer _, Identifier _, Identifier _, ulong _, uint? _, CancellationToken token) => mutation(token));
-        client.Setup(mock => mock.StoreOffsetAsync(It.IsAny<Consumer>(), It.IsAny<Identifier>(),
-                It.IsAny<Identifier>(), OriginalOffset, PartitionId, Original, It.IsAny<CancellationToken>()))
-            .Returns((Consumer _, Identifier _, Identifier _, ulong _, uint _, PartitionContext _, CancellationToken token) => mutation(token));
-        client.Setup(mock => mock.DeleteOffsetAsync(It.IsAny<Consumer>(), It.IsAny<Identifier>(),
-                It.IsAny<Identifier>(), PartitionId, It.IsAny<CancellationToken>()))
-            .Returns((Consumer _, Identifier _, Identifier _, uint? _, CancellationToken token) => mutation(token));
-        return client;
-    }
-
-    private static Task Mutate(IggyConsumer consumer, OffsetMutation mutation, CancellationToken token)
-    {
-        return mutation switch
-        {
-            OffsetMutation.Store => consumer.StoreOffsetAsync(OriginalOffset, PartitionId, ct: token),
-            OffsetMutation.StoreWithContext => consumer.StoreOffsetAsync(OriginalOffset, PartitionId, Original, token),
-            OffsetMutation.Delete => consumer.DeleteOffsetAsync(PartitionId, token),
-            _ => throw new ArgumentOutOfRangeException(nameof(mutation))
-        };
-    }
-
-    private static IggyConsumerConfig Config(AutoCommitMode mode = AutoCommitMode.AfterReceive)
-    {
-        return new IggyConsumerConfig
-        {
-            StreamId = Identifier.Numeric(1),
-            TopicId = Identifier.Numeric(2),
-            PartitionId = PartitionId,
-            Consumer = Consumer.New(1),
-            PollingStrategy = PollingStrategy.Next(),
-            BatchSize = 10,
-            AutoCommit = false,
-            AutoCommitMode = mode,
-            PollingIntervalMs = 0
-        };
-    }
-
-    private static Mock<IIggyClient> ClientMock(ConcurrentQueue<(ulong Offset, PartitionContext Context)> stores)
-    {
-        var client = new Mock<IIggyClient>(MockBehavior.Loose);
-        client.Setup(mock => mock.StoreOffsetAsync(It.IsAny<Consumer>(), It.IsAny<Identifier>(),
-                It.IsAny<Identifier>(), It.IsAny<ulong>(), It.IsAny<uint>(), It.IsAny<PartitionContext>(),
-                It.IsAny<CancellationToken>()))
-            .Callback((Consumer _, Identifier _, Identifier _, ulong offset, uint _, PartitionContext context,
-                CancellationToken _) => stores.Enqueue((offset, context)))
-            .Returns(Task.CompletedTask);
-        client.Setup(mock => mock.StoreOffsetAsync(It.IsAny<Consumer>(), It.IsAny<Identifier>(),
-                It.IsAny<Identifier>(), It.IsAny<ulong>(), It.IsAny<uint?>(), It.IsAny<CancellationToken>()))
-            .Callback((Consumer _, Identifier _, Identifier _, ulong offset, uint? _, CancellationToken _) =>
-                stores.Enqueue((offset, default)))
-            .Returns(Task.CompletedTask);
-        return client;
-    }
-
-    private static PolledMessages Batch(ulong offset, PartitionContext context)
-    {
-        return new PolledMessages
-        {
-            Context = context,
-            PartitionId = PartitionId,
-            CurrentOffset = offset,
-            Messages =
-            [
-                new MessageResponse
-                {
-                    Header = new MessageHeader { Offset = offset, PayloadLength = 1 },
-                    Payload = [1],
-                    UserHeaders = null
-                }
-            ]
-        };
-    }
-
-    private static PolledMessagesRental Rental(ulong offset, PartitionContext context)
-    {
-        var owner = new RentedConsumerTests.TrackingMemoryOwner(16);
-        return new PolledMessagesRental(owner)
-        {
-            Context = context,
-            PartitionId = PartitionId,
-            CurrentOffset = offset,
-            Messages =
-            [
-                new RentedMessageResponse
-                {
-                    Header = new MessageHeader { Offset = offset, PayloadLength = 1 },
-                    Payload = owner.Memory[..1]
-                }
-            ]
-        };
-    }
-
-    /// <summary>A poll that never answers, so a consumer that dropped the replacement waits for the timeout.</summary>
-    private static async Task<T> Idle<T>(CancellationToken token)
-    {
-        await Task.Delay(System.Threading.Timeout.Infinite, token);
-        throw new OperationCanceledException(token);
-    }
-
-    private static CancellationTokenSource Timeout()
-    {
-        var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
-        cancellation.CancelAfter(TimeSpan.FromSeconds(5));
-        return cancellation;
-    }
-
-    /// <summary>The next message, or null when the consumer delivered nothing before the timeout.</summary>
-    private static async Task<T?> NextAsync<T>(IAsyncEnumerator<T> messages) where T : class
-    {
-        try
-        {
-            return await messages.MoveNextAsync() ? messages.Current : null;
-        }
-        catch (OperationCanceledException)
-        {
-            return null;
-        }
-    }
-}
diff --git a/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/PartitionReaderTests.cs b/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/PartitionReaderTests.cs
new file mode 100644
index 0000000..f4368dc
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/PartitionReaderTests.cs
@@ -0,0 +1,1286 @@
+// 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.
+
+using System.Buffers.Binary;
+using System.Collections.Concurrent;
+using Apache.Iggy.Configuration;
+using Apache.Iggy.Consumers;
+using Apache.Iggy.Contracts;
+using Apache.Iggy.Contracts.Tcp;
+using Apache.Iggy.Encryption;
+using Apache.Iggy.Enums;
+using Apache.Iggy.Exceptions;
+using Apache.Iggy.Extensions;
+using Apache.Iggy.IggyClient;
+using Apache.Iggy.IggyClient.Implementations;
+using Apache.Iggy.Messages;
+using Apache.Iggy.Kinds;
+using Apache.Iggy.Tests.VsrTests;
+using Apache.Iggy.Utils;
+using Apache.Iggy.Vsr;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using static Apache.Iggy.Tests.VsrTests.MockFrames;
+
+namespace Apache.Iggy.Tests.ConsumerTests;
+
+/// <summary>
+///     The partition reader against a fake cluster whose nodes serve a log of <see cref="ReaderCluster.Limit" />
+///     messages per partition, two per read, store and delete consumer offsets, and park a read that finds nothing
+///     until its cancel, which they answer as the server does for a committing read.
+/// </summary>
+public sealed class PartitionReaderTests
+{
+    private static readonly Identifier Stream = Identifier.Numeric(1);
+    private static readonly Identifier Topic = Identifier.Numeric(2);
+    private static readonly TimeSpan PromptLimit = TimeSpan.FromSeconds(5);
+    private static readonly TimeSpan RetryInterval = TimeSpan.FromMilliseconds(50);
+
+    /// <summary>A lost reply drops the connection, so what follows it waits for the client to connect again.</summary>
+    private static readonly TimeSpan LostReplyLimit = TimeSpan.FromSeconds(30);
+
+    [Fact]
+    public async Task given_partitions_of_two_primaries_when_reading_should_fetch_each_primary_on_its_own()
+    {
+        using var cluster = new ReaderCluster(2);
+        using var client = await cluster.ConnectAsync();
+        await using var reader = await Reader(client, CommitPolicy.Manual, 0, 1, 2, 3).BuildAsync(Token);
+
+        var delivered = await ReadAsync(reader, 4);
+
+        Assert.Equal([0u, 1u, 2u, 3u], delivered.Select(batch => batch.PartitionId).Order());
+        Assert.All(delivered, batch => Assert.Equal([0ul, 1ul], batch.Messages.Select(message => message.Header.Offset)));
+        Assert.All(cluster.Fetches, fetch => Assert.All(fetch.Request.Targets,
+            target => Assert.Equal(cluster.Primary(target.PartitionId), fetch.Node)));
+        Assert.Contains(cluster.Fetches, fetch => fetch.Node == 0);
+        Assert.Contains(cluster.Fetches, fetch => fetch.Node == 1);
+    }
+
+    /// <summary>
+    ///     The client routed partition 2 to node 0 before node 1 took it over, so the first fetch of the reader names
+    ///     it at node 0, which refuses its section as not primary. The reader looks the route up again by itself.
+    /// </summary>
+    [Fact]
+    public async Task given_a_moved_primary_when_its_section_is_refused_should_read_the_partition_on_the_new_primary()
+    {
+        using var cluster = new ReaderCluster(2);
+        using var client = await cluster.ConnectAsync();
+        await client.PollMessagesAsync(Stream, Topic, 2, Consumer.New(4), PollingStrategy.Offset(0),
+            new PollOptions(), Token);
+        cluster.Move(2, 1);
+        await using var reader = await Reader(client, CommitPolicy.Manual, 0, 2).BuildAsync(Token);
+
+        var delivered = await ReadAsync(reader, 2);
+
+        Assert.Equal([(0u, 0ul), (2u, 0ul)],
+            delivered.Select(batch => (batch.PartitionId, batch.Messages[0].Header.Offset)).Order());
+        Assert.Contains(cluster.Fetches, fetch => fetch.Node == 0
+                                                  && fetch.Request.Targets.Any(target => target.PartitionId == 2));
+        Assert.Contains(cluster.Fetches, fetch => fetch.Node == 1
+                                                  && fetch.Request.Targets.Any(target => target.PartitionId == 2));
+    }
+
+    [Fact]
+    public async Task given_manual_commits_when_committing_should_store_a_checkpoint_of_this_reader_only()
+    {
+        using var cluster = new ReaderCluster(1);
+        using var client = await cluster.ConnectAsync();
+        await using var reader = await Reader(client, CommitPolicy.Manual, 1).BuildAsync(Token);
+        var batch = await reader.NextAsync(Token);
+        var polled = await client.PollMessagesAsync(Stream, Topic, 1, Consumer.New(4), PollingStrategy.Offset(0),
+            new PollOptions(), Token);
+
+        await reader.CommitAsync(batch.Checkpoint!, Token);
+        var foreign = await Assert.ThrowsAsync<IggyInvalidStatusCodeException>(() =>
+            reader.CommitAsync(polled.Checkpoint!, Token));
+
+        Assert.Equal(VsrError.INVALID_COMMAND, foreign.StatusCode);
+        Assert.Equal(1ul, cluster.Stored[1]);
+    }
+
+    [Fact]
+    public async Task given_on_read_when_committing_or_marking_should_refuse_it()
+    {
+        using var cluster = new ReaderCluster(1);
+        using var client = await cluster.ConnectAsync();
+        await using var reader = await Reader(client, CommitPolicy.OnRead, 1).BuildAsync(Token);
+        var batch = await reader.NextAsync(Token);
+
+        var commit = await Assert.ThrowsAsync<IggyInvalidStatusCodeException>(() =>
+            reader.CommitAsync(batch.Checkpoint!, Token));
+        var mark = Assert.Throws<IggyInvalidStatusCodeException>(() => reader.Mark(batch.Checkpoint!));
+
+        Assert.Equal((VsrError.INVALID_COMMAND, VsrError.INVALID_COMMAND), (commit.StatusCode, mark.StatusCode));
+        Assert.Equal(1ul, cluster.Stored[1]);
+    }
+
+    /// <summary>
+    ///     With on read commits the server stored the offsets of both partitions, but the application took only one
+    ///     batch, so the close moves the other partition back before its first undelivered message.
+    /// </summary>
+    [Fact]
+    public async Task given_on_read_when_closing_with_an_undelivered_batch_should_move_its_offset_back()
+    {
+        using var cluster = new ReaderCluster(1) { Limit = 2 };
+        using var client = await cluster.ConnectAsync();
+        var reader = await Reader(client, CommitPolicy.OnRead, 1, 2).BuildAsync(Token);
+        var taken = await reader.NextAsync(Token);
+        await UntilAsync(() => cluster.Stored.Count == 2);
+
+        await reader.CloseAsync();
+
+        var other = taken.PartitionId == 1 ? 2u : 1u;
+        Assert.Equal(1ul, cluster.Stored[taken.PartitionId]);
+        Assert.False(cluster.Stored.ContainsKey(other));
+    }
+
+    [Fact]
+    public async Task given_on_read_when_seeking_to_an_offset_should_store_the_offset_before_it_first()
+    {
+        using var cluster = new ReaderCluster(1) { Limit = 10 };
+        using var client = await cluster.ConnectAsync();
+        var reader = await Reader(client, CommitPolicy.OnRead, 1).BuildAsync(Token);
+        try
+        {
+            Assert.Equal([0ul, 1ul], (await reader.NextAsync(Token)).Messages.Select(message => message.Header.Offset));
+
+            await reader.SeekAsync(1, PollingStrategy.Offset(6), Token);
+
+            Assert.Equal(5ul, cluster.Stored[1]);
+            Assert.Equal([6ul, 7ul], (await reader.NextAsync(Token)).Messages.Select(message => message.Header.Offset));
+        }
+        finally
+        {
+            await reader.CloseAsync();
+        }
+
+        Assert.Equal(7ul, cluster.Stored[1]);
+    }
+
+    [Theory]
+    [InlineData(MessagePolling.Offset, 0ul, true)]
+    [InlineData(MessagePolling.First, 0ul, true)]
+    [InlineData(MessagePolling.Next, 0ul, true)]
+    [InlineData(MessagePolling.Last, 0ul, true)]
+    [InlineData(MessagePolling.Timestamp, 0ul, true)]
+    [InlineData(MessagePolling.Offset, 6ul, false)]
+    public async Task given_a_seek_that_moves_no_offset_when_seeking_should_store_nothing(MessagePolling kind, ulong value,
+        bool onRead)
+    {
+        using var cluster = new ReaderCluster(1) { Limit = 10 };
+        using var client = await cluster.ConnectAsync();
+        var reader = await Reader(client, onRead ? CommitPolicy.OnRead : CommitPolicy.Manual, 1).BuildAsync(Token);
+        try
+        {
+            await reader.NextAsync(Token);
+
+            await reader.SeekAsync(1, new PollingStrategy { Kind = kind, Value = value }, Token);
+
+            ulong? expected = onRead ? 1ul : null;
+            Assert.Equal(expected, cluster.Stored.TryGetValue(1, out var stored) ? stored : null);
+        }
+        finally
+        {
+            await reader.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task given_on_read_when_the_seek_store_is_refused_should_fail_the_seek_and_move_nothing()
+    {
+        using var cluster = new ReaderCluster(1) { Limit = 10 };
+        using var client = await cluster.ConnectAsync();
+        var reader = await Reader(client, CommitPolicy.OnRead, 1, 2).BuildAsync(Token);
+        try
+        {
+            var taken = await reader.NextAsync(Token);
+            var queued = taken.PartitionId == 1 ? 2u : 1u;
+            cluster.StoresRefused = true;
+
+            var refused = await Assert.ThrowsAsync<IggyInvalidStatusCodeException>(() =>
+                reader.SeekAsync(queued, PollingStrategy.Offset(6), Token));
+
+            cluster.StoresRefused = false;
+            Assert.Equal(VsrError.HISTORY_UNAVAILABLE, refused.StatusCode);
+            Assert.Equal(1ul, cluster.Stored[queued]);
+            var next = await reader.NextAsync(Token);
+            Assert.Equal(queued, next.PartitionId);
+            Assert.Equal([0ul, 1ul], next.Messages.Select(message => message.Header.Offset));
+            var resumed = await reader.NextAsync(Token);
+            while (resumed.PartitionId != queued)
+            {
+                resumed = await reader.NextAsync(Token);
+            }
+
+            Assert.Equal([2ul, 3ul], resumed.Messages.Select(message => message.Header.Offset));
+        }
+        finally
+        {
+            await reader.CloseAsync();
+        }
+    }
+
+    /// <summary>
+    ///     The store of the seek waits for the committing read in flight, as every offset store of the partition does,
+    ///     and the seek then drops the batch of that read and starts the new generation at the sought offset.
+    /// </summary>
+    [Fact]
+    public async Task given_on_read_when_seeking_while_a_committing_read_is_in_flight_should_store_after_that_read_and_drop_it()
+    {
+        using var cluster = new ReaderCluster(1) { Limit = 10, HoldFetches = true };
+        using var client = await cluster.ConnectAsync();
+        var reader = await Reader(client, CommitPolicy.OnRead, 1).BuildAsync(Token);
+        try
+        {
+            using var waiting = CancellationTokenSource.CreateLinkedTokenSource(Token);
+            var next = reader.NextAsync(waiting.Token).AsTask();
+            await UntilAsync(() => cluster.HeldFetches == 1);
+
+            var seek = reader.SeekAsync(1, PollingStrategy.Offset(6), Token);
+
+            await Task.Delay(TimeSpan.FromMilliseconds(200), Token);
+            Assert.Equal((false, false), (cluster.Stored.ContainsKey(1), seek.IsCompleted));
+            await waiting.CancelAsync();
+            await Assert.ThrowsAnyAsync<OperationCanceledException>(() => next);
+            cluster.HoldFetches = false;
+            await cluster.ReleaseHeldAsync();
+            await seek.WaitAsync(PromptLimit, Token);
+            var batch = await reader.NextAsync(Token);
+            Assert.Equal([6ul, 7ul], batch.Messages.Select(message => message.Header.Offset));
+            Assert.Equal(2, cluster.Fetches.Count);
+            var resumed = Assert.Single(cluster.Fetches.Last().Request.Targets);
+            Assert.Equal((MessagePolling.Offset, 6ul), (resumed.Kind, resumed.Value));
+        }
+        finally
+        {
+            cluster.HoldFetches = false;
+            await cluster.ReleaseHeldAsync();
+            await reader.CloseAsync();
+        }
+
+        Assert.Equal(7ul, cluster.Stored[1]);
+    }
+
+    /// <summary>
+    ///     A lost read stored offsets 2 and 3 without handing them over. The next read from Next starts at 4, so the
+    ///     reader reads 2 to 5 again by offset without a commit before it goes on from Next.
+    /// </summary>
+    [Fact]
+    public async Task given_an_oversized_first_record_when_initializing_on_read_should_keep_limits_without_a_size_retry()
+    {
+        var client = new Mock<IIggyClient>();
+        List<PollOptions> reads = [];
+        client.Setup(instance => instance.PollMessagesAsync(Stream, Topic, 1, It.IsAny<Consumer>(),
+                It.IsAny<PollingStrategy>(), It.IsAny<PollOptions>(), It.IsAny<CancellationToken>()))
+            .Callback<Identifier, Identifier, uint, Consumer, PollingStrategy, PollOptions, CancellationToken>(
+                (_, _, _, _, _, options, _) => reads.Add(options))
+            .ReturnsAsync(() => new PolledMessages
+            {
+                PartitionId = 1,
+                CurrentOffset = 0,
+                Context = ReaderCluster.Served,
+                Messages = [new MessageResponse { Header = new MessageHeader { Offset = 0 }, Payload = new byte[2000] }]
+            });
+        var options = new PollOptions
+        {
+            Count = 8,
+            MinCount = 3,
+            MaxBytes = 512,
+            MaxWait = TimeSpan.FromMilliseconds(100),
+            Commit = CommitPolicy.OnRead
+        };
+        await using var reader = await client.Object.Reader(Stream, Topic, Consumer.New(4))
+            .WithPartitions(1).WithOptions(options).BuildAsync(Token);
+        var batch = await reader.NextAsync(Token);
+
+        Assert.Equal(2000, Assert.Single(batch.Messages).Payload.Length);
+        Assert.Equal(2, reads.Count);
+        Assert.Equal(options with { Count = 1, MinCount = 1, Commit = CommitPolicy.Manual }, reads[0]);
+        Assert.Equal(options, reads[1]);
+    }
+
+    [Theory]
+    [InlineData(7ul)]
+    [InlineData(9ul)]
+    public async Task given_on_read_when_the_first_committing_reply_is_lost_should_recover_before_any_delivery(ulong limit)
+    {
+        using var cluster = new ReaderCluster(1) { Limit = limit, LoseNextCommittingRead = true };
+        cluster.Stored[1] = 4;
+        using var client = await cluster.ConnectAsync();
+        var reader = await Reader(client, CommitPolicy.OnRead, 1).BuildAsync(Token);
+        try
+        {
+            using var timeout = CancellationTokenSource.CreateLinkedTokenSource(Token);
+            timeout.CancelAfter(TimeSpan.FromSeconds(30));
+            var batch = await reader.NextAsync(timeout.Token);
+            Assert.Equal([5ul, 6ul], batch.Messages.Select(message => message.Header.Offset));
+        }
+        finally
+        {
+            await reader.CloseAsync();
+        }
+    }
+
+    [Theory]
+    [InlineData(0ul)]
+    [InlineData(5ul)]
+    public async Task given_a_lost_first_reply_when_closing_before_delivery_should_rewind_to_the_captured_start(ulong start)
+    {
+        using var cluster = new ReaderCluster(1) { Limit = start + 4, LoseNextCommittingRead = true };
+        if (start > 0)
+        {
+            cluster.Stored[1] = start - 1;
+        }
+
+        using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(Token);
+        cluster.OnLostCommittingRead = cancellation.Cancel;
+        using var client = await cluster.ConnectAsync();
+        var reader = await Reader(client, CommitPolicy.OnRead, 1).BuildAsync(Token);
+        await Assert.ThrowsAnyAsync<OperationCanceledException>(async () => await reader.NextAsync(cancellation.Token));
+        await reader.CloseAsync();
+
+        if (start == 0)
+        {
+            Assert.False(cluster.Stored.ContainsKey(1));
+        }
+        else
+        {
+            Assert.Equal(start - 1, cluster.Stored[1]);
+        }
+    }
+
+    [Fact]
+    public async Task given_on_read_when_a_lost_read_stored_messages_should_read_them_again_without_a_commit()
+    {
+        using var cluster = new ReaderCluster(1) { Limit = 8 };
+        using var client = await cluster.ConnectAsync();
+        await using var reader = await Reader(client, CommitPolicy.OnRead, 1).BuildAsync(Token);
+        Assert.Equal([0ul, 1ul], (await reader.NextAsync(Token)).Messages.Select(message => message.Header.Offset));
+        await UntilAsync(() => cluster.Stored.TryGetValue(1, out var stored) && stored == 1);
+
+        cluster.LoseNextCommittingRead = true;
+        var delivered = await ReadAsync(reader, 3, LostReplyLimit);
+
+        Assert.Equal([2ul, 3ul, 4ul, 5ul, 6ul, 7ul],
+            delivered.SelectMany(batch => batch.Messages).Select(message => message.Header.Offset));
+        Assert.Equal([(MessagePolling.Offset, 2ul, false), (MessagePolling.Offset, 4ul, false)],
+            cluster.Polls.Where(poll => poll.Kind == MessagePolling.Offset)
+                .Select(poll => (poll.Kind, poll.Value, poll.AutoCommit)));
+        Assert.Equal(7ul, cluster.Stored[1]);
+    }
+
+    /// <summary>
+    ///     A lost read stored offsets 4 and 5, so the next read from Next skipped to 6 and the server stored up to 9.
+    ///     The reads again by offset stop at 9, so the next read from Next starts at 10 and delivers nothing twice.
+    /// </summary>
+    [Fact]
+    public async Task given_on_read_when_a_lost_read_is_read_again_should_stop_at_the_stored_end()
+    {
+        var log = new ScriptedLog { End = 4 };
+        await using var reader = await log.Client().Reader(Stream, Topic, Consumer.New(4)).WithPartitions(1)
+            .WithOptions(new PollOptions
+            {
+                Count = 4,
+                MaxWait = TimeSpan.FromMilliseconds(100),
+                Commit = CommitPolicy.OnRead
+            })
+            .WithRetryInterval(RetryInterval).BuildAsync(Token);
+        var first = await reader.NextAsync(Token);
+        log.End = 6;
+        log.LoseNextCommittingRead = true;
+
+        var delivered = await ReadAsync(reader, 3);
+
+        Assert.Equal(Enumerable.Range(0, 14).Select(offset => (ulong)offset),
+            delivered.Prepend(first).SelectMany(batch => batch.Messages).Select(message => message.Header.Offset));
+    }
+
+    /// <summary>
+    ///     The committing read of offsets 4 to 7 stored its offset, but its reply does not decode. The reader reads
+    ///     the range again by offset one message at a time, reports the bad message 5 with a skip past it, and after
+    ///     the seek delivers the rest.
+    /// </summary>
+    [Fact]
+    public async Task given_on_read_when_a_committing_poll_does_not_decode_should_read_its_range_again_and_skip_only_the_bad_message()
+    {
+        var log = new ScriptedLog { End = 8, Malformed = { 5 } };
+        await using var reader = await log.Client().Reader(Stream, Topic, Consumer.New(4)).WithPartitions(1)
+            .WithOptions(new PollOptions
+            {
+                Count = 4,
+                MaxWait = TimeSpan.FromMilliseconds(100),
+                Commit = CommitPolicy.OnRead
+            })
+            .WithRetryInterval(RetryInterval).BuildAsync(Token);
+        var first = await reader.NextAsync(Token);
+        var second = await reader.NextAsync(Token);
+
+        var error = await NextErrorAsync(reader);
+        await reader.SeekAsync(1, error.Skip!.Value, Token);
+        var third = await reader.NextAsync(Token);
+
+        Assert.Equal([0ul, 1ul, 2ul, 3ul, 4ul, 6ul, 7ul],
+            new[] { first, second, third }.SelectMany(batch => batch.Messages).Select(message => message.Header.Offset));
+        Assert.Equal((MessagePolling.Offset, 6ul), (error.Skip.Value.Kind, error.Skip.Value.Value));
+    }
+
+    /// <summary>
+    ///     A lost read stored offsets 4 and 5, which the next read skipped, and every read of them again comes back
+    ///     empty. A read again asks for no wait, so the reader waits its retry interval between them.
+    /// </summary>
+    [Fact]
+    public async Task given_on_read_when_reading_a_lost_range_again_finds_nothing_should_wait_its_retry_interval()
+    {
+        var log = new ScriptedLog { End = 4, RecoveryEmpty = true };
+        var reader = await log.Client().Reader(Stream, Topic, Consumer.New(4)).WithPartitions(1)
+            .WithOptions(new PollOptions
+            {
+                Count = 4,
+                MaxWait = TimeSpan.FromMilliseconds(100),
+                Commit = CommitPolicy.OnRead
+            })
+            .WithRetryInterval(RetryInterval).BuildAsync(Token);
+        await reader.NextAsync(Token);
+        log.End = 6;
+        log.LoseNextCommittingRead = true;
+        var window = TimeSpan.FromMilliseconds(500);
+        using (var waiting = CancellationTokenSource.CreateLinkedTokenSource(Token))
+        {
+            waiting.CancelAfter(window);
+            await Assert.ThrowsAnyAsync<OperationCanceledException>(async () => await reader.NextAsync(waiting.Token));
+        }
+
+        await reader.DisposeAsync();
+
+        Assert.InRange(log.RecoveryReads, 1, (int)(window / RetryInterval) + 2);
+    }
+
+    /// <summary>
+    ///     With on read commits a seek waits for the read of its partition in flight. The reader closes meanwhile, so
+    ///     no read takes the new position and the close does not store it. The seek fails instead of reporting a
+    ///     position that nothing applies.
+    /// </summary>
+    /// <summary>
+    ///     The seek's store waits for the read in flight, and the close waits for the seek before its rewind, so the
+    ///     rewind sees the sought position and keeps that offset instead of moving back before the dropped batch.
+    /// </summary>
+    [Fact]
+    public async Task given_on_read_when_the_reader_closes_while_a_seek_waits_for_its_read_should_apply_the_seek_and_keep_its_offset()
+    {
+        using var cluster = new ReaderCluster(1) { Limit = 10, HoldFetches = true };
+        using var client = await cluster.ConnectAsync();
+        var reader = await Reader(client, CommitPolicy.OnRead, 1).BuildAsync(Token);
+        var next = reader.NextAsync(Token).AsTask();
+        await UntilAsync(() => cluster.HeldFetches == 1);
+        var seek = reader.SeekAsync(1, PollingStrategy.Offset(6), Token);
+        await Task.Delay(TimeSpan.FromMilliseconds(200), Token);
+
+        var closing = reader.CloseAsync().AsTask();
+        cluster.HoldFetches = false;
+        await cluster.ReleaseHeldAsync();
+        await closing.WaitAsync(PromptLimit, Token);
+
+        await seek.WaitAsync(PromptLimit, Token);
+        await Assert.ThrowsAnyAsync<Exception>(() => next);
+        Assert.Equal(5ul, cluster.Stored[1]);
+    }
+
+    /// <summary>
+    ///     A committing fetch waits on the server for 30 seconds. The close cancels it on the server and ends once the
+    ///     server answers the cancel, instead of waiting out the fetch.
+    /// </summary>
+    [Fact]
+    public async Task given_on_read_when_closing_with_a_parked_committing_fetch_should_cancel_it_and_end_at_its_answer()
+    {
+        using var cluster = new ReaderCluster(1) { Limit = 0 };
+        using var client = await cluster.ConnectAsync();
+        var reader = await client.Reader(Stream, Topic, Consumer.New(4))
+            .WithPartitions(1)
+            .WithStart(PollingStrategy.Offset(0))
+            .WithOptions(new PollOptions { MaxWait = TimeSpan.FromSeconds(30), Commit = CommitPolicy.OnRead })
+            .WithRetryInterval(RetryInterval).BuildAsync(Token);
+        using var waiting = CancellationTokenSource.CreateLinkedTokenSource(Token);
+        var next = reader.NextAsync(waiting.Token).AsTask();
+        await UntilAsync(() => cluster.Fetches.Any(fetch => fetch.Request.AutoCommit));
+
+        await reader.CloseAsync().AsTask().WaitAsync(PromptLimit, Token);
+        await waiting.CancelAsync();
+
+        Assert.Equal(1, cluster.Cancels);
+        await Assert.ThrowsAnyAsync<Exception>(() => next);
+    }
+
+    /// <summary>
+    ///     The first close waits for a committing read whose cancel the server has not answered yet. A second close
+    ///     waits for the first instead of returning while it still runs.
+    /// </summary>
+    [Fact]
+    public async Task given_a_close_in_progress_when_closing_again_should_wait_for_the_first_close()
+    {
+        using var cluster = new ReaderCluster(1) { Limit = 4, HoldFetches = true };
+        using var client = await cluster.ConnectAsync();
+        var reader = await Reader(client, CommitPolicy.OnRead, 1).WithStart(PollingStrategy.Offset(0)).BuildAsync(Token);
+        using var waiting = CancellationTokenSource.CreateLinkedTokenSource(Token);
+        var next = reader.NextAsync(waiting.Token).AsTask();
+        await UntilAsync(() => cluster.HeldFetches == 1);
+        var first = reader.CloseAsync().AsTask();
+
+        var second = reader.CloseAsync().AsTask();
+        await Task.WhenAny(second, Task.Delay(TimeSpan.FromMilliseconds(200), Token));
+        var waited = !second.IsCompleted;
+        cluster.HoldFetches = false;
+        await cluster.ReleaseHeldAsync();
+        await first.WaitAsync(PromptLimit, Token);
+        await second.WaitAsync(PromptLimit, Token);
+        await waiting.CancelAsync();
+
+        Assert.True(waited);
+        await Assert.ThrowsAnyAsync<Exception>(() => next);
+    }
+
+    /// <summary>
+    ///     Another session stored offset 3 while no read of this reader was lost, so the next read from Next starts at
+    ///     4. The reader delivers from there and reads nothing again.
+    /// </summary>
+    [Fact]
+    public async Task given_on_read_when_the_stored_offset_moves_ahead_without_a_lost_read_should_deliver_from_there()
+    {
+        using var cluster = new ReaderCluster(1) { Limit = 8 };
+        using var client = await cluster.ConnectAsync();
+        await using var reader = await Reader(client, CommitPolicy.OnRead, 1).BuildAsync(Token);
+        Assert.Equal([0ul, 1ul], (await reader.NextAsync(Token)).Messages.Select(message => message.Header.Offset));
+        await UntilAsync(() => cluster.Stored.TryGetValue(1, out var stored) && stored == 1);
+
+        cluster.Stored[1] = 3;
+        var delivered = await ReadAsync(reader, 2);
+
+        Assert.Equal([4ul, 5ul, 6ul, 7ul],
+            delivered.SelectMany(batch => batch.Messages).Select(message => message.Header.Offset));
+        Assert.DoesNotContain(cluster.Polls, poll => poll.Kind == MessagePolling.Offset);
+    }
+
+    /// <summary>
+    ///     Every committing fetch of partition 1 ends with an unknown outcome. The reader abandons each one and waits
+    ///     its retry interval before it reads the partition again, instead of fetching at the pace of the round trip.
+    /// </summary>
+    [Fact]
+    public async Task given_on_read_when_every_committing_read_ends_uncertain_should_wait_its_retry_interval_after_each()
+    {
+        using var cluster = new ReaderCluster(1) { Limit = 1000 };
+        cluster.UncertainSections.Add(1);
+        using var client = await cluster.ConnectAsync();
+        var reader = await Reader(client, CommitPolicy.OnRead, 1).BuildAsync(Token);
+        var window = TimeSpan.FromMilliseconds(500);
+        using (var waiting = CancellationTokenSource.CreateLinkedTokenSource(Token))
+        {
+            waiting.CancelAfter(window);
+            await Assert.ThrowsAnyAsync<OperationCanceledException>(async () => await reader.NextAsync(waiting.Token));
+        }
+
+        await reader.DisposeAsync();
+
+        Assert.InRange(cluster.Fetches.Count(fetch => fetch.Request.AutoCommit), 1, (int)(window / RetryInterval) + 2);
+    }
+
+    /// <summary>
+    ///     A lost read stored offsets 5 and 6, the next read from Next served 7 and 8, and the reader closes while it
+    ///     reads 5 and 6 again. Nothing was delivered, so the close moves the offset back before the lost read.
+    /// </summary>
+    [Fact]
+    public async Task given_on_read_when_closing_while_a_lost_read_is_read_again_should_move_the_offset_back_before_it()
+    {
+        using var cluster = new ReaderCluster(1) { Limit = 9, LoseNextCommittingRead = true, RecoveryUnanswered = true };
+        cluster.Stored[1] = 4;
+        using var client = await cluster.ConnectAsync();
+        var reader = await Reader(client, CommitPolicy.OnRead, 1).BuildAsync(Token);
+        using var waiting = CancellationTokenSource.CreateLinkedTokenSource(Token);
+        var next = reader.NextAsync(waiting.Token).AsTask();
+        await UntilAsync(() => cluster.Polls.Any(poll => poll is { Kind: MessagePolling.Offset, Value: 5, AutoCommit: false }),
+            (long)LostReplyLimit.TotalMilliseconds);
+        await waiting.CancelAsync();
+        await Assert.ThrowsAnyAsync<OperationCanceledException>(() => next);
+
+        await reader.CloseAsync();
+
+        Assert.Equal(4ul, cluster.Stored[1]);
+    }
+
+    /// <summary>
+    ///     A rejoin gave the partition a new owner generation, so the server refuses a read from the old position as
+    ///     not owned. The reader drops that position and reads on from the stored offset.
+    /// </summary>
+    [Fact]
+    public async Task given_a_new_owner_generation_when_a_read_is_refused_as_not_owned_should_read_on_from_the_stored_offset()
+    {
+        using var cluster = new ReaderCluster(1) { Assignment = [1] };
+        using var client = await cluster.ConnectAsync();
+        var reader = await client.Reader(Stream, Topic, Consumer.Group("readers"))
+            .WithOptions(new PollOptions { MaxWait = TimeSpan.FromMilliseconds(100) })
+            .WithRetryInterval(RetryInterval).BuildAsync(Token);
+        try
+        {
+            await reader.CommitAsync((await reader.NextAsync(Token)).Checkpoint!, Token);
+            cluster.OwnerGeneration = ReaderCluster.Served.OwnerGeneration + 1;
+            cluster.Limit = 4;
+
+            var next = (await ReadAsync(reader, 1))[0];
+
+            Assert.Equal([2ul, 3ul], next.Messages.Select(message => message.Header.Offset));
+            Assert.Equal(cluster.OwnerGeneration, next.Context.OwnerGeneration);
+        }
+        finally
+        {
+            await reader.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task given_a_partition_that_stays_not_owned_when_reading_should_report_it_after_lasting_refusals()
+    {
+        using var cluster = new ReaderCluster(1) { Assignment = [1] };
+        cluster.NotOwned.Add(1);
+        using var client = await cluster.ConnectAsync();
+        var reader = await client.Reader(Stream, Topic, Consumer.Group("readers"))
+            .WithOptions(new PollOptions { MaxWait = TimeSpan.FromMilliseconds(100) })
+            .WithRetryInterval(RetryInterval).BuildAsync(Token);
+        try
+        {
+            var error = await NextErrorAsync(reader);
+
+            Assert.Equal((1u, VsrError.CONSUMER_GROUP_PARTITION_NOT_OWNED),
+                (error.PartitionId, Assert.IsType<IggyInvalidStatusCodeException>(error.InnerException).StatusCode));
+        }
+        finally
+        {
+            await reader.CloseAsync();
+        }
+    }
+
+    /// <summary>
+    ///     The read of partition 1 is still in flight when a rebalance takes the partition away and gives it back, and
+    ///     the new assignment starts its own read. The old read ends first, which must not free the new one, or a
+    ///     second read of the same position starts and its batch is delivered twice.
+    /// </summary>
+    [Fact]
+    public async Task given_a_partition_assigned_again_when_its_old_read_ends_should_not_read_the_new_position_twice()
+    {
+        using var cluster = new ReaderCluster(1) { Limit = 4, Assignment = [1], HoldFetches = true };
+        using var client = await cluster.ConnectAsync();
+        var reader = await client.Reader(Stream, Topic, Consumer.Group("readers"))
+            .WithStart(PollingStrategy.Offset(0))
+            .WithOptions(new PollOptions { MaxWait = TimeSpan.FromSeconds(30) })
+            .WithRetryInterval(RetryInterval).BuildAsync(Token);
+        try
+        {
+            await UntilAsync(() => cluster.HeldFetches == 1);
+            cluster.Assignment = [];
+            await UntilAsync(() => reader.Partitions.Count == 0, ConsumerGroupClientState.AssignmentRefreshMs * 2);
+            cluster.Assignment = [1];
+            await UntilAsync(() => reader.Partitions.Count == 1 && cluster.HeldFetches == 2,
+                ConsumerGroupClientState.AssignmentRefreshMs * 2);
+
+            await cluster.ReleaseHeldAsync(1);
+            // A second read of the new assignment would start now.
+            await Task.Delay(TimeSpan.FromMilliseconds(200), Token);
+            cluster.HoldFetches = false;
+            await cluster.ReleaseHeldAsync();
+            var delivered = await ReadAsync(reader, 2);
+
+            Assert.Equal([0ul, 1ul, 2ul, 3ul],
+                delivered.SelectMany(batch => batch.Messages).Select(message => message.Header.Offset));
+        }
+        finally
+        {
+            await reader.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task given_on_read_when_reassignment_interrupts_gap_recovery_should_report_the_gap_and_deliver_the_stored_batch()
+    {
+        using var cluster = new ReaderCluster(1) { Limit = 6, Assignment = [1] };
+        using var client = await cluster.ConnectAsync();
+        var reader = await client.Reader(Stream, Topic, Consumer.Group("readers"))
+            .WithOptions(new PollOptions { MaxWait = TimeSpan.FromMilliseconds(100), Commit = CommitPolicy.OnRead })
+            .WithRetryInterval(RetryInterval).BuildAsync(Token);
+        try
+        {
+            Assert.Equal([0ul, 1ul], (await reader.NextAsync(Token)).Messages.Select(message => message.Header.Offset));
+            cluster.LoseNextCommittingRead = true;
+            cluster.AssignmentAfterRead = [];
+            cluster.RecoveryNotOwned = true;
+            using var timeout = CancellationTokenSource.CreateLinkedTokenSource(Token);
+            timeout.CancelAfter(LostReplyLimit);
+            var error = await Assert.ThrowsAsync<ReadException>(async () => await reader.NextAsync(timeout.Token));
+            Assert.Equal(VsrError.FETCH_PROGRESS_UNCERTAIN,
+                Assert.IsAssignableFrom<IggyInvalidStatusCodeException>(error.InnerException).StatusCode);
+            Assert.Equal([4ul, 5ul], (await reader.NextAsync(timeout.Token)).Messages.Select(message => message.Header.Offset));
+        }
+        finally
+        {
+            await reader.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task given_a_message_that_does_not_decode_when_reading_should_report_a_skip_and_resume_after_a_seek()
+    {
+        using var cluster = new ReaderCluster(1) { Limit = 5 };
+        cluster.Undecodable.Add(2);
+        using var client = await cluster.ConnectAsync();
+        await using var reader = await Reader(client, CommitPolicy.Manual, 1).BuildAsync(Token);
+        Assert.Equal(0ul, (await reader.NextAsync(Token)).Messages[0].Header.Offset);
+
+        var error = await Assert.ThrowsAsync<ReadException>(() => reader.NextAsync(Token).AsTask());
+        await reader.SeekAsync(1, error.Skip!.Value, Token);
+        var resumed = await reader.NextAsync(Token);
+
+        Assert.Equal((1u, MessagePolling.Offset, 3ul), (error.PartitionId, error.Skip.Value.Kind, error.Skip.Value.Value));
+        Assert.IsType<MalformedResponseException>(error.InnerException);
+        Assert.Null(error.Offset);
+        Assert.Equal(GoldenPollBody(1, 2, ReaderCluster.Served)[DeferredPollOptions.POLL_REPLY_HEADER_SIZE..^1],
+            error.Record);
+        Assert.Equal([3ul, 4ul], resumed.Messages.Select(message => message.Header.Offset));
+    }
+
+    [Fact]
+    public async Task given_a_message_that_does_not_decrypt_when_reading_should_report_its_offset_and_frame_as_served()
+    {
+        using var cluster = new ReaderCluster(1) { Limit = 5 };
+        using var encryptor = new AesMessageEncryptor(AesMessageEncryptor.GenerateKey());
+        using var client = await cluster.ConnectAsync(encryptor);
+        await using var reader = await Reader(client, CommitPolicy.Manual, 1).BuildAsync(Token);
+        var frameStart = DeferredPollOptions.POLL_REPLY_HEADER_SIZE + BatchWireFormat.BATCH_HEADER_SIZE;
+        var frameEnd = frameStart + BatchWireFormat.FRAME_HEADER_SIZE + "first-payload"u8.Length;
+
+        var error = await Assert.ThrowsAsync<ReadException>(() => reader.NextAsync(Token).AsTask());
+
+        Assert.IsType<MessageDecryptionException>(error.InnerException);
+        Assert.Equal((1u, 0ul, MessagePolling.Offset, 1ul),
+            (error.PartitionId, error.Offset, error.Skip!.Value.Kind, error.Skip.Value.Value));
+        Assert.Equal(GoldenPollBody(1, 0, ReaderCluster.Served)[frameStart..frameEnd], error.Record);
+    }
+
+    [Fact]
+    public async Task given_a_lost_history_on_one_partition_when_reading_should_stop_only_it_until_a_seek()
+    {
+        using var cluster = new ReaderCluster(1) { Limit = 1000 };
+        using var client = await cluster.ConnectAsync();
+        await using var reader = await Reader(client, CommitPolicy.Manual, 1, 2).BuildAsync(Token);
+        await ReadAsync(reader, 2);
+        cluster.HistoryRefused.Add(1);
+
+        var error = await NextErrorAsync(reader);
+        var others = await ReadAsync(reader, 4);
+        await reader.SeekAsync(1, PollingStrategy.Offset(0), Token);
+        cluster.HistoryRefused.Clear();
+        var resumed = await NextOfAsync(reader, 1);
+
+        Assert.Equal((1u, VsrError.HISTORY_UNAVAILABLE),
+            (error.PartitionId, Assert.IsType<IggyInvalidStatusCodeException>(error.InnerException).StatusCode));
+        Assert.All(others, batch => Assert.Equal(2u, batch.PartitionId));
+        Assert.Equal((1u, 0ul), (resumed.PartitionId, resumed.Messages[0].Header.Offset));
+    }
+
+    [Fact]
+    public async Task given_a_last_start_with_an_oversized_first_message_when_reading_should_stop_the_partition_until_a_seek()
+    {
+        using var cluster = new ReaderCluster(1) { Limit = 4, OversizedLast = true };
+        using var client = await cluster.ConnectAsync();
+        await using var reader = await Reader(client, CommitPolicy.Manual, 1)
+            .WithStart(PollingStrategy.Last()).BuildAsync(Token);
+
+        var error = await Assert.ThrowsAsync<ReadException>(() => reader.NextAsync(Token).AsTask());
+        await reader.SeekAsync(1, PollingStrategy.Offset(2), Token);
+        var resumed = await reader.NextAsync(Token);
+
+        Assert.Equal(VsrError.INVALID_SIZE_BYTES,
+            Assert.IsType<IggyInvalidStatusCodeException>(error.InnerException).StatusCode);
+        Assert.Equal(2ul, resumed.Messages[0].Header.Offset);
+    }
+
+    [Fact]
+    public async Task given_a_backward_seek_when_reading_should_deliver_the_earlier_messages_again()
+    {
+        using var cluster = new ReaderCluster(1) { Limit = 4 };
+        using var client = await cluster.ConnectAsync();
+        await using var reader = await Reader(client, CommitPolicy.Manual, 1).BuildAsync(Token);
+        await ReadAsync(reader, 2);
+
+        await reader.SeekAsync(1, PollingStrategy.Offset(0), Token);
+        var again = await reader.NextAsync(Token);
+
+        Assert.Equal([0ul, 1ul], again.Messages.Select(message => message.Header.Offset));
+    }
+
+    [Theory]
+    [InlineData(CommitAfter.Delivery)]
+    [InlineData(CommitAfter.Processing)]
+    public async Task given_auto_commit_when_closing_should_store_the_last_counted_position(CommitAfter after)
+    {
+        using var cluster = new ReaderCluster(1) { Limit = 4 };
+        using var client = await cluster.ConnectAsync();
+        var policy = CommitPolicy.Auto(new AutoCommit { Interval = TimeSpan.FromHours(1), After = after });
+        var reader = await Reader(client, policy, 1).BuildAsync(Token);
+        await ReadAsync(reader, 2);
+
+        await reader.CloseAsync();
+
+        Assert.Equal(3ul, cluster.Stored[1]);
+    }
+
+    [Fact]
+    public async Task given_auto_commit_after_processing_each_batch_when_asking_for_the_next_should_store_the_previous()
+    {
+        using var cluster = new ReaderCluster(1) { Limit = 6 };
+        using var client = await cluster.ConnectAsync();
+        var policy = CommitPolicy.Auto(new AutoCommit { Every = CommitEvery.Batch, After = CommitAfter.Processing });
+        await using var reader = await Reader(client, policy, 1).BuildAsync(Token);
+        await reader.NextAsync(Token);
+        await Task.Delay(TimeSpan.FromMilliseconds(200), Token);
+        Assert.False(cluster.Stored.ContainsKey(1));
+
+        await reader.NextAsync(Token);
+
+        await UntilAsync(() => cluster.Stored.TryGetValue(1, out var stored) && stored == 1);
+    }
+
+    [Fact]
+    public async Task given_auto_commit_after_marks_when_marking_should_store_only_the_marked_position()
+    {
+        using var cluster = new ReaderCluster(1) { Limit = 4 };
+        using var client = await cluster.ConnectAsync();
+        var policy = CommitPolicy.Auto(new AutoCommit { Interval = TimeSpan.FromHours(1), After = CommitAfter.Marked });
+        var reader = await Reader(client, policy, 1).BuildAsync(Token);
+        var batches = await ReadAsync(reader, 2);
+
+        reader.Mark(batches[0].CheckpointAt(0)!);
+        await reader.CloseAsync();
+
+        Assert.Equal(0ul, cluster.Stored[1]);
+    }
+
+    [Fact]
+    public async Task given_a_group_reader_when_built_should_join_and_read_the_assignment_of_its_member()
+    {
+        using var cluster = new ReaderCluster(2) { Assignment = [1, 2] };
+        using var client = await cluster.ConnectAsync();
+        var reader = await client.Reader(Stream, Topic, Consumer.Group("readers"))
+            .WithOptions(new PollOptions { MaxWait = TimeSpan.FromMilliseconds(100) })
+            .WithRetryInterval(RetryInterval).BuildAsync(Token);
+
+        var delivered = await ReadAsync(reader, 2);
+        await reader.CloseAsync();
+
+        Assert.Equal([1u, 2u], delivered.Select(batch => batch.PartitionId).Order());
+        Assert.Equal([1u, 2u], cluster.Fetches.SelectMany(fetch => fetch.Request.Targets)
+            .Select(target => target.PartitionId).Distinct().Order());
+        Assert.Equal((1, 1), (cluster.Joins, cluster.Leaves));
+    }
+
+    /// <summary>
+    ///     The coordinator hands a joining member its partitions one at a time, so the first assignment holds only
+    ///     partition 1. The reader asks again at its refresh interval and reads partition 3 well before the client
+    ///     cache of the assignment expires.
+    /// </summary>
+    [Fact]
+    public async Task given_a_group_reader_when_its_assignment_grows_should_read_the_new_partition_within_its_refresh()
+    {
+        using var cluster = new ReaderCluster(2) { Assignment = [1] };
+        using var client = await cluster.ConnectAsync();
+        var reader = await client.Reader(Stream, Topic, Consumer.Group("readers"))
+            .WithOptions(new PollOptions { MaxWait = TimeSpan.FromMilliseconds(100) })
+            .WithRetryInterval(RetryInterval).BuildAsync(Token);
+        try
+        {
+            Assert.Equal(1u, (await reader.NextAsync(Token)).PartitionId);
+            cluster.Assignment = [1, 3];
+
+            var added = await NextOfAsync(reader, 3, TimeSpan.FromMilliseconds(2500));
+
+            Assert.Equal([0ul, 1ul], added.Messages.Select(message => message.Header.Offset));
+        }
+        finally
+        {
+            await reader.CloseAsync();
+        }
+    }
+
+    /// <summary>
+    ///     The server stored the on read fetch of both partitions, then partition 3 left the assignment of the member
+    ///     before the application took its batch. Dropping the batch would lose its messages.
+    /// </summary>
+    [Fact]
+    public async Task given_on_read_when_a_partition_leaves_with_an_undelivered_batch_should_still_hand_it_over()
+    {
+        using var cluster = new ReaderCluster(2) { Assignment = [1, 3], AssignmentAfterRead = [1] };
+        using var client = await cluster.ConnectAsync();
+        var reader = await client.Reader(Stream, Topic, Consumer.Group("readers"))
+            .WithStart(PollingStrategy.Offset(0))
+            .WithOptions(new PollOptions { MaxWait = TimeSpan.FromMilliseconds(100), Commit = CommitPolicy.OnRead })
+            .WithRetryInterval(RetryInterval).BuildAsync(Token);
+        var first = await reader.NextAsync(Token);
+        var synced = cluster.Syncs;
+        await UntilAsync(() => cluster.Syncs > synced, ConsumerGroupClientState.AssignmentRefreshMs * 2);
+        // The reader applies the reply of the sync after the node counted it.
+        await Task.Delay(100, Token);
+
+        var second = await ReadAsync(reader, 1);
+        await reader.CloseAsync();
+
+        Assert.Equal([1u, 3u], new[] { first.PartitionId, second[0].PartitionId }.Order());
+        Assert.Equal([0ul, 1ul], second[0].Messages.Select(message => message.Header.Offset));
+    }
+
+    [Theory]
+    [InlineData(false)]
+    [InlineData(true)]
+    public async Task given_on_read_when_a_partition_returns_should_keep_its_stored_batch_unless_sought(bool seek)
+    {
+        using var cluster = new ReaderCluster(2) { Limit = 4, Assignment = [1, 3], AssignmentAfterRead = [1] };
+        using var client = await cluster.ConnectAsync();
+        var reader = await client.Reader(Stream, Topic, Consumer.Group("readers"))
+            .WithStart(PollingStrategy.Offset(0))
+            .WithOptions(new PollOptions { MaxWait = TimeSpan.FromMilliseconds(100), Commit = CommitPolicy.OnRead })
+            .WithRetryInterval(RetryInterval).BuildAsync(Token);
+        try
+        {
+            Assert.Equal(1u, (await reader.NextAsync(Token)).PartitionId);
+            await UntilAsync(() => !reader.Partitions.Contains(3u), ConsumerGroupClientState.AssignmentRefreshMs * 2);
+            cluster.Assignment = [1, 3];
+            await UntilAsync(() => reader.Partitions.Contains(3u), ConsumerGroupClientState.AssignmentRefreshMs * 2);
+            if (seek)
+            {
+                await reader.SeekAsync(3, PollingStrategy.Offset(2), Token);
+            }
+
+            var batch = (await ReadAsync(reader, 1))[0];
+            while (batch.PartitionId != 3)
+            {
+                batch = (await ReadAsync(reader, 1))[0];
+            }
+
+            Assert.Equal(seek ? [2ul, 3ul] : [0ul, 1ul], batch.Messages.Select(message => message.Header.Offset));
+        }
+        finally
+        {
+            await reader.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task given_a_failed_route_lookup_when_the_caller_asks_again_at_once_should_wait_its_retry_interval()
+    {
+        using var cluster = new ReaderCluster(1) { RoutesRefused = true };
+        using var client = await cluster.ConnectAsync();
+        var reader = await Reader(client, CommitPolicy.OnRead, 1).WithStart(PollingStrategy.Offset(0)).BuildAsync(Token);
+        var window = TimeSpan.FromMilliseconds(500);
+        var until = DateTime.UtcNow + window;
+        var readerErrors = 0;
+
+        while (DateTime.UtcNow < until)
+        {
+            using var attempt = CancellationTokenSource.CreateLinkedTokenSource(Token);
+            attempt.CancelAfter(TimeSpan.FromMilliseconds(50));
+            try
+            {
+                await reader.NextAsync(attempt.Token);
+            }
+            catch (ReadException)
+            {
+                readerErrors++;
+            }
+            catch (OperationCanceledException) when (!Token.IsCancellationRequested)
+            {
+            }
+        }
+
+        await reader.CloseAsync();
+
+        Assert.True(readerErrors > 0);
+        Assert.InRange(cluster.RouteLookups, 1, (int)(window / RetryInterval) + 2);
+    }
+
+    [Fact]
+    public async Task given_partitions_of_one_primary_when_a_fetch_reaches_its_count_should_give_each_partition_its_turn()
+    {
+        using var cluster = new ReaderCluster(2) { Limit = 100, CountLimited = true };
+        using var client = await cluster.ConnectAsync();
+        var reader = await client.Reader(Stream, Topic, Consumer.New(4))
+            .WithPartitions(1, 3, 5)
+            .WithOptions(new PollOptions { Count = 2, MaxWait = TimeSpan.FromMilliseconds(100) })
+            .WithRetryInterval(RetryInterval).BuildAsync(Token);
+
+        var delivered = await ReadAsync(reader, 3);
+        await reader.CloseAsync();
+
+        Assert.Equal([1u, 3u, 5u], delivered.Select(batch => batch.PartitionId));
+    }
+
+    /// <summary>
+    ///     Every partition of the primary ended its last read before its head, so each one reads in a fetch of its own,
+    ///     with the full limits, and the reader keeps as many reads in flight as separate polls would.
+    /// </summary>
+    [Fact]
+    public async Task given_backlogged_partitions_of_one_primary_when_slots_are_free_should_read_each_in_its_own_fetch()
+    {
+        using var cluster = new ReaderCluster(1) { Limit = 100, ReportsHead = true, HoldFetches = true };
+        using var client = await cluster.ConnectAsync();
+        var reader = await Reader(client, CommitPolicy.Manual, 0, 1, 2, 3).BuildAsync(Token);
+        try
+        {
+            await UntilAsync(() => cluster.HeldFetches == 1);
+            await cluster.ReleaseHeldAsync();
+
+            await UntilAsync(() => cluster.Held.Sum(fetch => fetch.Targets.Length) == 4);
+
+            Assert.All(cluster.Held, fetch => Assert.Single(fetch.Targets));
+            Assert.Equal(4, cluster.HeldFetches);
+        }
+        finally
+        {
+            cluster.HoldFetches = false;
+            await cluster.ReleaseHeldAsync();
+            await reader.CloseAsync();
+        }
+    }
+
+    /// <summary>
+    ///     A committing reader reads only while <c>NextAsync</c> waits, and then deals its backlogged partitions over the
+    ///     free slots as a reader that commits nothing does, one committing fetch per partition.
+    /// </summary>
+    [Fact]
+    public async Task given_backlogged_partitions_of_one_primary_when_reading_on_read_should_commit_each_in_its_own_fetch()
+    {
+        using var cluster = new ReaderCluster(1) { Limit = 100, ReportsHead = true, HoldFetches = true };
+        using var client = await cluster.ConnectAsync();
+        // A start by offset needs no check poll, so the four partitions share the first fetch.
+        var reader = await Reader(client, CommitPolicy.OnRead, 0, 1, 2, 3).WithStart(PollingStrategy.Offset(0))
+            .BuildAsync(Token);
+        try
+        {
+            var first = reader.NextAsync(Token).AsTask();
+            await UntilAsync(() => cluster.HeldFetches == 1);
+            await cluster.ReleaseHeldAsync();
+            List<PolledMessages> delivered = [await first.WaitAsync(PromptLimit, Token)];
+            delivered.AddRange(await ReadAsync(reader, 3));
+            Assert.Equal([0u, 1u, 2u, 3u], delivered.Select(batch => batch.PartitionId).Order());
+
+            var fifth = reader.NextAsync(Token).AsTask();
+
+            await UntilAsync(() => cluster.Held.Sum(fetch => fetch.Targets.Length) == 4);
+            Assert.All(cluster.Held, fetch => Assert.Single(fetch.Targets));
+            Assert.All(cluster.Held, fetch => Assert.True(fetch.AutoCommit));
+            Assert.Equal(4, cluster.HeldFetches);
+            cluster.HoldFetches = false;
+            await cluster.ReleaseHeldAsync();
+            Assert.Equal([2ul, 3ul],
+                (await fifth.WaitAsync(PromptLimit, Token)).Messages.Select(message => message.Header.Offset));
+        }
+        finally
+        {
+            cluster.HoldFetches = false;
+            await cluster.ReleaseHeldAsync();
+            await reader.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task given_reads_that_do_not_wait_when_a_reply_leaves_a_target_out_should_wait_its_retry_interval()
+    {
+        using var cluster = new ReaderCluster(1) { Sectionless = true };
+        using var client = await cluster.ConnectAsync();
+        var reader = await client.Reader(Stream, Topic, Consumer.New(4))
+            .WithPartitions(1)
+            .WithOptions(new PollOptions { MaxWait = TimeSpan.Zero })
+            .WithRetryInterval(RetryInterval).BuildAsync(Token);
+        var window = TimeSpan.FromMilliseconds(500);
+
+        await Task.Delay(window, Token);
+        await reader.CloseAsync();
+
+        Assert.InRange(cluster.Fetches.Count, 1, (int)(window / RetryInterval) + 2);
+    }
+
+    [Fact]
+    public async Task given_invalid_configurations_when_building_should_refuse_them()
+    {
+        using var cluster = new ReaderCluster(1);
+        using var client = await cluster.ConnectAsync();
+        var polled = await client.PollMessagesAsync(Stream, Topic, 1, Consumer.New(4), PollingStrategy.Offset(0),
+            new PollOptions(), Token);
+
+        var noPartitions = await Assert.ThrowsAsync<IggyInvalidStatusCodeException>(() =>
+            client.Reader(Stream, Topic, Consumer.New(4)).BuildAsync(Token));
+        var groupPartitions = await Assert.ThrowsAsync<IggyInvalidStatusCodeException>(() =>
+            client.Reader(Stream, Topic, Consumer.Group("readers")).WithPartitions(1).BuildAsync(Token));
+        var continuation = await Assert.ThrowsAsync<IggyInvalidStatusCodeException>(() =>
+            Reader(client, CommitPolicy.Manual, 1).WithStart(polled.Continuation).BuildAsync(Token));
+        var groupOverHttp = await Assert.ThrowsAsync<FeatureUnavailableException>(() =>
+            new HttpMessageStream(new HttpClient { BaseAddress = new Uri("http://localhost") })
+                .Reader(Stream, Topic, Consumer.Group("readers")).BuildAsync(Token));
+
+        Assert.Equal((VsrError.INVALID_COMMAND, VsrError.INVALID_COMMAND, VsrError.INVALID_COMMAND),
+            (noPartitions.StatusCode, groupPartitions.StatusCode, continuation.StatusCode));
+        Assert.NotNull(groupOverHttp);
+        Assert.Equal(0, cluster.Joins);
+    }
+
+    private static CancellationToken Token => TestContext.Current.CancellationToken;
+
+    private static ReaderBuilder Reader(TcpMessageStream client, CommitPolicy commit, params uint[] partitions)
+    {
+        return client.Reader(Stream, Topic, Consumer.New(4))
+            .WithPartitions(partitions)
+            .WithOptions(new PollOptions { MaxWait = TimeSpan.FromMilliseconds(100), Commit = commit })
+            .WithRetryInterval(RetryInterval);
+    }
+
+    private static async Task<List<PolledMessages>> ReadAsync(PartitionReader reader, int batches,
+        TimeSpan? limit = null)
+    {
+        using var timeout = CancellationTokenSource.CreateLinkedTokenSource(Token);
+        timeout.CancelAfter(limit ?? PromptLimit);
+        List<PolledMessages> delivered = [];
+        while (delivered.Count < batches)
+        {
+            delivered.Add(await reader.NextAsync(timeout.Token));
+        }
+
+        return delivered;
+    }
+
+    private static async Task<PolledMessages> NextOfAsync(PartitionReader reader, uint partition,
+        TimeSpan? limit = null)
+    {
+        using var timeout = CancellationTokenSource.CreateLinkedTokenSource(Token);
+        timeout.CancelAfter(limit ?? PromptLimit);
+        while (true)
+        {
+            var batch = await reader.NextAsync(timeout.Token);
+            if (batch.PartitionId == partition)
+            {
+                return batch;
+            }
+        }
+    }
+
+    private static async Task<ReadException> NextErrorAsync(PartitionReader reader)
+    {
+        using var timeout = CancellationTokenSource.CreateLinkedTokenSource(Token);
+        timeout.CancelAfter(PromptLimit);
+        while (true)
+        {
+            try
+            {
+                await reader.NextAsync(timeout.Token);
+            }
+            catch (ReadException error)
+            {
+                return error;
+            }
+        }
+    }
+
+    private static async Task UntilAsync(Func<bool> predicate, long? limitMs = null)
+    {
+        using var timeout = CancellationTokenSource.CreateLinkedTokenSource(Token);
+        timeout.CancelAfter(limitMs is { } limit ? TimeSpan.FromMilliseconds(limit) : PromptLimit);
+        while (!predicate())
+        {
+            await Task.Delay(5, timeout.Token);
+        }
+    }
+
+    /// <summary>
+    ///     The log of partition 1 up to <see cref="End" /> behind a client that reads it with polls only. A poll
+    ///     serves its count from its start, and a committing one stores its last offset. The committing poll after
+    ///     <see cref="LoseNextCommittingRead" /> stores, loses its reply and finds the log grown to 20 messages.
+    /// </summary>
+    private sealed class ScriptedLog
+    {
+        private readonly object _gate = new();
+        private int _recoveryReads;
+        private ulong? _stored;
+
+        internal ulong End { get; set; }
+
+        internal bool LoseNextCommittingRead { get; set; }
+
+        /// <summary>Offsets whose message does not decode, so a reply that holds one fails to decode.</summary>
+        internal HashSet<ulong> Malformed { get; } = [];
+
+        /// <summary>A read by offset without a commit finds nothing.</summary>
+        internal bool RecoveryEmpty { get; init; }
+
+        internal int RecoveryReads => Volatile.Read(ref _recoveryReads);
+
+        internal IIggyClient Client()
+        {
+            var client = new Mock<IIggyClient>();
+            client.Setup(instance => instance.PollMessagesAsync(Stream, Topic, 1, It.IsAny<Consumer>(),
+                    It.IsAny<PollingStrategy>(), It.IsAny<PollOptions>(), It.IsAny<CancellationToken>()))
+                .Returns<Identifier, Identifier, uint, Consumer, PollingStrategy, PollOptions, CancellationToken>(
+                    (_, _, _, _, start, options, _) => Poll(start, options));
+            return client.Object;
+        }
+
+        private Task<PolledMessages> Poll(PollingStrategy start, PollOptions options)
+        {
+            lock (_gate)
+            {
+                var from = start.Kind == MessagePolling.Next ? _stored + 1 ?? 0 : start.Value;
+                var until = Math.Min(End, from + options.Count);
+                var commits = options.Commit.Mode == CommitMode.OnRead;
+                if (start.Kind == MessagePolling.Offset && !commits)
+                {
+                    Interlocked.Increment(ref _recoveryReads);
+                    until = RecoveryEmpty ? from : until;
+                }
+
+                MessageResponse[] messages =
+                [
+                    .. Enumerable.Range(0, (int)(until > from ? until - from : 0)).Select(index =>
+                        new MessageResponse { Header = new MessageHeader { Offset = from + (ulong)index }, Payload = [1] })
+                ];
+                if (commits && messages.Length > 0)
+                {
+                    _stored = until - 1;
+                    if (LoseNextCommittingRead)
+                    {
+                        LoseNextCommittingRead = false;
+                        End = 20;
+                        return Task.FromException<PolledMessages>(new VsrFetchProgressUncertainException(7));
+                    }
+                }
+
+                if (messages.Any(message => Malformed.Contains(message.Header.Offset)))
+                {
+                    return Task.FromException<PolledMessages>(new MalformedResponseException("The batch does not decode."));
+                }
+
+                return Task.FromResult(new PolledMessages
+                {
+                    PartitionId = 1,
+                    CurrentOffset = End - 1,
+                    Context = ReaderCluster.Served,
+                    Messages = messages
+                });
+            }
+        }
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/PollFailureTests.cs b/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/PollFailureTests.cs
deleted file mode 100644
index bc37502..0000000
--- a/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/PollFailureTests.cs
+++ /dev/null
@@ -1,457 +0,0 @@
-// 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.
-
-using System.Collections.Concurrent;
-using Apache.Iggy.Consumers;
-using Apache.Iggy.Contracts;
-using Apache.Iggy.Enums;
-using Apache.Iggy.Exceptions;
-using Apache.Iggy.IggyClient;
-using Apache.Iggy.Kinds;
-using Apache.Iggy.Vsr;
-using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Logging.Abstractions;
-using Moq;
-
-namespace Apache.Iggy.Tests.ConsumerTests;
-
-public sealed class PollFailureTests
-{
-    private const string GroupName = "group-1";
-
-    // The event id of the log line a poll writes before it rejoins the group.
-    private const int NotJoinedEventId = 3;
-
-    [Theory]
-    [InlineData(false)]
-    [InlineData(true)]
-    public async Task given_an_in_flight_poll_when_canceled_should_end_receiving_without_an_error_event(bool rented)
-    {
-        var polling = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
-        var mock = new Mock<IIggyClient>(MockBehavior.Loose);
-        // A canceled progress poll that already reached the server ends with an unknown outcome, not a cancellation.
-        async Task<T> Interrupted<T>(CancellationToken token)
-        {
-            polling.TrySetResult();
-            await Task.Delay(Timeout.Infinite, token).ContinueWith(_ => { }, TaskScheduler.Default);
-            throw new VsrRequestOutcomeUnknownException(new IOException("The poll was interrupted."));
-        }
-
-        SetupPoll(mock, rented, Interrupted<PolledMessages>, Interrupted<PolledMessagesRental>);
-        var consumer = new IggyConsumer(mock.Object, Config(), NullLoggerFactory.Instance);
-        var errors = 0;
-        consumer.SubscribeToErrorEvents(_ =>
-        {
-            Interlocked.Increment(ref errors);
-            return Task.CompletedTask;
-        });
-        await consumer.InitAsync(TestContext.Current.CancellationToken);
-
-        using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
-        var receiving = rented ? MoveNextRentedAsync(consumer, cancellation.Token) : MoveNextAsync(consumer, cancellation.Token);
-        await polling.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
-        await cancellation.CancelAsync();
-
-        await Assert.ThrowsAnyAsync<OperationCanceledException>(() => receiving)
-            .WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
-        Assert.Equal(0, Volatile.Read(ref errors));
-        await consumer.DisposeAsync();
-    }
-
-    [Theory]
-    [InlineData(false)]
-    [InlineData(true)]
-    public async Task given_a_failing_client_when_receiving_without_a_polling_interval_should_wait_before_polling_again(
-        bool rented)
-    {
-        const int WindowMs = 300;
-        // The window is shorter than the retry delay, so only a starved scheduler lets a second poll in.
-        const int MaxPolls = 2;
-        var polls = 0;
-        var mock = new Mock<IIggyClient>(MockBehavior.Loose);
-        Task<T> Failed<T>(CancellationToken _)
-        {
-            Interlocked.Increment(ref polls);
-            return Task.FromException<T>(new IOException("The client is disconnected."));
-        }
-
-        SetupPoll(mock, rented, Failed<PolledMessages>, Failed<PolledMessagesRental>);
-        var consumer = new IggyConsumer(mock.Object, Config(), NullLoggerFactory.Instance);
-        await consumer.InitAsync(TestContext.Current.CancellationToken);
-
-        using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
-        cancellation.CancelAfter(WindowMs);
-        await Assert.ThrowsAnyAsync<OperationCanceledException>(() => rented
-            ? MoveNextRentedAsync(consumer, cancellation.Token)
-            : MoveNextAsync(consumer, cancellation.Token));
-
-        Assert.InRange(Volatile.Read(ref polls), 1, MaxPolls);
-        await consumer.DisposeAsync();
-    }
-
-    [Theory]
-    [InlineData(false)]
-    [InlineData(true)]
-    public async Task given_a_poll_in_its_retry_delay_when_the_consumer_is_disposed_should_end_receiving_without_an_error(
-        bool rented)
-    {
-        var polls = 0;
-        var mock = new Mock<IIggyClient>(MockBehavior.Loose);
-        Task<T> Failed<T>(CancellationToken _)
-        {
-            Interlocked.Increment(ref polls);
-            return Task.FromException<T>(new IOException("The client is disconnected."));
-        }
-
-        SetupPoll(mock, rented, Failed<PolledMessages>, Failed<PolledMessagesRental>);
-        var consumer = new IggyConsumer(mock.Object, Config(), NullLoggerFactory.Instance);
-        var reported = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
-        // The error event goes out right before the retry delay starts.
-        consumer.SubscribeToErrorEvents(_ =>
-        {
-            reported.TrySetResult();
-            return Task.CompletedTask;
-        });
-        await consumer.InitAsync(TestContext.Current.CancellationToken);
-
-        var receiving = rented
-            ? MoveNextRentedAsync(consumer, TestContext.Current.CancellationToken)
-            : MoveNextAsync(consumer, TestContext.Current.CancellationToken);
-        await reported.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
-        await consumer.DisposeAsync();
-
-        Assert.False(await receiving.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken));
-        Assert.Equal(1, Volatile.Read(ref polls));
-    }
-
-    [Theory]
-    [InlineData(false)]
-    [InlineData(true)]
-    public async Task given_a_disposed_consumer_when_receiving_should_end_without_polling(bool rented)
-    {
-        var polls = 0;
-        var mock = new Mock<IIggyClient>(MockBehavior.Loose);
-        Task<T> Counted<T>(CancellationToken _)
-        {
-            Interlocked.Increment(ref polls);
-            return Task.FromException<T>(new IOException("The client is disposed."));
-        }
-
-        SetupPoll(mock, rented, Counted<PolledMessages>, Counted<PolledMessagesRental>);
-        var consumer = new IggyConsumer(mock.Object, Config(), NullLoggerFactory.Instance);
-        await consumer.InitAsync(TestContext.Current.CancellationToken);
-        await consumer.DisposeAsync();
-
-        Assert.False(await (rented
-            ? MoveNextRentedAsync(consumer, TestContext.Current.CancellationToken)
-            : MoveNextAsync(consumer, TestContext.Current.CancellationToken)));
-        Assert.Equal(0, Volatile.Read(ref polls));
-    }
-
-    [Theory]
-    [InlineData(false)]
-    [InlineData(true)]
-    public async Task given_a_group_rejoin_in_flight_when_the_consumer_is_disposed_should_end_receiving_without_an_error(
-        bool rented)
-    {
-        var rejoin = new HeldLookup(2);
-        var client = new GroupClient(rejoin.Lookup);
-        var consumer = new IggyConsumer(client.Mock.Object, GroupConfig(), NullLoggerFactory.Instance);
-        await consumer.InitAsync(TestContext.Current.CancellationToken);
-        client.ReplaceSession();
-
-        var receiving = rented
-            ? MoveNextRentedAsync(consumer, TestContext.Current.CancellationToken)
-            : MoveNextAsync(consumer, TestContext.Current.CancellationToken);
-        await rejoin.Started.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
-        var disposing = consumer.DisposeAsync().AsTask();
-        rejoin.Answer.SetResult(Group());
-        await disposing;
-
-        Assert.False(await receiving.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken));
-    }
-
-    [Fact]
-    public async Task given_a_group_lookup_in_flight_when_the_consumer_is_disposed_should_not_join_after_leaving()
-    {
-        var rejoin = new HeldLookup(2);
-        var client = new GroupClient(rejoin.Lookup);
-        var consumer = new IggyConsumer(client.Mock.Object, GroupConfig(), NullLoggerFactory.Instance);
-        await consumer.InitAsync(TestContext.Current.CancellationToken);
-        client.ReplaceSession();
-
-        var receiving = MoveNextAsync(consumer, TestContext.Current.CancellationToken);
-        await rejoin.Started.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
-        var disposing = consumer.DisposeAsync().AsTask();
-        rejoin.Answer.SetResult(Group());
-        await disposing;
-        await receiving.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
-
-        Assert.Equal([true], client.Memberships);
-    }
-
-    [Fact]
-    public async Task given_a_poll_waiting_for_an_event_rejoin_when_the_consumer_is_disposed_should_end_without_rejoining()
-    {
-        var rejoin = new HeldLookup(2);
-        var client = new GroupClient(rejoin.Lookup);
-        var skipped = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
-        var logger = new Mock<ILogger>();
-        logger.Setup(log => log.IsEnabled(It.IsAny<LogLevel>())).Returns(true);
-        // The poll logs that it skips polling after its dispose check and right before it waits for the rejoin.
-        logger.Setup(log => log.Log(It.IsAny<LogLevel>(), It.Is<EventId>(id => id.Id == NotJoinedEventId),
-                It.IsAny<It.IsAnyType>(), It.IsAny<Exception?>(), It.IsAny<Func<It.IsAnyType, Exception?, string>>()))
-            .Callback(() => skipped.TrySetResult());
-        var loggers = new Mock<ILoggerFactory>();
-        loggers.Setup(factory => factory.CreateLogger(It.IsAny<string>())).Returns(logger.Object);
-        var consumer = new IggyConsumer(client.Mock.Object, GroupConfig(), loggers.Object);
-        await consumer.InitAsync(TestContext.Current.CancellationToken);
-        client.ReplaceSession();
-        var eventRejoin = client.RaiseAuthenticatedAsync();
-        await rejoin.Started.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
-
-        var receiving = MoveNextAsync(consumer, TestContext.Current.CancellationToken);
-        await skipped.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
-        var disposing = consumer.DisposeAsync().AsTask();
-        rejoin.Answer.SetException(new IOException("The client is disposed."));
-        await disposing;
-        await eventRejoin.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
-
-        Assert.False(await receiving.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken));
-        Assert.Equal(1, client.Joins);
-    }
-
-    [Fact]
-    public async Task given_a_disposed_group_consumer_when_a_late_event_reports_a_new_session_should_not_rejoin()
-    {
-        var client = new GroupClient();
-        var consumer = new IggyConsumer(client.Mock.Object, GroupConfig(), NullLoggerFactory.Instance);
-        await consumer.InitAsync(TestContext.Current.CancellationToken);
-        await consumer.DisposeAsync();
-        client.ReplaceSession();
-
-        // An event published before the dispose unsubscribed the handler still reaches it.
-        await client.RaiseAuthenticatedAsync();
-
-        Assert.Equal(1, client.Joins);
-    }
-
-    [Fact]
-    public async Task given_a_consumer_disposed_before_init_when_initializing_should_throw_object_disposed()
-    {
-        var mock = new Mock<IIggyClient>(MockBehavior.Loose);
-        var consumer = new IggyConsumer(mock.Object, Config(), NullLoggerFactory.Instance);
-        await consumer.DisposeAsync();
-
-        await Assert.ThrowsAsync<ObjectDisposedException>(() =>
-            consumer.InitAsync(TestContext.Current.CancellationToken));
-        mock.Verify(c => c.ConnectAsync(It.IsAny<CancellationToken>()), Times.Never);
-    }
-
-    [Fact]
-    public async Task given_a_disposed_consumer_when_a_subclass_polls_should_throw_object_disposed()
-    {
-        var consumer = new PollingConsumer(new Mock<IIggyClient>(MockBehavior.Loose).Object);
-        await consumer.InitAsync(TestContext.Current.CancellationToken);
-        await consumer.DisposeAsync();
-
-        await Assert.ThrowsAsync<ObjectDisposedException>(() =>
-            consumer.PollAsync(TestContext.Current.CancellationToken));
-    }
-
-    private static void SetupPoll(Mock<IIggyClient> mock, bool rented,
-        Func<CancellationToken, Task<PolledMessages>> poll, Func<CancellationToken, Task<PolledMessagesRental>> rental)
-    {
-        if (rented)
-        {
-            mock.Setup(c => c.PollMessagesRentedAsync(It.IsAny<Identifier>(), It.IsAny<Identifier>(),
-                    It.IsAny<uint?>(), It.IsAny<Consumer>(), It.IsAny<Func<uint, PollingStrategy>>(), It.IsAny<uint>(),
-                    It.IsAny<bool>(), It.IsAny<CancellationToken>()))
-                .Returns((Identifier _, Identifier _, uint? _, Consumer _, Func<uint, PollingStrategy> _, uint _,
-                    bool _, CancellationToken token) => rental(token));
-            return;
-        }
-
-        mock.Setup(c => c.PollMessagesAsync(It.IsAny<Identifier>(), It.IsAny<Identifier>(), It.IsAny<uint?>(),
-                It.IsAny<Consumer>(), It.IsAny<Func<uint, PollingStrategy>>(), It.IsAny<uint>(), It.IsAny<bool>(),
-                It.IsAny<CancellationToken>()))
-            .Returns((Identifier _, Identifier _, uint? _, Consumer _, Func<uint, PollingStrategy> _, uint _, bool _,
-                CancellationToken token) => poll(token));
-    }
-
-    private static async Task<bool> MoveNextAsync(IggyConsumer consumer, CancellationToken token)
-    {
-        await using var messages = consumer.ReceiveAsync(token).GetAsyncEnumerator(token);
-        return await messages.MoveNextAsync();
-    }
-
-    private static async Task<bool> MoveNextRentedAsync(IggyConsumer consumer, CancellationToken token)
-    {
-        await using var messages = consumer.ReceiveRentedAsync(token).GetAsyncEnumerator(token);
-        return await messages.MoveNextAsync();
-    }
-
-    private static IggyConsumerConfig Config()
-    {
-        return new IggyConsumerConfig
-        {
-            StreamId = Identifier.Numeric(1),
-            TopicId = Identifier.Numeric(1),
-            Consumer = Consumer.New(1),
-            PollingStrategy = PollingStrategy.Next(),
-            BatchSize = 10,
-            PartitionId = 1,
-            AutoCommitMode = AutoCommitMode.Disabled,
-            AutoCommit = false,
-            PollingIntervalMs = 0
-        };
-    }
-
-    [Theory]
-    [InlineData(false)]
-    [InlineData(true)]
-    public async Task given_initialization_in_flight_when_disposed_should_finish_cleanup_before_returning(bool joining)
-    {
-        var lookup = new HeldLookup(1);
-        var client = new GroupClient(joining ? null : lookup.Lookup);
-        var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
-        var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
-        if (joining)
-        {
-            client.Mock.Setup(mock => mock.JoinConsumerGroupAsync(It.IsAny<Identifier>(), It.IsAny<Identifier>(),
-                    It.IsAny<Identifier>(), It.IsAny<CancellationToken>()))
-                .Returns(() =>
-                {
-                    started.TrySetResult();
-                    return release.Task;
-                });
-        }
-        var consumer = new IggyConsumer(client.Mock.Object, GroupConfig(), NullLoggerFactory.Instance);
-        using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
-        var initialize = consumer.InitAsync(timeout.Token);
-        await (joining ? started.Task : lookup.Started).WaitAsync(timeout.Token);
-        var dispose = consumer.DisposeAsync().AsTask();
-        try
-        {
-            Assert.False(dispose.IsCompleted);
-        }
-        finally
-        {
-            release.TrySetResult();
-            lookup.Answer.TrySetResult(Group());
-        }
-        Assert.NotNull(await Record.ExceptionAsync(() => initialize));
-        await dispose.WaitAsync(timeout.Token);
-        await consumer.DisposeAsync();
-        client.Mock.Verify(mock => mock.SubscribeConnectionEvents(It.IsAny<Func<ConnectionStateChangedEventArgs, Task>>()), Times.Never);
-        client.Mock.Verify(mock => mock.LeaveConsumerGroupAsync(It.IsAny<Identifier>(), It.IsAny<Identifier>(),
-            It.IsAny<Identifier>(), It.IsAny<CancellationToken>()), joining ? Times.Once() : Times.Never());
-        await Assert.ThrowsAsync<ObjectDisposedException>(() => consumer.InitAsync(timeout.Token));
-    }
-
-    private static IggyConsumerConfig GroupConfig()
-    {
-        var config = Config();
-        config.Consumer = Consumer.Group(GroupName);
-        return config;
-    }
-
-    private static ConsumerGroupResponse Group() =>
-        new() { Id = 1, Name = GroupName, MembersCount = 1, PartitionsCount = 1 };
-
-    /// <summary>
-    ///     A client whose session generation keys the group membership, as the TCP client's does. It counts the joins
-    ///     and keeps the state handler after the consumer unsubscribes it.
-    /// </summary>
-    private sealed class GroupClient
-    {
-        private ulong _generation;
-        private int _joins;
-        private int _lookups;
-        private Func<ConnectionStateChangedEventArgs, Task>? _handler;
-        private readonly ConcurrentQueue<bool> _memberships = new();
-
-        /// <param name="lookup">Answers the group lookup of the given 1-based number, or null for the group.</param>
-        internal GroupClient(Func<int, Task<ConsumerGroupResponse?>?>? lookup = null)
-        {
-            Mock.Setup(c => c.LeaveConsumerGroupAsync(It.IsAny<Identifier>(), It.IsAny<Identifier>(),
-                    It.IsAny<Identifier>(), It.IsAny<CancellationToken>()))
-                .Callback(() => _memberships.Enqueue(false))
-                .Returns(Task.CompletedTask);
-            Mock.As<ISessionGenerationProvider>().SetupGet(c => c.SessionGeneration)
-                .Returns(() => Interlocked.Read(ref _generation));
-            Mock.Setup(c => c.SubscribeConnectionEvents(It.IsAny<Func<ConnectionStateChangedEventArgs, Task>>()))
-                .Callback<Func<ConnectionStateChangedEventArgs, Task>>(handler => _handler = handler);
-            Mock.Setup(c => c.GetConsumerGroupByIdAsync(It.IsAny<Identifier>(), It.IsAny<Identifier>(),
-                    It.IsAny<Identifier>(), It.IsAny<CancellationToken>()))
-                .Returns(() => lookup?.Invoke(Interlocked.Increment(ref _lookups))
-                    ?? Task.FromResult<ConsumerGroupResponse?>(Group()));
-            Mock.Setup(c => c.JoinConsumerGroupAsync(It.IsAny<Identifier>(), It.IsAny<Identifier>(),
-                    It.IsAny<Identifier>(), It.IsAny<CancellationToken>()))
-                .Callback(() =>
-                {
-                    Interlocked.Increment(ref _joins);
-                    _memberships.Enqueue(true);
-                })
-                .Returns(Task.CompletedTask);
-        }
-
-        internal Mock<IIggyClient> Mock { get; } = new(MockBehavior.Loose);
-
-        internal int Joins => Volatile.Read(ref _joins);
-
-        /// <summary>Whether the consumer is a group member after each join and leave, in the order of the calls.</summary>
-        internal bool[] Memberships => _memberships.ToArray();
-
-        /// <summary>A new session ends the membership, so the next poll or state event rejoins the group.</summary>
-        internal void ReplaceSession() => Interlocked.Increment(ref _generation);
-
-        internal Task RaiseAuthenticatedAsync() =>
-            _handler!(new ConnectionStateChangedEventArgs(ConnectionState.Connected, ConnectionState.Authenticated));
-    }
-
-    /// <summary>Holds the group lookup of one number until the test completes <see cref="Answer" />.</summary>
-    private sealed class HeldLookup(int number)
-    {
-        private readonly TaskCompletionSource _started = new(TaskCreationOptions.RunContinuationsAsynchronously);
-
-        internal TaskCompletionSource<ConsumerGroupResponse?> Answer { get; } =
-            new(TaskCreationOptions.RunContinuationsAsynchronously);
-
-        internal Task Started => _started.Task;
-
-        internal Task<ConsumerGroupResponse?>? Lookup(int lookup)
-        {
-            if (lookup != number)
-            {
-                return null;
-            }
-
-            _started.TrySetResult();
-            return Answer.Task;
-        }
-    }
-
-    private sealed class PollingConsumer : IggyConsumer
-    {
-        public PollingConsumer(IIggyClient client) : base(client, Config(), NullLoggerFactory.Instance)
-        {
-        }
-
-        public Task PollAsync(CancellationToken token) => PollRentedMessagesAsync(token);
-    }
-}
diff --git a/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/ReaderCluster.cs b/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/ReaderCluster.cs
new file mode 100644
index 0000000..9a72c0f
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/ReaderCluster.cs
@@ -0,0 +1,422 @@
+// 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.
+
+using System.Buffers.Binary;
+using System.Collections.Concurrent;
+using Apache.Iggy.Configuration;
+using Apache.Iggy.Consumers;
+using Apache.Iggy.Contracts;
+using Apache.Iggy.Contracts.Tcp;
+using Apache.Iggy.Encryption;
+using Apache.Iggy.Enums;
+using Apache.Iggy.Exceptions;
+using Apache.Iggy.Extensions;
+using Apache.Iggy.IggyClient.Implementations;
+using Apache.Iggy.Kinds;
+using Apache.Iggy.Tests.VsrTests;
+using Apache.Iggy.Utils;
+using Apache.Iggy.Vsr;
+using Microsoft.Extensions.Logging.Abstractions;
+using static Apache.Iggy.Tests.VsrTests.MockFrames;
+
+namespace Apache.Iggy.Tests.ConsumerTests;
+
+/// <summary>A fetch as one node of the cluster received it.</summary>
+internal readonly record struct ReceivedFetch(int Node, FetchRequest Request);
+
+/// <summary>A deferred poll as the cluster received it.</summary>
+internal readonly record struct ReceivedPoll(uint PartitionId, MessagePolling Kind, ulong Value, bool AutoCommit);
+
+/// <summary>
+///     Nodes that each lead the partitions that <see cref="Primary" /> names. A partition holds the offsets 0 to
+///     <see cref="Limit" /> minus 1, and a read serves two messages from its start. A read of a partition that
+///     this node does not lead is refused as not primary.
+/// </summary>
+internal sealed class ReaderCluster : IDisposable
+{
+    private const int PartitionsCount = 4;
+
+    /// <summary>The context of every route and every read.</summary>
+    internal static readonly PartitionContext Served = new(17, 9, 52);
+
+    /// <summary>A StoreConsumerOffset body ends with the partition id, the offset and the ack quorum.</summary>
+    private const int StoredPartitionFromEnd = sizeof(uint) + sizeof(ulong) + sizeof(byte);
+
+    /// <summary>A DeleteConsumerOffset body ends with the partition id and the ack quorum.</summary>
+    private const int DeletedPartitionFromEnd = sizeof(uint) + sizeof(byte);
+
+    private readonly ConcurrentDictionary<uint, int> _moved = new();
+    private readonly MockNode[] _nodes;
+    private readonly ConcurrentDictionary<(int Node, UInt128 Client, ulong Request), MockRequest> _parked = new();
+    private readonly ConcurrentQueue<(int Node, MockRequest Request)> _held = new();
+    private int _cancels;
+    private int _joins;
+    private int _leaves;
+    private int _syncs;
+    private int _routeLookups;
+
+    internal ReaderCluster(int nodes)
+    {
+        _nodes = [.. Enumerable.Range(0, nodes).Select(_ => new MockNode())];
+        for (var node = 0; node < nodes; node++)
+        {
+            var index = node;
+            _nodes[node].Serve(request => Handle(index, request));
+        }
+    }
+
+    internal ulong Limit { get; set; } = 2;
+
+    /// <summary>The owner generation of every partition. A read that names another one is refused as not owned.</summary>
+    internal ulong OwnerGeneration { get; set; } = Served.OwnerGeneration;
+
+    /// <summary>Partitions whose committing fetch section is answered as not committed, so its outcome is unknown.</summary>
+    internal ConcurrentBag<uint> UncertainSections { get; } = [];
+
+    /// <summary>Partitions whose fetch is refused as not owned whatever it names.</summary>
+    internal ConcurrentBag<uint> NotOwned { get; } = [];
+
+    /// <summary>A poll that reads by offset without a commit gets no reply, so a recovery stays in flight.</summary>
+    internal bool RecoveryUnanswered { get; set; }
+
+    /// <summary>Fetches get no reply until <see cref="ReleaseHeldAsync" />, which serves them as they are then.</summary>
+    internal bool HoldFetches { get; set; }
+
+    /// <summary>Every offset store is refused with status 87.</summary>
+    internal bool StoresRefused { get; set; }
+
+    internal int HeldFetches => _held.Count;
+
+    /// <summary>The fetches that wait for <see cref="ReleaseHeldAsync" />, as the nodes received them.</summary>
+    internal IReadOnlyList<FetchRequest> Held => [.. _held.Select(held => ReadFetch(held.Request))];
+
+    /// <summary>A served section reports the head of its partition, as the server does, instead of its last offset.</summary>
+    internal bool ReportsHead { get; init; }
+
+    internal bool OversizedLast { get; init; }
+
+    internal bool RecoveryNotOwned { get; set; }
+
+    internal bool LoseNextCommittingRead { get; set; }
+
+    internal Action? OnLostCommittingRead { get; set; }
+
+    internal uint[] Assignment { get; set; } = [];
+
+    /// <summary>The assignment that the member moves to once a committing fetch is served.</summary>
+    internal uint[]? AssignmentAfterRead { get; set; }
+
+    internal HashSet<ulong> Undecodable { get; } = [];
+
+    internal ConcurrentBag<uint> HistoryRefused { get; } = [];
+
+    internal ConcurrentDictionary<uint, ulong> Stored { get; } = new();
+
+    internal ConcurrentQueue<ReceivedFetch> Fetches { get; } = new();
+
+    internal ConcurrentQueue<ReceivedPoll> Polls { get; } = new();
+
+    internal int Cancels => Volatile.Read(ref _cancels);
+
+    internal int Joins => Volatile.Read(ref _joins);
+
+    internal int Leaves => Volatile.Read(ref _leaves);
+
+    internal int Syncs => Volatile.Read(ref _syncs);
+
+    /// <summary>Refuses every route lookup, so the reader cannot plan a read.</summary>
+    internal bool RoutesRefused { get; set; }
+
+    internal int RouteLookups => Volatile.Read(ref _routeLookups);
+
+    /// <summary>A fetch reads its targets in order until it holds its count, as the server does.</summary>
+    internal bool CountLimited { get; init; }
+
+    /// <summary>A fetch replies without sections.</summary>
+    internal bool Sectionless { get; init; }
+
+    public void Dispose()
+    {
+        foreach (var node in _nodes)
+        {
+            node.Dispose();
+        }
+    }
+
+    /// <summary>Serves the <paramref name="count" /> oldest held fetches, or all of them.</summary>
+    internal async Task ReleaseHeldAsync(int count = int.MaxValue)
+    {
+        while (count-- > 0 && _held.TryDequeue(out var held))
+        {
+            if (Fetch(held.Node, held.Request) is { } reply)
+            {
+                await _nodes[held.Node].AnswerParkedAsync(held.Request, reply);
+            }
+        }
+    }
+
+    internal int Primary(uint partition)
+    {
+        return _moved.TryGetValue(partition, out var node) ? node : (int)(partition % (uint)_nodes.Length);
+    }
+
+    internal void Move(uint partition, int node)
+    {
+        _moved[partition] = node;
+    }
+
+    internal async Task<TcpMessageStream> ConnectAsync(IMessageEncryptor? encryptor = null)
+    {
+        var client = new TcpMessageStream(new IggyClientConfigurator
+        {
+            BaseAddress = $"127.0.0.1:{_nodes[0].Port}",
+            Protocol = Protocol.Tcp,
+            MessageEncryptor = encryptor
+        }, NullLoggerFactory.Instance);
+        await client.ConnectAsync(TestContext.Current.CancellationToken);
+        await client.LoginUserAsync("user", "secret", TestContext.Current.CancellationToken);
+        return client;
+    }
+
+    private byte[]? Handle(int node, MockRequest request)
+    {
+        switch ((VsrOperation)request.Operation)
+        {
+            case VsrOperation.StoreConsumerOffset when StoresRefused:
+                return Reply(request.Operation, [], VsrError.HISTORY_UNAVAILABLE);
+            case VsrOperation.StoreConsumerOffset:
+                Stored[BinaryPrimitives.ReadUInt32LittleEndian(request.Body.AsSpan(^StoredPartitionFromEnd))] =
+                    BinaryPrimitives.ReadUInt64LittleEndian(
+                        request.Body.AsSpan(^(StoredPartitionFromEnd - sizeof(uint))));
+                return Answer(request);
+            case VsrOperation.DeleteConsumerOffset:
+                Stored.TryRemove(BinaryPrimitives.ReadUInt32LittleEndian(
+                    request.Body.AsSpan(^DeletedPartitionFromEnd)), out _);
+                return Answer(request);
+            case VsrOperation.JoinConsumerGroup:
+                Interlocked.Increment(ref _joins);
+                return Answer(request);
+            case VsrOperation.LeaveConsumerGroup:
+                Interlocked.Increment(ref _leaves);
+                return Answer(request);
+        }
+
+        switch (request.Code)
+        {
+            case GET_CLUSTER_METADATA_CODE:
+                return Roster();
+            case CommandCodes.GET_PARTITION_ROUTE_CODE:
+                Interlocked.Increment(ref _routeLookups);
+                if (RoutesRefused)
+                {
+                    return Reply(request.Operation, [], VsrError.UNAUTHORIZED);
+                }
+
+                var routed = BinaryPrimitives.ReadUInt32LittleEndian(request.Body.AsSpan(^sizeof(uint)));
+                var primary = (byte)Primary(routed);
+                return RouteReply(request, Context, primary, PartitionsCount, primary);
+            case CommandCodes.SYNC_CONSUMER_GROUP_CODE:
+                Interlocked.Increment(ref _syncs);
+                return Reply(OPERATION_NON_REPLICATED, AssignmentBody(1, Assignment));
+            case CommandCodes.FETCH_MESSAGES_CODE when HoldFetches:
+                _held.Enqueue((node, request));
+                return null;
+            case CommandCodes.FETCH_MESSAGES_CODE:
+                return Fetch(node, request);
+            case CommandCodes.POLL_MESSAGES_DEFERRED_CODE:
+                return Poll(node, request);
+            case CommandCodes.CANCEL_DEFERRED_REQUEST_CODE:
+                Interlocked.Increment(ref _cancels);
+                if (_parked.TryRemove((node, request.ClientId, request.RequestId), out var parked))
+                {
+                    _ = Task.Run(() => _nodes[node].AnswerParkedAsync(parked,
+                        Reply(parked.Operation, [], VsrError.TRANSIENT_NOT_ACCEPTED)));
+                }
+
+                return null;
+            default:
+                return Answer(request);
+        }
+    }
+
+    private byte[]? Fetch(int node, MockRequest request)
+    {
+        var fetch = ReadFetch(request);
+        Fetches.Enqueue(new ReceivedFetch(node, fetch));
+        // The server fences the whole fetch, so the refusal names no target.
+        if (fetch.Targets.Any(target => NotOwned.Contains(target.PartitionId)
+                                        || target.OwnerGeneration != 0 && target.OwnerGeneration != OwnerGeneration))
+        {
+            return Reply(request.Operation, [], VsrError.CONSUMER_GROUP_PARTITION_NOT_OWNED);
+        }
+
+        List<FetchSection> sections = [];
+        var served = false;
+        var held = 0u;
+        foreach (var target in fetch.Targets)
+        {
+            var partition = target.PartitionId;
+            if (Primary(partition) != node)
+            {
+                sections.Add(EmptySection(partition, 0, default, VsrError.NOT_PRIMARY));
+                continue;
+            }
+
+            if (fetch.AutoCommit && UncertainSections.Contains(partition))
+            {
+                sections.Add(EmptySection(partition, 0, default, VsrError.TRANSIENT_NOT_COMMITTED));
+                served = true;
+                continue;
+            }
+
+            if (HistoryRefused.Contains(partition))
+            {
+                sections.Add(EmptySection(partition, 0, default, VsrError.HISTORY_UNAVAILABLE));
+                served = true;
+                continue;
+            }
+
+            var start = target.Kind == MessagePolling.Next ? NextOffset(partition) : target.Value;
+            if (start >= Limit || CountLimited && held >= fetch.Count)
+            {
+                sections.Add(EmptySection(partition, start, Context));
+                continue;
+            }
+
+            served = true;
+            held += GOLDEN_BATCH_MESSAGES;
+            var section = GoldenSection(partition, start, Context);
+            section = ReportsHead ? section with { CurrentOffset = Limit - 1 } : section;
+            sections.Add(Undecodable.Contains(start) ? section with { Records = [] } : section);
+            if (fetch.AutoCommit)
+            {
+                Stored[partition] = start + GOLDEN_BATCH_MESSAGES - 1;
+            }
+        }
+
+        if (served && fetch.AutoCommit && LoseNextCommittingRead)
+        {
+            LoseNextCommittingRead = false;
+            OnLostCommittingRead?.Invoke();
+            throw new IOException("Reply lost after storing.");
+        }
+
+        if (served && fetch.AutoCommit && AssignmentAfterRead is { } assignment)
+        {
+            Assignment = assignment;
+            AssignmentAfterRead = null;
+        }
+
+        if (!served && sections.All(section => section.Status == 0))
+        {
+            Park(node, request, fetch.WaitUs, Reply(request.Operation, FetchBody([.. sections])));
+            return null;
+        }
+
+        return Reply(request.Operation, FetchBody(Sectionless ? [] : [.. sections]));
+    }
+
+    private byte[]? Poll(int node, MockRequest request)
+    {
+        var (partition, kind, value, _) = ReadPoll(request);
+        var autoCommit = request.Body[^(TcpContracts.DEFERRED_POLL_TAIL_SIZE + 1)] != 0;
+        Polls.Enqueue(new ReceivedPoll(partition, kind, value, autoCommit));
+        if (Primary(partition) != node)
+        {
+            return Reply(request.Operation, [], VsrError.NOT_PRIMARY);
+        }
+
+        if (kind == MessagePolling.Last && OversizedLast)
+        {
+            return Reply(request.Operation, [], VsrError.INVALID_SIZE_BYTES);
+        }
+
+        if (RecoveryNotOwned && kind == MessagePolling.Offset && !autoCommit)
+        {
+            return Reply(request.Operation, [], VsrError.CONSUMER_GROUP_PARTITION_NOT_OWNED);
+        }
+
+        if (RecoveryUnanswered && kind == MessagePolling.Offset && !autoCommit)
+        {
+            return null;
+        }
+
+        var start = kind switch
+        {
+            MessagePolling.Next => NextOffset(partition),
+            MessagePolling.Offset => value,
+            MessagePolling.Last => Limit - GOLDEN_BATCH_MESSAGES,
+            _ => 0ul
+        };
+        if (start >= Limit)
+        {
+            var empty = GoldenPollBody(partition, start, Context)[..DeferredPollOptions.POLL_REPLY_HEADER_SIZE];
+            BinaryPrimitives.WriteUInt32LittleEndian(empty.AsSpan(sizeof(uint) + sizeof(ulong)), 0);
+            var tail = request.Body.AsSpan(request.Body.Length - TcpContracts.DEFERRED_POLL_TAIL_SIZE);
+            Park(node, request, BinaryPrimitives.ReadUInt64LittleEndian(tail), Reply(request.Operation, empty));
+            return null;
+        }
+
+        var body = GoldenPollBody(partition, start, Context);
+        if (Undecodable.Contains(start))
+        {
+            body = body[..^1];
+        }
+
+        if (autoCommit)
+        {
+            Stored[partition] = start + GOLDEN_BATCH_MESSAGES - 1;
+        }
+
+        return Reply(request.Operation, body);
+    }
+
+    /// <summary>Holds a read that finds nothing until its wait ends, then answers it with <paramref name="empty" />.</summary>
+    private void Park(int node, MockRequest request, ulong waitUs, byte[] empty)
+    {
+        var key = (node, request.ClientId, request.RequestId);
+        _parked[key] = request;
+        _ = Task.Run(async () =>
+        {
+            await Task.Delay(TimeSpan.FromMicroseconds(waitUs));
+            if (_parked.TryRemove(key, out _))
+            {
+                await _nodes[node].AnswerParkedAsync(request, empty);
+            }
+        });
+    }
+
+    private PartitionContext Context => Served with { OwnerGeneration = OwnerGeneration };
+
+    private ulong NextOffset(uint partition)
+    {
+        return Stored.TryGetValue(partition, out var stored) ? stored + 1 : 0;
+    }
+
+    private byte[] Roster()
+    {
+        var roster = new List<byte>();
+        WriteString(roster, "reader-cluster");
+        roster.AddRange(BitConverter.GetBytes((uint)_nodes.Length));
+        for (var node = 0; node < _nodes.Length; node++)
+        {
+            WriteNode(roster, _nodes[node].Port, node == 0, (byte)node);
+        }
+
+        return Reply(OPERATION_NON_REPLICATED, roster.ToArray());
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/RentedBatchTests.cs b/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/RentedBatchTests.cs
new file mode 100644
index 0000000..c382c8d
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/RentedBatchTests.cs
@@ -0,0 +1,110 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+using System.Buffers;
+using Apache.Iggy.Consumers;
+using Apache.Iggy.Contracts;
+
+namespace Apache.Iggy.Tests.ConsumerTests;
+
+public class RentedBatchTests
+{
+    [Fact]
+    public void RentedBatchHandle_AcquireRelease_Balanced_DisposesOnce()
+    {
+        var owner = new TrackingMemoryOwner(16);
+        var rental = new PolledMessagesRental(owner)
+        {
+            PartitionId = 1,
+            CurrentOffset = 0,
+            Messages = Array.Empty<RentedMessageResponse>()
+        };
+        var handle = new RentedBatchHandle(rental); // refCount=1 (self-ref)
+
+        handle.Acquire(); // 2
+        handle.Acquire(); // 3
+        Assert.Equal(0, owner.DisposeCount);
+
+        handle.Release(); // 2
+        handle.Release(); // 1 (self-ref still alive)
+        Assert.Equal(0, owner.DisposeCount);
+
+        handle.Release(); // 0 -> disposed
+        Assert.Equal(1, owner.DisposeCount);
+    }
+
+    [Fact]
+    public void PolledMessagesRental_Dispose_Idempotent()
+    {
+        var owner = new TrackingMemoryOwner(16);
+        var rental = new PolledMessagesRental(owner)
+        {
+            PartitionId = 1,
+            CurrentOffset = 0,
+            Messages = Array.Empty<RentedMessageResponse>()
+        };
+
+        rental.Dispose();
+        rental.Dispose();
+        rental.Dispose();
+
+        Assert.Equal(1, owner.DisposeCount);
+    }
+
+    [Fact]
+    public async Task PolledMessagesRental_ConcurrentDispose_ReturnsBufferOnce()
+    {
+        var owner = new TrackingMemoryOwner(16);
+        var rental = new PolledMessagesRental(owner)
+        {
+            PartitionId = 1,
+            CurrentOffset = 0,
+            Messages = Array.Empty<RentedMessageResponse>()
+        };
+
+        using var start = new ManualResetEventSlim(false);
+        Task[] tasks = Enumerable.Range(0, 64).Select(_ => Task.Run(() =>
+        {
+            start.Wait();
+            rental.Dispose();
+        })).ToArray();
+
+        start.Set();
+        await Task.WhenAll(tasks);
+
+        Assert.Equal(1, owner.DisposeCount);
+    }
+
+    internal sealed class TrackingMemoryOwner : IMemoryOwner<byte>
+    {
+        private readonly byte[] _buffer;
+
+        public int DisposeCount { get; private set; }
+
+        public TrackingMemoryOwner(int size)
+        {
+            _buffer = new byte[size];
+        }
+
+        public Memory<byte> Memory => _buffer;
+
+        public void Dispose()
+        {
+            DisposeCount++;
+        }
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/RentedConsumerTests.cs b/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/RentedConsumerTests.cs
deleted file mode 100644
index 3c417e6..0000000
--- a/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/RentedConsumerTests.cs
+++ /dev/null
@@ -1,520 +0,0 @@
-// 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.
-
-using System.Buffers;
-using System.Collections.Concurrent;
-using System.Text;
-using Apache.Iggy.Consumers;
-using Apache.Iggy.Contracts;
-using Apache.Iggy.Encryption;
-using Apache.Iggy.Exceptions;
-using Apache.Iggy.IggyClient;
-using Apache.Iggy.Kinds;
-using Apache.Iggy.Messages;
-using Apache.Iggy.Vsr;
-using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Logging.Abstractions;
-using Moq;
-
-namespace Apache.Iggy.Tests.ConsumerTests;
-
-public class RentedConsumerTests
-{
-    private const ulong BlockingOperationId = 73;
-
-    [Fact]
-    public async Task given_a_rented_progress_poll_behind_a_retained_mutation_when_receiving_should_stop_without_resolving_it()
-    {
-        var client = BuildClientMock(new Queue<PolledMessagesRental>());
-        var attempts = 0;
-        client.Setup(mock => mock.PollMessagesRentedAsync(It.IsAny<Identifier>(), It.IsAny<Identifier>(),
-                It.IsAny<uint?>(), It.IsAny<Consumer>(), It.IsAny<Func<uint, PollingStrategy>>(), It.IsAny<uint>(),
-                It.IsAny<bool>(), It.IsAny<CancellationToken>()))
-            .Returns(async (Identifier _, Identifier _, uint? partition, Consumer _, Func<uint, PollingStrategy> strategy,
-                uint _, bool _, CancellationToken token) =>
-            {
-                strategy(partition ?? 0);
-                if (++attempts > 1)
-                {
-                    await Task.Delay(Timeout.Infinite, token);
-                }
-
-                throw new VsrLaneBlockedException(BlockingOperationId);
-            });
-        await using var consumer = new IggyConsumer(client.Object, BuildConfig(), NullLoggerFactory.Instance);
-        await consumer.InitAsync(TestContext.Current.CancellationToken);
-        using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
-        cancellation.CancelAfter(TimeSpan.FromSeconds(5));
-        await using var messages = consumer.ReceiveRentedAsync(cancellation.Token).GetAsyncEnumerator(cancellation.Token);
-
-        var error = await Assert.ThrowsAsync<VsrLaneBlockedException>(() => messages.MoveNextAsync().AsTask());
-
-        Assert.Equal(BlockingOperationId, error.OperationId);
-        Assert.Equal(1, attempts);
-        client.Verify(mock => mock.ResolveOperationAsync(It.IsAny<ulong>(), It.IsAny<TimeSpan>(),
-            It.IsAny<CancellationToken>()), Times.Never);
-        client.Verify(mock => mock.AbandonOperationAsync(It.IsAny<ulong>()), Times.Never);
-    }
-
-    [Fact]
-    public async Task given_an_uncertain_rented_progress_poll_when_receiving_should_report_it_and_poll_again()
-    {
-        var owner = new TrackingMemoryOwner(16);
-        var client = BuildClientMock(new Queue<PolledMessagesRental>());
-        var attempts = 0;
-        var unknown = new VsrRequestOutcomeUnknownException(new IOException());
-        client.Setup(mock => mock.PollMessagesRentedAsync(It.IsAny<Identifier>(), It.IsAny<Identifier>(),
-                It.IsAny<uint?>(), It.IsAny<Consumer>(), It.IsAny<Func<uint, PollingStrategy>>(), It.IsAny<uint>(),
-                It.IsAny<bool>(), It.IsAny<CancellationToken>()))
-            .Returns((Identifier _, Identifier _, uint? partition, Consumer _, Func<uint, PollingStrategy> strategy,
-                uint _, bool _, CancellationToken _) =>
-            {
-                strategy(partition ?? 0);
-                return ++attempts > 1
-                    ? Task.FromResult(new PolledMessagesRental(owner)
-                    {
-                        PartitionId = 1,
-                        CurrentOffset = 0,
-                        Messages = BuildMessages(owner, 1)
-                    })
-                    : Task.FromException<PolledMessagesRental>(unknown);
-            });
-        await using var consumer = new IggyConsumer(client.Object, BuildConfig(), NullLoggerFactory.Instance);
-        var errors = new ConcurrentQueue<Exception>();
-        consumer.SubscribeToErrorEvents(error =>
-        {
-            errors.Enqueue(error.Exception);
-            return Task.CompletedTask;
-        });
-        await consumer.InitAsync(TestContext.Current.CancellationToken);
-        using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
-        cancellation.CancelAfter(TimeSpan.FromSeconds(5));
-        await using var messages = consumer.ReceiveRentedAsync(cancellation.Token).GetAsyncEnumerator(cancellation.Token);
-
-        Assert.True(await messages.MoveNextAsync());
-
-        using var received = messages.Current;
-        Assert.Equal("msg-0", Encoding.UTF8.GetString(received.Message.Payload.Span));
-        Assert.Same(unknown, Assert.Single(errors));
-        Assert.Equal(2, attempts);
-        client.Verify(mock => mock.ResolveOperationAsync(It.IsAny<ulong>(), It.IsAny<TimeSpan>(),
-            It.IsAny<CancellationToken>()), Times.Never);
-        client.Verify(mock => mock.AbandonOperationAsync(It.IsAny<ulong>()), Times.Never);
-    }
-
-    [Fact]
-    public async Task ReceiveRentedAsync_Should_YieldMessages_WithExpectedPayloads()
-    {
-        var owner = new TrackingMemoryOwner(1024);
-        IReadOnlyList<RentedMessageResponse> messages = BuildMessages(owner, 3);
-        var rental = new PolledMessagesRental(owner)
-        {
-            PartitionId = 1,
-            CurrentOffset = 2,
-            Messages = messages
-        };
-        Mock<IIggyClient> client = BuildClientMock(new Queue<PolledMessagesRental>(new[] { rental }));
-        var consumer = new IggyConsumer(client.Object, BuildConfig(), NullLoggerFactory.Instance);
-        await consumer.InitAsync(TestContext.Current.CancellationToken);
-
-        var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
-        var got = new List<ReceivedRentedMessage>();
-        await using (IAsyncEnumerator<ReceivedRentedMessage> e = consumer.ReceiveRentedAsync(cts.Token)
-                         .GetAsyncEnumerator(TestContext.Current.CancellationToken))
-        {
-            for (var i = 0; i < 3; i++)
-            {
-                Assert.True(await e.MoveNextAsync());
-                got.Add(e.Current);
-            }
-        }
-
-        Assert.Equal(3, got.Count);
-        for (var i = 0; i < 3; i++)
-        {
-            Assert.Equal(MessageStatus.Success, got[i].Status);
-            Assert.Equal($"msg-{i}", Encoding.UTF8.GetString(got[i].Message.Payload.Span));
-            Assert.Equal((ulong)i, got[i].CurrentOffset);
-            Assert.Equal(1u, got[i].PartitionId);
-            Assert.Null(got[i].Error);
-        }
-
-        // Buffer still rented — none of the messages have been disposed.
-        Assert.Equal(0, owner.DisposeCount);
-
-        foreach (var m in got)
-        {
-            m.Dispose();
-        }
-
-        // After disposing every message of the batch, rental returned to pool exactly once.
-        Assert.Equal(1, owner.DisposeCount);
-        await consumer.DisposeAsync();
-    }
-
-    [Fact]
-    public async Task ReceiveRentedAsync_Should_NotReturnBuffer_UntilAllMessagesDisposed()
-    {
-        var owner = new TrackingMemoryOwner(1024);
-        IReadOnlyList<RentedMessageResponse> messages = BuildMessages(owner, 3);
-        var rental = new PolledMessagesRental(owner)
-        {
-            PartitionId = 1,
-            CurrentOffset = 2,
-            Messages = messages
-        };
-        Mock<IIggyClient> client = BuildClientMock(new Queue<PolledMessagesRental>(new[] { rental }));
-        var consumer = new IggyConsumer(client.Object, BuildConfig(), NullLoggerFactory.Instance);
-        await consumer.InitAsync(TestContext.Current.CancellationToken);
-
-        var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
-        var got = new List<ReceivedRentedMessage>();
-        await using (IAsyncEnumerator<ReceivedRentedMessage> e = consumer.ReceiveRentedAsync(cts.Token)
-                         .GetAsyncEnumerator(TestContext.Current.CancellationToken))
-        {
-            for (var i = 0; i < 3; i++)
-            {
-                Assert.True(await e.MoveNextAsync());
-                got.Add(e.Current);
-            }
-        }
-
-        // Dispose only the first two: buffer must still be alive.
-        got[0].Dispose();
-        Assert.Equal(0, owner.DisposeCount);
-        got[1].Dispose();
-        Assert.Equal(0, owner.DisposeCount);
-
-        // Final dispose returns the buffer.
-        got[2].Dispose();
-        Assert.Equal(1, owner.DisposeCount);
-
-        // Calling Dispose again is a no-op — refcount must not drop below zero.
-        got[0].Dispose();
-        got[1].Dispose();
-        got[2].Dispose();
-        Assert.Equal(1, owner.DisposeCount);
-
-        await consumer.DisposeAsync();
-    }
-
-    [Fact]
-    public async Task ReceiveRentedAsync_Should_SurfaceDecryptionException_FromClient()
-    {
-        var client = new Mock<IIggyClient>(MockBehavior.Loose);
-        client.Setup(c => c.ConnectAsync(It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
-        client.Setup(c => c.PollMessagesRentedAsync(It.IsAny<Identifier>(), It.IsAny<Identifier>(),
-                It.IsAny<uint?>(), It.IsAny<Consumer>(), It.IsAny<Func<uint, PollingStrategy>>(), It.IsAny<uint>(),
-                It.IsAny<bool>(), It.IsAny<CancellationToken>()))
-            .ThrowsAsync(new MessageDecryptionException(0, 1, new InvalidOperationException("decrypt fail")));
-        var consumer = new IggyConsumer(client.Object, BuildConfig(), NullLoggerFactory.Instance);
-        await consumer.InitAsync(TestContext.Current.CancellationToken);
-
-        var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
-        await Assert.ThrowsAsync<MessageDecryptionException>(async () =>
-        {
-            await foreach (var _ in consumer.ReceiveRentedAsync(cts.Token))
-            {
-            }
-        });
-
-        await consumer.DisposeAsync();
-    }
-
-    [Fact]
-    public async Task ReceiveRentedAsync_Should_Throw_WhenAutoCommitCombinedWithEncryptor()
-    {
-        var client = new Mock<IIggyClient>(MockBehavior.Loose);
-        client.Setup(c => c.ConnectAsync(It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
-        client.SetupGet(c => c.MessageEncryptor)
-            .Returns(new AesMessageEncryptor(AesMessageEncryptor.GenerateKey()));
-
-        var config = BuildConfig();
-        config.AutoCommit = true;
-        var consumer = new IggyConsumer(client.Object, config, NullLoggerFactory.Instance);
-        await consumer.InitAsync(TestContext.Current.CancellationToken);
-
-        var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
-        var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
-        {
-            await foreach (var _ in consumer.ReceiveRentedAsync(cts.Token))
-            {
-            }
-        });
-        Assert.Contains("AutoCommit", ex.Message);
-
-        await consumer.DisposeAsync();
-    }
-
-    [Fact]
-    public async Task ReceiveRentedAsync_Should_Throw_WhenConsumerNotInitialized()
-    {
-        Mock<IIggyClient> client = BuildClientMock(new Queue<PolledMessagesRental>());
-        var consumer = new IggyConsumer(client.Object, BuildConfig(), NullLoggerFactory.Instance);
-
-        await Assert.ThrowsAsync<ConsumerNotInitializedException>(async () =>
-        {
-            await foreach (var _ in consumer.ReceiveRentedAsync(TestContext.Current.CancellationToken))
-            {
-            }
-        });
-
-        await consumer.DisposeAsync();
-    }
-
-    [Fact]
-    public void RentedBatchHandle_AcquireRelease_Balanced_DisposesOnce()
-    {
-        var owner = new TrackingMemoryOwner(16);
-        var rental = new PolledMessagesRental(owner)
-        {
-            PartitionId = 1,
-            CurrentOffset = 0,
-            Messages = Array.Empty<RentedMessageResponse>()
-        };
-        var handle = new RentedBatchHandle(rental); // refCount=1 (self-ref)
-
-        handle.Acquire(); // 2
-        handle.Acquire(); // 3
-        Assert.Equal(0, owner.DisposeCount);
-
-        handle.Release(); // 2
-        handle.Release(); // 1 (self-ref still alive)
-        Assert.Equal(0, owner.DisposeCount);
-
-        handle.Release(); // 0 -> disposed
-        Assert.Equal(1, owner.DisposeCount);
-    }
-
-    [Fact]
-    public void PolledMessagesRental_Dispose_Idempotent()
-    {
-        var owner = new TrackingMemoryOwner(16);
-        var rental = new PolledMessagesRental(owner)
-        {
-            PartitionId = 1,
-            CurrentOffset = 0,
-            Messages = Array.Empty<RentedMessageResponse>()
-        };
-
-        rental.Dispose();
-        rental.Dispose();
-        rental.Dispose();
-
-        Assert.Equal(1, owner.DisposeCount);
-    }
-
-    [Fact]
-    public async Task PolledMessagesRental_ConcurrentDispose_ReturnsBufferOnce()
-    {
-        var owner = new TrackingMemoryOwner(16);
-        var rental = new PolledMessagesRental(owner)
-        {
-            PartitionId = 1,
-            CurrentOffset = 0,
-            Messages = Array.Empty<RentedMessageResponse>()
-        };
-
-        using var start = new ManualResetEventSlim(false);
-        Task[] tasks = Enumerable.Range(0, 64).Select(_ => Task.Run(() =>
-        {
-            start.Wait();
-            rental.Dispose();
-        })).ToArray();
-
-        start.Set();
-        await Task.WhenAll(tasks);
-
-        Assert.Equal(1, owner.DisposeCount);
-    }
-
-    [Fact]
-    public async Task PollRented_MidLoopPublishFailure_DoesNotLeakBuffer()
-    {
-        var owner = new TrackingMemoryOwner(1024);
-        IReadOnlyList<RentedMessageResponse> messages = BuildMessages(owner, 5);
-        var rental = new PolledMessagesRental(owner)
-        {
-            PartitionId = 1,
-            CurrentOffset = 4,
-            Messages = messages
-        };
-        Mock<IIggyClient> client = BuildClientMock(new Queue<PolledMessagesRental>(new[] { rental }));
-        var consumer
-            = new FailingPublishConsumer(client.Object, BuildConfig(), NullLoggerFactory.Instance) { FailAfter = 2 };
-        await consumer.InitAsync(TestContext.Current.CancellationToken);
-
-        using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
-        var got = new List<ReceivedRentedMessage>();
-        IAsyncEnumerator<ReceivedRentedMessage> enumerator = consumer.ReceiveRentedAsync(cts.Token)
-            .GetAsyncEnumerator(TestContext.Current.CancellationToken);
-
-        for (var i = 0; i < 2; i++)
-        {
-            Assert.True(await enumerator.MoveNextAsync());
-            got.Add(enumerator.Current);
-        }
-
-        // First 2 publishes succeeded, 3rd injected failure aborted the loop.
-        // Producer self-ref was released in finally; consumer refs (2) still hold the buffer.
-        Assert.Equal(2, got.Count);
-        Assert.Equal(0, owner.DisposeCount);
-
-        foreach (var m in got)
-        {
-            m.Dispose();
-        }
-
-        // All refs released -> rental disposed exactly once.
-        Assert.Equal(1, owner.DisposeCount);
-
-        cts.Cancel();
-        try
-        {
-            await enumerator.DisposeAsync();
-        }
-        catch (OperationCanceledException)
-        {
-        }
-
-        await consumer.DisposeAsync();
-    }
-
-    internal static IggyConsumerConfig BuildConfig()
-    {
-        return new IggyConsumerConfig
-        {
-            StreamId = Identifier.Numeric(1),
-            TopicId = Identifier.Numeric(1),
-            Consumer = Consumer.New(1),
-            PollingStrategy = PollingStrategy.Next(),
-            BatchSize = 10,
-            PartitionId = 1,
-            AutoCommitMode = AutoCommitMode.Disabled,
-            AutoCommit = false,
-            PollingIntervalMs = 0
-        };
-    }
-
-    /// <summary>
-    ///     Slices payload bytes into the supplied owner's memory and returns a list of
-    ///     <see cref="RentedMessageResponse" /> instances backed by that single rented buffer.
-    /// </summary>
-    internal static IReadOnlyList<RentedMessageResponse> BuildMessages(TrackingMemoryOwner owner, int count)
-    {
-        var list = new List<RentedMessageResponse>(count);
-        Memory<byte> buffer = owner.Memory;
-        var written = 0;
-        for (var i = 0; i < count; i++)
-        {
-            var bytes = Encoding.UTF8.GetBytes($"msg-{i}");
-            bytes.CopyTo(buffer.Slice(written, bytes.Length));
-            Memory<byte> slice = buffer.Slice(written, bytes.Length);
-            written += bytes.Length;
-
-            list.Add(new RentedMessageResponse
-            {
-                Header = new MessageHeader
-                {
-                    Offset = (ulong)i,
-                    PayloadLength = bytes.Length
-                },
-                Payload = slice,
-                RawUserHeaders = ReadOnlyMemory<byte>.Empty
-            });
-        }
-
-        return list;
-    }
-
-    /// <summary>
-    ///     Builds a <see cref="Mock{IIggyClient}" /> that dequeues rentals on each
-    ///     <c>PollMessagesRentedAsync</c> call. When the queue is empty, returns an
-    ///     empty rental so the consumer can spin without dereferencing null.
-    /// </summary>
-    internal static Mock<IIggyClient> BuildClientMock(Queue<PolledMessagesRental> rentals)
-    {
-        var mock = new Mock<IIggyClient>(MockBehavior.Loose);
-        mock.Setup(c => c.ConnectAsync(It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
-        mock.Setup(c => c.PollMessagesRentedAsync(It.IsAny<Identifier>(), It.IsAny<Identifier>(), It.IsAny<uint?>(),
-                It.IsAny<Consumer>(), It.IsAny<Func<uint, PollingStrategy>>(), It.IsAny<uint>(), It.IsAny<bool>(),
-                It.IsAny<CancellationToken>()))
-            .ReturnsAsync(() =>
-            {
-                if (rentals.Count > 0)
-                {
-                    return rentals.Dequeue();
-                }
-
-                return new PolledMessagesRental(EmptyMemoryOwner.Instance)
-                {
-                    PartitionId = 1,
-                    CurrentOffset = 0,
-                    Messages = Array.Empty<RentedMessageResponse>()
-                };
-            });
-        return mock;
-    }
-
-    /// <summary>
-    ///     <see cref="IMemoryOwner{T}" /> wrapper that counts how many times
-    ///     <see cref="IDisposable.Dispose" /> has been invoked, so tests can assert that
-    ///     the rental returns to the pool exactly once.
-    /// </summary>
-    internal sealed class TrackingMemoryOwner : IMemoryOwner<byte>
-    {
-        private readonly byte[] _buffer;
-
-        public int DisposeCount { get; private set; }
-
-        public TrackingMemoryOwner(int size)
-        {
-            _buffer = new byte[size];
-        }
-
-        public Memory<byte> Memory => _buffer;
-
-        public void Dispose()
-        {
-            DisposeCount++;
-        }
-    }
-
-    private sealed class FailingPublishConsumer : IggyConsumer
-    {
-        private int _calls;
-
-        public int FailAfter { get; set; }
-
-        public FailingPublishConsumer(IIggyClient client, IggyConsumerConfig config,
-            ILoggerFactory loggerFactory)
-            : base(client, config, loggerFactory)
-        {
-        }
-
-        protected override async Task PublishRentedAsync(RentedBatchHandle rental, RentedMessageResponse message,
-            uint partitionId, MessageStatus status, Exception? error, CancellationToken ct)
-        {
-            if (Interlocked.Increment(ref _calls) > FailAfter)
-            {
-                throw new InvalidOperationException("inject publish failure");
-            }
-
-            await base.PublishRentedAsync(rental, message, partitionId, status, error, ct);
-        }
-    }
-}
diff --git a/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/RentedTypedConsumerTests.cs b/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/RentedTypedConsumerTests.cs
deleted file mode 100644
index 457f2ba..0000000
--- a/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/RentedTypedConsumerTests.cs
+++ /dev/null
@@ -1,186 +0,0 @@
-// 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.
-
-using System.Text;
-using Apache.Iggy.Consumers;
-using Apache.Iggy.Contracts;
-using Apache.Iggy.IggyClient;
-using Apache.Iggy.Kinds;
-using Microsoft.Extensions.Logging.Abstractions;
-using Moq;
-
-namespace Apache.Iggy.Tests.ConsumerTests;
-
-public class RentedTypedConsumerTests
-{
-    [Fact]
-    public async Task ReceiveDeserializedAsync_Should_YieldDeserialized_AndReturnBuffer()
-    {
-        var owner = new RentedConsumerTests.TrackingMemoryOwner(1024);
-        IReadOnlyList<RentedMessageResponse> messages = RentedConsumerTests.BuildMessages(owner, 3);
-        var context = new PartitionContext(17, 9, 52);
-        var rental = new PolledMessagesRental(owner)
-        {
-            Context = context,
-            PartitionId = 1,
-            CurrentOffset = 2,
-            Messages = messages
-        };
-        Mock<IIggyClient> client
-            = RentedConsumerTests.BuildClientMock(new Queue<PolledMessagesRental>(new[] { rental }));
-        var deserializer = new StringDeserializer();
-        var consumer
-            = new IggyConsumer<string>(client.Object, BuildTypedConfig(deserializer), NullLoggerFactory.Instance);
-        await consumer.InitAsync(TestContext.Current.CancellationToken);
-
-        var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
-        var got = new List<ReceivedMessage<string>>();
-        await using (IAsyncEnumerator<ReceivedMessage<string>> e = consumer
-                         .ReceiveDeserializedAsync(cts.Token)
-                         .GetAsyncEnumerator(TestContext.Current.CancellationToken))
-        {
-            for (var i = 0; i < 3; i++)
-            {
-                Assert.True(await e.MoveNextAsync());
-                got.Add(e.Current);
-            }
-        }
-
-        Assert.Equal(3, got.Count);
-        for (var i = 0; i < 3; i++)
-        {
-            Assert.Equal(MessageStatus.Success, got[i].Status);
-            Assert.Equal($"msg-{i}", got[i].Data);
-            Assert.Equal((ulong)i, got[i].CurrentOffset);
-            Assert.Equal(1u, got[i].PartitionId);
-            Assert.Equal(context, got[i].Context);
-            Assert.Null(got[i].Error);
-        }
-
-        // Entire batch deserialized before first yield; rental already returned to pool.
-        Assert.Equal(1, owner.DisposeCount);
-        await consumer.DisposeAsync();
-    }
-
-    [Fact]
-    public async Task ReceiveDeserializedAsync_Should_ReleaseEntireBatch_BeforeFirstYield()
-    {
-        var owner = new RentedConsumerTests.TrackingMemoryOwner(1024);
-        IReadOnlyList<RentedMessageResponse> messages = RentedConsumerTests.BuildMessages(owner, 3);
-        var rental = new PolledMessagesRental(owner)
-        {
-            PartitionId = 1,
-            CurrentOffset = 2,
-            Messages = messages
-        };
-        Mock<IIggyClient> client
-            = RentedConsumerTests.BuildClientMock(new Queue<PolledMessagesRental>(new[] { rental }));
-        var deserializer = new StringDeserializer();
-        var consumer
-            = new IggyConsumer<string>(client.Object, BuildTypedConfig(deserializer), NullLoggerFactory.Instance);
-        await consumer.InitAsync(TestContext.Current.CancellationToken);
-
-        var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
-        await using IAsyncEnumerator<ReceivedMessage<string>> e = consumer.ReceiveDeserializedAsync(cts.Token)
-            .GetAsyncEnumerator(TestContext.Current.CancellationToken);
-
-        Assert.True(await e.MoveNextAsync());
-        Assert.Equal(1, owner.DisposeCount);
-        Assert.Equal("msg-0", e.Current.Data);
-
-        // Remaining messages come from the pre-deserialized list — no further disposal needed.
-        Assert.True(await e.MoveNextAsync());
-        Assert.Equal(1, owner.DisposeCount);
-        Assert.Equal("msg-1", e.Current.Data);
-
-        Assert.True(await e.MoveNextAsync());
-        Assert.Equal(1, owner.DisposeCount);
-        Assert.Equal("msg-2", e.Current.Data);
-
-        await consumer.DisposeAsync();
-    }
-
-    [Fact]
-    public async Task DeserializerThrows_Should_YieldDeserializationFailed_AndStillReturnBuffer()
-    {
-        var owner = new RentedConsumerTests.TrackingMemoryOwner(1024);
-        IReadOnlyList<RentedMessageResponse> messages = RentedConsumerTests.BuildMessages(owner, 1);
-        var rental = new PolledMessagesRental(owner)
-        {
-            PartitionId = 1,
-            CurrentOffset = 0,
-            Messages = messages
-        };
-        Mock<IIggyClient> client
-            = RentedConsumerTests.BuildClientMock(new Queue<PolledMessagesRental>(new[] { rental }));
-        var deserializer = new StringDeserializer { ThrowOnNext = true };
-        var consumer
-            = new IggyConsumer<string>(client.Object, BuildTypedConfig(deserializer), NullLoggerFactory.Instance);
-        await consumer.InitAsync(TestContext.Current.CancellationToken);
-
-        var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
-        ReceivedMessage<string>? got = null;
-        await using (IAsyncEnumerator<ReceivedMessage<string>> e = consumer
-                         .ReceiveDeserializedAsync(cts.Token)
-                         .GetAsyncEnumerator(TestContext.Current.CancellationToken))
-        {
-            Assert.True(await e.MoveNextAsync());
-            got = e.Current;
-        }
-
-        Assert.NotNull(got);
-        Assert.Equal(MessageStatus.DeserializationFailed, got!.Status);
-        Assert.Null(got.Data);
-        Assert.IsType<InvalidOperationException>(got.Error);
-
-        // Even on deserialization failure, the using-block inside the typed consumer releases the rental.
-        Assert.Equal(1, owner.DisposeCount);
-        await consumer.DisposeAsync();
-    }
-
-    private static IggyConsumerConfig<string> BuildTypedConfig(IDeserializer<string> deserializer)
-    {
-        return new IggyConsumerConfig<string>
-        {
-            StreamId = Identifier.Numeric(1),
-            TopicId = Identifier.Numeric(1),
-            Consumer = Consumer.New(1),
-            PollingStrategy = PollingStrategy.Next(),
-            BatchSize = 10,
-            PartitionId = 1,
-            AutoCommitMode = AutoCommitMode.Disabled,
-            AutoCommit = false,
-            PollingIntervalMs = 0,
-            Deserializer = deserializer
-        };
-    }
-
-    private sealed class StringDeserializer : IDeserializer<string>
-    {
-        public bool ThrowOnNext { get; set; }
-
-        public string Deserialize(ReadOnlyMemory<byte> data)
-        {
-            if (ThrowOnNext)
-            {
-                throw new InvalidOperationException("deserialize fail");
-            }
-
-            return Encoding.UTF8.GetString(data.Span);
-        }
-    }
-}
diff --git a/foreign/csharp/Iggy_SDK_Tests/ContractsTests/DeferredPollContractsTests.cs b/foreign/csharp/Iggy_SDK_Tests/ContractsTests/DeferredPollContractsTests.cs
new file mode 100644
index 0000000..efeb129
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK_Tests/ContractsTests/DeferredPollContractsTests.cs
@@ -0,0 +1,165 @@
+// 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.
+
+using Apache.Iggy.Contracts;
+using Apache.Iggy.Contracts.Tcp;
+using Apache.Iggy.Exceptions;
+using Apache.Iggy.Kinds;
+using Apache.Iggy.Vsr;
+
+namespace Apache.Iggy.Tests.ContractsTests;
+
+/// <summary>
+///     Cross-SDK golden body of the deferred poll (code 106), produced by the
+///     Rust encoder: consumer group 7, stream 1, topic "orders", partition 3, Next, count 100, auto commit, and the
+///     default options (a one-second wait, minimum count 1, a 1 MiB reply limit, an eleven-second request
+///     timeout).
+/// </summary>
+public sealed class DeferredPollContractsTests
+{
+    private const string PollBody =
+        "0201040700000001040100000002066f726465727301030000000500000000000000006400000001";
+
+    private const string DeferredPollBody =
+        "0201040700000001040100000002066f72646572730103000000050000000000000000640000000140420f0000000000" +
+        "0100000000001000c0d8a70000000000";
+
+    private const uint Count = 100;
+    private const uint Partition = 3;
+
+    private static readonly Consumer Group = Consumer.Group(7);
+    private static readonly Identifier Stream = Identifier.Numeric(1);
+    private static readonly Identifier Topic = Identifier.String("orders");
+
+    [Fact]
+    public void GetMessages_WithoutOptions_EncodesThePollGoldenBody()
+    {
+        var expected = Convert.FromHexString(PollBody);
+        var body = new byte[expected.Length];
+
+        TcpContracts.GetMessages(body, Group, Stream, Topic, PollingStrategy.Next(), Count, true, Partition);
+
+        Assert.Equal(expected, body);
+    }
+
+    [Fact]
+    public void GetMessages_WithOptions_AppendsTheDeferredTailToThePollBody()
+    {
+        var expected = Convert.FromHexString(DeferredPollBody);
+        var body = new byte[expected.Length];
+
+        TcpContracts.GetMessages(body, Group, Stream, Topic, PollingStrategy.Next(), Count, true, Partition,
+            new DeferredPollOptions());
+
+        Assert.Equal(expected, body);
+        Assert.Equal(Convert.FromHexString(PollBody), body[..^TcpContracts.DEFERRED_POLL_TAIL_SIZE]);
+    }
+
+    [Fact]
+    public void DefaultOptions_MatchTheRustDefaultsAndPassValidation()
+    {
+        var options = new DeferredPollOptions();
+
+        options.Validate(1);
+
+        Assert.Equal(TimeSpan.FromSeconds(1), options.MaxWait);
+        Assert.Equal(1u, options.MinCount);
+        Assert.Equal(1024u * 1024, options.MaxBytes);
+        Assert.Equal(TimeSpan.FromSeconds(11), options.RequestTimeout);
+    }
+
+    [Theory]
+    [InlineData(1_000_000, 0u, 1_048_576u, 11_000_000, VsrError.INVALID_MESSAGES_COUNT)]
+    [InlineData(1_000_000, Count + 1, 1_048_576u, 11_000_000, VsrError.INVALID_MESSAGES_COUNT)]
+    [InlineData(-1, 1u, 1_048_576u, 11_000_000, VsrError.INVALID_COMMAND)]
+    [InlineData(600_000_001, 1u, 1_048_576u, 630_000_000, VsrError.INVALID_COMMAND)]
+    [InlineData(0, 1u, 1_048_576u, 0, VsrError.INVALID_COMMAND)]
+    [InlineData(0, 1u, 1_048_576u, 630_000_001, VsrError.INVALID_COMMAND)]
+    [InlineData(10_000_000, 1u, 1_048_576u, 10_999_999, VsrError.INVALID_COMMAND)]
+    [InlineData(1_000_000, 1u, 343u, 11_000_000, VsrError.INVALID_SIZE_BYTES)]
+    public void Validate_RefusesWhatTheServerRefuses(long maxWaitUs, uint minCount, uint maxBytes,
+        long requestTimeoutUs, int status)
+    {
+        var options = new DeferredPollOptions
+        {
+            MaxWait = TimeSpan.FromMicroseconds(maxWaitUs),
+            MinCount = minCount,
+            MaxBytes = maxBytes,
+            RequestTimeout = TimeSpan.FromMicroseconds(requestTimeoutUs)
+        };
+
+        var error = Assert.Throws<IggyInvalidStatusCodeException>(() => options.Validate(Count));
+
+        Assert.Equal(status, error.StatusCode);
+    }
+
+    [Fact]
+    public void Validate_AcceptsTheLimits()
+    {
+        new DeferredPollOptions { MinCount = Count, MaxBytes = 344 }.Validate(Count);
+        new DeferredPollOptions { MaxWait = TimeSpan.Zero, RequestTimeout = TimeSpan.FromMicroseconds(1) }
+            .Validate(Count);
+        new DeferredPollOptions { MaxWait = TimeSpan.FromSeconds(600), RequestTimeout = TimeSpan.FromSeconds(630) }
+            .Validate(Count);
+        new DeferredPollOptions { MaxWait = TimeSpan.FromSeconds(10), RequestTimeout = TimeSpan.FromSeconds(11) }
+            .Validate(Count);
+    }
+
+    [Fact]
+    public void Remaining_ShrinksTheWaitAndTheTimeoutByTheSpentTime()
+    {
+        var options = new DeferredPollOptions { MaxWait = TimeSpan.FromSeconds(10), MinCount = 5, MaxBytes = 4096 };
+
+        var remaining = options.Remaining(TimeSpan.FromSeconds(4));
+        var spentWait = options.Remaining(TimeSpan.FromSeconds(10.5));
+
+        Assert.Equal(options with { MaxWait = TimeSpan.FromSeconds(6), RequestTimeout = TimeSpan.FromSeconds(7) },
+            remaining);
+        Assert.Equal(TimeSpan.Zero, spentWait.MaxWait);
+        Assert.Equal(TimeSpan.FromSeconds(0.5), spentWait.RequestTimeout);
+        spentWait.Validate(5);
+    }
+
+    /// <summary>
+    ///     A stopwatch measures parts of a microsecond, and the wire rounds the wait and the timeout down to whole ones
+    ///     each. Spent time in whole microseconds keeps the gap that the options passed with.
+    /// </summary>
+    [Fact]
+    public void Remaining_WithPartsOfAMicrosecond_KeepsTheReplyMargin()
+    {
+        var options = new DeferredPollOptions
+        {
+            MaxWait = TimeSpan.FromSeconds(10) + TimeSpan.FromTicks(5),
+            RequestTimeout = TimeSpan.FromSeconds(11) + TimeSpan.FromTicks(1)
+        };
+        options.Validate(Count);
+
+        var remaining = options.Remaining(TimeSpan.FromTicks(3));
+
+        remaining.Validate(Count);
+        Assert.Equal(options.RequestTimeoutUs - options.MaxWaitUs, remaining.RequestTimeoutUs - remaining.MaxWaitUs);
+    }
+
+    [Fact]
+    public void Remaining_WithTheRequestTimeoutSpent_ReportsThatNothingWasSent()
+    {
+        var error = Assert.Throws<IggyInvalidStatusCodeException>(() =>
+            new DeferredPollOptions().Remaining(TimeSpan.FromSeconds(11)));
+
+        Assert.Equal(VsrError.TRANSIENT_NOT_ACCEPTED, error.StatusCode);
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK_Tests/ContractsTests/DeferredPollingFixtureTests.cs b/foreign/csharp/Iggy_SDK_Tests/ContractsTests/DeferredPollingFixtureTests.cs
new file mode 100644
index 0000000..aaf09e5
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK_Tests/ContractsTests/DeferredPollingFixtureTests.cs
@@ -0,0 +1,336 @@
+// 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.
+
+using System.Buffers.Binary;
+using System.Text;
+using System.Text.Json;
+using Apache.Iggy.Configuration;
+using Apache.Iggy.Contracts;
+using Apache.Iggy.Contracts.Tcp;
+using Apache.Iggy.Enums;
+using Apache.Iggy.Exceptions;
+using Apache.Iggy.Extensions;
+using Apache.Iggy.IggyClient.Implementations;
+using Apache.Iggy.Kinds;
+using Apache.Iggy.Mappers;
+using Apache.Iggy.Tests.ConsumerTests;
+using Apache.Iggy.Tests.VsrTests;
+using Apache.Iggy.Utils;
+using Apache.Iggy.Vsr;
+using Microsoft.Extensions.Logging.Abstractions;
+using static Apache.Iggy.Tests.VsrTests.MockFrames;
+
+namespace Apache.Iggy.Tests.ContractsTests;
+
+/// <summary>
+///     The deferred polling wire vectors that the Rust encoder generates for every SDK: each request encodes from its
+///     fields to the same bytes, each reply decodes to the same sections and messages, and each refused vector is
+///     refused.
+/// </summary>
+public sealed class DeferredPollingFixtureTests
+{
+    private const int OffsetWireKind = 1;
+
+    private static readonly JsonElement Fixture = JsonDocument
+        .Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "deferred_polling.json")))
+        .RootElement;
+
+    public static TheoryData<string> DeferredPolls => Cases("deferred_polls");
+
+    public static TheoryData<string> Fetches => Cases("fetches");
+
+    public static TheoryData<string> Cancellations => Cases("cancellations");
+
+    public static TheoryData<string> FetchReplies => Cases("fetch_replies");
+
+    public static TheoryData<string> RefusedFetchReplies => Cases("refused", "fetch_replies");
+
+    public static TheoryData<string> RefusedFetches => Cases("refused", "fetches");
+
+    public static TheoryData<string, int, bool> CommitOutcomes => Outcomes("request", "section");
+
+    [Fact]
+    public void given_the_fixture_constants_when_compared_should_match_the_client()
+    {
+        var constants = Fixture.GetProperty("constants");
+        var codes = constants.GetProperty("codes");
+        var errors = constants.GetProperty("errors");
+
+        Assert.Equal(
+            (CommandCodes.POLL_MESSAGES_CODE, CommandCodes.POLL_MESSAGES_DEFERRED_CODE,
+                CommandCodes.CANCEL_DEFERRED_REQUEST_CODE, CommandCodes.FETCH_MESSAGES_CODE),
+            (codes.GetProperty("poll_messages").GetInt32(), codes.GetProperty("poll_messages_deferred").GetInt32(),
+                codes.GetProperty("cancel_deferred_request").GetInt32(), codes.GetProperty("fetch_messages").GetInt32()));
+        Assert.Equal(
+            (VsrError.TRANSIENT_NOT_COMMITTED, VsrError.TRANSIENT_NOT_ACCEPTED, VsrError.INVALID_SIZE_BYTES,
+                VsrError.HISTORY_UNAVAILABLE, VsrError.NOT_PRIMARY, VsrError.DEFERRED_POLL_CAPACITY_EXCEEDED,
+                VsrError.PARTITION_LANE_BLOCKED, VsrError.FETCH_PROGRESS_UNCERTAIN),
+            (errors.GetProperty("transient_not_committed").GetInt32(),
+                errors.GetProperty("transient_not_accepted").GetInt32(),
+                errors.GetProperty("invalid_size_bytes").GetInt32(), errors.GetProperty("history_unavailable").GetInt32(),
+                errors.GetProperty("not_primary").GetInt32(),
+                errors.GetProperty("deferred_poll_capacity_exceeded").GetInt32(),
+                errors.GetProperty("partition_lane_blocked").GetInt32(),
+                errors.GetProperty("fetch_progress_uncertain").GetInt32()));
+        Assert.Equal(
+            (DeferredPollOptions.MIN_MAX_BYTES, TcpContracts.MAX_PARTITIONS_PER_REQUEST, TcpContracts.MAX_RECORD_SIZE,
+                TcpContracts.MAX_RECORD_REPLY_SIZE, VsrHeader.HEADER_SIZE, FetchedMessages.SECTION_HEADER_SIZE),
+            (constants.GetProperty("min_deferred_poll_max_bytes").GetUInt32(),
+                constants.GetProperty("max_partitions_per_request").GetInt32(),
+                constants.GetProperty("max_record_size").GetInt32(),
+                constants.GetProperty("max_record_reply_size").GetInt32(),
+                constants.GetProperty("request_header_size").GetInt32(),
+                constants.GetProperty("fetch_section_header_size").GetInt32()));
+    }
+
+    [Theory]
+    [MemberData(nameof(DeferredPolls))]
+    public void given_a_deferred_poll_vector_when_encoded_should_match_its_body(string name)
+    {
+        var vector = Case("deferred_polls", name);
+        var request = vector.GetProperty("request");
+        var expected = Convert.FromHexString(vector.GetProperty("body_hex").GetString()!);
+        var body = new byte[expected.Length];
+
+        TcpContracts.GetMessages(body, ConsumerOf(request.GetProperty("consumer")),
+            IdentifierOf(request.GetProperty("stream_id")), IdentifierOf(request.GetProperty("topic_id")),
+            StrategyOf(request.GetProperty("strategy")), request.GetProperty("count").GetUInt32(),
+            request.GetProperty("auto_commit").GetBoolean(), request.GetProperty("partition_id").GetUInt32(),
+            OptionsOf(request));
+
+        Assert.Equal(Convert.ToHexString(expected), Convert.ToHexString(body));
+    }
+
+    [Theory]
+    [MemberData(nameof(Fetches))]
+    public void given_a_fetch_vector_when_encoded_should_match_its_body(string name)
+    {
+        var vector = Case("fetches", name);
+        var request = vector.GetProperty("request");
+        var targets = request.GetProperty("targets").EnumerateArray().ToList();
+
+        var body = TcpContracts.FetchMessages(ConsumerOf(request.GetProperty("consumer")),
+            IdentifierOf(request.GetProperty("stream_id")), IdentifierOf(request.GetProperty("topic_id")),
+            OptionsOf(request), request.GetProperty("count").GetUInt32(), request.GetProperty("auto_commit").GetBoolean(),
+            targets[0].GetProperty("max_bytes").GetUInt32(),
+            [
+                .. targets.Select(target => (target.GetProperty("partition_id").GetUInt32(),
+                    StrategyOf(target.GetProperty("strategy")).WithContext(new PartitionContext(
+                        target.GetProperty("incarnation").GetUInt64(),
+                        target.GetProperty("owner_generation").GetUInt64(), 0))))
+            ]);
+
+        Assert.All(targets, target => Assert.Equal(targets[0].GetProperty("max_bytes").GetUInt32(),
+            target.GetProperty("max_bytes").GetUInt32()));
+        Assert.Equal(vector.GetProperty("body_hex").GetString(), Convert.ToHexString(body).ToLowerInvariant());
+    }
+
+    [Theory]
+    [MemberData(nameof(Cancellations))]
+    public void given_a_cancellation_vector_when_encoded_should_match_its_header(string name)
+    {
+        var vector = Case("cancellations", name);
+        var header = new byte[VsrHeader.HEADER_SIZE];
+
+        VsrHeader.EncodeRequestHeader(header, new ConsensusSession(), CommandCodes.CANCEL_DEFERRED_REQUEST_CODE,
+            ReadOnlySpan<byte>.Empty, retainedFrame: new SessionFrame(vector.GetProperty("client").GetUInt64(),
+                vector.GetProperty("request").GetUInt64(), vector.GetProperty("session").GetUInt64()));
+
+        Assert.Equal(vector.GetProperty("header_hex").GetString(), Convert.ToHexString(header).ToLowerInvariant());
+    }
+
+    [Theory]
+    [MemberData(nameof(FetchReplies))]
+    public void given_a_fetch_reply_vector_when_decoded_should_hold_its_sections_and_messages(string name)
+    {
+        var vector = Case("fetch_replies", name);
+        using var reply = Decode(vector.GetProperty("body_hex").GetString()!);
+        var expected = vector.GetProperty("sections").EnumerateArray().ToList();
+
+        Assert.Equal(expected.Count, reply.Sections.Count);
+        foreach (var (section, wanted) in reply.Sections.Zip(expected))
+        {
+            var context = wanted.GetProperty("context");
+            Assert.Equal(
+                (wanted.GetProperty("partition_id").GetUInt32(), wanted.GetProperty("status").GetUInt32(),
+                    wanted.GetProperty("current_offset").GetUInt64(), wanted.GetProperty("messages_count").GetUInt32(),
+                    new PartitionContext(context.GetProperty("incarnation").GetUInt64(),
+                        context.GetProperty("owner_generation").GetUInt64(),
+                        context.GetProperty("metadata_op").GetUInt64()),
+                    wanted.GetProperty("next_offset").GetUInt64(), wanted.GetProperty("records_len").GetInt32()),
+                (section.PartitionId, section.Status, section.CurrentOffset, section.MessagesCount, section.Context,
+                    section.NextOffset, section.Records.Length));
+            var messages = section.Status == 0 ? section.Decode().Messages : [];
+            Assert.Equal(
+                wanted.GetProperty("messages").EnumerateArray().Select(message => (
+                    message.GetProperty("offset").GetUInt64(), UInt128.Parse(message.GetProperty("id").GetString()!),
+                    message.GetProperty("timestamp").GetUInt64(), message.GetProperty("origin_timestamp").GetUInt64(),
+                    message.GetProperty("payload").GetString())),
+                messages.Select(message => (message.Header.Offset, message.Header.Id,
+                    DateTimeOffsetUtils.ToUnixTimeMicroSeconds(message.Header.Timestamp), message.Header.OriginTimestamp,
+                    (string?)Encoding.UTF8.GetString(message.Payload))));
+        }
+    }
+
+    [Theory]
+    [MemberData(nameof(RefusedFetchReplies))]
+    public void given_a_refused_fetch_reply_vector_when_decoded_should_refuse_it(string name)
+    {
+        var vector = Case(Fixture.GetProperty("refused"), "fetch_replies", name);
+
+        Assert.Throws<MalformedResponseException>(() => Decode(vector.GetProperty("body_hex").GetString()!).Dispose());
+    }
+
+    /// <summary>
+    ///     The client validates a fetch before it encodes it, so each refused fetch is decoded into the arguments of
+    ///     the call and refused before anything reaches the node. The client sends one byte limit for every target,
+    ///     so a target limit above the request limit cannot be expressed and is refused by construction.
+    /// </summary>
+    [Theory]
+    [MemberData(nameof(RefusedFetches))]
+    public async Task given_a_refused_fetch_vector_when_fetched_should_refuse_it_before_sending(string name)
+    {
+        var body = Convert.FromHexString(Case(Fixture.GetProperty("refused"), "fetches", name)
+            .GetProperty("body_hex").GetString()!);
+        var request = DecodeFetch(body);
+        if (request.Targets.Any(target => target.MaxBytes > request.MaxBytes))
+        {
+            return;
+        }
+
+        using var node = new MockNode();
+        node.Serve(served => served.Code == GET_CLUSTER_METADATA_CODE ? StandaloneRoster(node.Port) : Answer(served));
+        using var client = new TcpMessageStream(new IggyClientConfigurator
+        {
+            BaseAddress = $"127.0.0.1:{node.Port}",
+            Protocol = Protocol.Tcp
+        }, NullLoggerFactory.Instance);
+        await client.ConnectAsync(TestContext.Current.CancellationToken);
+        await client.LoginUserAsync("user", "secret", TestContext.Current.CancellationToken);
+
+        await Assert.ThrowsAsync<IggyInvalidStatusCodeException>(() => client.FetchMessagesAsync(
+            Identifier.Numeric(1), Identifier.Numeric(2),
+            [.. request.Targets.Select(target => (target.PartitionId, new PollingStrategy
+            {
+                Kind = target.Kind,
+                Value = target.Value
+            }))],
+            Consumer.New(1), request.Count, request.AutoCommit, new DeferredPollOptions
+            {
+                MaxWait = TimeSpan.FromMicroseconds(request.WaitUs),
+                MinCount = request.MinCount,
+                MaxBytes = request.MaxBytes,
+                RequestTimeout = TimeSpan.FromMicroseconds(request.RequestTimeoutUs)
+            }, TestContext.Current.CancellationToken));
+        Assert.Equal(0, node.Requests(CommandCodes.FETCH_MESSAGES_CODE));
+    }
+
+    /// <summary>
+    ///     A committing poll or fetch that fails with a certain status stored nothing. Every listed uncertain status,
+    ///     like every status the fixture does not list, leaves the outcome unknown.
+    /// </summary>
+    [Theory]
+    [MemberData(nameof(CommitOutcomes))]
+    public void given_a_commit_outcome_vector_when_classified_should_match_its_certainty(string scope, int status,
+        bool certain)
+    {
+        var errorScope = scope == "request" ? CommitErrorScope.Request : CommitErrorScope.Section;
+
+        Assert.Equal(certain, errorScope.ProvesNothingStored(status));
+    }
+
+    private static TheoryData<string> Cases(params string[] path)
+    {
+        var element = path.Aggregate(Fixture, (current, key) => current.GetProperty(key));
+        return [.. element.EnumerateArray().Select(vector => vector.GetProperty("case").GetString()!)];
+    }
+
+    private static TheoryData<string, int, bool> Outcomes(params string[] scopes)
+    {
+        var outcomes = new TheoryData<string, int, bool>();
+        foreach (var scope in scopes)
+        {
+            foreach (var outcome in Fixture.GetProperty("commit_outcomes").GetProperty(scope).EnumerateArray())
+            {
+                outcomes.Add(scope, outcome.GetProperty("status").GetInt32(), outcome.GetProperty("certain").GetBoolean());
+            }
+        }
+
+        return outcomes;
+    }
+
+    private static JsonElement Case(string section, string name)
+    {
+        return Case(Fixture, section, name);
+    }
+
+    private static JsonElement Case(JsonElement parent, string section, string name)
+    {
+        return parent.GetProperty(section).EnumerateArray()
+            .Single(vector => vector.GetProperty("case").GetString() == name);
+    }
+
+    private static FetchedMessages Decode(string bodyHex)
+    {
+        var body = Convert.FromHexString(bodyHex);
+        var owner = new RentedBatchTests.TrackingMemoryOwner(body.Length);
+        body.CopyTo(owner.Memory);
+        return BinaryMapper.MapFetchedMessages(owner, null);
+    }
+
+    /// <summary>A refused fetch body: the consumer, stream and topic of the vectors, then the fields and the targets.</summary>
+    private static FetchRequest DecodeFetch(byte[] body)
+    {
+        var request = new MockRequest(0, CommandCodes.FETCH_MESSAGES_CODE, 0) { Body = body };
+        return ReadFetch(request);
+    }
+
+    private static Consumer ConsumerOf(JsonElement consumer)
+    {
+        var id = IdentifierOf(consumer.GetProperty("id"));
+        return consumer.GetProperty("kind").GetInt32() == 1
+            ? new Consumer { Type = ConsumerType.Consumer, ConsumerId = id }
+            : new Consumer { Type = ConsumerType.ConsumerGroup, ConsumerId = id };
+    }
+
+    private static Identifier IdentifierOf(JsonElement identifier)
+    {
+        return identifier.GetProperty("kind").GetString() == "numeric"
+            ? Identifier.Numeric(identifier.GetProperty("value").GetUInt32())
+            : Identifier.String(identifier.GetProperty("value").GetString()!);
+    }
+
+    private static PollingStrategy StrategyOf(JsonElement strategy)
+    {
+        return new PollingStrategy
+        {
+            Kind = (MessagePolling)(strategy.GetProperty("kind").GetInt32() - OffsetWireKind),
+            Value = strategy.GetProperty("value").GetUInt64()
+        };
+    }
+
+    private static DeferredPollOptions OptionsOf(JsonElement request)
+    {
+        return new DeferredPollOptions
+        {
+            MaxWait = TimeSpan.FromMicroseconds(request.GetProperty("wait_us").GetUInt64()),
+            MinCount = request.GetProperty("min_count").GetUInt32(),
+            MaxBytes = request.GetProperty("max_bytes").GetUInt32(),
+            RequestTimeout = TimeSpan.FromMicroseconds(request.GetProperty("request_timeout_us").GetUInt64())
+        };
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK_Tests/ContractsTests/FetchMessagesContractsTests.cs b/foreign/csharp/Iggy_SDK_Tests/ContractsTests/FetchMessagesContractsTests.cs
new file mode 100644
index 0000000..935a03b
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK_Tests/ContractsTests/FetchMessagesContractsTests.cs
@@ -0,0 +1,226 @@
+// 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.
+
+using System.Buffers.Binary;
+using Apache.Iggy.Contracts;
+using Apache.Iggy.Contracts.Tcp;
+using Apache.Iggy.Exceptions;
+using Apache.Iggy.Kinds;
+using Apache.Iggy.Mappers;
+using Apache.Iggy.Tests.ConsumerTests;
+using Apache.Iggy.Vsr;
+
+namespace Apache.Iggy.Tests.ContractsTests;
+
+/// <summary>
+///     Cross-SDK golden bodies of the fetch (code 109) from wire.md: consumer group 7, stream 1, topic "orders", a
+///     five-second wait, a six-second request timeout, minimum count 1, count 100, a 1 MiB reply limit and auto
+///     commit. The targets are (1, Offset 41) and (2, Next), each with a 512 KiB limit, incarnation 3 and owner 9.
+///     The reply holds the message "hello" at offset 41 of partition 1, and refuses partition 2 as not primary.
+/// </summary>
+public sealed class FetchMessagesContractsTests
+{
+    private const string FetchBody =
+        "0201040700000001040100000002066f7264657273404b4c0000000000808d5b0000000000010000006400000000001000" +
+        "0102000000010000000129000000000000000000080003000000000000000900000000000000020000000500000000000000" +
+        "000000080003000000000000000900000000000000";
+
+    private const string FetchReplyBody =
+        "020000000100000000000000290000000000000001000000030000000000000009000000000000000c000000000000002a00" +
+        "0000000000003501000002000000590000000000000000000000000000000000000000000000000000000000000000000000" +
+        "000000000000000000000000000000000100000000000000290000000000000064401e18240a060000401e18240a06003501" +
+        "000000000000bd2d787d3cdb745c010000000000000000000000000000000000000000000000000000000000000000000000" +
+        "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" +
+        "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" +
+        "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" +
+        "000000000000000000000000000000000000000000008e5ca59edbbdbfb90100000000000000000000000000000000000000" +
+        "000000000000000005000000000000000000000068656c6c6f";
+
+    private const uint TargetMaxBytes = 512 * 1024;
+    private const int SecondSectionStatus = FetchedMessages.SECTIONS_COUNT_SIZE + FetchedMessages.SECTION_HEADER_SIZE + 4;
+    private const int MessagesCountInHeader = 16;
+    private const int RecordsLengthInHeader = 52;
+
+    private static readonly PartitionContext Fence = new(3, 9, 0);
+
+    private static readonly DeferredPollOptions Options = new()
+    {
+        MaxWait = TimeSpan.FromSeconds(5),
+        RequestTimeout = TimeSpan.FromSeconds(6)
+    };
+
+    private static readonly (uint PartitionId, PollingStrategy Strategy)[] Targets =
+    [
+        (1, PollingStrategy.Offset(41).WithContext(Fence)),
+        (2, PollingStrategy.Next().WithContext(Fence))
+    ];
+
+    [Fact]
+    public void FetchMessages_EncodesTheFetchGoldenBody()
+    {
+        var body = TcpContracts.FetchMessages(Consumer.Group(7), Identifier.Numeric(1), Identifier.String("orders"),
+            Options, 100, true, TargetMaxBytes, Targets);
+
+        Assert.Equal(Convert.FromHexString(FetchBody), body);
+    }
+
+    [Fact]
+    public void FetchBody_NamesItsCommitItsProgressAndItsFences()
+    {
+        var body = Convert.FromHexString(FetchBody);
+        var offsetsOnly = TcpContracts.FetchMessages(Consumer.Group(7), Identifier.Numeric(1),
+            Identifier.String("orders"), Options, 100, false, TargetMaxBytes, [(4, PollingStrategy.Offset(0))]);
+
+        Assert.True(TcpContracts.FetchAutoCommits(body));
+        Assert.True(TcpContracts.FetchMovesConsumer(body));
+        Assert.Equal([(1u, 3ul), (2u, 3ul)], TcpContracts.FetchTargetFences(body));
+        Assert.False(TcpContracts.FetchAutoCommits(offsetsOnly));
+        Assert.False(TcpContracts.FetchMovesConsumer(offsetsOnly));
+        Assert.Equal([(4u, 0ul)], TcpContracts.FetchTargetFences(offsetsOnly));
+    }
+
+    [Fact]
+    public void MapFetchedMessages_DecodesTheFetchReplyGoldenBody()
+    {
+        var owner = Owner(Convert.FromHexString(FetchReplyBody));
+        var reply = BinaryMapper.MapFetchedMessages(owner, null);
+
+        Assert.Equal(2, reply.Sections.Count);
+        var served = reply.Sections[0];
+        Assert.Equal((1u, 0u, 41ul, 1u, 42ul, new PartitionContext(3, 9, 12)),
+            (served.PartitionId, served.Status, served.CurrentOffset, served.MessagesCount, served.NextOffset,
+                served.Context));
+        var refused = reply.Sections[1];
+        Assert.Equal((2u, (uint)VsrError.NOT_PRIMARY, 0, 0ul), (refused.PartitionId, refused.Status,
+            refused.Records.Length, refused.NextOffset));
+
+        var rental = served.DecodeRented();
+        var message = Assert.Single(rental.Messages);
+        Assert.Equal((41ul, new UInt128(0, 1)), (message.Header.Offset, message.Header.Id));
+        Assert.Equal("hello"u8.ToArray(), message.Payload.ToArray());
+        Assert.Equal((1u, 41ul, new PartitionContext(3, 9, 12)), (rental.PartitionId, rental.CurrentOffset,
+            rental.Context));
+        Assert.Equal(VsrError.NOT_PRIMARY, Assert.Throws<IggyInvalidStatusCodeException>(() => refused.Decode()).StatusCode);
+
+        // The reply buffer stays rented while a rental of one of its sections lives.
+        reply.Dispose();
+        Assert.Equal(0, owner.DisposeCount);
+        Assert.Equal("hello"u8.ToArray(), message.Payload.ToArray());
+        rental.Dispose();
+        Assert.Equal(1, owner.DisposeCount);
+        Assert.Throws<ObjectDisposedException>(() => served.DecodeRented());
+    }
+
+    [Theory]
+    [InlineData("short")]
+    [InlineData("too many sections")]
+    [InlineData("table past the body")]
+    [InlineData("records of a refused section")]
+    [InlineData("records past the body")]
+    [InlineData("trailing bytes")]
+    public void MapFetchedMessages_WithAMalformedReply_Throws(string malformation)
+    {
+        var golden = Convert.FromHexString(FetchReplyBody);
+        var body = malformation switch
+        {
+            "short" => golden[..3],
+            "too many sections" => Count(golden[..FetchedMessages.SECTIONS_COUNT_SIZE],
+                TcpContracts.MAX_PARTITIONS_PER_REQUEST + 1),
+            "table past the body" => golden[..FetchedMessages.HeadSize(1)],
+            "records of a refused section" => WriteUInt32(
+                [.. golden, 0], FetchedMessages.HeadSize(1) + RecordsLengthInHeader, 1),
+            "records past the body" => WriteUInt32(golden,
+                FetchedMessages.SECTIONS_COUNT_SIZE + RecordsLengthInHeader,
+                (uint)(golden.Length - FetchedMessages.HeadSize(2) + 1)),
+            _ => [.. golden, 0]
+        };
+
+        Assert.Throws<MalformedResponseException>(() => BinaryMapper.MapFetchedMessages(Owner(body), null));
+    }
+
+    [Fact]
+    public void MapFetchedMessages_WithASectionThatReadNothing_DecodesNoMessages()
+    {
+        var golden = Convert.FromHexString(FetchReplyBody);
+        var body = golden[..FetchedMessages.HeadSize(2)];
+        WriteUInt32(body, FetchedMessages.SECTIONS_COUNT_SIZE + MessagesCountInHeader, 0);
+        WriteUInt32(body, FetchedMessages.SECTIONS_COUNT_SIZE + RecordsLengthInHeader, 0);
+        WriteUInt32(body, SecondSectionStatus, 0);
+
+        using var reply = BinaryMapper.MapFetchedMessages(Owner(body), null);
+
+        Assert.All(reply.Sections, section => Assert.Empty(section.Decode().Messages));
+    }
+
+    [Theory]
+    [InlineData(0, 999_999, false)]
+    [InlineData(0, 1_000_000, false)]
+    [InlineData(0, 1_000_001, true)]
+    [InlineData(5_000_000, 5_999_999, false)]
+    [InlineData(5_000_000, 6_000_000, true)]
+    public void ValidateFetch_KeepsTheReplyMarginAlsoWithoutAWait(long maxWaitUs, long requestTimeoutUs, bool valid)
+    {
+        var options = new DeferredPollOptions
+        {
+            MaxWait = TimeSpan.FromMicroseconds(maxWaitUs),
+            RequestTimeout = TimeSpan.FromMicroseconds(requestTimeoutUs)
+        };
+
+        if (valid)
+        {
+            options.ValidateFetch(100);
+        }
+        else
+        {
+            Assert.Equal(VsrError.INVALID_COMMAND,
+                Assert.Throws<IggyInvalidStatusCodeException>(() => options.ValidateFetch(100)).StatusCode);
+        }
+    }
+
+    [Fact]
+    public void ValidateFetch_WithLessThanOneMicrosecondOfHeadroom_RefusesTheRoundedWireTimeout()
+    {
+        var options = new DeferredPollOptions
+        {
+            MaxWait = TimeSpan.Zero,
+            RequestTimeout = TimeSpan.FromSeconds(1) + TimeSpan.FromTicks(TimeSpan.TicksPerMicrosecond - 1)
+        };
+
+        Assert.Equal(VsrError.INVALID_COMMAND,
+            Assert.Throws<IggyInvalidStatusCodeException>(() => options.ValidateFetch(1)).StatusCode);
+        options.Validate(1);
+    }
+
+    private static RentedBatchTests.TrackingMemoryOwner Owner(byte[] body)
+    {
+        var owner = new RentedBatchTests.TrackingMemoryOwner(body.Length);
+        body.CopyTo(owner.Memory);
+        return owner;
+    }
+
+    private static byte[] Count(byte[] body, int count)
+    {
+        BinaryPrimitives.WriteUInt32LittleEndian(body, (uint)count);
+        return body;
+    }
+
+    private static byte[] WriteUInt32(byte[] body, int position, uint value)
+    {
+        BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(position), value);
+        return body;
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK_Tests/ContractsTests/MessageBatchGoldenVectorTests.cs b/foreign/csharp/Iggy_SDK_Tests/ContractsTests/MessageBatchGoldenVectorTests.cs
index af459c2..70289b0 100644
--- a/foreign/csharp/Iggy_SDK_Tests/ContractsTests/MessageBatchGoldenVectorTests.cs
+++ b/foreign/csharp/Iggy_SDK_Tests/ContractsTests/MessageBatchGoldenVectorTests.cs
@@ -16,7 +16,10 @@
 // under the License.
 
 using System.Buffers.Binary;
+using Apache.Iggy.Contracts;
 using Apache.Iggy.Contracts.Tcp;
+using Apache.Iggy.Encryption;
+using Apache.Iggy.Exceptions;
 using Apache.Iggy.Extensions;
 using Apache.Iggy.Vsr;
 using Apache.Iggy.Kinds;
@@ -167,6 +170,32 @@
             second.Header.Checksum);
     }
 
+    [Fact]
+    public void MapRentedMessages_MalformedRecords_KeepTheRemainingBytes()
+    {
+        var pollBody = Convert.FromHexString(POLL_BODY)[..^1];
+
+        var error = Assert.Throws<MalformedResponseException>(() =>
+            Mappers.BinaryMapper.MapRentedMessages(pollBody, EmptyMemoryOwner.Instance));
+
+        Assert.Equal(pollBody[DeferredPollOptions.POLL_REPLY_HEADER_SIZE..], error.Record);
+    }
+
+    [Fact]
+    public void MapRentedMessages_WrongKey_KeepsTheFrameAsServed()
+    {
+        var pollBody = Convert.FromHexString(POLL_BODY);
+        using var encryptor = new AesMessageEncryptor(AesMessageEncryptor.GenerateKey());
+        var frameStart = DeferredPollOptions.POLL_REPLY_HEADER_SIZE + BatchWireFormat.BATCH_HEADER_SIZE;
+        var frameEnd = frameStart + BatchWireFormat.FRAME_HEADER_SIZE + "first-payload"u8.Length;
+
+        var error = Assert.Throws<MessageDecryptionException>(() =>
+            Mappers.BinaryMapper.MapRentedMessages(pollBody, EmptyMemoryOwner.Instance, encryptor));
+
+        Assert.Equal(100ul, error.Offset);
+        Assert.Equal(pollBody[frameStart..frameEnd], error.Record);
+    }
+
     private static Message[] GoldenMessages()
     {
         var messages = new[]
diff --git a/foreign/csharp/Iggy_SDK_Tests/ContractsTests/OffsetContractsTests.cs b/foreign/csharp/Iggy_SDK_Tests/ContractsTests/OffsetContractsTests.cs
new file mode 100644
index 0000000..6e7d04a
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK_Tests/ContractsTests/OffsetContractsTests.cs
@@ -0,0 +1,87 @@
+// 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.
+
+using Apache.Iggy.Contracts.Tcp;
+using Apache.Iggy.Kinds;
+
+namespace Apache.Iggy.Tests.ContractsTests;
+
+/// <summary>
+///     Golden bodies of the consumer offset requests, in the layout of the Rust encoders in
+///     core/binary_protocol/src/requests/consumer_offsets: the consumer, the stream and the topic, the partition flag
+///     and id, then the offset and the quorum acknowledgement where the request carries them. One request names
+///     consumer group 7, stream 1, topic "orders" and partition 3. The other names consumer "reader", stream 1,
+///     topic 2 and no partition.
+/// </summary>
+public sealed class OffsetContractsTests
+{
+    private const string GroupPrefix = "0201040700000001040100000002066f7264657273";
+    private const string ConsumerPrefix = "010206726561646572010401000000010402000000";
+    private const string Partition3 = "0103000000";
+    private const string NoPartition = "0000000000";
+    private const string Offset42 = "2a00000000000000";
+    private const string Quorum = "01";
+    private const ulong Offset = 42;
+
+    private static readonly Identifier Stream = Identifier.Numeric(1);
+
+    [Theory]
+    [InlineData(true)]
+    [InlineData(false)]
+    public void UpdateOffset_EncodesTheStoreGoldenBody(bool group)
+    {
+        var (consumer, topic, partitionId, head) = Request(group);
+
+        var body = TcpContracts.UpdateOffset(Stream, topic, consumer, Offset, partitionId);
+
+        Assert.Equal(Convert.FromHexString(head + Offset42 + Quorum), body);
+    }
+
+    [Theory]
+    [InlineData(true)]
+    [InlineData(false)]
+    public void GetOffset_EncodesTheGetGoldenBody(bool group)
+    {
+        var (consumer, topic, partitionId, head) = Request(group);
+
+        var body = TcpContracts.GetOffset(Stream, topic, consumer, partitionId);
+
+        Assert.Equal(Convert.FromHexString(head), body);
+    }
+
+    [Theory]
+    [InlineData(true)]
+    [InlineData(false)]
+    public void DeleteOffset_EncodesTheDeleteGoldenBody(bool group)
+    {
+        var (consumer, topic, partitionId, head) = Request(group);
+
+        var body = TcpContracts.DeleteOffset(Stream, topic, consumer, partitionId);
+
+        Assert.Equal(Convert.FromHexString(head + Quorum), body);
+    }
+
+    /// <summary>
+    ///     The request of the group or of the plain consumer, and the golden bytes up to the partition id.
+    /// </summary>
+    private static (Consumer Consumer, Identifier Topic, uint? PartitionId, string Head) Request(bool group)
+    {
+        return group
+            ? (Consumer.Group(7), Identifier.String("orders"), 3, GroupPrefix + Partition3)
+            : (Consumer.New("reader"), Identifier.Numeric(2), null, ConsumerPrefix + NoPartition);
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK_Tests/ContractsTests/PollOptionsTests.cs b/foreign/csharp/Iggy_SDK_Tests/ContractsTests/PollOptionsTests.cs
new file mode 100644
index 0000000..042bb44
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK_Tests/ContractsTests/PollOptionsTests.cs
@@ -0,0 +1,134 @@
+// 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.
+
+using Apache.Iggy.Configuration;
+using Apache.Iggy.Contracts;
+using Apache.Iggy.Contracts.Tcp;
+using Apache.Iggy.Enums;
+using Apache.Iggy.Exceptions;
+using Apache.Iggy.Vsr;
+
+namespace Apache.Iggy.Tests.ContractsTests;
+
+public sealed class PollOptionsTests
+{
+    [Fact]
+    public void given_no_options_when_defaulted_should_answer_at_once_and_commit_nothing()
+    {
+        var options = new PollOptions();
+
+        Assert.Equal(new PollOptions
+        {
+            Count = 1000,
+            MaxWait = TimeSpan.Zero,
+            MinCount = 1,
+            MaxBytes = 1024 * 1024,
+            Commit = CommitPolicy.Manual
+        }, options);
+        Assert.Equal(TimeSpan.FromSeconds(10), options.RequestTimeout);
+        options.Validate();
+    }
+
+    [Fact]
+    public void given_no_auto_commit_settings_when_defaulted_should_store_every_10000_messages_or_5_seconds_after_processing()
+    {
+        Assert.Equal(new AutoCommit
+        {
+            Interval = TimeSpan.FromSeconds(5),
+            Every = CommitEvery.Messages(10_000),
+            After = CommitAfter.Processing
+        }, AutoCommit.Default);
+        Assert.Equal(CommitMode.Auto, CommitPolicy.Auto().Mode);
+        Assert.Equal(AutoCommit.Default, CommitPolicy.Auto().Settings);
+    }
+
+    [Fact]
+    public void given_auto_commit_without_an_interval_or_an_event_when_validating_should_refuse_it()
+    {
+        static PollOptions Auto(TimeSpan? interval, CommitEvery? every) => new()
+        {
+            Commit = CommitPolicy.Auto(new AutoCommit { Interval = interval, Every = every, After = CommitAfter.Delivery })
+        };
+
+        var refused = Assert.Throws<IggyInvalidStatusCodeException>(() => Auto(null, null).Validate());
+
+        Assert.Equal(VsrError.INVALID_CONFIGURATION, refused.StatusCode);
+        Auto(TimeSpan.FromSeconds(1), null).Validate();
+        Auto(null, CommitEvery.Batch).Validate();
+    }
+
+    [Fact]
+    public void given_auto_commit_with_a_zero_interval_when_validating_should_refuse_it()
+    {
+        var options = new PollOptions
+        {
+            Commit = CommitPolicy.Auto(new AutoCommit { Interval = TimeSpan.Zero, Every = CommitEvery.Message })
+        };
+
+        var refused = Assert.Throws<IggyInvalidStatusCodeException>(() => options.Validate());
+
+        Assert.Equal(VsrError.INVALID_CONFIGURATION, refused.StatusCode);
+    }
+
+    [Fact]
+    public void given_a_wait_when_building_options_should_keep_the_other_defaults()
+    {
+        var options = PollOptions.Wait(TimeSpan.FromSeconds(3));
+
+        Assert.Equal(new PollOptions { MaxWait = TimeSpan.FromSeconds(3) }, options);
+        Assert.Equal(TimeSpan.FromSeconds(13), options.RequestTimeout);
+    }
+
+    [Theory]
+    [InlineData(0u, 1u, 1024u * 1024, 0, VsrError.INVALID_MESSAGES_COUNT)]
+    [InlineData(10u, 11u, 1024u * 1024, 0, VsrError.INVALID_MESSAGES_COUNT)]
+    [InlineData(10u, 0u, 1024u * 1024, 0, VsrError.INVALID_MESSAGES_COUNT)]
+    [InlineData(10u, 1u, 100u, 0, VsrError.INVALID_SIZE_BYTES)]
+    [InlineData(10u, 1u, 1024u * 1024, 601, VsrError.INVALID_COMMAND)]
+    public void given_invalid_options_when_validating_should_fail_like_the_wire_options(uint count, uint minCount,
+        uint maxBytes, int maxWaitSeconds, int status)
+    {
+        var options = new PollOptions
+        {
+            Count = count,
+            MinCount = minCount,
+            MaxBytes = maxBytes,
+            MaxWait = TimeSpan.FromSeconds(maxWaitSeconds)
+        };
+
+        var refused = Assert.Throws<IggyInvalidStatusCodeException>(() => options.Validate());
+
+        Assert.Equal(status, refused.StatusCode);
+    }
+
+    [Fact]
+    public void given_the_default_reply_frame_limit_when_a_read_returns_the_largest_message_should_hold_it()
+    {
+        Assert.Equal(64_156_564, TcpContracts.MAX_RECORD_REPLY_SIZE);
+        Assert.True(new IggyClientConfigurator { BaseAddress = "127.0.0.1:8090", Protocol = Protocol.Tcp }.MaxResponseFrameSize
+                    >= TcpContracts.MAX_RECORD_REPLY_SIZE);
+    }
+
+    [Fact]
+    public void given_a_zero_count_of_messages_when_building_the_event_should_refuse_it()
+    {
+        Assert.Throws<ArgumentOutOfRangeException>(() => CommitEvery.Messages(0));
+        Assert.Equal((CommitEveryKind.Messages, 3u), (CommitEvery.Messages(3).Kind, CommitEvery.Messages(3).Count));
+        Assert.Equal(CommitEveryKind.Message, CommitEvery.Message.Kind);
+        Assert.Equal(CommitEveryKind.Batch, CommitEvery.Batch.Kind);
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK_Tests/Fixtures/deferred_polling.json b/foreign/csharp/Iggy_SDK_Tests/Fixtures/deferred_polling.json
new file mode 100644
index 0000000..452b461
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK_Tests/Fixtures/deferred_polling.json
@@ -0,0 +1,610 @@
+{
+  "schema_version": 1,
+  "generated_by": "core/common/examples/deferred_polling_fixtures.rs",
+  "conventions": {
+    "body_hex": "Every *_hex field is lowercase hex without separators. body_hex is the command payload without framing.",
+    "header_hex": "header_hex is the whole request header of a cancellation, which has no body, before the transport stamps its checksum and checksum_body.",
+    "oversized": "A reply may carry a first record above max_bytes, up to max_record_size, so a client reply cap is at least max_record_reply_size.",
+    "refused": "Every refused fetch fails to decode or to validate. Every refused fetch reply fails to decode.",
+    "status": "A section status of status_ok was read. Any other status is the error code of that partition alone, and its section holds no records.",
+    "strategy": "strategy.kind is the wire kind: 1 offset, 2 timestamp, 3 first, 4 last, 5 next."
+  },
+  "constants": {
+    "codes": {
+      "cancel_deferred_request": 108,
+      "fetch_messages": 109,
+      "poll_messages": 100,
+      "poll_messages_deferred": 106
+    },
+    "status_ok": 0,
+    "errors": {
+      "deferred_poll_capacity_exceeded": 90,
+      "fetch_progress_uncertain": 14010,
+      "history_unavailable": 87,
+      "invalid_size_bytes": 80,
+      "not_primary": 89,
+      "partition_lane_blocked": 14009,
+      "too_many_consumer_offsets": 3024,
+      "transient_not_accepted": 58,
+      "transient_not_committed": 57
+    },
+    "min_deferred_poll_max_bytes": 344,
+    "max_deferred_poll_wait_us": 600000000,
+    "max_deferred_poll_timeout_us": 630000000,
+    "min_deferred_poll_reply_margin_us": 1000000,
+    "fetch_reply_margin_us": 200000,
+    "max_partitions_per_request": 1000,
+    "max_record_size": 64100304,
+    "max_record_reply_size": 64156564,
+    "request_header_size": 256,
+    "fetch_section_header_size": 56
+  },
+  "deferred_polls": [
+    {
+      "case": "consumer_offset_without_commit",
+      "request": {
+        "consumer": {
+          "kind": 1,
+          "id": {
+            "kind": "numeric",
+            "value": 1
+          }
+        },
+        "stream_id": {
+          "kind": "numeric",
+          "value": 1
+        },
+        "topic_id": {
+          "kind": "named",
+          "value": "orders"
+        },
+        "partition_id": 2,
+        "strategy": {
+          "kind": 1,
+          "value": 41
+        },
+        "count": 100,
+        "auto_commit": false,
+        "wait_us": 5000000,
+        "min_count": 1,
+        "max_bytes": 1048576,
+        "request_timeout_us": 6000000
+      },
+      "body_hex": "0101040100000001040100000002066f726465727301020000000129000000000000006400000000404b4c00000000000100000000001000808d5b0000000000"
+    },
+    {
+      "case": "group_next_with_commit",
+      "request": {
+        "consumer": {
+          "kind": 2,
+          "id": {
+            "kind": "named",
+            "value": "orders-group"
+          }
+        },
+        "stream_id": {
+          "kind": "numeric",
+          "value": 1
+        },
+        "topic_id": {
+          "kind": "named",
+          "value": "orders"
+        },
+        "partition_id": 2,
+        "strategy": {
+          "kind": 5,
+          "value": 0
+        },
+        "count": 100,
+        "auto_commit": true,
+        "wait_us": 5000000,
+        "min_count": 1,
+        "max_bytes": 1048576,
+        "request_timeout_us": 6000000
+      },
+      "body_hex": "02020c6f72646572732d67726f757001040100000002066f726465727301020000000500000000000000006400000001404b4c00000000000100000000001000808d5b0000000000"
+    }
+  ],
+  "fetches": [
+    {
+      "case": "consumer_two_targets_without_commit",
+      "request": {
+        "consumer": {
+          "kind": 1,
+          "id": {
+            "kind": "numeric",
+            "value": 1
+          }
+        },
+        "stream_id": {
+          "kind": "numeric",
+          "value": 1
+        },
+        "topic_id": {
+          "kind": "numeric",
+          "value": 2
+        },
+        "wait_us": 5000000,
+        "request_timeout_us": 6000000,
+        "min_count": 1,
+        "count": 1000,
+        "max_bytes": 1048576,
+        "auto_commit": false,
+        "targets": [
+          {
+            "partition_id": 1,
+            "strategy": {
+              "kind": 1,
+              "value": 41
+            },
+            "max_bytes": 524288,
+            "incarnation": 11,
+            "owner_generation": 0
+          },
+          {
+            "partition_id": 3,
+            "strategy": {
+              "kind": 5,
+              "value": 0
+            },
+            "max_bytes": 524288,
+            "incarnation": 11,
+            "owner_generation": 0
+          }
+        ]
+      },
+      "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b000000000001000000e803000000001000000200000001000000012900000000000000000008000b00000000000000000000000000000003000000050000000000000000000008000b000000000000000000000000000000"
+    },
+    {
+      "case": "group_three_targets_with_commit",
+      "request": {
+        "consumer": {
+          "kind": 2,
+          "id": {
+            "kind": "numeric",
+            "value": 9
+          }
+        },
+        "stream_id": {
+          "kind": "numeric",
+          "value": 1
+        },
+        "topic_id": {
+          "kind": "numeric",
+          "value": 2
+        },
+        "wait_us": 5000000,
+        "request_timeout_us": 6000000,
+        "min_count": 1,
+        "count": 1000,
+        "max_bytes": 1048576,
+        "auto_commit": true,
+        "targets": [
+          {
+            "partition_id": 1,
+            "strategy": {
+              "kind": 5,
+              "value": 0
+            },
+            "max_bytes": 524288,
+            "incarnation": 11,
+            "owner_generation": 4
+          },
+          {
+            "partition_id": 2,
+            "strategy": {
+              "kind": 5,
+              "value": 0
+            },
+            "max_bytes": 524288,
+            "incarnation": 11,
+            "owner_generation": 4
+          },
+          {
+            "partition_id": 4,
+            "strategy": {
+              "kind": 1,
+              "value": 7
+            },
+            "max_bytes": 524288,
+            "incarnation": 11,
+            "owner_generation": 4
+          }
+        ]
+      },
+      "body_hex": "02010409000000010401000000010402000000404b4c0000000000808d5b000000000001000000e803000000001000010300000001000000050000000000000000000008000b00000000000000040000000000000002000000050000000000000000000008000b00000000000000040000000000000004000000010700000000000000000008000b000000000000000400000000000000"
+    }
+  ],
+  "cancellations": [
+    {
+      "case": "cancel_deferred_poll",
+      "cancels_code": 106,
+      "client": 7,
+      "request": 21,
+      "session": 3,
+      "header_hex": "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000050000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007000000000000000000000000000000000000000000000000000000000000000000000000000000150000000000000002000000000000000300000000000000000000006c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
+    },
+    {
+      "case": "cancel_fetch",
+      "cancels_code": 109,
+      "client": 7,
+      "request": 22,
+      "session": 3,
+      "header_hex": "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000050000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007000000000000000000000000000000000000000000000000000000000000000000000000000000160000000000000002000000000000000300000000000000000000006c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
+    }
+  ],
+  "fetch_replies": [
+    {
+      "case": "ok_section_and_empty_start_section",
+      "sections": [
+        {
+          "partition_id": 1,
+          "status": 0,
+          "current_offset": 44,
+          "messages_count": 3,
+          "context": {
+            "incarnation": 11,
+            "owner_generation": 4,
+            "metadata_op": 42
+          },
+          "next_offset": 44,
+          "records_len": 416,
+          "messages": [
+            {
+              "offset": 41,
+              "id": "258",
+              "timestamp": 1700000000000500,
+              "origin_timestamp": 1700000000000000,
+              "payload": "first"
+            },
+            {
+              "offset": 42,
+              "id": "259",
+              "timestamp": 1700000000000500,
+              "origin_timestamp": 1700000000000000,
+              "payload": "second"
+            },
+            {
+              "offset": 43,
+              "id": "260",
+              "timestamp": 1700000000000500,
+              "origin_timestamp": 1700000000000000,
+              "payload": "third"
+            }
+          ]
+        },
+        {
+          "partition_id": 3,
+          "status": 0,
+          "current_offset": 9,
+          "messages_count": 0,
+          "context": {
+            "incarnation": 11,
+            "owner_generation": 4,
+            "metadata_op": 42
+          },
+          "next_offset": 10,
+          "records_len": 0,
+          "messages": []
+        }
+      ],
+      "body_hex": "0200000001000000000000002c00000000000000030000000b0000000000000004000000000000002a000000000000002c00000000000000a001000003000000000000000900000000000000000000000b0000000000000004000000000000002a000000000000000a000000000000000000000001000000000000002900000000000000f4411e18240a060000401e18240a0600a00100000000000088fc3268f2c0e02103000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000bb5b1cd43b71761a02010000000000000000000000000000000000000000000000000000050000000000000000000000666972737453c25c376f98c265030100000000000000000000000000000100000000000000000000000600000000000000000000007365636f6e64a761c18f632fb022040100000000000000000000000000000200000000000000000000000500000000000000000000007468697264"
+    },
+    {
+      "case": "ok_section_beside_error_sections",
+      "sections": [
+        {
+          "partition_id": 1,
+          "status": 89,
+          "current_offset": 0,
+          "messages_count": 0,
+          "context": {
+            "incarnation": 0,
+            "owner_generation": 0,
+            "metadata_op": 0
+          },
+          "next_offset": 0,
+          "records_len": 0,
+          "messages": []
+        },
+        {
+          "partition_id": 2,
+          "status": 0,
+          "current_offset": 7,
+          "messages_count": 1,
+          "context": {
+            "incarnation": 11,
+            "owner_generation": 4,
+            "metadata_op": 42
+          },
+          "next_offset": 7,
+          "records_len": 308,
+          "messages": [
+            {
+              "offset": 6,
+              "id": "340282366920938463463374607431768211455",
+              "timestamp": 1700000000000500,
+              "origin_timestamp": 1700000000000000,
+              "payload": "only"
+            }
+          ]
+        },
+        {
+          "partition_id": 4,
+          "status": 58,
+          "current_offset": 0,
+          "messages_count": 0,
+          "context": {
+            "incarnation": 0,
+            "owner_generation": 0,
+            "metadata_op": 0
+          },
+          "next_offset": 0,
+          "records_len": 0,
+          "messages": []
+        }
+      ],
+      "body_hex": "03000000010000005900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000700000000000000010000000b0000000000000004000000000000002a00000000000000070000000000000034010000040000003a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000600000000000000f4411e18240a060000401e18240a060034010000000000003e89bfefc17b805301000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000fbb4aeb35a85bdaeffffffffffffffffffffffffffffffff0000000000000000000000000400000000000000000000006f6e6c79"
+    },
+    {
+      "case": "no_sections",
+      "sections": [],
+      "body_hex": "00000000"
+    }
+  ],
+  "refused": {
+    "fetches": [
+      {
+        "case": "no_targets",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b000000000001000000e8030000000010000000000000"
+      },
+      {
+        "case": "duplicate_partition",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b000000000001000000e803000000001000000200000001000000010000000000000000000008000b00000000000000000000000000000001000000050000000000000000000008000b000000000000000000000000000000"
+      },
+      {
+        "case": "first_start_point",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b000000000001000000e803000000001000000100000001000000030000000000000000000008000b000000000000000000000000000000"
+      },
+      {
+        "case": "min_count_above_count",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b0000000000e9030000e803000000001000000100000001000000010000000000000000000008000b000000000000000000000000000000"
+      },
+      {
+        "case": "target_bytes_above_request_bytes",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b000000000001000000e803000000001000000100000001000000010000000000000000010010000b000000000000000000000000000000"
+      },
+      {
+        "case": "timeout_without_reply_margin",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000404b4c000000000001000000e803000000001000000100000001000000010000000000000000000008000b000000000000000000000000000000"
+      }
+    ],
+    "fetch_replies": [
+      {
+        "case": "error_section_with_records",
+        "body_hex": "01000000010000005900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000"
+      },
+      {
+        "case": "truncated_table",
+        "body_hex": "02000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
+      }
+    ]
+  },
+  "commit_outcomes": {
+    "rule": "A committing deferred poll (106) or fetch (109) that fails with a certain status stored nothing. Every other status, a status not listed here, a lost reply and a transport failure after the send leave the outcome unknown.",
+    "request": [
+      {
+        "status": 5,
+        "error": "feature_unavailable",
+        "certain": true
+      },
+      {
+        "status": 6,
+        "error": "invalid_identifier",
+        "certain": true
+      },
+      {
+        "status": 30,
+        "error": "stale_client",
+        "certain": false
+      },
+      {
+        "status": 40,
+        "error": "unauthenticated",
+        "certain": false
+      },
+      {
+        "status": 41,
+        "error": "unauthorized",
+        "certain": false
+      },
+      {
+        "status": 57,
+        "error": "transient_not_committed",
+        "certain": false
+      },
+      {
+        "status": 58,
+        "error": "transient_not_accepted",
+        "certain": true
+      },
+      {
+        "status": 72,
+        "error": "cannot_decrypt_data",
+        "certain": false
+      },
+      {
+        "status": 80,
+        "error": "invalid_size_bytes",
+        "certain": true
+      },
+      {
+        "status": 87,
+        "error": "history_unavailable",
+        "certain": true
+      },
+      {
+        "status": 89,
+        "error": "not_primary",
+        "certain": true
+      },
+      {
+        "status": 90,
+        "error": "deferred_poll_capacity_exceeded",
+        "certain": true
+      },
+      {
+        "status": 1009,
+        "error": "stream_id_not_found",
+        "certain": true
+      },
+      {
+        "status": 1010,
+        "error": "stream_name_not_found",
+        "certain": true
+      },
+      {
+        "status": 2010,
+        "error": "topic_id_not_found",
+        "certain": true
+      },
+      {
+        "status": 2011,
+        "error": "topic_name_not_found",
+        "certain": true
+      },
+      {
+        "status": 3007,
+        "error": "partition_not_found",
+        "certain": true
+      },
+      {
+        "status": 3024,
+        "error": "too_many_consumer_offsets",
+        "certain": true
+      },
+      {
+        "status": 4100,
+        "error": "invalid_offset",
+        "certain": true
+      },
+      {
+        "status": 5009,
+        "error": "consumer_group_partition_not_owned",
+        "certain": true
+      },
+      {
+        "status": 11001,
+        "error": "shard_communication_error",
+        "certain": false
+      }
+    ],
+    "section": [
+      {
+        "status": 5,
+        "error": "feature_unavailable",
+        "certain": false
+      },
+      {
+        "status": 6,
+        "error": "invalid_identifier",
+        "certain": false
+      },
+      {
+        "status": 30,
+        "error": "stale_client",
+        "certain": false
+      },
+      {
+        "status": 40,
+        "error": "unauthenticated",
+        "certain": false
+      },
+      {
+        "status": 41,
+        "error": "unauthorized",
+        "certain": false
+      },
+      {
+        "status": 57,
+        "error": "transient_not_committed",
+        "certain": false
+      },
+      {
+        "status": 58,
+        "error": "transient_not_accepted",
+        "certain": true
+      },
+      {
+        "status": 72,
+        "error": "cannot_decrypt_data",
+        "certain": false
+      },
+      {
+        "status": 80,
+        "error": "invalid_size_bytes",
+        "certain": true
+      },
+      {
+        "status": 87,
+        "error": "history_unavailable",
+        "certain": true
+      },
+      {
+        "status": 89,
+        "error": "not_primary",
+        "certain": true
+      },
+      {
+        "status": 90,
+        "error": "deferred_poll_capacity_exceeded",
+        "certain": false
+      },
+      {
+        "status": 1009,
+        "error": "stream_id_not_found",
+        "certain": false
+      },
+      {
+        "status": 1010,
+        "error": "stream_name_not_found",
+        "certain": false
+      },
+      {
+        "status": 2010,
+        "error": "topic_id_not_found",
+        "certain": false
+      },
+      {
+        "status": 2011,
+        "error": "topic_name_not_found",
+        "certain": false
+      },
+      {
+        "status": 3007,
+        "error": "partition_not_found",
+        "certain": true
+      },
+      {
+        "status": 3024,
+        "error": "too_many_consumer_offsets",
+        "certain": true
+      },
+      {
+        "status": 4100,
+        "error": "invalid_offset",
+        "certain": false
+      },
+      {
+        "status": 5009,
+        "error": "consumer_group_partition_not_owned",
+        "certain": true
+      },
+      {
+        "status": 11001,
+        "error": "shard_communication_error",
+        "certain": false
+      }
+    ]
+  }
+}
diff --git a/foreign/csharp/Iggy_SDK_Tests/Fixtures/resource_options.json b/foreign/csharp/Iggy_SDK_Tests/Fixtures/resource_options.json
index c5cc2ac..202888f 100644
--- a/foreign/csharp/Iggy_SDK_Tests/Fixtures/resource_options.json
+++ b/foreign/csharp/Iggy_SDK_Tests/Fixtures/resource_options.json
@@ -16,18 +16,18 @@
   },
   "constants": {
     "protocol_version": {
-      "packed": 12288,
-      "text": "0.12.0",
+      "packed": 12289,
+      "text": "0.12.1",
       "major": 0,
       "minor": 12,
-      "patch": 0
+      "patch": 1
     },
     "protocol_version_min": {
-      "packed": 12288,
-      "text": "0.12.0",
+      "packed": 12289,
+      "text": "0.12.1",
       "major": 0,
       "minor": 12,
-      "patch": 0
+      "patch": 1
     },
     "command_codes": {
       "create_stream": {
diff --git a/foreign/csharp/Iggy_SDK_Tests/Iggy_SDK_Tests.csproj b/foreign/csharp/Iggy_SDK_Tests/Iggy_SDK_Tests.csproj
index 6b93152..1ccfec9 100644
--- a/foreign/csharp/Iggy_SDK_Tests/Iggy_SDK_Tests.csproj
+++ b/foreign/csharp/Iggy_SDK_Tests/Iggy_SDK_Tests.csproj
@@ -55,6 +55,9 @@
         <None Include="Fixtures/metadata_changes.json">
             <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
         </None>
+        <None Include="Fixtures/deferred_polling.json">
+            <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+        </None>
     </ItemGroup>
 
 </Project>
diff --git a/foreign/csharp/Iggy_SDK_Tests/VsrTests/ConsumerGroupClientStateTests.cs b/foreign/csharp/Iggy_SDK_Tests/VsrTests/ConsumerGroupClientStateTests.cs
index d7d6029..79d6e2b 100644
--- a/foreign/csharp/Iggy_SDK_Tests/VsrTests/ConsumerGroupClientStateTests.cs
+++ b/foreign/csharp/Iggy_SDK_Tests/VsrTests/ConsumerGroupClientStateTests.cs
@@ -66,6 +66,20 @@
     }
 
     [Fact]
+    public void AssignedPartitions_KeepAssignmentOrderAndFollowTheLatestAssignment()
+    {
+        var state = new ConsumerGroupClientState();
+        Assert.Empty(state.AssignedPartitions(Key));
+
+        state.SetAssignment(Key, 1, [2, 0, 1]);
+        state.NextGroupPartition(Key);
+        Assert.Equal<uint[]>([2, 0, 1], [.. state.AssignedPartitions(Key)]);
+
+        state.SetAssignment(Key, 2, [4]);
+        Assert.Equal<uint[]>([4], [.. state.AssignedPartitions(Key)]);
+    }
+
+    [Fact]
     public void NextGroupPartition_WithoutAssignmentIsNull()
     {
         var state = new ConsumerGroupClientState();
diff --git a/foreign/csharp/Iggy_SDK_Tests/VsrTests/DeferredPollingTests.cs b/foreign/csharp/Iggy_SDK_Tests/VsrTests/DeferredPollingTests.cs
new file mode 100644
index 0000000..6ce0a1b
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK_Tests/VsrTests/DeferredPollingTests.cs
@@ -0,0 +1,534 @@
+// 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.
+
+using System.Buffers.Binary;
+using System.Collections.Concurrent;
+using System.Diagnostics;
+using Apache.Iggy.Configuration;
+using Apache.Iggy.Consumers;
+using Apache.Iggy.Contracts;
+using Apache.Iggy.Contracts.Tcp;
+using Apache.Iggy.Enums;
+using Apache.Iggy.Exceptions;
+using Apache.Iggy.IggyClient.Implementations;
+using Apache.Iggy.Kinds;
+using Apache.Iggy.Messages;
+using Apache.Iggy.Tests.ContractsTests;
+using Apache.Iggy.Utils;
+using Apache.Iggy.Vsr;
+using Microsoft.Extensions.Logging.Abstractions;
+using static Apache.Iggy.Tests.VsrTests.MockFrames;
+using Partitioning = Apache.Iggy.Kinds.Partitioning;
+
+namespace Apache.Iggy.Tests.VsrTests;
+
+/// <summary>
+///     Deferred polls (code 106) and fetches (code 109) against one node, which parks a request by not
+///     answering it.
+/// </summary>
+public sealed class DeferredPollingTests
+{
+    private const int PollCode = CommandCodes.POLL_MESSAGES_DEFERRED_CODE;
+    private const int FetchCode = CommandCodes.FETCH_MESSAGES_CODE;
+    private const int CancelCode = CommandCodes.CANCEL_DEFERRED_REQUEST_CODE;
+
+    /// <summary>The golden batch holds the offsets 100 and 101, so this current offset drains its partition.</summary>
+    private const ulong DrainedOffset = 101;
+
+    /// <summary>Enough retried calls that a clock counting in whole milliseconds overstates the time of some.</summary>
+    private const int RetriedCalls = 20;
+
+    /// <summary>A send reply that confirms no batch: the confirmation count alone.</summary>
+    private static readonly byte[] NoConfirmations = new byte[sizeof(uint)];
+
+    private static readonly Identifier Stream = Identifier.Numeric(1);
+    private static readonly Identifier Topic = Identifier.Numeric(2);
+    private static readonly uint[] Assignment = [1, 3, 5];
+
+    /// <summary>Longer than any test, so a request the node does not answer stays parked until the test ends it.</summary>
+    private static readonly DeferredPollOptions LongWait = new()
+    {
+        MaxWait = TimeSpan.FromSeconds(30),
+        RequestTimeout = TimeSpan.FromSeconds(31)
+    };
+
+    /// <summary>How long a negative check waits for a request that must not come.</summary>
+    private static readonly TimeSpan SettleDelay = TimeSpan.FromMilliseconds(200);
+
+    /// <summary>Well under <see cref="LongWait" />, so a call that waits for a parked request fails the check.</summary>
+    private static readonly TimeSpan PromptLimit = TimeSpan.FromSeconds(5);
+
+    /// <summary>
+    ///     The node never answers a parked request or its cancel, as the server does. The cancel goes out on the link
+    ///     of the parked request and names it, and the link keeps serving.
+    /// </summary>
+    [Theory]
+    [InlineData(PollCode)]
+    [InlineData(FetchCode)]
+    public async Task given_a_parked_request_when_the_caller_cancels_should_cancel_it_on_its_link(int code)
+    {
+        using var node = new MockNode();
+        var requests = new ConcurrentQueue<MockRequest>();
+        node.Serve(request =>
+        {
+            requests.Enqueue(request);
+            return request.Code is PollCode or FetchCode or CancelCode ? null : Standalone(node, request);
+        });
+        using var client = await ConnectAsync(node);
+        using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+        Task parked = code == PollCode
+            ? PollPartitionAsync(client, cancellation.Token)
+            : client.FetchMessagesAsync(Stream, Topic, [(1, PollingStrategy.Offset(0))], Consumer.New(4), 10, false,
+                LongWait, cancellation.Token);
+        await UntilAsync(() => node.Requests(code) == 1);
+        var connections = node.Connections;
+
+        await cancellation.CancelAsync();
+
+        await Assert.ThrowsAnyAsync<OperationCanceledException>(() => parked);
+        await UntilAsync(() => node.Requests(CancelCode) == 1);
+        var request = Assert.Single(requests, request => request.Code == code);
+        var cancel = Assert.Single(requests, request => request.Code == CancelCode);
+        Assert.Equal(request.RequestId, cancel.RequestId);
+        Assert.Equal(request.ClientId, cancel.ClientId);
+        Assert.Equal(request.Session, cancel.Session);
+        Assert.Equal(OPERATION_NON_REPLICATED, cancel.Operation);
+        Assert.Empty(cancel.Body);
+        await client.PollMessagesAsync(Stream, Topic, 1, Consumer.New(4), PollingStrategy.Offset(0), 10, false,
+            TestContext.Current.CancellationToken);
+        Assert.Equal(connections, node.Connections);
+    }
+
+    /// <summary>
+    ///     A parked request that commits gets the cancel too and keeps its reply slot. The server answers it once:
+    ///     refused when the cancel won and nothing was stored, so the call ends as canceled, or with the messages it
+    ///     stored when the read passed first, so the call delivers them.
+    /// </summary>
+    [Theory]
+    [InlineData(PollCode, true)]
+    [InlineData(PollCode, false)]
+    [InlineData(FetchCode, true)]
+    [InlineData(FetchCode, false)]
+    public async Task given_a_parked_committing_request_when_the_caller_cancels_should_cancel_it_and_wait_for_its_reply(
+        int code, bool cancelWins)
+    {
+        using var node = new MockNode();
+        var stores = 0;
+        var parked = new TaskCompletionSource<MockRequest>(TaskCreationOptions.RunContinuationsAsynchronously);
+        node.Serve(request =>
+        {
+            if (request.Code == code)
+            {
+                parked.TrySetResult(request);
+                return null;
+            }
+            if (request.Operation == (byte)VsrOperation.StoreConsumerOffset)
+            {
+                Interlocked.Increment(ref stores);
+            }
+            return request.Code == CancelCode ? null : Standalone(node, request);
+        });
+        using var client = await ConnectAsync(node);
+        var token = TestContext.Current.CancellationToken;
+        using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(token);
+        var call = ReadCommittingAsync(client, code, cancellation.Token);
+        var request = await parked.Task.WaitAsync(PromptLimit, token);
+
+        await cancellation.CancelAsync();
+
+        await UntilAsync(() => node.Requests(CancelCode) == 1);
+        var storing = client.StoreOffsetAsync(Consumer.New(4), Stream, Topic, 0, 1, token);
+        await Task.Delay(SettleDelay, token);
+        Assert.False(call.IsCompleted);
+        Assert.Equal((false, 0), (storing.IsCompleted, Volatile.Read(ref stores)));
+        if (cancelWins)
+        {
+            await node.AnswerParkedAsync(request, Reply(request.Operation, [], VsrError.TRANSIENT_NOT_ACCEPTED));
+            await Assert.ThrowsAnyAsync<OperationCanceledException>(() => call.WaitAsync(PromptLimit, token));
+        }
+        else
+        {
+            await node.AnswerParkedAsync(request, code == PollCode
+                ? Batch(request, DrainedOffset)
+                : Reply(request.Operation, FetchBody(GoldenSection(1, 0, default))));
+            Assert.Equal(GOLDEN_BATCH_MESSAGES, await call.WaitAsync(PromptLimit, token));
+        }
+
+        await storing.WaitAsync(PromptLimit, token);
+        Assert.Equal(1, Volatile.Read(ref stores));
+    }
+
+    /// <summary>
+    ///     A plain poll that commits is not parked and has no cancel on the server, so once it went out the caller's
+    ///     cancel leaves it running and the call delivers what it stored.
+    /// </summary>
+    [Fact]
+    public async Task given_a_committing_poll_in_flight_when_the_caller_cancels_should_deliver_its_reply()
+    {
+        using var node = new MockNode();
+        var parked = new TaskCompletionSource<MockRequest>(TaskCreationOptions.RunContinuationsAsynchronously);
+        node.Serve(request =>
+        {
+            if (request.Code == CommandCodes.POLL_MESSAGES_CODE)
+            {
+                parked.TrySetResult(request);
+                return null;
+            }
+            return Standalone(node, request);
+        });
+        using var client = await ConnectAsync(node);
+        var token = TestContext.Current.CancellationToken;
+        using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(token);
+        var call = client.PollMessagesAsync(Stream, Topic, 1, Consumer.New(4), PollingStrategy.Offset(0), 10, true,
+            cancellation.Token);
+        var request = await parked.Task.WaitAsync(PromptLimit, token);
+
+        await cancellation.CancelAsync();
+
+        await Task.Delay(SettleDelay, token);
+        Assert.False(call.IsCompleted);
+        Assert.Equal(0, node.Requests(CancelCode));
+        await node.AnswerParkedAsync(request, Batch(request, DrainedOffset));
+        Assert.Equal((int)GOLDEN_BATCH_MESSAGES, (await call.WaitAsync(PromptLimit, token)).Messages.Count);
+    }
+
+    /// <summary>
+    ///     A poll that moves a consumer holds the progress lane of its partition while it is parked. A send takes the
+    ///     other lane of the partition, and an offset store moves the consumer too, so only the store waits for the poll.
+    /// </summary>
+    [Theory]
+    [InlineData(true, false)]
+    [InlineData(false, true)]
+    public async Task given_a_parked_progress_poll_when_the_client_sends_to_its_partition_should_send_at_once(
+        bool autoCommit, bool next)
+    {
+        using var node = new MockNode();
+        var stores = 0;
+        var parked = new TaskCompletionSource<MockRequest>(TaskCreationOptions.RunContinuationsAsynchronously);
+        node.Serve(request =>
+        {
+            if (request.Operation == (byte)VsrOperation.StoreConsumerOffset)
+            {
+                Interlocked.Increment(ref stores);
+            }
+            if (request.Code == PollCode)
+            {
+                parked.TrySetResult(request);
+            }
+            return request.Code is PollCode or CancelCode ? null : Standalone(node, request);
+        });
+        using var client = await ConnectAsync(node);
+        using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+        var poll = client.PollMessagesAsync(Stream, Topic, 1, Consumer.New(4),
+            next ? PollingStrategy.Next() : PollingStrategy.Offset(0), 10, autoCommit, LongWait, cancellation.Token);
+        var request = await parked.Task.WaitAsync(PromptLimit, TestContext.Current.CancellationToken);
+        var store = client.StoreOffsetAsync(Consumer.New(4), Stream, Topic, 0, 1, TestContext.Current.CancellationToken);
+
+        await client.SendMessagesAsync(Stream, Topic, Partitioning.PartitionId(1),
+                new Message(Guid.NewGuid(), "payload"u8.ToArray()), TestContext.Current.CancellationToken)
+            .WaitAsync(PromptLimit, TestContext.Current.CancellationToken);
+
+        await Task.Delay(SettleDelay, TestContext.Current.CancellationToken);
+        Assert.Equal(0, Volatile.Read(ref stores));
+        await cancellation.CancelAsync();
+        if (autoCommit)
+        {
+            // A committing poll keeps its lane until the server answers the cancel.
+            await UntilAsync(() => node.Requests(CancelCode) == 1);
+            await node.AnswerParkedAsync(request, Reply(request.Operation, [], VsrError.TRANSIENT_NOT_ACCEPTED));
+            await Assert.ThrowsAnyAsync<OperationCanceledException>(() => poll);
+        }
+        else
+        {
+            await Assert.ThrowsAsync<VsrRequestOutcomeUnknownException>(() => poll);
+        }
+        await store.WaitAsync(PromptLimit, TestContext.Current.CancellationToken);
+        Assert.Equal(1, Volatile.Read(ref stores));
+    }
+
+    /// <summary>The server holds a bounded number of parked requests per session and shard, so the client never sends more.</summary>
+    [Fact]
+    public async Task given_the_parked_request_limit_when_another_deferred_poll_starts_should_wait_for_a_free_slot()
+    {
+        const int Limit = DeferredPollOptions.MAX_PARKED_REQUESTS;
+        using var node = new MockNode();
+        node.Serve(request => request.Code is PollCode or CancelCode ? null : Standalone(node, request));
+        using var client = await ConnectAsync(node);
+        using var parked = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+        using var waiting = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+        var held = Enumerable.Range(0, Limit).Select(_ => PollPartitionAsync(client, parked.Token)).ToArray();
+        await UntilAsync(() => node.Requests(PollCode) == Limit);
+
+        var next = PollPartitionAsync(client, waiting.Token);
+        await Task.Delay(SettleDelay, TestContext.Current.CancellationToken);
+        Assert.Equal(Limit, node.Requests(PollCode));
+        await parked.CancelAsync();
+
+        await UntilAsync(() => node.Requests(PollCode) == Limit + 1 && node.Requests(CancelCode) == Limit);
+        foreach (var poll in held)
+        {
+            await Assert.ThrowsAnyAsync<OperationCanceledException>(() => poll);
+        }
+        await waiting.CancelAsync();
+        await Assert.ThrowsAnyAsync<OperationCanceledException>(() => next);
+    }
+
+    [Fact]
+    public async Task given_a_full_server_when_it_refuses_a_deferred_poll_should_retry_on_the_same_route()
+    {
+        using var node = new MockNode();
+        var polls = 0;
+        node.Serve(request => request.Code != PollCode ? Standalone(node, request)
+            : Interlocked.Increment(ref polls) == 1
+                ? Reply(request.Operation, [], VsrError.DEFERRED_POLL_CAPACITY_EXCEEDED)
+                : Batch(request, DrainedOffset));
+        using var client = await ConnectAsync(node);
+
+        var polled = await PollPartitionAsync(client, TestContext.Current.CancellationToken);
+
+        Assert.Equal(2, polled.Messages.Count);
+        Assert.Equal(2, Volatile.Read(ref polls));
+        Assert.Equal(1, node.Requests(CommandCodes.GET_PARTITION_ROUTE_CODE));
+    }
+
+    /// <summary>
+    ///     Every attempt asks for the wait less the time its call spent, and never for less. A clock that moves in
+    ///     steps counts time that did not pass, and an idle call then ends before its max wait. The retry pause of
+    ///     a refused first attempt spreads the attempts over many steps.
+    /// </summary>
+    [Theory]
+    [InlineData(PollCode)]
+    [InlineData(FetchCode)]
+    public async Task given_refused_deferred_calls_when_retried_should_shorten_the_wait_by_at_most_the_time_spent(
+        int code)
+    {
+        using var node = new MockNode();
+        var options = new DeferredPollOptions();
+        var callStarted = 0L;
+        var attempts = new ConcurrentQueue<(long ShortenedUs, long SpentUs)>();
+        node.Serve(request =>
+        {
+            if (request.Code != code)
+            {
+                return Standalone(node, request);
+            }
+
+            var spentUs = (long)Stopwatch.GetElapsedTime(Volatile.Read(ref callStarted)).TotalMicroseconds;
+            var waitUs = code == PollCode
+                ? BinaryPrimitives.ReadUInt64LittleEndian(request.Body.AsSpan(^TcpContracts.DEFERRED_POLL_TAIL_SIZE))
+                : ReadFetch(request).WaitUs;
+            attempts.Enqueue(((long)options.MaxWait.TotalMicroseconds - (long)waitUs, spentUs));
+            if (attempts.Count % 2 == 1)
+            {
+                return Reply(request.Operation, [], VsrError.DEFERRED_POLL_CAPACITY_EXCEEDED);
+            }
+
+            return code == PollCode ? Batch(request, DrainedOffset) : Reply(request.Operation, FetchBody());
+        });
+        using var client = await ConnectAsync(node);
+
+        for (var call = 0; call < RetriedCalls; call++)
+        {
+            Volatile.Write(ref callStarted, Stopwatch.GetTimestamp());
+            Task sent = code == PollCode
+                ? client.PollMessagesAsync(Stream, Topic, 1, Consumer.New(4), PollingStrategy.Offset(0), 10, false,
+                    options, TestContext.Current.CancellationToken)
+                : client.FetchMessagesAsync(Stream, Topic, [(1, PollingStrategy.Offset(0))], Consumer.New(4), 10, false,
+                    options, TestContext.Current.CancellationToken);
+            await sent;
+        }
+
+        Assert.Equal(2 * RetriedCalls, attempts.Count);
+        Assert.All(attempts, attempt => Assert.InRange(attempt.ShortenedUs, 0, attempt.SpentUs));
+    }
+
+    /// <summary>
+    ///     A deferred group poll without a partition would park on one partition while another holds messages, so a
+    ///     group member fetches its partitions instead.
+    /// </summary>
+    /// <summary>
+    ///     A committing poll that the server answers as not committed may have stored its offset. It fails as
+    ///     uncertain with its operation, and its partition takes no committing poll and no offset store until the
+    ///     caller abandons that operation, also after a sign-in.
+    /// </summary>
+    [Fact]
+    public async Task given_a_committing_poll_with_an_unknown_outcome_when_reading_its_partition_should_block_it_until_abandoned()
+    {
+        using var node = new MockNode();
+        var answered = 0;
+        node.Serve(request => request.Code != PollCode ? Standalone(node, request)
+            : Interlocked.Exchange(ref answered, 1) == 0
+                ? Reply(request.Operation, [], VsrError.TRANSIENT_NOT_COMMITTED)
+                : Batch(request, DrainedOffset));
+        using var client = await ConnectAsync(node);
+        var token = TestContext.Current.CancellationToken;
+
+        var uncertain = await Assert.ThrowsAsync<VsrFetchProgressUncertainException>(() => PollOnReadAsync(client, token));
+        await client.LoginUserAsync("user", "secret", token);
+        var blocked = await Assert.ThrowsAsync<VsrPartitionLaneBlockedException>(() => PollOnReadAsync(client, token));
+        var store = await Assert.ThrowsAsync<VsrPartitionLaneBlockedException>(() =>
+            client.StoreOffsetAsync(Consumer.New(4), Stream, Topic, 0, 1, token));
+        var delete = await Assert.ThrowsAsync<VsrPartitionLaneBlockedException>(() =>
+            client.DeleteOffsetAsync(Consumer.New(4), Stream, Topic, 1, token));
+        await client.AbandonOperationAsync(uncertain.OperationId!.Value);
+        var polled = await PollOnReadAsync(client, token);
+
+        Assert.Equal(VsrError.FETCH_PROGRESS_UNCERTAIN, uncertain.StatusCode);
+        Assert.Equal((uncertain.OperationId.Value, 1u), (blocked.OperationId, blocked.PartitionId));
+        Assert.Equal((uncertain.OperationId.Value, 1u), (store.OperationId, store.PartitionId));
+        Assert.Equal((uncertain.OperationId.Value, 1u), (delete.OperationId, delete.PartitionId));
+        Assert.Equal((int)GOLDEN_BATCH_MESSAGES, polled.Messages.Count);
+        Assert.Equal(2, node.Requests(PollCode));
+    }
+
+    /// <summary>
+    ///     The client drops its links while a committing poll waits on the server, which can still store its offset.
+    ///     The poll fails as uncertain, and after the client connects and signs in again its partition stays blocked
+    ///     until the caller abandons that poll.
+    /// </summary>
+    [Fact]
+    public async Task given_a_parked_committing_poll_when_the_client_disconnects_should_fail_it_as_uncertain_until_abandoned()
+    {
+        using var node = new MockNode();
+        var parked = new TaskCompletionSource<MockRequest>(TaskCreationOptions.RunContinuationsAsynchronously);
+        node.Serve(request => request.Code != PollCode ? Standalone(node, request)
+            : parked.TrySetResult(request) ? null
+            : Batch(request, DrainedOffset));
+        using var client = await ConnectAsync(node);
+        var token = TestContext.Current.CancellationToken;
+        var poll = PollOnReadAsync(client, token);
+        await parked.Task.WaitAsync(PromptLimit, token);
+
+        await client.DisconnectAsync();
+        var uncertain = await Assert.ThrowsAsync<VsrFetchProgressUncertainException>(() =>
+            poll.WaitAsync(PromptLimit, token));
+        await client.ConnectAsync(token);
+        await client.LoginUserAsync("user", "secret", token);
+        var blocked = await Assert.ThrowsAsync<VsrPartitionLaneBlockedException>(() => PollOnReadAsync(client, token));
+        await client.AbandonOperationAsync(blocked.OperationId);
+        var polled = await PollOnReadAsync(client, token);
+
+        Assert.Equal(uncertain.OperationId, blocked.OperationId);
+        Assert.Equal((int)GOLDEN_BATCH_MESSAGES, polled.Messages.Count);
+    }
+
+    [Theory]
+    [InlineData(false)]
+    [InlineData(true)]
+    public async Task given_a_group_poll_without_a_partition_when_it_is_deferred_should_refuse_it_before_the_wire(
+        bool rented)
+    {
+        using var node = new MockNode();
+        node.Serve(request => Standalone(node, request));
+        using var client = await ConnectAsync(node);
+        var token = TestContext.Current.CancellationToken;
+
+        var error = await Assert.ThrowsAsync<IggyInvalidStatusCodeException>(() => rented
+            ? (Task)client.PollMessagesRentedAsync(Stream, Topic, null, Consumer.Group(3), PollingStrategy.Next(), 10,
+                false, LongWait, token)
+            : client.PollMessagesAsync(Stream, Topic, null, Consumer.Group(3), PollingStrategy.Next(), 10, false,
+                LongWait, token));
+
+        Assert.Equal(VsrError.INVALID_COMMAND, error.StatusCode);
+        Assert.Contains("FetchMessagesAsync", error.Message);
+        Assert.Equal(0, node.Requests(PollCode));
+        Assert.Equal(0, node.Requests(FetchCode));
+    }
+
+    [Theory]
+    [InlineData(PollCode)]
+    [InlineData(FetchCode)]
+    [InlineData(CancelCode)]
+    public async Task given_a_deferred_code_when_sent_raw_should_refuse_it_before_the_wire(int code)
+    {
+        using var node = new MockNode();
+        node.Serve(request => Standalone(node, request));
+        using var client = await ConnectAsync(node);
+
+        var raw = await Assert.ThrowsAsync<IggyInvalidStatusCodeException>(() =>
+            client.SendBinaryRequestAsync((uint)code, [], TestContext.Current.CancellationToken));
+        var toReplica = await Assert.ThrowsAsync<IggyInvalidStatusCodeException>(() =>
+            client.SendBinaryRequestToReplicaAsync((uint)code, [], 0, TestContext.Current.CancellationToken));
+
+        Assert.Equal(VsrError.INVALID_COMMAND, raw.StatusCode);
+        Assert.Equal(VsrError.INVALID_COMMAND, toReplica.StatusCode);
+        Assert.Equal(0, node.Requests(code));
+    }
+
+    private static byte[] Standalone(MockNode node, MockRequest request) => request.Code switch
+    {
+        CommandCodes.SYNC_CONSUMER_GROUP_CODE => Reply(OPERATION_NON_REPLICATED, AssignmentBody(1, Assignment)),
+        GET_CLUSTER_METADATA_CODE => StandaloneRoster(node.Port),
+        _ when request.Operation == (byte)VsrOperation.SendMessages => Reply(request.Operation, NoConfirmations),
+        _ => Answer(request)
+    };
+
+    /// <summary>The golden batch, served from the polled partition with <paramref name="currentOffset" /> as its last offset.</summary>
+    private static byte[] Batch(MockRequest request, ulong currentOffset)
+    {
+        var body = Convert.FromHexString(MessageBatchGoldenVectorTests.POLL_BODY);
+        BinaryPrimitives.WriteUInt32LittleEndian(body, PolledPartition(request));
+        BinaryPrimitives.WriteUInt64LittleEndian(body.AsSpan(sizeof(uint)), currentOffset);
+        return Reply(request.Operation, body);
+    }
+
+    private static Task<PolledMessages> PollOnReadAsync(TcpMessageStream client, CancellationToken token) =>
+        client.PollMessagesAsync(Stream, Topic, 1, Consumer.New(4), PollingStrategy.Next(),
+            new PollOptions { MaxWait = TimeSpan.FromSeconds(1), Commit = CommitPolicy.OnRead }, token);
+
+    private static Task<PolledMessages> PollPartitionAsync(TcpMessageStream client, CancellationToken token) =>
+        client.PollMessagesAsync(Stream, Topic, 1, Consumer.New(4), PollingStrategy.Offset(0), 10, false, LongWait,
+            token);
+
+    /// <summary>A deferred poll or a fetch of partition 1 that commits, answered with its message count.</summary>
+    private static async Task<uint> ReadCommittingAsync(TcpMessageStream client, int code, CancellationToken token)
+    {
+        if (code == PollCode)
+        {
+            var polled = await client.PollMessagesAsync(Stream, Topic, 1, Consumer.New(4), PollingStrategy.Offset(0),
+                10, true, LongWait, token);
+            return (uint)polled.Messages.Count;
+        }
+
+        using var fetched = await client.FetchMessagesAsync(Stream, Topic, [(1, PollingStrategy.Offset(0))],
+            Consumer.New(4), 10, true, LongWait, token);
+        return Assert.Single(fetched.Sections).MessagesCount;
+    }
+
+    private static async Task<TcpMessageStream> ConnectAsync(MockNode node)
+    {
+        var client = new TcpMessageStream(new IggyClientConfigurator
+        {
+            BaseAddress = $"127.0.0.1:{node.Port}",
+            Protocol = Protocol.Tcp
+        }, NullLoggerFactory.Instance);
+        await client.ConnectAsync(TestContext.Current.CancellationToken);
+        await client.LoginUserAsync("user", "secret", TestContext.Current.CancellationToken);
+
+        return client;
+    }
+
+    private static async Task UntilAsync(Func<bool> predicate)
+    {
+        using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+        timeout.CancelAfter(TimeSpan.FromSeconds(10));
+        while (!predicate())
+        {
+            await Task.Delay(5, timeout.Token);
+        }
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK_Tests/VsrTests/FetchMessagesTests.cs b/foreign/csharp/Iggy_SDK_Tests/VsrTests/FetchMessagesTests.cs
new file mode 100644
index 0000000..423f4fa
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK_Tests/VsrTests/FetchMessagesTests.cs
@@ -0,0 +1,816 @@
+// 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.
+
+using System.Buffers.Binary;
+using System.Collections.Concurrent;
+using Apache.Iggy.Configuration;
+using Apache.Iggy.Contracts;
+using Apache.Iggy.Contracts.Tcp;
+using Apache.Iggy.Enums;
+using Apache.Iggy.Exceptions;
+using Apache.Iggy.IggyClient.Implementations;
+using Apache.Iggy.Kinds;
+using Apache.Iggy.Utils;
+using Apache.Iggy.Vsr;
+using Microsoft.Extensions.Logging.Abstractions;
+using static Apache.Iggy.Tests.VsrTests.MockFrames;
+
+namespace Apache.Iggy.Tests.VsrTests;
+
+/// <summary>
+///     Fetches (code 109) against one node, which parks a fetch by not answering it. Mirrors the fetch of
+///     core/sdk/src/driver.rs.
+/// </summary>
+public sealed class FetchMessagesTests
+{
+    private const int FetchCode = CommandCodes.FETCH_MESSAGES_CODE;
+    private const int PollCode = CommandCodes.POLL_MESSAGES_DEFERRED_CODE;
+    private const int CancelCode = CommandCodes.CANCEL_DEFERRED_REQUEST_CODE;
+
+    /// <summary>A frame limit that binds below the byte limit of the options, also with 1000 targets.</summary>
+    private const int SmallFrameLimit = 100_000;
+
+    /// <summary>The partitions a route names, so that every partition of a test exists.</summary>
+    private const uint TopicPartitions = TcpContracts.MAX_PARTITIONS_PER_REQUEST + 1;
+
+    private static readonly Identifier Stream = Identifier.Numeric(1);
+    private static readonly Identifier Topic = Identifier.Numeric(2);
+    private static readonly PartitionContext Route = new(17, 0, 1);
+    private static readonly PartitionContext Served = new(17, 9, 52);
+
+    /// <summary>Longer than any test, so a fetch the node does not answer stays parked until the test ends it.</summary>
+    private static readonly DeferredPollOptions LongWait = new()
+    {
+        MaxWait = TimeSpan.FromSeconds(30),
+        RequestTimeout = TimeSpan.FromSeconds(31)
+    };
+
+    /// <summary>How long a negative check waits for a request that must not come.</summary>
+    private static readonly TimeSpan SettleDelay = TimeSpan.FromMilliseconds(200);
+
+    private static readonly TimeSpan PromptLimit = TimeSpan.FromSeconds(5);
+
+    /// <summary>A refusal that comes later than the request timeout of a retry less the reply margin.</summary>
+    private static readonly TimeSpan SlowRefusal = TimeSpan.FromMilliseconds(600);
+
+    private static readonly DeferredPollOptions ShortTimeout = new()
+    {
+        MaxWait = TimeSpan.Zero,
+        RequestTimeout = TimeSpan.FromMicroseconds(DeferredPollOptions.REPLY_MARGIN_US) + TimeSpan.FromMilliseconds(500)
+    };
+
+    /// <summary>
+    ///     The fetch goes to the primary of its first target with header context zero. A target without a fence
+    ///     takes the context of its route, and each target gets the byte limit of the options.
+    /// </summary>
+    [Fact]
+    public async Task given_targets_with_and_without_a_fence_when_fetched_should_send_one_request_with_context_zero()
+    {
+        using var node = new MockNode();
+        var fetches = new ConcurrentQueue<MockRequest>();
+        node.Serve(request =>
+        {
+            if (request.Code != FetchCode)
+            {
+                return Standalone(node, request);
+            }
+
+            fetches.Enqueue(request);
+            return Reply(request.Operation, FetchBody(GoldenSection(1, 0, Served), EmptySection(2, 7, Served)));
+        });
+        using var client = await ConnectAsync(node);
+        var fenced = new PartitionContext(18, 4, 0);
+
+        using var reply = await client.FetchMessagesAsync(Stream, Topic,
+            [(1, PollingStrategy.Offset(0)), (2, PollingStrategy.Next().WithContext(fenced))], Consumer.Group(3), 10,
+            false, LongWait, TestContext.Current.CancellationToken);
+
+        var request = Assert.Single(fetches);
+        Assert.Equal(default, request.Context);
+        var fetch = ReadFetch(request);
+        Assert.Equal((1u, 10u, false, LongWait.MaxBytes), (fetch.MinCount, fetch.Count, fetch.AutoCommit,
+            fetch.MaxBytes));
+        Assert.Equal([
+            new FetchTarget(1, MessagePolling.Offset, 0, LongWait.MaxBytes, Route.Incarnation, Route.OwnerGeneration),
+            new FetchTarget(2, MessagePolling.Next, 0, LongWait.MaxBytes, fenced.Incarnation, fenced.OwnerGeneration)
+        ], fetch.Targets);
+        Assert.True(fetch.RequestTimeoutUs >= fetch.WaitUs + DeferredPollOptions.REPLY_MARGIN_US);
+        Assert.Equal([0ul, 1ul], reply.Sections[0].Decode().Messages.Select(message => message.Header.Offset));
+        Assert.Equal((7ul, 0u), (reply.Sections[1].NextOffset, reply.Sections[1].MessagesCount));
+    }
+
+    /// <summary>
+    ///     The reply holds the section table and the records, so the records of each target may fill the frame limit
+    ///     less the table. The client applies the limit before it sends the fetch.
+    /// </summary>
+    [Theory]
+    [InlineData(1)]
+    [InlineData(1000)]
+    public async Task given_a_frame_limit_below_the_byte_limit_when_fetched_should_cap_each_target_by_it(int targets)
+    {
+        using var node = new MockNode();
+        var fetches = new ConcurrentQueue<FetchRequest>();
+        node.Serve(request =>
+        {
+            if (request.Code != FetchCode)
+            {
+                return Standalone(node, request);
+            }
+
+            fetches.Enqueue(ReadFetch(request));
+            return Reply(request.Operation, FetchBody());
+        });
+        using var client = await ConnectAsync(node, SmallFrameLimit);
+
+        using var reply = await client.FetchMessagesAsync(Stream, Topic, Targets(targets), Consumer.New(4), 10, false,
+            LongWait, TestContext.Current.CancellationToken);
+
+        var cap = (uint)(SmallFrameLimit - HEADER_SIZE - FetchedMessages.HeadSize(targets));
+        var fetch = Assert.Single(fetches);
+        Assert.Equal(cap, fetch.MaxBytes);
+        Assert.All(fetch.Targets, target => Assert.Equal(cap, target.MaxBytes));
+        Assert.Empty(reply.Sections);
+    }
+
+    [Fact]
+    public async Task given_a_frame_limit_without_room_for_one_message_when_fetched_should_refuse_it_before_the_wire()
+    {
+        using var node = new MockNode();
+        node.Serve(request => Standalone(node, request));
+        const int targets = TcpContracts.MAX_PARTITIONS_PER_REQUEST;
+        using var client = await ConnectAsync(node,
+            HEADER_SIZE + FetchedMessages.HeadSize(targets) + (int)DeferredPollOptions.MIN_MAX_BYTES - 1);
+
+        var error = await Assert.ThrowsAsync<IggyInvalidStatusCodeException>(() => client.FetchMessagesAsync(Stream,
+            Topic, Targets(targets), Consumer.New(4), 10, false, LongWait, TestContext.Current.CancellationToken));
+
+        Assert.Equal(VsrError.INVALID_SIZE_BYTES, error.StatusCode);
+        Assert.Equal(0, node.Requests(FetchCode));
+    }
+
+    [Theory]
+    [InlineData("no target", VsrError.INVALID_COMMAND)]
+    [InlineData("too many targets", VsrError.INVALID_COMMAND)]
+    [InlineData("a partition twice", VsrError.INVALID_COMMAND)]
+    [InlineData("a first start", VsrError.INVALID_COMMAND)]
+    [InlineData("no reply margin", VsrError.INVALID_COMMAND)]
+    [InlineData("count zero", VsrError.INVALID_MESSAGES_COUNT)]
+    public async Task given_a_fetch_the_server_refuses_when_fetched_should_refuse_it_before_the_wire(string fetch,
+        int status)
+    {
+        using var node = new MockNode();
+        node.Serve(request => Standalone(node, request));
+        using var client = await ConnectAsync(node);
+        var targets = fetch switch
+        {
+            "no target" => [],
+            "too many targets" => Targets(TcpContracts.MAX_PARTITIONS_PER_REQUEST + 1),
+            "a partition twice" => [(1, PollingStrategy.Offset(0)), (1, PollingStrategy.Next())],
+            "a first start" => [(1, PollingStrategy.First())],
+            _ => Targets(1)
+        };
+        var options = fetch == "no reply margin"
+            ? new DeferredPollOptions { MaxWait = TimeSpan.Zero, RequestTimeout = TimeSpan.FromMilliseconds(500) }
+            : LongWait;
+
+        var error = await Assert.ThrowsAsync<IggyInvalidStatusCodeException>(() => client.FetchMessagesAsync(Stream,
+            Topic, targets, Consumer.New(4), fetch == "count zero" ? 0u : 10u, false, options,
+            TestContext.Current.CancellationToken));
+
+        Assert.Equal(status, error.StatusCode);
+        Assert.Equal(0, node.Requests(FetchCode));
+    }
+
+    [Fact]
+    public async Task given_a_zero_wait_fetch_with_only_the_reply_margin_when_fetched_should_refuse_it_before_routing()
+    {
+        using var node = new MockNode();
+        node.Serve(request => Standalone(node, request));
+        using var client = await ConnectAsync(node);
+        var options = new DeferredPollOptions
+        {
+            MaxWait = TimeSpan.Zero,
+            RequestTimeout = TimeSpan.FromSeconds(1)
+        };
+
+        var error = await Assert.ThrowsAsync<IggyInvalidStatusCodeException>(() => client.FetchMessagesAsync(Stream,
+            Topic, [(1, PollingStrategy.Offset(0))], Consumer.New(4), 10, false, options,
+            TestContext.Current.CancellationToken));
+
+        Assert.Equal(VsrError.INVALID_COMMAND, error.StatusCode);
+        Assert.Equal(0, node.Requests(CommandCodes.GET_PARTITION_ROUTE_CODE));
+        Assert.Equal(0, node.Requests(FetchCode));
+    }
+
+    [Fact]
+    public async Task given_a_zero_wait_fetch_with_reply_headroom_when_fetched_should_send_it_with_the_remaining_budget()
+    {
+        using var node = new MockNode();
+        var fetches = new ConcurrentQueue<FetchRequest>();
+        node.Serve(request =>
+        {
+            if (request.Code != FetchCode)
+            {
+                return Standalone(node, request);
+            }
+
+            fetches.Enqueue(ReadFetch(request));
+            return Reply(request.Operation, FetchBody(GoldenSection(1, 0, Served)));
+        });
+        using var client = await ConnectAsync(node);
+
+        using var reply = await client.FetchMessagesAsync(Stream, Topic, Targets(1), Consumer.New(4), 10, false,
+            ShortTimeout, TestContext.Current.CancellationToken);
+
+        var sent = Assert.Single(fetches);
+        Assert.Equal(0ul, sent.WaitUs);
+        Assert.InRange(sent.RequestTimeoutUs, DeferredPollOptions.REPLY_MARGIN_US, ShortTimeout.RequestTimeoutUs);
+        Assert.Equal(GOLDEN_BATCH_MESSAGES, Assert.Single(reply.Sections).MessagesCount);
+    }
+
+    /// <summary>
+    ///     Every attempt sends what is left of the wait and the request timeout, and a fetch keeps the reply margin
+    ///     also without a wait. A retry with less than the margin left fails with 58 and is not sent.
+    /// </summary>
+    [Fact]
+    public async Task given_a_refused_fetch_when_less_than_the_reply_margin_is_left_should_fail_without_a_retry()
+    {
+        using var node = new MockNode();
+        var attempts = new ConcurrentQueue<FetchRequest>();
+        node.Serve(request =>
+        {
+            if (request.Code != FetchCode)
+            {
+                return Standalone(node, request);
+            }
+
+            attempts.Enqueue(ReadFetch(request));
+            return Reply(request.Operation, [], VsrError.DEFERRED_POLL_CAPACITY_EXCEEDED);
+        }, (request, _) => request.Code == FetchCode ? Task.Delay(SlowRefusal) : Task.CompletedTask);
+        using var client = await ConnectAsync(node);
+
+        var error = await Assert.ThrowsAsync<IggyInvalidStatusCodeException>(() => client.FetchMessagesAsync(Stream,
+            Topic, Targets(1), Consumer.New(4), 10, false, ShortTimeout, TestContext.Current.CancellationToken));
+
+        Assert.Equal(VsrError.TRANSIENT_NOT_ACCEPTED, error.StatusCode);
+        var attempt = Assert.Single(attempts);
+        Assert.Equal(0ul, attempt.WaitUs);
+        Assert.InRange(attempt.RequestTimeoutUs, DeferredPollOptions.REPLY_MARGIN_US,
+            (ulong)ShortTimeout.RequestTimeout.TotalMicroseconds);
+    }
+
+    /// <summary>A fetch parks as one request, so it takes one slot of the parked request limit for all its targets.</summary>
+    [Fact]
+    public async Task given_parked_polls_and_a_parked_fetch_at_the_limit_when_another_poll_starts_should_wait_for_the_fetch()
+    {
+        const int Limit = DeferredPollOptions.MAX_PARKED_REQUESTS;
+        using var node = new MockNode();
+        node.Serve(request => request.Code is PollCode or FetchCode or CancelCode ? null : Standalone(node, request));
+        using var client = await ConnectAsync(node);
+        var token = TestContext.Current.CancellationToken;
+        using var parked = CancellationTokenSource.CreateLinkedTokenSource(token);
+        using var fetching = CancellationTokenSource.CreateLinkedTokenSource(token);
+        var polls = Enumerable.Range(0, Limit - 1).Select(_ => PollPartitionAsync(client, parked.Token)).ToList();
+        await UntilAsync(() => node.Requests(PollCode) == Limit - 1);
+        var fetch = client.FetchMessagesAsync(Stream, Topic, Targets(2), Consumer.New(4), 10, false, LongWait,
+            fetching.Token);
+        await UntilAsync(() => node.Requests(FetchCode) == 1);
+
+        polls.Add(PollPartitionAsync(client, parked.Token));
+        await Task.Delay(SettleDelay, token);
+        Assert.Equal(Limit - 1, node.Requests(PollCode));
+        await fetching.CancelAsync();
+
+        await Assert.ThrowsAnyAsync<OperationCanceledException>(() => fetch);
+        await UntilAsync(() => node.Requests(PollCode) == Limit);
+        await parked.CancelAsync();
+        foreach (var poll in polls)
+        {
+            await Assert.ThrowsAnyAsync<OperationCanceledException>(() => poll);
+        }
+    }
+
+    /// <summary>
+    ///     A refusal as not primary of the whole fetch does not say which target moved, so the next grouping looks up
+    ///     every route of the fetch again. A section refused as not primary marks only its own route. The fetch is
+    ///     not sent to another node.
+    /// </summary>
+    [Theory]
+    [InlineData(false)]
+    [InlineData(true)]
+    public async Task given_a_refusal_as_not_primary_when_fetched_should_regroup_the_partitions_it_names(bool whole)
+    {
+        using var node = new MockNode();
+        var routes = new ConcurrentDictionary<uint, int>();
+        node.Serve(request =>
+        {
+            switch (request.Code)
+            {
+                case CommandCodes.GET_PARTITION_ROUTE_CODE:
+                    routes.AddOrUpdate(RoutedPartition(request), 1, (_, count) => count + 1);
+                    return Standalone(node, request);
+                case FetchCode:
+                    return whole
+                        ? Reply(request.Operation, [], VsrError.NOT_PRIMARY)
+                        : Reply(request.Operation, FetchBody(GoldenSection(1, 0, Served),
+                            EmptySection(2, 0, default, VsrError.NOT_PRIMARY)));
+                default:
+                    return Standalone(node, request);
+            }
+        });
+        using var client = await ConnectAsync(node);
+        var token = TestContext.Current.CancellationToken;
+        Assert.Equal<IReadOnlyList<uint>>([[1u, 2u]], await client.ReadinessGroupsAsync(Stream, Topic, [1, 2], token));
+
+        var fetch = client.FetchMessagesAsync(Stream, Topic, Targets(2), Consumer.New(4), 10, false, LongWait, token);
+        if (whole)
+        {
+            Assert.Equal(VsrError.NOT_PRIMARY,
+                (await Assert.ThrowsAsync<IggyInvalidStatusCodeException>(() => fetch)).StatusCode);
+        }
+        else
+        {
+            (await fetch).Dispose();
+        }
+
+        await client.ReadinessGroupsAsync(Stream, Topic, [1, 2], token);
+        Assert.Equal((whole ? 2 : 1, 2), (routes[1], routes[2]));
+        Assert.Equal(1, node.Requests(FetchCode));
+    }
+
+    /// <summary>
+    ///     A fetch that can move an offset takes the progress lanes of all its targets, so a store on one of them
+    ///     waits for the fetch. A store on another partition does not, and neither does a store behind a fetch that
+    ///     moves nothing.
+    /// </summary>
+    [Theory]
+    [InlineData(false)]
+    [InlineData(true)]
+    public async Task given_a_parked_fetch_when_the_client_stores_an_offset_should_wait_only_behind_progress(
+        bool autoCommit)
+    {
+        using var node = new MockNode();
+        var stored = new ConcurrentQueue<uint>();
+        var parked = new TaskCompletionSource<MockRequest>(TaskCreationOptions.RunContinuationsAsynchronously);
+        node.Serve(request =>
+        {
+            if (request.Operation == (byte)VsrOperation.StoreConsumerOffset)
+            {
+                stored.Enqueue(StoredPartition(request));
+            }
+
+            if (request.Code == FetchCode)
+            {
+                parked.TrySetResult(request);
+                return null;
+            }
+
+            return request.Code == CancelCode ? null : Standalone(node, request);
+        });
+        using var client = await ConnectAsync(node);
+        var token = TestContext.Current.CancellationToken;
+        using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(token);
+        var fetch = client.FetchMessagesAsync(Stream, Topic, [(5, PollingStrategy.Offset(0)),
+            (3, PollingStrategy.Offset(0))], Consumer.New(4), 10, autoCommit, LongWait, cancellation.Token);
+        var request = await parked.Task.WaitAsync(PromptLimit, token);
+
+        var behind = client.StoreOffsetAsync(Consumer.New(4), Stream, Topic, 0, 3, token);
+        await client.StoreOffsetAsync(Consumer.New(4), Stream, Topic, 0, 4, token).WaitAsync(PromptLimit, token);
+        await Task.Delay(SettleDelay, token);
+
+        Assert.Equal(autoCommit ? [4u] : [3u, 4u], stored.Order());
+        if (autoCommit)
+        {
+            await node.AnswerParkedAsync(request, Reply(request.Operation, FetchBody()));
+            (await fetch.WaitAsync(PromptLimit, token)).Dispose();
+        }
+        else
+        {
+            await cancellation.CancelAsync();
+            await Assert.ThrowsAnyAsync<OperationCanceledException>(() => fetch);
+        }
+
+        await behind.WaitAsync(PromptLimit, token);
+        Assert.Equal([3u, 4u], stored.Order());
+    }
+
+    [Fact]
+    public async Task given_a_live_committing_fetch_when_its_deadline_passes_should_block_each_target_until_abandoned()
+    {
+        using var node = new MockNode();
+        using var replacement = new MockNode();
+        byte primary = 0;
+        var roster = new List<byte>();
+        WriteString(roster, "fetch-recovery");
+        roster.AddRange(BitConverter.GetBytes(2u));
+        WriteNode(roster, node.Port, true, 0);
+        WriteNode(roster, replacement.Port, false, 1);
+        byte[] RouteOrControl(MockRequest request) => request.Code switch
+        {
+            GET_CLUSTER_METADATA_CODE => Reply(request.Operation, roster.ToArray()),
+            CommandCodes.GET_PARTITION_ROUTE_CODE => RouteReply(request, Route, primary, TopicPartitions, view: primary),
+            _ => Answer(request)
+        };
+        var parked = new TaskCompletionSource<MockRequest>(TaskCreationOptions.RunContinuationsAsynchronously);
+        var stored = new ConcurrentQueue<uint>();
+        node.Serve(request =>
+        {
+            if (request.Operation == (byte)VsrOperation.StoreConsumerOffset)
+            {
+                stored.Enqueue(StoredPartition(request));
+            }
+            if (request.Code == FetchCode)
+            {
+                return parked.TrySetResult(request) ? null
+                    : Reply(request.Operation, [], VsrError.NOT_PRIMARY);
+            }
+            return RouteOrControl(request);
+        });
+        var replacementStores = new ConcurrentQueue<uint>();
+        replacement.Serve(request =>
+        {
+            if (request.Operation == (byte)VsrOperation.StoreConsumerOffset)
+            {
+                replacementStores.Enqueue(StoredPartition(request));
+            }
+            return request.Code == FetchCode ? Reply(request.Operation, FetchBody()) : RouteOrControl(request);
+        });
+        using var client = await ConnectAsync(node);
+        var token = TestContext.Current.CancellationToken;
+        var unknown = await Assert.ThrowsAnyAsync<VsrRequestOutcomeUnknownException>(() => client.FetchMessagesAsync(
+            Stream, Topic, [(5, PollingStrategy.Offset(0)), (3, PollingStrategy.Offset(0))], Consumer.New(4), 10,
+            true, ShortTimeout, token));
+        var request = await parked.Task.WaitAsync(PromptLimit, token);
+        primary = 1;
+        await Assert.ThrowsAsync<IggyInvalidStatusCodeException>(() => client.FetchMessagesAsync(Stream, Topic,
+            [(3, PollingStrategy.Offset(0))], Consumer.New(4), 10, false, LongWait, token));
+
+        var blocked = await Assert.ThrowsAsync<VsrPartitionLaneBlockedException>(() => client.StoreOffsetAsync(
+            Consumer.New(4), Stream, Topic, 0, 3, token));
+        Assert.Equal(3u, blocked.PartitionId);
+        Assert.Equal(unknown.OperationId, blocked.OperationId);
+        Assert.Empty(stored);
+        Assert.Empty(replacementStores);
+        var second = await Assert.ThrowsAsync<VsrPartitionLaneBlockedException>(() => client.FetchMessagesAsync(Stream,
+            Topic, [(1, PollingStrategy.Offset(0)), (5, PollingStrategy.Offset(0))], Consumer.New(4), 10, true,
+            LongWait, token));
+        Assert.Equal(5u, second.PartitionId);
+        Assert.Equal(blocked.OperationId, second.OperationId);
+        using var unrelated = await client.FetchMessagesAsync(Stream, Topic, Targets(1), Consumer.New(4), 10,
+            true, LongWait, token);
+        using var stateless = await client.FetchMessagesAsync(Stream, Topic, [(3, PollingStrategy.Offset(0))],
+            Consumer.New(4), 10, false, LongWait, token);
+        await client.GetOffsetAsync(Consumer.New(4), Stream, Topic, 3, token);
+        await Assert.ThrowsAnyAsync<VsrRequestOutcomeUnknownException>(() => client.ResolveOperationAsync(
+            blocked.OperationId, PromptLimit, token));
+
+        await node.AnswerParkedAsync(request, Reply(request.Operation, FetchBody()));
+        await Assert.ThrowsAsync<VsrPartitionLaneBlockedException>(() => client.StoreOffsetAsync(Consumer.New(4),
+            Stream, Topic, 0, 3, token));
+        var fetches = node.Requests(FetchCode);
+        await client.AbandonOperationAsync(blocked.OperationId);
+        await client.StoreOffsetAsync(Consumer.New(4), Stream, Topic, 0, 3, token);
+        Assert.Equal(fetches, node.Requests(FetchCode));
+        Assert.Empty(stored);
+        Assert.Equal([3u], replacementStores);
+    }
+
+    [Theory]
+    [InlineData(VsrError.TRANSIENT_NOT_COMMITTED)]
+    [InlineData(VsrError.REQUEST_ALREADY_APPLIED)]
+    [InlineData(VsrError.INVALID_COMMAND)]
+    [InlineData(11001)]
+    public async Task given_partial_committing_uncertainty_when_fetching_should_preserve_healthy_sections_and_block_only_uncertain_targets(
+        int status)
+    {
+        using var node = new MockNode();
+        node.Serve(request => request.Code == FetchCode
+            ? Reply(request.Operation, FetchBody(GoldenSection(1, 0, Served), EmptySection(3, 0, default, (uint)status)))
+            : Standalone(node, request));
+        using var client = await ConnectAsync(node);
+        var token = TestContext.Current.CancellationToken;
+        using var reply = await client.FetchMessagesAsync(Stream, Topic,
+            [(1, PollingStrategy.Offset(0)), (3, PollingStrategy.Offset(0))], Consumer.New(4), 10, true, LongWait, token);
+        Assert.Equal((int)GOLDEN_BATCH_MESSAGES, reply.Sections[0].Decode().Messages.Count);
+        var uncertain = reply.Sections[1];
+        Assert.Equal((uint)VsrFetchProgressUncertainException.STATUS_CODE, uncertain.Status);
+        var error = Assert.Throws<VsrFetchProgressUncertainException>(() => uncertain.Decode());
+        Assert.NotNull(error.OperationId);
+        await client.StoreOffsetAsync(Consumer.New(4), Stream, Topic, 0, 1, token);
+        var blocked = await Assert.ThrowsAsync<VsrPartitionLaneBlockedException>(() => client.StoreOffsetAsync(
+            Consumer.New(4), Stream, Topic, 0, 3, token));
+        Assert.Equal(error.OperationId, blocked.OperationId);
+        Assert.Equal(3u, blocked.PartitionId);
+        Assert.Equal(14009, blocked.StatusCode);
+        Assert.True(blocked.RequiresExplicitRecovery);
+        await client.AbandonOperationAsync(blocked.OperationId);
+        await client.StoreOffsetAsync(Consumer.New(4), Stream, Topic, 0, 3, token);
+    }
+
+    [Theory]
+    [InlineData("missing table")]
+    [InlineData("duplicate target")]
+    [InlineData("foreign target")]
+    public async Task given_a_malformed_committing_table_when_fetching_should_block_every_target(string malformed)
+    {
+        using var node = new MockNode();
+        var body = malformed switch
+        {
+            "duplicate target" => FetchBody(EmptySection(3, 0, Served), EmptySection(3, 0, Served)),
+            "foreign target" => FetchBody(EmptySection(9, 0, Served)),
+            _ => []
+        };
+        node.Serve(request => request.Code == FetchCode ? Reply(request.Operation, body) : Standalone(node, request));
+        using var client = await ConnectAsync(node);
+        var token = TestContext.Current.CancellationToken;
+        var failure = await Assert.ThrowsAsync<VsrFetchProgressUncertainException>(() => client.FetchMessagesAsync(
+            Stream, Topic, [(1, PollingStrategy.Offset(0)), (3, PollingStrategy.Offset(0))], Consumer.New(4), 10,
+            true, LongWait, token));
+        var blocked = await Assert.ThrowsAsync<VsrPartitionLaneBlockedException>(() => client.StoreOffsetAsync(
+            Consumer.New(4), Stream, Topic, 0, 3, token));
+        Assert.Equal(failure.OperationId, blocked.OperationId);
+    }
+
+    [Fact]
+    public async Task given_uncertain_fetch_progress_when_signing_in_again_should_preserve_alias_blocks_and_separate_new_histories()
+    {
+        using var node = new MockNode();
+        var route = Route;
+        node.Serve(request => request.Code switch
+        {
+            FetchCode => Reply(request.Operation, [], VsrError.TRANSIENT_NOT_COMMITTED),
+            _ when request.Operation == (byte)VsrOperation.StoreConsumerOffset && request.Context.Incarnation == 0 =>
+                Reply(request.Operation, [], VsrError.HISTORY_UNAVAILABLE),
+            CommandCodes.GET_PARTITION_ROUTE_CODE => RouteReply(request, route, 0, TopicPartitions),
+            _ => Standalone(node, request)
+        });
+        using var client = await ConnectAsync(node);
+        var token = TestContext.Current.CancellationToken;
+        var failure = await Assert.ThrowsAsync<VsrFetchProgressUncertainException>(() => client.FetchMessagesAsync(
+            Stream, Topic, [(3, PollingStrategy.Offset(0).WithContext(default))], Consumer.New(4), 10, true, LongWait, token));
+        await client.LoginUserAsync("user", "secret", token);
+        var blocked = await Assert.ThrowsAsync<VsrPartitionLaneBlockedException>(() => client.StoreOffsetAsync(
+            Consumer.New(4), Identifier.String("stream-alias"), Identifier.String("topic-alias"), 0, 3, token));
+        Assert.Equal(failure.OperationId, blocked.OperationId);
+        var zeroContext = await Assert.ThrowsAsync<VsrPartitionLaneBlockedException>(() => client.StoreOffsetAsync(
+            Consumer.New(4), Stream, Topic, 0, 3, default(PartitionContext), token));
+        Assert.Equal(failure.OperationId, zeroContext.OperationId);
+        route = route with { Incarnation = route.Incarnation + 1 };
+        await client.StoreOffsetAsync(Consumer.New(4), Identifier.String("new-stream"), Identifier.String("new-topic"),
+            0, 3, token);
+        await Assert.ThrowsAsync<VsrFetchProgressUncertainException>(() => client.ResolveOperationAsync(
+            blocked.OperationId, PromptLimit, token));
+        await client.AbandonOperationAsync(blocked.OperationId);
+        await Assert.ThrowsAsync<VsrOperationNotRetainedException>(() => client.ResolveOperationAsync(
+            blocked.OperationId, PromptLimit, token));
+        var stale = await Assert.ThrowsAsync<IggyInvalidStatusCodeException>(() => client.StoreOffsetAsync(
+            Consumer.New(4), Stream, Topic, 0, 3, default(PartitionContext), token));
+        Assert.Equal(VsrError.HISTORY_UNAVAILABLE, stale.StatusCode);
+    }
+
+    [Theory]
+    [InlineData(false)]
+    [InlineData(true)]
+    public async Task given_full_fetch_recovery_capacity_when_fetching_should_refuse_before_send_until_abandonment(bool bytes)
+    {
+        using var node = new MockNode();
+        var fetches = 0;
+        node.Serve(request => request.Code == FetchCode
+            ? Interlocked.Increment(ref fetches) == 1
+                ? Reply(request.Operation, [], VsrError.TRANSIENT_NOT_COMMITTED)
+                : Reply(request.Operation, FetchBody())
+            : Standalone(node, request));
+        var frameSize = VsrHeader.HEADER_SIZE + TcpContracts.FetchMessages(Consumer.New(4), Stream, Topic,
+            LongWait, 10, true, LongWait.MaxBytes, [(3, PollingStrategy.Offset(0))]).Length;
+        using var client = await ConnectAsync(node, maxPendingOperations: bytes ? null : 1,
+            maxPendingOperationBytes: bytes ? frameSize : null);
+        var token = TestContext.Current.CancellationToken;
+        var failure = await Assert.ThrowsAsync<VsrFetchProgressUncertainException>(() => client.FetchMessagesAsync(
+            Stream, Topic, [(3, PollingStrategy.Offset(0))], Consumer.New(4), 10, true, LongWait, token));
+        var capacity = await Assert.ThrowsAsync<IggyInvalidStatusCodeException>(() => client.FetchMessagesAsync(
+            Stream, Topic, Targets(1), Consumer.New(4), 10, true, ShortTimeout, token));
+        Assert.Equal(VsrError.TRANSIENT_NOT_ACCEPTED, capacity.StatusCode);
+        Assert.Equal(1, Volatile.Read(ref fetches));
+        using var stateless = await client.FetchMessagesAsync(Stream, Topic, Targets(1), Consumer.New(4), 10, false,
+            LongWait, token);
+        await client.AbandonOperationAsync(failure.OperationId!.Value);
+        using var admitted = await client.FetchMessagesAsync(Stream, Topic, Targets(1), Consumer.New(4), 10, true,
+            LongWait, token);
+        using var afterProof = await client.FetchMessagesAsync(Stream, Topic, Targets(1), Consumer.New(4), 10, true,
+            LongWait, token);
+        Assert.Equal(4, Volatile.Read(ref fetches));
+    }
+
+    [Fact]
+    public async Task given_a_committing_fetch_waiting_behind_progress_when_canceled_should_release_without_sending()
+    {
+        using var node = new MockNode();
+        var parked = new TaskCompletionSource<MockRequest>(TaskCreationOptions.RunContinuationsAsynchronously);
+        node.Serve(request => request.Code == FetchCode
+            ? parked.TrySetResult(request) ? null : Reply(request.Operation, FetchBody())
+            : Standalone(node, request));
+        using var client = await ConnectAsync(node, maxPendingOperations: 1);
+        var token = TestContext.Current.CancellationToken;
+        var active = client.FetchMessagesAsync(Stream, Topic, [(3, PollingStrategy.Next())], Consumer.New(4), 10,
+            false, LongWait, token);
+        var request = await parked.Task.WaitAsync(PromptLimit, token);
+        using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(token);
+        var waiting = client.FetchMessagesAsync(Stream, Topic, [(3, PollingStrategy.Offset(0))], Consumer.New(4), 10,
+            true, LongWait, cancellation.Token);
+        await Task.Delay(SettleDelay, token);
+        await cancellation.CancelAsync();
+        await Assert.ThrowsAnyAsync<OperationCanceledException>(() => waiting);
+        Assert.Equal(1, node.Requests(FetchCode));
+        await node.AnswerParkedAsync(request, Reply(request.Operation, FetchBody()));
+        (await active).Dispose();
+        for (var index = 0; index < 2; index++)
+        {
+            using var reply = await client.FetchMessagesAsync(Stream, Topic, [(3, PollingStrategy.Offset(0))],
+                Consumer.New(4), 10, true, LongWait, token);
+        }
+        Assert.Equal(3, node.Requests(FetchCode));
+        Assert.Equal(0, node.Requests(CancelCode));
+    }
+
+    [Theory]
+    [InlineData(false)]
+    [InlineData(true)]
+    public async Task given_a_correlated_fetch_reply_when_the_caller_signed_out_should_settle_progress_before_session_isolation(bool partial)
+    {
+        using var node = new MockNode();
+        var fetch = new TaskCompletionSource<MockRequest>(TaskCreationOptions.RunContinuationsAsynchronously);
+        var logout = new TaskCompletionSource<MockRequest>(TaskCreationOptions.RunContinuationsAsynchronously);
+        node.Serve(request =>
+        {
+            if (request.Code == FetchCode)
+            {
+                fetch.TrySetResult(request);
+                return null;
+            }
+            if (request.Operation == (byte)VsrOperation.Logout)
+            {
+                logout.TrySetResult(request);
+                return null;
+            }
+            return Standalone(node, request);
+        });
+        using var client = await ConnectAsync(node);
+        var token = TestContext.Current.CancellationToken;
+        var pending = client.FetchMessagesAsync(Stream, Topic,
+            [(1, PollingStrategy.Offset(0)), (3, PollingStrategy.Offset(0))], Consumer.New(4), 10, true, LongWait, token);
+        var request = await fetch.Task.WaitAsync(PromptLimit, token);
+        var signingOut = client.LogoutUserAsync(token);
+        var logoutRequest = await logout.Task.WaitAsync(PromptLimit, token);
+        await node.AnswerParkedAsync(request, Reply(request.Operation, partial
+            ? FetchBody(EmptySection(1, 0, Served), EmptySection(3, 0, default, VsrError.TRANSIENT_NOT_COMMITTED))
+            : FetchBody(EmptySection(1, 0, Served), EmptySection(3, 0, Served))));
+        var ended = await Assert.ThrowsAsync<IggyInvalidStatusCodeException>(() => pending);
+        Assert.Equal(VsrError.UNAUTHENTICATED, ended.StatusCode);
+        await node.AnswerParkedAsync(logoutRequest, Answer(logoutRequest));
+        await signingOut;
+        await client.LoginUserAsync("user", "secret", token);
+        await client.StoreOffsetAsync(Consumer.New(4), Stream, Topic, 0, 1, token);
+        if (partial)
+        {
+            var blocked = await Assert.ThrowsAsync<VsrPartitionLaneBlockedException>(() => client.StoreOffsetAsync(
+                Consumer.New(4), Stream, Topic, 0, 3, token));
+            Assert.Equal(3u, blocked.PartitionId);
+            Assert.Equal(14009, blocked.StatusCode);
+            Assert.True(blocked.RequiresExplicitRecovery);
+            await client.AbandonOperationAsync(blocked.OperationId);
+        }
+        await client.StoreOffsetAsync(Consumer.New(4), Stream, Topic, 0, 3, token);
+        Assert.Equal(1, node.Requests(FetchCode));
+    }
+
+    /// <summary>
+    ///     A fetch that can move an offset is never sent twice, so after a lost reply its outcome is unknown. A fetch
+    ///     that moves nothing is a plain read, and it goes out again.
+    /// </summary>
+    [Theory]
+    [InlineData(false, false)]
+    [InlineData(true, false)]
+    [InlineData(false, true)]
+    public async Task given_a_lost_fetch_reply_when_the_fetch_moves_a_consumer_should_not_send_it_again(bool next,
+        bool autoCommit)
+    {
+        using var node = new MockNode();
+        var fetches = 0;
+        node.Serve(request =>
+        {
+            if (request.Code != FetchCode)
+            {
+                return Standalone(node, request);
+            }
+
+            if (Interlocked.Increment(ref fetches) == 1)
+            {
+                throw new IOException("The node lost the reply after it read the fetch.");
+            }
+
+            return Reply(request.Operation, FetchBody(GoldenSection(1, 0, Served)));
+        });
+        using var client = await ConnectAsync(node);
+        var strategy = next ? PollingStrategy.Next() : PollingStrategy.Offset(0);
+
+        var fetch = client.FetchMessagesAsync(Stream, Topic, [(1, strategy.WithContext(Route))], Consumer.New(4), 10,
+            autoCommit, LongWait, TestContext.Current.CancellationToken);
+
+        if (!next && !autoCommit)
+        {
+            using var reply = await fetch;
+            Assert.Equal(GOLDEN_BATCH_MESSAGES, Assert.Single(reply.Sections).MessagesCount);
+            Assert.Equal(2, Volatile.Read(ref fetches));
+            return;
+        }
+
+        if (autoCommit)
+        {
+            Assert.NotNull((await Assert.ThrowsAsync<VsrFetchProgressUncertainException>(() => fetch)).OperationId);
+        }
+        else
+        {
+            Assert.Null((await Assert.ThrowsAsync<VsrRequestOutcomeUnknownException>(() => fetch)).OperationId);
+        }
+        Assert.Equal(1, Volatile.Read(ref fetches));
+    }
+
+    /// <summary>Targets 1 to <paramref name="count" /> at offset 0, fenced as their routes, so none needs a lookup.</summary>
+    private static (uint PartitionId, PollingStrategy Strategy)[] Targets(int count)
+    {
+        return
+        [
+            .. Enumerable.Range(1, count)
+                .Select(partition => ((uint)partition, PollingStrategy.Offset(0).WithContext(Route)))
+        ];
+    }
+
+    private static Task<PolledMessages> PollPartitionAsync(TcpMessageStream client, CancellationToken token) =>
+        client.PollMessagesAsync(Stream, Topic, 1, Consumer.New(4), PollingStrategy.Offset(0), 10, false, LongWait,
+            token);
+
+    private static byte[] Standalone(MockNode node, MockRequest request) => request.Code switch
+    {
+        GET_CLUSTER_METADATA_CODE => StandaloneRoster(node.Port),
+        CommandCodes.GET_PARTITION_ROUTE_CODE => RouteReply(request, Route, 0, TopicPartitions),
+        _ => Answer(request)
+    };
+
+    private static uint RoutedPartition(MockRequest request)
+    {
+        return BinaryPrimitives.ReadUInt32LittleEndian(request.Body.AsSpan(request.Body.Length - sizeof(uint)));
+    }
+
+    /// <summary>A StoreConsumerOffset body ends with the partition id, the offset and the ack quorum.</summary>
+    private static uint StoredPartition(MockRequest request)
+    {
+        return BinaryPrimitives.ReadUInt32LittleEndian(
+            request.Body.AsSpan(request.Body.Length - sizeof(byte) - sizeof(ulong) - sizeof(uint)));
+    }
+
+    private static async Task<TcpMessageStream> ConnectAsync(MockNode node, int? maxResponseFrameSize = null,
+        int? maxPendingOperations = null, long? maxPendingOperationBytes = null)
+    {
+        var configurator = new IggyClientConfigurator
+        {
+            BaseAddress = $"127.0.0.1:{node.Port}",
+            Protocol = Protocol.Tcp,
+            MaxPendingOperations = maxPendingOperations ?? IggyClientConfigurator.DEFAULT_MAX_PENDING_OPERATIONS,
+            MaxPendingOperationBytes = maxPendingOperationBytes ?? IggyClientConfigurator.DEFAULT_MAX_PENDING_OPERATION_BYTES
+        };
+        if (maxResponseFrameSize is { } limit)
+        {
+            configurator.MaxResponseFrameSize = limit;
+        }
+
+        var client = new TcpMessageStream(configurator, NullLoggerFactory.Instance);
+        await client.ConnectAsync(TestContext.Current.CancellationToken);
+        await client.LoginUserAsync("user", "secret", TestContext.Current.CancellationToken);
+
+        return client;
+    }
+
+    private static async Task UntilAsync(Func<bool> predicate)
+    {
+        using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+        timeout.CancelAfter(TimeSpan.FromSeconds(10));
+        while (!predicate())
+        {
+            await Task.Delay(5, timeout.Token);
+        }
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK_Tests/VsrTests/MockNode.cs b/foreign/csharp/Iggy_SDK_Tests/VsrTests/MockNode.cs
index d2bfc9e..7457418 100644
--- a/foreign/csharp/Iggy_SDK_Tests/VsrTests/MockNode.cs
+++ b/foreign/csharp/Iggy_SDK_Tests/VsrTests/MockNode.cs
@@ -16,11 +16,15 @@
 // under the License.
 
 using System.Buffers.Binary;
+using System.Collections.Concurrent;
 using System.Net;
 using System.Net.Sockets;
 using System.Text;
 using Apache.Iggy.Contracts;
+using Apache.Iggy.Contracts.Tcp;
+using Apache.Iggy.Enums;
 using Apache.Iggy.Mappers;
+using Apache.Iggy.Tests.ContractsTests;
 using Apache.Iggy.Utils;
 using Apache.Iggy.Vsr;
 using static Apache.Iggy.Tests.VsrTests.MockFrames;
@@ -52,6 +56,29 @@
     internal const int PING_CODE = 1;
     internal const uint TRANSIENT_NOT_ACCEPTED = 58;
 
+    /// <summary>A target prefix: the partition id, then the strategy kind and value.</summary>
+    private const int PartitionStrategySize = sizeof(uint) + 1 + sizeof(ulong);
+
+    /// <summary>What a PollMessages body carries after its partition id: the strategy, the count, the commit flag.</summary>
+    private const int PollTailAfterPartition = 1 + sizeof(ulong) + sizeof(uint) + 1;
+
+    /// <summary>A FetchMessages target: the partition id, the strategy, the byte limit and the fence.</summary>
+    private const int FetchTargetSize = PartitionStrategySize + sizeof(uint) + 2 * sizeof(ulong);
+
+    /// <summary>The wire numbers the polling kinds from 1, in the order of <see cref="MessagePolling" />.</summary>
+    private const int FirstWireKind = 1;
+
+    /// <summary>A poll reply body starts with the partition, the current offset, the count and the context.</summary>
+    private const int PollContextPosition = sizeof(uint) + sizeof(ulong) + sizeof(uint);
+
+    private const int PollHeaderSize = PollContextPosition + PartitionContext.ENCODED_SIZE;
+
+    /// <summary>The base offset of the golden batch, counted from the start of its records.</summary>
+    private const int RecordsBaseOffsetPosition = sizeof(ulong);
+
+    /// <summary>How many messages the golden batch holds.</summary>
+    internal const uint GOLDEN_BATCH_MESSAGES = 2;
+
     /// <summary>A reply for anything the roster read does not claim: a register, or an empty read.</summary>
     internal static byte[] Answer(MockRequest request)
     {
@@ -140,6 +167,151 @@
         return body;
     }
 
+    /// <summary>A FetchMessages request as the node read it.</summary>
+    internal static FetchRequest ReadFetch(MockRequest request)
+    {
+        var body = request.Body.AsSpan();
+        var position = 1;
+        for (var identifier = 0; identifier < 3; identifier++)
+        {
+            position += 2 + body[position + 1];
+        }
+
+        var waitUs = BinaryPrimitives.ReadUInt64LittleEndian(body[position..]);
+        var requestTimeoutUs = BinaryPrimitives.ReadUInt64LittleEndian(body[(position + sizeof(ulong))..]);
+        position += 2 * sizeof(ulong);
+        var minCount = BinaryPrimitives.ReadUInt32LittleEndian(body[position..]);
+        var count = BinaryPrimitives.ReadUInt32LittleEndian(body[(position + sizeof(uint))..]);
+        var maxBytes = BinaryPrimitives.ReadUInt32LittleEndian(body[(position + 2 * sizeof(uint))..]);
+        position += 3 * sizeof(uint);
+        var autoCommit = body[position++] == 1;
+        var targets = new FetchTarget[BinaryPrimitives.ReadUInt32LittleEndian(body[position..])];
+        position += sizeof(uint);
+        for (var index = 0; index < targets.Length; index++)
+        {
+            var target = body[(position + index * FetchTargetSize)..];
+            targets[index] = new FetchTarget(BinaryPrimitives.ReadUInt32LittleEndian(target),
+                (MessagePolling)(target[sizeof(uint)] - FirstWireKind),
+                BinaryPrimitives.ReadUInt64LittleEndian(target[(sizeof(uint) + 1)..]),
+                BinaryPrimitives.ReadUInt32LittleEndian(target[PartitionStrategySize..]),
+                BinaryPrimitives.ReadUInt64LittleEndian(target[(PartitionStrategySize + sizeof(uint))..]),
+                BinaryPrimitives.ReadUInt64LittleEndian(target[(PartitionStrategySize + sizeof(uint) + sizeof(ulong))..]));
+        }
+
+        return new FetchRequest(waitUs, requestTimeoutUs, minCount, count, maxBytes, autoCommit, targets);
+    }
+
+    /// <summary>A FetchMessages reply body: the section count, the section table, then the records.</summary>
+    internal static byte[] FetchBody(params FetchSection[] sections)
+    {
+        var body = new byte[FetchedMessages.HeadSize(sections.Length) + sections.Sum(section => section.Records.Length)];
+        BinaryPrimitives.WriteUInt32LittleEndian(body, (uint)sections.Length);
+        var records = FetchedMessages.HeadSize(sections.Length);
+        for (var index = 0; index < sections.Length; index++)
+        {
+            var section = sections[index];
+            var header = body.AsSpan(FetchedMessages.HeadSize(index));
+            BinaryPrimitives.WriteUInt32LittleEndian(header, section.PartitionId);
+            BinaryPrimitives.WriteUInt32LittleEndian(header[4..], section.Status);
+            BinaryPrimitives.WriteUInt64LittleEndian(header[8..], section.CurrentOffset);
+            BinaryPrimitives.WriteUInt32LittleEndian(header[16..], section.MessagesCount);
+            BinaryPrimitives.WriteUInt64LittleEndian(header[20..], section.Context.Incarnation);
+            BinaryPrimitives.WriteUInt64LittleEndian(header[28..], section.Context.OwnerGeneration);
+            BinaryPrimitives.WriteUInt64LittleEndian(header[36..], section.Context.MetadataOp);
+            BinaryPrimitives.WriteUInt64LittleEndian(header[44..], section.NextOffset);
+            BinaryPrimitives.WriteUInt32LittleEndian(header[52..], (uint)section.Records.Length);
+            section.Records.CopyTo(body.AsSpan(records));
+            records += section.Records.Length;
+        }
+
+        return body;
+    }
+
+    /// <summary>
+    ///     A section that serves the golden batch from <paramref name="firstOffset" />, so it holds that offset and
+    ///     the next one.
+    /// </summary>
+    internal static FetchSection GoldenSection(uint partitionId, ulong firstOffset, PartitionContext context)
+    {
+        var records = GoldenPollBody(partitionId, firstOffset, context)[PollHeaderSize..];
+        return new FetchSection(partitionId, 0, firstOffset + GOLDEN_BATCH_MESSAGES - 1, GOLDEN_BATCH_MESSAGES,
+            context, firstOffset + GOLDEN_BATCH_MESSAGES, records);
+    }
+
+    /// <summary>The golden poll body moved to another partition, first offset and context.</summary>
+    internal static byte[] GoldenPollBody(uint partitionId, ulong firstOffset, PartitionContext context)
+    {
+        var body = Convert.FromHexString(MessageBatchGoldenVectorTests.POLL_BODY);
+        BinaryPrimitives.WriteUInt32LittleEndian(body, partitionId);
+        BinaryPrimitives.WriteUInt64LittleEndian(body.AsSpan(PollContextPosition), context.Incarnation);
+        BinaryPrimitives.WriteUInt64LittleEndian(body.AsSpan(PollContextPosition + sizeof(ulong)),
+            context.OwnerGeneration);
+        BinaryPrimitives.WriteUInt64LittleEndian(body.AsSpan(PollContextPosition + 2 * sizeof(ulong)),
+            context.MetadataOp);
+        BinaryPrimitives.WriteUInt64LittleEndian(body.AsSpan(PollHeaderSize + RecordsBaseOffsetPosition), firstOffset);
+
+        return body;
+    }
+
+    /// <summary>A section that read nothing: an empty read without a status, or a refusal with one.</summary>
+    internal static FetchSection EmptySection(uint partitionId, ulong nextOffset, PartitionContext context,
+        uint status = 0)
+    {
+        return status == 0
+            ? new FetchSection(partitionId, 0, nextOffset, 0, context, nextOffset, [])
+            : new FetchSection(partitionId, status, 0, 0, default, 0, []);
+    }
+
+    /// <summary>The PollMessages part of a poll request: a deferred poll appends its wait options to it.</summary>
+    internal static ReadOnlySpan<byte> PollBody(MockRequest request)
+    {
+        var tail = request.Code == CommandCodes.POLL_MESSAGES_DEFERRED_CODE ? TcpContracts.DEFERRED_POLL_TAIL_SIZE : 0;
+        return request.Body.AsSpan(0, request.Body.Length - tail);
+    }
+
+    /// <summary>The partition a PollMessages or PollMessagesDeferred request reads.</summary>
+    internal static uint PolledPartition(MockRequest request)
+    {
+        return BinaryPrimitives.ReadUInt32LittleEndian(PollBody(request)[^(PollTailAfterPartition + sizeof(uint))..]);
+    }
+
+    /// <summary>The partition, the strategy and the count of a PollMessages or PollMessagesDeferred request.</summary>
+    internal static (uint PartitionId, MessagePolling Kind, ulong Value, uint Count) ReadPoll(MockRequest request)
+    {
+        var tail = PollBody(request)[^PollTailAfterPartition..];
+        return (PolledPartition(request), (MessagePolling)(tail[0] - FirstWireKind),
+            BinaryPrimitives.ReadUInt64LittleEndian(tail[1..]),
+            BinaryPrimitives.ReadUInt32LittleEndian(tail[(1 + sizeof(ulong))..]));
+    }
+
+    /// <summary>A FetchMessages target as the client sent it, with its fence.</summary>
+    internal readonly record struct FetchTarget(
+        uint PartitionId,
+        MessagePolling Kind,
+        ulong Value,
+        uint MaxBytes,
+        ulong Incarnation,
+        ulong OwnerGeneration);
+
+    internal readonly record struct FetchRequest(
+        ulong WaitUs,
+        ulong RequestTimeoutUs,
+        uint MinCount,
+        uint Count,
+        uint MaxBytes,
+        bool AutoCommit,
+        FetchTarget[] Targets);
+
+    /// <summary>One section of a FetchMessages reply. The records are those a poll reply carries after its header.</summary>
+    internal readonly record struct FetchSection(
+        uint PartitionId,
+        uint Status,
+        ulong CurrentOffset,
+        uint MessagesCount,
+        PartitionContext Context,
+        ulong NextOffset,
+        byte[] Records);
+
     internal static void WriteNode(List<byte> body, ushort port, bool leader, byte replicaId = 0)
     {
         body.Add(replicaId);
@@ -197,6 +369,7 @@
     private volatile bool _killed;
     private int _connections;
     private readonly List<MockRequest> _recorded = [];
+    private readonly ConcurrentDictionary<(UInt128 Client, ulong Request), Func<byte[], Task>> _parked = new();
     private int _pings;
     private int _registrations;
 
@@ -248,9 +421,12 @@
         }
     }
 
-    /// <param name="handler">Answers each request.</param>
+    /// <param name="handler">
+    ///     Answers each request, or returns null to leave it unanswered, the way the server holds a parked deferred
+    ///     request and never answers its cancel.
+    /// </param>
     /// <param name="beforeSend">Runs before each reply goes out. The reply waits until its task ends.</param>
-    public void Serve(Func<MockRequest, byte[]> handler, Func<MockRequest, byte[], Task>? beforeSend = null)
+    public void Serve(Func<MockRequest, byte[]?> handler, Func<MockRequest, byte[], Task>? beforeSend = null)
     {
         _ = Task.Run(async () =>
         {
@@ -277,6 +453,18 @@
         });
     }
 
+    /// <summary>Sends the reply of a request that the handler left unanswered, on the link that carried it.</summary>
+    public Task AnswerParkedAsync(MockRequest request, byte[] reply)
+    {
+        if (!_parked.TryRemove((request.ClientId, request.RequestId), out var send))
+        {
+            throw new InvalidOperationException($"Request {request.RequestId} is not parked.");
+        }
+
+        CorrelateReply(request, reply);
+        return send(reply);
+    }
+
     public void Kill()
     {
         _killed = true;
@@ -298,12 +486,27 @@
         Kill();
     }
 
-    private async Task Exchange(TcpClient connection, Func<MockRequest, byte[]> handler,
+    private async Task Exchange(TcpClient connection, Func<MockRequest, byte[]?> handler,
         Func<MockRequest, byte[], Task>? beforeSend)
     {
         try
         {
             await using var stream = connection.GetStream();
+            using var writes = new SemaphoreSlim(1, 1);
+            async Task SendAsync(byte[] frame)
+            {
+                await writes.WaitAsync();
+                try
+                {
+                    await stream.WriteAsync(frame);
+                    await stream.FlushAsync();
+                }
+                finally
+                {
+                    writes.Release();
+                }
+            }
+
             var header = new byte[HEADER_SIZE];
             while (!_killed)
             {
@@ -336,14 +539,17 @@
                     Interlocked.Increment(ref _pings);
                 }
 
-                var reply = handler(request);
+                if (handler(request) is not { } reply)
+                {
+                    _parked[(request.ClientId, request.RequestId)] = SendAsync;
+                    continue;
+                }
                 CorrelateReply(request, reply);
                 if (beforeSend is not null)
                 {
                     await beforeSend(request, reply);
                 }
-                await stream.WriteAsync(reply);
-                await stream.FlushAsync();
+                await SendAsync(reply);
             }
         }
         catch (Exception)
diff --git a/foreign/csharp/Iggy_SDK_Tests/VsrTests/MutationLaneTests.cs b/foreign/csharp/Iggy_SDK_Tests/VsrTests/MutationLaneTests.cs
index 20246b8..b2e3ecc 100644
--- a/foreign/csharp/Iggy_SDK_Tests/VsrTests/MutationLaneTests.cs
+++ b/foreign/csharp/Iggy_SDK_Tests/VsrTests/MutationLaneTests.cs
@@ -18,8 +18,10 @@
 using System.Buffers;
 using Apache.Iggy.Configuration;
 using Apache.Iggy.Contracts;
+using Apache.Iggy.Contracts.Tcp;
 using Apache.Iggy.Enums;
 using Apache.Iggy.Exceptions;
+using Apache.Iggy.Kinds;
 using Apache.Iggy.Utils;
 using Apache.Iggy.Vsr;
 
@@ -233,13 +235,15 @@
         lanes.Complete(await third.WaitAsync(TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken));
     }
 
+    /// <summary>A send, retained or not, leaves the progress lane of its partition free.</summary>
     [Theory]
     [InlineData(false)]
     [InlineData(true)]
     public async Task given_full_mutation_capacity_when_progress_enters_another_lane_should_ignore_mutation_budgets(bool retained)
     {
         var lanes = new MutationLanes(Session(), Configuration(1, VsrHeader.HEADER_SIZE));
-        var mutation = await EnterAsync(lanes, new MutationLane(17, 3));
+        var lane = new MutationLane(17, 3);
+        var mutation = await EnterAsync(lanes, lane);
         if (retained)
         {
             lanes.Retain(mutation);
@@ -247,8 +251,7 @@
         using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
         try
         {
-            var progress = await lanes.EnterAsync(new MutationLane(17, 4), CommandCodes.POLL_MESSAGES_CODE,
-                ReadOnlyMemory<byte>.Empty, new PartitionContext(17, 0, 1), false, FirstGeneration, cancellation.Token)
+            var progress = await ProgressAsync(lanes, lane, cancellation.Token)
                 .WaitAsync(TimeSpan.FromSeconds(2), cancellation.Token);
             lanes.Complete(progress);
             Assert.True(lanes.Owns(mutation));
@@ -268,9 +271,9 @@
         using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
         try
         {
-            var progress = await lanes.EnterAsync(lane, CommandCodes.POLL_MESSAGES_CODE, new byte[1],
-                new PartitionContext(17, 0, 1), false, FirstGeneration, cancellation.Token);
-            var follower = EnterAsync(lanes, lane, cancellation.Token);
+            var progress = await lanes.EnterAsync(lane with { Progress = true }, CommandCodes.POLL_MESSAGES_CODE,
+                new byte[1], new PartitionContext(17, 0, 1), false, FirstGeneration, cancellation.Token);
+            var follower = ProgressAsync(lanes, lane, cancellation.Token);
             var independent = await EnterAsync(lanes, new MutationLane(17, 4), cancellation.Token)
                 .WaitAsync(TimeSpan.FromSeconds(2), cancellation.Token);
             lanes.Complete(independent);
@@ -286,23 +289,129 @@
     }
 
     [Fact]
-    public async Task given_a_retained_operation_when_progress_enters_its_lane_should_report_the_blocker_without_allocating_an_id()
+    public async Task given_a_retained_offset_store_when_progress_enters_should_report_the_blocker_without_allocating_an_id()
     {
         var session = Session();
         var lanes = new MutationLanes(session, Configuration());
         var lane = new MutationLane(17, 3);
-        var mutation = await EnterAsync(lanes, lane);
+        var mutation = await EnterAsync(lanes, lane, code: CommandCodes.STORE_CONSUMER_OFFSET_CODE);
         lanes.Retain(mutation);
         var counter = session.RequestCounter;
-        var blocked = await Assert.ThrowsAsync<VsrLaneBlockedException>(() => lanes.EnterAsync(lane,
-            CommandCodes.POLL_MESSAGES_CODE, ReadOnlyMemory<byte>.Empty, new PartitionContext(17, 0, 1),
-            false, session.Generation, TestContext.Current.CancellationToken));
+        var blocked = await Assert.ThrowsAsync<VsrLaneBlockedException>(() => ProgressAsync(lanes, lane));
         Assert.Equal(mutation.Identity.RequestId, blocked.OperationId);
+        Assert.Equal(14007, blocked.StatusCode);
+        Assert.False(blocked.RequiresExplicitRecovery);
         Assert.Equal(counter, session.RequestCounter);
         lanes.Complete(mutation);
     }
 
     [Fact]
+    public async Task given_a_retained_target_when_a_fetch_enters_should_name_the_partition_and_release_earlier_lanes()
+    {
+        var session = Session();
+        var lanes = new MutationLanes(session, Configuration());
+        var blockedLane = new MutationLane(17, 3);
+        var token = TestContext.Current.CancellationToken;
+        try
+        {
+            var mutation = await EnterAsync(lanes, blockedLane, code: CommandCodes.STORE_CONSUMER_OFFSET_CODE);
+            lanes.Retain(mutation);
+            var counter = session.RequestCounter;
+            var ordinary = await Assert.ThrowsAsync<VsrLaneBlockedException>(() => ProgressAsync(lanes, blockedLane));
+            Assert.Null(ordinary.PartitionId);
+            Assert.Equal(mutation.Identity.RequestId, ordinary.OperationId);
+
+            var blocked = await Assert.ThrowsAsync<VsrLaneBlockedException>(() => FetchAsync(lanes, [5, 1, 3], token));
+            Assert.Equal(3U, blocked.PartitionId);
+            Assert.Equal(mutation.Identity.RequestId, blocked.OperationId);
+            Assert.Equal(counter, session.RequestCounter);
+            lanes.Complete(await FetchAsync(lanes, [1], token).WaitAsync(WaitLimit, token));
+
+            lanes.Abandon(blocked.OperationId);
+            lanes.Complete(await FetchAsync(lanes, [5, 1, 3], token).WaitAsync(WaitLimit, token));
+            Assert.Equal(0, lanes.OperationCount);
+        }
+        finally
+        {
+            lanes.Reset();
+        }
+    }
+
+    /// <summary>
+    ///     An offset store queues for the mutation lane only once the progress lane is its turn, so it never holds
+    ///     back a send while it waits for a poll, and a poll that queues after it waits for it.
+    /// </summary>
+    [Fact]
+    public async Task given_a_progress_lane_in_use_when_a_store_and_a_send_enter_its_partition_should_admit_only_the_send()
+    {
+        var lanes = new MutationLanes(Session(), Configuration());
+        var lane = new MutationLane(17, 3);
+        using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+        try
+        {
+            var progress = await ProgressAsync(lanes, lane, cancellation.Token);
+            var store = EnterAsync(lanes, lane, cancellation.Token, code: CommandCodes.STORE_CONSUMER_OFFSET_CODE);
+            var send = await EnterAsync(lanes, lane, cancellation.Token).WaitAsync(WaitLimit, cancellation.Token);
+            var poll = ProgressAsync(lanes, lane, cancellation.Token);
+            lanes.Complete(progress);
+            await Task.Delay(SettleTime, cancellation.Token);
+            Assert.False(store.IsCompleted);
+            Assert.False(poll.IsCompleted);
+
+            lanes.Complete(send);
+            var stored = await store.WaitAsync(WaitLimit, cancellation.Token);
+            await Task.Delay(SettleTime, cancellation.Token);
+            Assert.False(poll.IsCompleted);
+            lanes.Complete(stored);
+            lanes.Complete(await poll.WaitAsync(WaitLimit, cancellation.Token));
+        }
+        finally
+        {
+            await cancellation.CancelAsync();
+            lanes.Reset();
+        }
+    }
+
+    /// <summary>
+    ///     A fetch that moves a consumer holds the progress lanes of all its partitions, and takes them in ascending
+    ///     order. A fetch that waits for one of them holds back no lane after it, so two fetches that share partitions
+    ///     never wait for each other in a cycle.
+    /// </summary>
+    [Fact]
+    public async Task given_a_fetch_holding_progress_lanes_when_a_store_a_poll_and_a_fetch_enter_should_wait_only_behind_it()
+    {
+        var lanes = new MutationLanes(Session(), Configuration());
+        using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+        try
+        {
+            var fetch = await FetchAsync(lanes, [3, 5], cancellation.Token);
+            var store = EnterAsync(lanes, new MutationLane(17, 5), cancellation.Token,
+                code: CommandCodes.STORE_CONSUMER_OFFSET_CODE);
+            var send = await EnterAsync(lanes, new MutationLane(17, 5), cancellation.Token)
+                .WaitAsync(WaitLimit, cancellation.Token);
+            var overlapping = FetchAsync(lanes, [3, 4], cancellation.Token);
+            var poll = await ProgressAsync(lanes, new MutationLane(17, 4), cancellation.Token)
+                .WaitAsync(WaitLimit, cancellation.Token);
+            await Task.Delay(SettleTime, cancellation.Token);
+            Assert.False(store.IsCompleted);
+            Assert.False(overlapping.IsCompleted);
+
+            lanes.Complete(send);
+            lanes.Complete(fetch);
+            lanes.Complete(await store.WaitAsync(WaitLimit, cancellation.Token));
+            await Task.Delay(SettleTime, cancellation.Token);
+            Assert.False(overlapping.IsCompleted);
+            lanes.Complete(poll);
+            lanes.Complete(await overlapping.WaitAsync(WaitLimit, cancellation.Token));
+        }
+        finally
+        {
+            await cancellation.CancelAsync();
+            lanes.Reset();
+        }
+    }
+
+    [Fact]
     public async Task given_a_queued_follower_when_restarting_should_keep_the_original_lane_position()
     {
         var lanes = new MutationLanes(Session(), Configuration());
@@ -645,9 +754,28 @@
     }
 
     private static Task<ClientOperation> EnterAsync(MutationLanes lanes, MutationLane lane,
-        CancellationToken? token = null, ulong generation = FirstGeneration, ReadOnlyMemory<byte> body = default) =>
-        lanes.EnterAsync(lane, CommandCodes.SEND_MESSAGES_CODE, body, new PartitionContext(lane.Incarnation, 0, 1), true,
-            generation, token ?? TestContext.Current.CancellationToken);
+        CancellationToken? token = null, ulong generation = FirstGeneration, ReadOnlyMemory<byte> body = default,
+        int code = CommandCodes.SEND_MESSAGES_CODE) =>
+        lanes.EnterAsync(lane, code, body, new PartitionContext(lane.Incarnation, 0, 1), true, generation,
+            token ?? TestContext.Current.CancellationToken);
+
+    /// <summary>Enters the progress lane of <paramref name="lane" /> the way a poll that moves a consumer does.</summary>
+    private static Task<ClientOperation> ProgressAsync(MutationLanes lanes, MutationLane lane,
+        CancellationToken? token = null) =>
+        lanes.EnterAsync(lane with { Progress = true }, CommandCodes.POLL_MESSAGES_CODE, ReadOnlyMemory<byte>.Empty,
+            new PartitionContext(lane.Incarnation, 0, 1), false, FirstGeneration,
+            token ?? TestContext.Current.CancellationToken);
+
+    /// <summary>
+    ///     Enters the progress lanes of <paramref name="partitions" /> in ascending order, the way a fetch that moves a
+    ///     consumer does. A fetch sends header context zero.
+    /// </summary>
+    private static Task<ClientOperation> FetchAsync(MutationLanes lanes, uint[] partitions, CancellationToken token) =>
+        lanes.EnterAsync([.. partitions.Order().Select(partition => new MutationLane(17, partition, Progress: true))],
+            CommandCodes.FETCH_MESSAGES_CODE, TcpContracts.FetchMessages(Consumer.New(1), Identifier.Numeric(1),
+                Identifier.Numeric(1), new DeferredPollOptions(), 10, true, DeferredPollOptions.MIN_MAX_BYTES,
+                [.. partitions.Select(partition => (partition, PollingStrategy.Offset(0)
+                    .WithContext(new PartitionContext(17, 0, 1))))]), default, false, FirstGeneration, token);
 
     private static ConsensusSession Session() => new(7, 42, new byte[LoginRegister.BIND_SECRET_BYTES]);
 
diff --git a/foreign/csharp/Iggy_SDK_Tests/VsrTests/NativeRecoveryTests.cs b/foreign/csharp/Iggy_SDK_Tests/VsrTests/NativeRecoveryTests.cs
index 5acafd1..d51b626 100644
--- a/foreign/csharp/Iggy_SDK_Tests/VsrTests/NativeRecoveryTests.cs
+++ b/foreign/csharp/Iggy_SDK_Tests/VsrTests/NativeRecoveryTests.cs
@@ -175,17 +175,31 @@
     [Theory]
     [InlineData(true, false)]
     [InlineData(false, true)]
-    public async Task given_a_progress_poll_when_its_outcome_is_uncertain_should_not_replay_or_retain_it(
+    public async Task given_a_progress_poll_when_its_outcome_is_uncertain_should_not_replay_it_and_block_only_a_commit(
         bool autoCommit, bool next)
     {
         using var cluster = new RecoveryCluster();
         using var client = await cluster.ConnectAsync();
         cluster.OnPoll = request => Reply(request.Operation, [], VsrError.TRANSIENT_NOT_COMMITTED);
-        var error = await Assert.ThrowsAsync<VsrRequestOutcomeUnknownException>(() => client.PollMessagesAsync(
+        var error = await Assert.ThrowsAnyAsync<VsrRequestOutcomeUnknownException>(() => client.PollMessagesAsync(
             Stream, Topic, 0, Consumer.New(3), next ? PollingStrategy.Next() : PollingStrategy.Offset(0), 1,
             autoCommit, cluster.Token));
-        Assert.Null(error.OperationId);
         Assert.Single(cluster.Polls);
+        if (autoCommit)
+        {
+            // The poll may have stored its offset, so its partition waits for the caller to abandon it.
+            var uncertain = Assert.IsType<VsrFetchProgressUncertainException>(error);
+            var blocked = await Assert.ThrowsAsync<VsrPartitionLaneBlockedException>(() =>
+                StoreAsync(client, cluster.Token));
+            Assert.Equal(uncertain.OperationId, blocked.OperationId);
+            Assert.Empty(cluster.Writes);
+            await client.AbandonOperationAsync(blocked.OperationId);
+        }
+        else
+        {
+            Assert.Null(error.OperationId);
+        }
+
         await StoreAsync(client, cluster.Token);
         Assert.Single(cluster.Writes);
     }
@@ -206,56 +220,6 @@
     }
 
     [Fact]
-    public async Task given_an_uncertain_write_when_a_consumer_polls_its_lane_should_leave_the_resolution_to_its_owner()
-    {
-        using var cluster = new RecoveryCluster();
-        using var client = await cluster.ConnectAsync();
-        var operationId = await RetainWriteAsync(cluster, client);
-        var original = Assert.Single(cluster.Writes);
-        await using var consumer = PartitionConsumer(client);
-        await consumer.InitAsync(cluster.Token);
-        await using var messages = consumer.ReceiveAsync(cluster.Token).GetAsyncEnumerator(cluster.Token);
-
-        var error = await Record.ExceptionAsync(() => messages.MoveNextAsync().AsTask());
-
-        Assert.Single(cluster.Writes);
-        Assert.Equal(operationId, Assert.IsType<VsrLaneBlockedException>(error).OperationId);
-        await client.ResolveOperationAsync(operationId, TimeSpan.FromSeconds(5), cluster.Token);
-        Assert.Equal(original.RequestId, cluster.Writes.Last().RequestId);
-    }
-
-    [Theory]
-    [InlineData(0)]
-    [InlineData(VsrError.HISTORY_UNAVAILABLE)]
-    public async Task given_an_uncertain_write_when_a_consumer_store_resolves_it_first_should_keep_the_outcome_for_its_owner(
-        int status)
-    {
-        using var cluster = new RecoveryCluster();
-        using var client = await cluster.ConnectAsync();
-        var operationId = await RetainWriteAsync(cluster, client);
-        cluster.OnWrite = request => Result(request, request.RequestId == operationId ? status : 0);
-        await using var consumer = PartitionConsumer(client);
-        await consumer.InitAsync(cluster.Token);
-        await consumer.StoreOffsetAsync(20, 0, ct: cluster.Token);
-
-        var resolved = await Record.ExceptionAsync(() =>
-            client.ResolveOperationAsync(operationId, TimeSpan.FromSeconds(5), cluster.Token));
-
-        if (status == 0)
-        {
-            Assert.Null(resolved);
-        }
-        else
-        {
-            var rejection = Assert.IsType<IggyInvalidStatusCodeException>(resolved);
-            Assert.Equal(status, rejection.StatusCode);
-            Assert.True(rejection.Committed);
-        }
-        Assert.Equal(2, cluster.Writes.Count(write => write.RequestId == operationId));
-        Assert.Equal(3, cluster.Writes.Count);
-    }
-
-    [Fact]
     public async Task given_a_running_replay_when_the_owner_resolves_concurrently_should_return_the_proved_outcome()
     {
         using var cluster = new RecoveryCluster();
@@ -1594,41 +1558,6 @@
         Assert.Equal(cached ? 2 : 1, cluster.Sends.Count);
     }
 
-    [Theory]
-    [InlineData(CommandCodes.GET_PARTITION_ROUTE_CODE)]
-    [InlineData(CommandCodes.SEND_MESSAGES_CODE)]
-    public async Task given_a_namespace_miss_from_a_deposed_metadata_primary_when_its_roster_names_another_should_ask_another_replica(
-        int code)
-    {
-        using var cluster = new RecoveryCluster { PrimaryLeads = true };
-        using var client = await cluster.ConnectAsync();
-        // The send binds the link of the partition primary, which the client believes is the metadata primary.
-        await client.SendMessagesAsync(Stream, Topic, Partitioning.PartitionId(0),
-            new Message(Guid.NewGuid(), "payload"u8.ToArray()), cluster.Token);
-        cluster.PrimaryLeads = false;
-        var missed = 0;
-        byte[]? MissOnce(MockRequest request) => Interlocked.Exchange(ref missed, 1) == 0
-            ? Reply(request.Operation, [], VsrError.TOPIC_ID_NOT_FOUND) : null;
-        if (code == CommandCodes.GET_PARTITION_ROUTE_CODE)
-        {
-            cluster.OnRoute = MissOnce;
-        }
-        else
-        {
-            var send = cluster.OnSend;
-            cluster.OnSend = request => MissOnce(request) ?? send(request);
-        }
-        var deposedLookups = cluster.Primary.Requests(CommandCodes.GET_PARTITION_ROUTE_CODE);
-        var electedLookups = cluster.Coordinator.Requests(CommandCodes.GET_PARTITION_ROUTE_CODE);
-
-        await client.SendMessagesAsync(Stream, Topic, Partitioning.PartitionId(1),
-            new Message(Guid.NewGuid(), "payload"u8.ToArray()), cluster.Token);
-
-        Assert.Equal(1, Volatile.Read(ref missed));
-        Assert.Equal(deposedLookups + 1, cluster.Primary.Requests(CommandCodes.GET_PARTITION_ROUTE_CODE));
-        Assert.Equal(electedLookups + 1, cluster.Coordinator.Requests(CommandCodes.GET_PARTITION_ROUTE_CODE));
-    }
-
     private static Task StoreAsync(TcpMessageStream client, CancellationToken token, uint partition = 0) =>
         client.StoreOffsetAsync(Consumer.New(3), Stream, Topic, 10, partition, token);
 
@@ -1643,16 +1572,6 @@
             deadline: Environment.TickCount64 + (long)budget.TotalMilliseconds);
     }
 
-    private static IggyConsumer PartitionConsumer(TcpMessageStream client) => new(client, new IggyConsumerConfig
-    {
-        StreamId = Stream,
-        TopicId = Topic,
-        PartitionId = 0,
-        Consumer = Consumer.New(3),
-        PollingStrategy = PollingStrategy.Next(),
-        AutoCommitMode = AutoCommitMode.Disabled
-    }, NullLoggerFactory.Instance);
-
     private static async Task<ulong> RetainWriteAsync(RecoveryCluster cluster, TcpMessageStream client)
     {
         using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(cluster.Token);
diff --git a/foreign/csharp/Iggy_SDK_Tests/VsrTests/PartitionContextTests.cs b/foreign/csharp/Iggy_SDK_Tests/VsrTests/PartitionContextTests.cs
index 4fd2a6a..80034b5 100644
--- a/foreign/csharp/Iggy_SDK_Tests/VsrTests/PartitionContextTests.cs
+++ b/foreign/csharp/Iggy_SDK_Tests/VsrTests/PartitionContextTests.cs
@@ -244,57 +244,6 @@
         Assert.Equal(1, node.Requests(CommandCodes.GET_PARTITION_ROUTE_CODE));
     }
 
-    /// <summary>
-    ///     A recreation between the poll and the commit replaces the incarnation. The commit carries the
-    ///     incarnation its messages came from, so the server refuses it instead of storing the offset in the
-    ///     replacement.
-    /// </summary>
-    [Fact]
-    public async Task given_an_after_receive_consumer_when_committing_should_store_the_polled_context()
-    {
-        using var node = new MockNode();
-        var stores = new ConcurrentQueue<MockRequest>();
-        node.Serve(request =>
-        {
-            if (request.Code == CommandCodes.POLL_MESSAGES_CODE)
-            {
-                return Reply(OPERATION_NON_REPLICATED, Convert.FromHexString(MessageBatchGoldenVectorTests.POLL_BODY));
-            }
-
-            if (request.Operation == (byte)VsrOperation.StoreConsumerOffset)
-            {
-                stores.Enqueue(request);
-                return Reply(request.Operation, new byte[4]);
-            }
-
-            return Standalone(node, request);
-        });
-        using var client = new TcpMessageStream(Configuration(node), NullLoggerFactory.Instance);
-        await using var consumer = new IggyConsumer(client, new IggyConsumerConfig
-        {
-            StreamId = Identifier.Numeric(1),
-            TopicId = Identifier.Numeric(2),
-            PartitionId = PartitionId,
-            Consumer = Consumer.New(1),
-            PollingStrategy = PollingStrategy.Next(),
-            AutoCommitMode = AutoCommitMode.AfterReceive,
-            PollingIntervalMs = 0,
-            Login = "iggy",
-            Password = "iggy"
-        }, NullLoggerFactory.Instance);
-        await consumer.InitAsync(TestContext.Current.CancellationToken);
-
-        await using (var messages = consumer.ReceiveAsync(TestContext.Current.CancellationToken)
-                         .GetAsyncEnumerator(TestContext.Current.CancellationToken))
-        {
-            Assert.True(await messages.MoveNextAsync());
-            Assert.True(await messages.MoveNextAsync());
-        }
-
-        var store = Assert.Single(stores);
-        Assert.Equal(Polled, store.Context);
-    }
-
     [Fact]
     public async Task given_a_send_refused_for_another_reason_should_keep_the_cached_context()
     {
diff --git a/foreign/csharp/Iggy_SDK_Tests/VsrTests/PartitionPollTests.cs b/foreign/csharp/Iggy_SDK_Tests/VsrTests/PartitionPollTests.cs
new file mode 100644
index 0000000..0ba197f
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK_Tests/VsrTests/PartitionPollTests.cs
@@ -0,0 +1,246 @@
+// 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.
+
+using System.Buffers.Binary;
+using System.Collections.Concurrent;
+using Apache.Iggy.Configuration;
+using Apache.Iggy.Contracts;
+using Apache.Iggy.Contracts.Tcp;
+using Apache.Iggy.Enums;
+using Apache.Iggy.Exceptions;
+using Apache.Iggy.IggyClient.Implementations;
+using Apache.Iggy.Kinds;
+using Apache.Iggy.Utils;
+using Apache.Iggy.Vsr;
+using Microsoft.Extensions.Logging.Abstractions;
+using static Apache.Iggy.Tests.VsrTests.MockFrames;
+
+namespace Apache.Iggy.Tests.VsrTests;
+
+/// <summary>
+///     The poll of one partition with <see cref="PollOptions" />: one deferred poll (code 106), the commit policy,
+///     the checkpoints of a batch and the continuation bound to its partition.
+/// </summary>
+public sealed class PartitionPollTests
+{
+    private const int PollCode = CommandCodes.POLL_MESSAGES_DEFERRED_CODE;
+    private const uint Partition = 1;
+
+    /// <summary>A StoreConsumerOffset body ends with the partition id, the offset and the ack quorum.</summary>
+    private const int StoredPartitionFromEnd = sizeof(uint) + sizeof(ulong) + sizeof(byte);
+
+    private static readonly Identifier Stream = Identifier.Numeric(1);
+    private static readonly Identifier Topic = Identifier.Numeric(2);
+
+    /// <summary>The context of the golden batch, which holds the offsets 100 and 101.</summary>
+    private static readonly PartitionContext Served = new(17, 9, 52);
+
+    [Theory]
+    [InlineData(false)]
+    [InlineData(true)]
+    public async Task given_poll_options_when_polling_a_partition_should_send_one_deferred_poll_with_their_limits(
+        bool onRead)
+    {
+        using var node = new MockNode();
+        var polls = new ConcurrentQueue<MockRequest>();
+        node.Serve(request =>
+        {
+            if (request.Code is PollCode or CommandCodes.POLL_MESSAGES_CODE)
+            {
+                polls.Enqueue(request);
+                return Golden(request);
+            }
+            return Standalone(node, request);
+        });
+        using var client = await ConnectAsync(node);
+        var options = new PollOptions
+        {
+            Count = 50,
+            MaxWait = TimeSpan.FromMilliseconds(1500),
+            MinCount = 2,
+            MaxBytes = 4096,
+            Commit = onRead ? CommitPolicy.OnRead : CommitPolicy.Manual
+        };
+
+        await client.PollMessagesAsync(Stream, Topic, Partition, Consumer.New(4), PollingStrategy.Next(), options,
+            TestContext.Current.CancellationToken);
+
+        var poll = Assert.Single(polls);
+        Assert.Equal(PollCode, poll.Code);
+        Assert.Equal((Partition, MessagePolling.Next, 0ul, 50u), ReadPoll(poll));
+        var tail = poll.Body[^TcpContracts.DEFERRED_POLL_TAIL_SIZE..];
+        Assert.Equal(onRead ? 1 : 0, poll.Body[^(TcpContracts.DEFERRED_POLL_TAIL_SIZE + 1)]);
+        Assert.Equal(2u, BinaryPrimitives.ReadUInt32LittleEndian(tail.AsSpan(8)));
+        Assert.Equal(4096u, BinaryPrimitives.ReadUInt32LittleEndian(tail.AsSpan(12)));
+        // Each attempt sends what is left of the wait and of the request timeout, which is the wait plus 10 s.
+        var waitUs = BinaryPrimitives.ReadUInt64LittleEndian(tail);
+        var requestTimeoutUs = BinaryPrimitives.ReadUInt64LittleEndian(tail.AsSpan(16));
+        Assert.InRange(waitUs, 1_000_000ul, 1_500_000ul);
+        Assert.InRange(requestTimeoutUs - waitUs, 9_999_000ul, 10_000_000ul);
+    }
+
+    [Fact]
+    public async Task given_auto_commit_when_polling_a_partition_should_refuse_it_before_sending()
+    {
+        using var node = new MockNode();
+        node.Serve(request => Standalone(node, request));
+        using var client = await ConnectAsync(node);
+        var options = new PollOptions { Commit = CommitPolicy.Auto() };
+
+        var refused = await Assert.ThrowsAsync<IggyInvalidStatusCodeException>(() => client.PollMessagesAsync(
+            Stream, Topic, Partition, Consumer.New(4), PollingStrategy.Next(), options,
+            TestContext.Current.CancellationToken));
+
+        Assert.Equal(VsrError.INVALID_CONFIGURATION, refused.StatusCode);
+        Assert.Equal(0, node.Requests(PollCode));
+    }
+
+    [Fact]
+    public async Task given_a_batch_when_taking_its_checkpoints_should_name_the_position_after_each_message()
+    {
+        using var node = new MockNode();
+        node.Serve(request => request.Code == PollCode ? Golden(request) : Standalone(node, request));
+        using var client = await ConnectAsync(node);
+
+        var polled = await PollAsync(client, PollingStrategy.Next());
+
+        Assert.Equal(new Checkpoint(Partition, 101, Served), polled.Checkpoint);
+        Assert.Equal(new Checkpoint(Partition, 100, Served), polled.CheckpointAt(0));
+        Assert.Null(polled.CheckpointAt(2));
+        Assert.Null(polled.CheckpointAt(-1));
+    }
+
+    [Fact]
+    public async Task given_a_batch_when_continuing_should_read_after_its_last_message_of_the_same_partition_only()
+    {
+        using var node = new MockNode();
+        var polls = new ConcurrentQueue<MockRequest>();
+        node.Serve(request =>
+        {
+            if (request.Code != PollCode)
+            {
+                return Standalone(node, request);
+            }
+            polls.Enqueue(request);
+            return Golden(request);
+        });
+        using var client = await ConnectAsync(node);
+        var polled = await PollAsync(client, PollingStrategy.Next());
+
+        await PollAsync(client, polled.Continuation);
+        var foreign = await Assert.ThrowsAsync<IggyInvalidStatusCodeException>(() => client.PollMessagesAsync(
+            Stream, Topic, Partition + 1, Consumer.New(4), polled.Continuation, new PollOptions(),
+            TestContext.Current.CancellationToken));
+
+        Assert.Equal(VsrError.INVALID_COMMAND, foreign.StatusCode);
+        Assert.Equal(2, polls.Count);
+        var continued = polls.Last();
+        Assert.Equal((Partition, MessagePolling.Offset, 102ul, 1000u), ReadPoll(continued));
+        Assert.Equal(Served, continued.Context);
+    }
+
+    [Theory]
+    [InlineData(MessagePolling.Next, 0ul, MessagePolling.Next, 0ul)]
+    [InlineData(MessagePolling.First, 0ul, MessagePolling.Offset, 0ul)]
+    [InlineData(MessagePolling.Offset, 7ul, MessagePolling.Offset, 7ul)]
+    [InlineData(MessagePolling.Timestamp, 9ul, MessagePolling.Timestamp, 9ul)]
+    [InlineData(MessagePolling.Last, 0ul, MessagePolling.Last, 0ul)]
+    public async Task given_an_empty_batch_when_continuing_should_keep_the_start_of_its_request(
+        MessagePolling startKind, ulong startValue, MessagePolling continuedKind, ulong continuedValue)
+    {
+        using var node = new MockNode();
+        node.Serve(request => request.Code == PollCode ? Empty(request) : Standalone(node, request));
+        using var client = await ConnectAsync(node);
+
+        var polled = await PollAsync(client, new PollingStrategy { Kind = startKind, Value = startValue });
+
+        Assert.Empty(polled.Messages);
+        Assert.Null(polled.Checkpoint);
+        Assert.Equal((continuedKind, continuedValue, Served), (polled.Continuation.Kind, polled.Continuation.Value,
+            polled.Continuation.Context));
+    }
+
+    [Fact]
+    public async Task given_a_checkpoint_when_storing_and_deleting_it_should_fence_its_partition_with_its_context()
+    {
+        using var node = new MockNode();
+        var writes = new ConcurrentQueue<(VsrOperation Operation, uint Partition, PartitionContext Context)>();
+        node.Serve(request =>
+        {
+            switch ((VsrOperation)request.Operation)
+            {
+                case VsrOperation.StoreConsumerOffset:
+                    writes.Enqueue((VsrOperation.StoreConsumerOffset,
+                        BinaryPrimitives.ReadUInt32LittleEndian(request.Body.AsSpan(^StoredPartitionFromEnd)),
+                        request.Context));
+                    Assert.Equal(101ul,
+                        BinaryPrimitives.ReadUInt64LittleEndian(request.Body.AsSpan(^(sizeof(ulong) + 1))));
+                    return Answer(request);
+                case VsrOperation.DeleteConsumerOffset:
+                    writes.Enqueue((VsrOperation.DeleteConsumerOffset, Partition, request.Context));
+                    return Answer(request);
+            }
+            return request.Code == PollCode ? Golden(request) : Standalone(node, request);
+        });
+        using var client = await ConnectAsync(node);
+        var checkpoint = (await PollAsync(client, PollingStrategy.Next())).Checkpoint!;
+
+        await client.StoreConsumerPositionAsync(Consumer.New(4), Stream, Topic, checkpoint,
+            TestContext.Current.CancellationToken);
+        await client.DeleteConsumerPositionAsync(Consumer.New(4), Stream, Topic, checkpoint,
+            TestContext.Current.CancellationToken);
+
+        Assert.Equal([(VsrOperation.StoreConsumerOffset, Partition, Served),
+            (VsrOperation.DeleteConsumerOffset, Partition, Served)], writes);
+    }
+
+    private static Task<PolledMessages> PollAsync(TcpMessageStream client, PollingStrategy strategy) =>
+        client.PollMessagesAsync(Stream, Topic, Partition, Consumer.New(4), strategy, new PollOptions(),
+            TestContext.Current.CancellationToken);
+
+    /// <summary>The golden batch of offsets 100 and 101 from the polled partition.</summary>
+    private static byte[] Golden(MockRequest request) =>
+        Reply(request.Operation, GoldenPollBody(PolledPartition(request), 100, Served));
+
+    /// <summary>A reply without messages, under <see cref="Served" />.</summary>
+    private static byte[] Empty(MockRequest request)
+    {
+        var body = GoldenPollBody(PolledPartition(request), 100, Served)[..DeferredPollOptions.POLL_REPLY_HEADER_SIZE];
+        BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(sizeof(uint) + sizeof(ulong)), 0);
+        return Reply(request.Operation, body);
+    }
+
+    private static byte[] Standalone(MockNode node, MockRequest request) => request.Code switch
+    {
+        GET_CLUSTER_METADATA_CODE => StandaloneRoster(node.Port),
+        CommandCodes.GET_PARTITION_ROUTE_CODE => RouteReply(request, Served, 0),
+        _ => Answer(request)
+    };
+
+    private static async Task<TcpMessageStream> ConnectAsync(MockNode node)
+    {
+        var client = new TcpMessageStream(new IggyClientConfigurator
+        {
+            BaseAddress = $"127.0.0.1:{node.Port}",
+            Protocol = Protocol.Tcp
+        }, NullLoggerFactory.Instance);
+        await client.ConnectAsync(TestContext.Current.CancellationToken);
+        await client.LoginUserAsync("user", "secret", TestContext.Current.CancellationToken);
+
+        return client;
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK_Tests/VsrTests/PrimaryPollingTests.cs b/foreign/csharp/Iggy_SDK_Tests/VsrTests/PrimaryPollingTests.cs
index 00d7db4..1cea6ac 100644
--- a/foreign/csharp/Iggy_SDK_Tests/VsrTests/PrimaryPollingTests.cs
+++ b/foreign/csharp/Iggy_SDK_Tests/VsrTests/PrimaryPollingTests.cs
@@ -17,6 +17,7 @@
 
 using System.Buffers.Binary;
 using System.Collections.Concurrent;
+using System.Diagnostics;
 using System.Net.Sockets;
 using System.Reflection;
 using System.Runtime.ExceptionServices;
@@ -46,6 +47,8 @@
     private static readonly Identifier Group = Identifier.Numeric(3);
     private const ulong MetadataCommit = 50;
 
+    private static readonly TimeSpan PromptLimit = TimeSpan.FromSeconds(5);
+
     [Theory]
     [InlineData(false)]
     [InlineData(true)]
@@ -452,9 +455,10 @@
         using var client = await cluster.ConnectAsync();
         var generation = ((ISessionGenerationProvider)client).SessionGeneration;
 
-        await Assert.ThrowsAsync<VsrRequestOutcomeUnknownException>(() => PollAsync(client));
+        var uncertain = await Assert.ThrowsAsync<VsrFetchProgressUncertainException>(() => PollAsync(client));
         Assert.Equal(1, polls);
         Assert.Equal(1, cluster.Coordinator.Connections);
+        await client.AbandonOperationAsync(uncertain.OperationId!.Value);
         await PollAsync(client);
 
         Assert.Equal(2, polls);
@@ -508,10 +512,19 @@
 
         try
         {
-            var error = await Assert.ThrowsAsync<VsrRequestOutcomeUnknownException>(() => client.PollMessagesAsync(
+            var error = await Assert.ThrowsAnyAsync<VsrRequestOutcomeUnknownException>(() => client.PollMessagesAsync(
                 Stream, Topic, 0, Consumer.New(3), next ? PollingStrategy.Next() : PollingStrategy.Offset(0), 1,
                 !next, TestContext.Current.CancellationToken));
-            Assert.Null(error.OperationId);
+            if (next)
+            {
+                Assert.Null(error.OperationId);
+            }
+            else
+            {
+                // A committing poll may have stored its offset, so its partition waits for the caller to abandon it.
+                await client.AbandonOperationAsync(Assert.IsType<VsrFetchProgressUncertainException>(error).OperationId!.Value);
+            }
+
             var invalid = Assert.IsType<IggyInvalidStatusCodeException>(error.InnerException);
             Assert.False(invalid.FromServer);
             Assert.False(invalid.Committed);
@@ -594,122 +607,6 @@
     }
 
     [Fact]
-    public async Task given_a_live_session_when_a_primary_refuses_a_consumer_poll_with_stale_client_should_keep_polling()
-    {
-        using var cluster = new PollCluster();
-        var polls = 0;
-        cluster.OnPrimaryPoll = request => Interlocked.Increment(ref polls) == 1
-            ? Reply(request.Operation, [], VsrError.STALE_CLIENT)
-            : Reply(request.Operation, Convert.FromHexString(MessageBatchGoldenVectorTests.POLL_BODY));
-        using var client = await cluster.ConnectAsync();
-        var generation = ((ISessionGenerationProvider)client).SessionGeneration;
-        var states = RecordStates(client);
-        await using var consumer = PartitionConsumer(client, PollingStrategy.Next());
-        var errors = RecordErrors(consumer);
-        using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
-        cancellation.CancelAfter(TimeSpan.FromSeconds(10));
-        await consumer.InitAsync(cancellation.Token);
-        await using var messages = consumer.ReceiveAsync(cancellation.Token).GetAsyncEnumerator(cancellation.Token);
-
-        Assert.True(await messages.MoveNextAsync());
-
-        var refusal = Assert.IsType<IggyInvalidStatusCodeException>(Assert.Single(errors));
-        Assert.Equal(VsrError.STALE_CLIENT, refusal.StatusCode);
-        Assert.Equal(2, polls);
-        Assert.Equal(2, cluster.Primaries[0].Requests(CommandCodes.BIND_SESSION_CODE));
-        Assert.Equal(1, cluster.Coordinator.Registrations);
-        Assert.Equal(generation, ((ISessionGenerationProvider)client).SessionGeneration);
-        Assert.Empty(states);
-    }
-
-    [Fact]
-    public async Task given_a_consumer_poll_in_flight_when_a_stale_client_refusal_detaches_its_link_should_poll_again()
-    {
-        using var cluster = new PollCluster();
-        var polls = 0;
-        var consumerPolling = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
-        cluster.OnPrimaryPoll = request =>
-        {
-            if (Interlocked.Increment(ref polls) > 1)
-            {
-                return Reply(request.Operation, Convert.FromHexString(MessageBatchGoldenVectorTests.POLL_BODY));
-            }
-            // The refusal waits until the consumer poll follows this request on the same link.
-            consumerPolling.Task.Wait(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
-            Thread.Sleep(100);
-            return Reply(request.Operation, [], VsrError.STALE_CLIENT);
-        };
-        using var client = await cluster.ConnectAsync();
-        var generation = ((ISessionGenerationProvider)client).SessionGeneration;
-        var states = RecordStates(client);
-        var refused = client.PollMessagesAsync(Stream, Topic, 0, Consumer.New(4), PollingStrategy.First(), 1, false,
-            TestContext.Current.CancellationToken);
-        await cluster.UntilAsync(() => Volatile.Read(ref polls) == 1);
-        await using var consumer = PartitionConsumer(client, PollingStrategy.Next());
-        var errors = RecordErrors(consumer);
-        using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
-        cancellation.CancelAfter(TimeSpan.FromSeconds(10));
-        await consumer.InitAsync(cancellation.Token);
-        await using var messages = consumer.ReceiveAsync(cancellation.Token).GetAsyncEnumerator(cancellation.Token);
-        var receiving = messages.MoveNextAsync().AsTask();
-        consumerPolling.TrySetResult();
-
-        Assert.True(await receiving);
-
-        var refusal = await Assert.ThrowsAsync<IggyInvalidStatusCodeException>(() => refused);
-        Assert.Equal(VsrError.STALE_CLIENT, refusal.StatusCode);
-        Assert.Null(Assert.IsType<VsrRequestOutcomeUnknownException>(Assert.Single(errors)).OperationId);
-        Assert.Equal(2, cluster.Primaries[0].Requests(CommandCodes.BIND_SESSION_CODE));
-        Assert.Equal(1, cluster.Coordinator.Registrations);
-        Assert.Equal(generation, ((ISessionGenerationProvider)client).SessionGeneration);
-        Assert.Empty(states);
-    }
-
-    [Fact]
-    public async Task given_a_consumer_poll_in_flight_when_its_link_is_lost_should_wait_for_the_link_and_poll_again()
-    {
-        using var cluster = new PollCluster();
-        var down = false;
-        cluster.OnPrimaryPoll = request => Volatile.Read(ref down)
-            ? throw new IOException("outage")
-            : Reply(request.Operation, Convert.FromHexString(MessageBatchGoldenVectorTests.POLL_BODY));
-        cluster.OnPrimaryAttachment = request => Volatile.Read(ref down)
-            ? throw new IOException("outage")
-            : BindingReply(request);
-        using var client = await cluster.ConnectAsync();
-        // Both primary links bind before the outage, so it fails only the link of the consumer poll.
-        foreach (var partition in new uint[] { 0, 1 })
-        {
-            await client.PollMessagesAsync(Stream, Topic, partition, Consumer.New(3), PollingStrategy.First(), 1, false,
-                TestContext.Current.CancellationToken);
-        }
-        var generation = ((ISessionGenerationProvider)client).SessionGeneration;
-        var states = RecordStates(client);
-        await using var consumer = PartitionConsumer(client, PollingStrategy.Next());
-        var errors = RecordErrors(consumer);
-        using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
-        cancellation.CancelAfter(TimeSpan.FromSeconds(10));
-        await consumer.InitAsync(cancellation.Token);
-        await using var messages = consumer.ReceiveAsync(cancellation.Token).GetAsyncEnumerator(cancellation.Token);
-        Volatile.Write(ref down, true);
-        var receiving = messages.MoveNextAsync().AsTask();
-
-        // While the link fails eight binds, the next poll waits for it instead of failing at once.
-        await cluster.UntilAsync(() => cluster.Primaries[0].Requests(CommandCodes.BIND_SESSION_CODE) >= 9);
-        Assert.False(receiving.IsCompleted, receiving.Exception?.GetBaseException().ToString());
-        Assert.Null(Assert.IsType<VsrRequestOutcomeUnknownException>(Assert.Single(errors)).OperationId);
-        Volatile.Write(ref down, false);
-
-        Assert.True(await receiving);
-        Assert.Single(errors);
-        Assert.Equal(3, cluster.Primaries[0].Requests(CommandCodes.POLL_MESSAGES_CODE));
-        Assert.Equal(1, cluster.Primaries[1].Connections);
-        Assert.Equal(1, cluster.Coordinator.Registrations);
-        Assert.Equal(generation, ((ISessionGenerationProvider)client).SessionGeneration);
-        Assert.Empty(states);
-    }
-
-    [Fact]
     public async Task given_a_stale_binding_when_links_start_should_replace_the_logical_session_once()
     {
         using var cluster = new PollCluster();
@@ -760,8 +657,9 @@
         };
         using var client = await cluster.ConnectAsync();
         using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+        // A poll that commits waits for its reply, so only a Next poll without commits ends on the cancel.
         var pending = client.PollMessagesAsync(Stream, Topic, 0, Consumer.Group(3), PollingStrategy.Next(), 10,
-            true, cancellation.Token);
+            false, cancellation.Token);
         try
         {
             await started.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
@@ -1949,16 +1847,13 @@
     private static byte[] Batch(MockRequest request)
     {
         var body = new byte[40];
-        BinaryPrimitives.WriteUInt32LittleEndian(body, Partition(request));
+        BinaryPrimitives.WriteUInt32LittleEndian(body, PolledPartition(request));
         return Reply(request.Operation, body);
     }
 
     private static bool IsOffsetWrite(MockRequest request) =>
         request.Operation is (byte)VsrOperation.StoreConsumerOffset or (byte)VsrOperation.DeleteConsumerOffset;
 
-    private static uint Partition(MockRequest request) =>
-        BinaryPrimitives.ReadUInt32LittleEndian(request.Body.AsSpan(request.Body.Length - 18));
-
     private static byte[] BindIdentity(byte[] body)
     {
         var position = 4;
@@ -1973,18 +1868,6 @@
     private static byte[] BindingReply(MockRequest request) => Reply(request.Operation,
         RegisterBody(BinaryPrimitives.ReadUInt64LittleEndian(BindIdentity(request.Body).AsSpan(16)))[4..]);
 
-    private static IggyConsumer PartitionConsumer(TcpMessageStream client, PollingStrategy strategy) => new(client,
-        new IggyConsumerConfig
-        {
-            StreamId = Stream,
-            TopicId = Topic,
-            PartitionId = 0,
-            Consumer = Consumer.New(3),
-            PollingStrategy = strategy,
-            AutoCommitMode = AutoCommitMode.Disabled,
-            PollingIntervalMs = 0
-        }, NullLoggerFactory.Instance);
-
     private static ConcurrentQueue<ConnectionState> RecordStates(TcpMessageStream client)
     {
         var states = new ConcurrentQueue<ConnectionState>();
@@ -1996,17 +1879,6 @@
         return states;
     }
 
-    private static ConcurrentQueue<Exception> RecordErrors(IggyConsumer consumer)
-    {
-        var errors = new ConcurrentQueue<Exception>();
-        consumer.SubscribeToErrorEvents(error =>
-        {
-            errors.Enqueue(error.Exception);
-            return Task.CompletedTask;
-        });
-        return errors;
-    }
-
     private sealed class PollCluster : IDisposable
     {
         internal readonly MockNode Coordinator = new();
@@ -2087,7 +1959,11 @@
                     {
                         return OnPrimarySend(request);
                     }
-                    return request.Code == CommandCodes.POLL_MESSAGES_CODE
+                    if (request.Code == CommandCodes.CANCEL_DEFERRED_REQUEST_CODE)
+                    {
+                        return null;
+                    }
+                    return request.Code is CommandCodes.POLL_MESSAGES_CODE or CommandCodes.POLL_MESSAGES_DEFERRED_CODE
                         ? OnPrimaryPoll(request)
                         : OnPrimaryControl?.Invoke(request) ?? DefaultControl(request) ?? Answer(request);
                 });
@@ -2168,7 +2044,7 @@
             }
         }
 
-        private static uint RoutedPartition(MockRequest request) =>
+        internal static uint RoutedPartition(MockRequest request) =>
             BinaryPrimitives.ReadUInt32LittleEndian(request.Body.AsSpan(request.Body.Length - sizeof(uint)));
     }
 }
diff --git a/foreign/csharp/Iggy_SDK_Tests/VsrTests/VsrMultiplexingTests.cs b/foreign/csharp/Iggy_SDK_Tests/VsrTests/VsrMultiplexingTests.cs
index 7946ff2..c25b6c2 100644
--- a/foreign/csharp/Iggy_SDK_Tests/VsrTests/VsrMultiplexingTests.cs
+++ b/foreign/csharp/Iggy_SDK_Tests/VsrTests/VsrMultiplexingTests.cs
@@ -78,6 +78,41 @@
         Assert.Equal(0, pair.Dropped);
     }
 
+    /// <summary>
+    ///     The server answers neither a cancel nor the canceled request. The cancel names the parked request on its
+    ///     link, a late reply for it is dropped, and the silence watchdog does not wait for replies that never come.
+    /// </summary>
+    [Theory]
+    [InlineData(CommandCodes.POLL_MESSAGES_DEFERRED_CODE)]
+    public async Task given_a_parked_deferred_request_when_canceled_should_send_a_header_only_cancel_and_keep_the_link(
+        int code)
+    {
+        const int FrameDeadlineMs = 100;
+        using var pair = await LinkPair.ConnectAsync(frameTimeoutMs: FrameDeadlineMs);
+        using var canceled = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+        var parked = pair.SendAsync(code, canceled.Token);
+        var request = await pair.ReadAsync();
+
+        await canceled.CancelAsync();
+
+        Assert.IsType<OperationCanceledException>((await parked).Error);
+        var cancel = await pair.ReadAsync();
+        Assert.Equal(CommandCodes.CANCEL_DEFERRED_REQUEST_CODE, cancel.Code);
+        Assert.Equal(OPERATION_NON_REPLICATED, cancel.Operation);
+        Assert.Empty(cancel.Body);
+        Assert.Equal(request.RequestId, cancel.RequestId);
+        Assert.Equal(request.ClientId, cancel.ClientId);
+        Assert.Equal(request.Session, cancel.Session);
+        await Task.Delay(3 * FrameDeadlineMs, TestContext.Current.CancellationToken);
+        await pair.ReplyAsync(request, [1]);
+        var next = pair.SendAsync(token: TestContext.Current.CancellationToken);
+        await pair.ReplyAsync(await pair.ReadAsync(), [2]);
+        using var reply = (await next).Response;
+        Assert.NotNull(reply);
+        Assert.Equal([2], reply.Memory.ToArray());
+        Assert.Equal(0, pair.Dropped);
+    }
+
     [Theory]
     [InlineData((byte)0, false)]
     [InlineData((byte)1, true)]
@@ -410,7 +445,8 @@
             {
                 Body = body,
                 ClientId = BinaryPrimitives.ReadUInt128LittleEndian(header.AsSpan(VsrHeader.REQUEST_CLIENT_OFFSET)),
-                RequestChecksum = BinaryPrimitives.ReadUInt128LittleEndian(header.AsSpan(VsrHeader.REQUEST_CHECKSUM_OFFSET))
+                RequestChecksum = BinaryPrimitives.ReadUInt128LittleEndian(header.AsSpan(VsrHeader.REQUEST_CHECKSUM_OFFSET)),
+                Session = BinaryPrimitives.ReadUInt64LittleEndian(header.AsSpan(VsrHeader.REQUEST_SESSION_OFFSET))
             };
         }
 
diff --git a/foreign/csharp/README.md b/foreign/csharp/README.md
index 501826f..b5df3d2 100644
--- a/foreign/csharp/README.md
+++ b/foreign/csharp/README.md
@@ -25,19 +25,19 @@
 ```
 
 The package installation and API examples target server/SDK 0.9.0. For source builds, use the server and SDK
-from the same checkout. The SDK targets .NET 8 and .NET 10; repository examples require .NET 10.
+from the same checkout. The SDK targets .NET 8 and .NET 10. Repository examples require .NET 10.
 `0.9.0` includes the independent message and consumer-offset durability options.
 
-This checkout uses binary protocol 0.12.0. Servers and applicable SDKs must deploy together. Mixed versions and
+This checkout uses binary protocol 0.12.1. Servers and applicable SDKs must deploy together. Mixed versions and
 rolling upgrades are unsupported. This checkout requires fresh storage and refuses older data directories
 without modification. `--fresh` deletes existing data in the configured directory.
 See the [server recovery guide](../../core/server/README.md#upgrade-recovery).
-Register creates a shared logical session; other TCP connections authenticate with BindSession (15), using the
+Register creates a shared logical session. Other TCP connections authenticate with BindSession (15), using the
 parent's identity and proof. A disconnect removes its binding without ending the session. Logout or committed expiry
-ends it; a new session must not replay an unresolved mutation from the old one.
+ends it. A new session must not replay an unresolved mutation from the old one.
 GetPartitionRoute (105) discovers partition primaries. Commands 14, 103, 104 and 123 are retired.
 Ordinary SDK sends and explicit offset mutations support the configured durability, including the Replicated default.
-Crash-safe send retries require Persisted message durability; crash-safe explicit offset retries require Persisted
+Crash-safe send retries require Persisted message durability. Crash-safe explicit offset retries require Persisted
 offset durability and Quorum acknowledgement. Weaker policies can lose data and receipts on a crash. HTTP NoAck's
 202 confirms dispatch, not partition admission or commit, and supplies no recoverable caller receipt.
 
@@ -159,7 +159,7 @@
 If that call fails, including validation, cancellation, timeout, or discovery after registration,
 the tentative session is discarded and automatic sign-in stays disabled until another explicit
 sign-in succeeds. Logout also withdraws configured auto-login. Calls that had not been sent under
-the previous caller session fail as unauthenticated; possibly applied writes retain their uncertainty.
+the previous caller session fail as unauthenticated. Possibly applied writes retain their uncertainty.
 An automatic sign-in rejected with an older password snapshot retries a newer password committed
 by the same caller session.
 
@@ -212,7 +212,7 @@
 
 Over TCP every request is wrapped in a 256-byte consensus header, the client registers a consensus session at
 login, and replicated writes complete after the required quorum acknowledges them.
-`Durability.Persisted` additionally waits for persistence on that quorum; explicit offset stores have
+`Durability.Persisted` additionally waits for persistence on that quorum. Explicit offset stores have
 an independent `ConsumerOffsetDurability` policy. A NoAck success is a weak acknowledgement and does not prove commit.
 Uncertain replicated operations can be resolved through the recovery methods below.
 
@@ -237,7 +237,7 @@
 
 - **Login registers a shared session in this checkout.** `LoginUserAsync` / `LoginWithPersonalAccessTokenAsync`
   authenticate Register. Auxiliary and reconnected transports bind the retained identity and proof without another Register.
-  Disconnecting removes only that binding; Logout, committed lease expiry or user deactivation ends the session.
+  Disconnecting removes only that binding. Logout, committed lease expiry or user deactivation ends the session.
   A `StaleClient` eviction also removes only the binding of that link. The reconnect binds the same session
   again, so its consumer-group memberships stay.
   A committed `StaleClient` result means that the server ended the session, for example because the user was
@@ -253,13 +253,13 @@
   cannot be replayed under a fresh session. The older 0.9.0 connection-lifetime rule does not apply to this checkout.
 - **Replica links are independent.** The client proactively opens one shared session link per roster replica.
   Calls use the advertised metadata or partition primary. Replies are matched by request id, so concurrent
-  reads and different partition groups can progress independently; cancelling one call preserves other calls.
+  reads and different partition groups can progress independently. Cancelling one call preserves other calls.
   With a one-node roster, the client uses `BaseAddress` instead of the address that the node advertises, so a
   port mapping or a port-forward works.
 - **The client picks partitions.** Balanced and message-key partitioning are resolved
   client-side (xxHash32 matches Rust for identical encoded key bytes and partition counts), and consumer-group polls round-robin
   over the partitions the coordinator assigned to this client.
-- **Consumer groups are assignment-based.** `JoinConsumerGroupAsync` makes this client a member; the assignment
+- **Consumer groups are assignment-based.** `JoinConsumerGroupAsync` makes this client a member. The assignment
   is synced on demand and refreshed on every `PingAsync`. Partition counts are cached for 30 seconds, so a topic
   another client widens is picked up without waiting for a ping.
 - **Credentials are bounds-checked locally.** A username outside 3-50 bytes, a password outside 3-100 bytes or a
@@ -267,7 +267,7 @@
 - **`PingAsync` costs more than a ping.** Besides the ping it re-syncs the assignment of every consumer group
   this client has joined, so it makes one extra round trip per joined group. The TCP client pings on its own
   every `HeartbeatInterval` (5 seconds by default) while connected, so an idle session survives the server's
-  heartbeat verification and assignments stay fresh; a lost session is repaired by the regular reconnect and
+  heartbeat verification and assignments stay fresh. A lost session is repaired by the regular reconnect and
   auto login.
 
 ### Retries and failed requests
@@ -295,16 +295,18 @@
 See [metadata freshness and counters](../../core/server/README.md#metadata-freshness-and-counters).
 
 Within one call deadline, the SDK retries an unresolved replicated mutation using its original session,
-request id, context, and frozen frame. Metadata mutations share one lane; partition mutations share a lane
+request id, context, and frozen frame. Metadata mutations share one lane. Partition mutations share a lane
 by incarnation and partition id, including requests using different names for the same partition.
 
 - `IggyInvalidStatusCodeException` carries the server status. `FromServer` distinguishes a server verdict
   from a local error, and `Committed` identifies a proved committed result.
 - `VsrRequestOutcomeUnknownException.OperationId` identifies a retained replicated mutation after its call
   ends without proof. Its lane stays blocked. `VsrLaneBlockedException.OperationId` identifies the operation
-  blocking a subsequent mutation in the same lane; independent groups and pure reads can continue.
+  blocking a subsequent mutation in the same lane. Independent groups and pure reads can continue. Its
+  `StatusCode` is the local status 14007.
+  For a blocked fetch target, its optional `PartitionId` identifies the partition needing repair.
 - `ResolveOperationAsync(operationId, timeout, token)` replays that frozen operation. A proved success or
-  business rejection releases the lane; a rejection throws its original status. Further uncertainty retains it.
+  business rejection releases the lane. A rejection throws its original status. Further uncertainty retains it.
   The client keeps the proved outcomes of its last `MaxPendingOperations` replays, also across a session
   reset. A later resolve of the same operation returns the same outcome without a request.
 - `AbandonOperationAsync(operationId)` releases local ownership without determining the outcome. It does
@@ -315,24 +317,41 @@
   before a session reset, such as logout or session replacement, or an abandon. A proved outcome that aged
   out also causes it. It never proves non-commit and never authorizes an automatic fresh resend.
 
-Auto-commit and `Next` polls hold a transient partition lane but are not retained or replayed after uncertainty.
-Their unknown-outcome error has no operation id. `IggyConsumer` reports it through its error event and polls again.
-Under `AutoCommitMode.Auto`, the server can store the offset of a batch whose reply was lost. A `Next` poll then
-starts after that batch, so the consumer skips it. Behind a retained mutation these polls throw
-`VsrLaneBlockedException`. `IggyConsumer` propagates it without resolving the blocker and ends its polling loop.
-The application decides how to resume. Background `IggyPublisher` sends report unknown outcomes through the
-message-batch-failed event; direct sends throw them to the caller.
+Auto-commit and `Next` polls, offset stores and offset deletes also share a progress lane per partition. Stores
+and deletes hold both lanes of their partition, and sends hold only the partition lane, so a poll that waits on the
+server never delays a send.
 
-`IggyConsumer` offset stores and deletes resolve any blocking operation before their own request. A background
+`Next` polls (100/106) that store nothing hold the progress lane only while they run, and are not retained or
+replayed after uncertainty. Their unknown-outcome error has no operation id. Behind a retained offset store or
+delete these polls throw `VsrLaneBlockedException`, and a retained send does not block them. A poll that commits
+(`CommitPolicy.OnRead` or auto commit) and whose outcome is unknown throws `VsrFetchProgressUncertainException`
+(local status 14010) with its operation id, also when its link drops while it waits. Its partition then refuses
+every poll that moves the consumer and every offset store or delete of this client with
+`VsrPartitionLaneBlockedException` (local status 14009), also after a reconnect or a new sign-in, until
+`AbandonOperationAsync` with that operation id. The server may still store the offset of the abandoned poll.
+
+The `PartitionReader` and `IggyConsumer` read several partitions of one primary in one internal request. When the
+reply of such a read that commits is lost, the client throws `VsrFetchProgressUncertainException` (local status
+14010) inside the reader and blocks those partitions (`VsrPartitionLaneBlockedException`, local status 14009).
+The reader abandons its own lost read, waits its retry interval and reads `Next` again: the server refuses a late
+commit of the old read once the stored offset moved, and a read that finds messages stored by the lost read reads
+them again by offset without a commit, up to the end that the server stored. A read that starts past its position
+while no read was lost, because the application or another session stored that offset, delivers from there. A lost
+range is reported only when the partition moved to another member before it could be read again.
+
+Background `IggyPublisher` sends report unknown outcomes through the message-batch-failed event. Direct sends throw
+them to the caller.
+
+A background
 `IggyPublisher` send resolves only a blocking batch of its own. Both continue after a proved commit or committed
 rejection. The client keeps that outcome for the caller that received the operation id, such as the receiver of
 the message-batch-failed event. That caller reads it with `ResolveOperationAsync`.
 
 `MaxPendingOperations` and `MaxPendingOperationBytes` bound active plus retained mutation frames across the
 client, defaulting to 256 operations and 64 MiB. Admission waits within the call deadline before allocating an
-id or frozen frame. A replay shares its reservation; outstanding writers keep bytes charged until released.
+id or frozen frame. A replay shares its reservation. Outstanding writers keep bytes charged until released.
 Both limits must be positive. The byte limit includes the 256-byte header and the mutation body.
-Smaller positive limits are valid but reject mutations that cannot fit; reads and login remain available.
+Smaller positive limits are valid but reject mutations that cannot fit. Reads and login remain available.
 Reads and control requests have a separate per-link limit of 256 pending calls and a 32-frame write queue.
 Their bodies, including unknown raw non-replicated commands, are outside the mutation byte budget.
 
@@ -352,9 +371,11 @@
 
 Each node answers non-replicated commands such as `GetStats` from its own state, so this reads the state of that node. Replicated commands, polls, consumer-offset reads and session-control codes fail with status 3 before anything is sent. An id that cluster metadata does not name fails with status 6. An unreachable node is retried until the request timeout, and a `NotPrimary` (89) reply is returned rather than sent to another node.
 
+The deferred poll (106), deferred cancel (108) and fetch (109) codes fail with status 3 in `SendBinaryRequestAsync` and `SendBinaryRequestToReplicaAsync`. Use `PollMessagesAsync` with `PollOptions` or a `PartitionReader` instead.
+
 ### Limitations
 
-- TCP uses VSR framing; HTTP uses the separate REST API. Join/leave, `GetMeAsync`, segment deletion,
+- TCP uses VSR framing. HTTP uses the separate REST API. Join/leave, `GetMeAsync`, segment deletion,
   and raw binary requests, also to a chosen replica, are TCP-only and throw `FeatureUnavailableException` on HTTP.
 - `StoreOffsetAsync` / `DeleteOffsetAsync` need an explicit partition id under VSR: the broker does not
   resolve a `null` partition for a consumer-offset request, so passing one throws client-side.
@@ -365,12 +386,24 @@
 
 - `IIggyPublisher.FlushUnsavedBufferAsync` was removed. Set `TopicOptions.Durability` to
   `Durability.Persisted` when creating a topic to wait for message persistence.
+- `IggyConsumerConfig.PollingIntervalMs` and `IggyConsumerBuilder.WithPollingInterval` were removed. The
+  consumer does not sleep after an empty poll. It waits on the server instead, up to `PollOptions.MaxWait`.
+- `IggyConsumer` runs on a `PartitionReader`. `IggyConsumerConfig.PollOptions` is a `PollOptions` whose
+  `CommitPolicy` replaces `AutoCommit` and `AutoCommitMode` (`AutoCommitMode.Auto` is `CommitPolicy.OnRead`,
+  `AfterReceive` is `CommitPolicy.Auto` with `CommitAfter.Processing`, `Disabled` is `CommitPolicy.Manual`).
+  `PollOptions.Count` replaces `BatchSize`, `Partitions` replaces `PartitionId`, and `JoinConsumerGroup` is
+  gone because a group consumer always joins. The consumer's `StoreOffsetAsync` and `DeleteOffsetAsync` gave way
+  to `CommitAsync(Checkpoint)`. A standalone consumer needs its partitions.
+- `IIggyConsumer` has `PollMessagesAsync` and `PollMessagesRentedAsync` overloads that take a required partition
+  and `PollOptions`. Their default implementations throw `FeatureUnavailableException`. `IIggyOffset` has
+  `StoreConsumerPositionAsync` and `DeleteConsumerPositionAsync`, which take a `Checkpoint`.
 - `MaxResponseFrameSize` bounds the reply frames the **VSR** reader accepts. A reply larger than the 64 MiB
   default is refused and the connection is dropped, so raise it if a single response legitimately exceeds that
-  - a large `GetSnapshotAsync` is the usual case.
+  - a large `GetSnapshotAsync` is the usual case. A read returns its first message even above its byte limit,
+  and the default holds the largest message with its headers, so do not set it below 64,156,564 bytes.
 - Clients built with `IggyConsumerBuilder` / `IggyPublisherBuilder` now auto-login with the credentials passed
   to `WithConnection`. Before, a builder-created client came back from a
-  reconnect unauthenticated; now the credentials are held for the lifetime of the connection and replayed.
+  reconnect unauthenticated. Now the credentials are held for the lifetime of the connection and replayed.
 - The TCP client pings each ready replica every `HeartbeatInterval` (5 seconds). Each replica reconnects
   independently, with at most four concurrent dials, a two-second dial and TLS deadline, and jittered
   backoff capped at ten seconds. `MaxRetries` bounds one burst of consecutive retries per replica, and zero
@@ -670,6 +703,129 @@
 PollingStrategy.Next();
 ```
 
+### Polling One Partition
+
+`PollMessagesAsync` with `PollOptions` reads one batch of one partition. It waits on the server until `MinCount`
+messages are ready, the reply reaches `MaxBytes`, or `MaxWait` ends, and returns the messages that are ready,
+which can be none:
+
+```c#
+var options = new PollOptions { Count = 100, MaxWait = TimeSpan.FromSeconds(5) };
+var polled = await client.PollMessagesAsync(streamId, topicId, 0, Consumer.New(1), PollingStrategy.Next(), options);
+
+foreach (var message in polled.Messages)
+{
+    Console.WriteLine(Encoding.UTF8.GetString(message.Payload));
+}
+
+if (polled.Checkpoint is { } checkpoint)
+{
+    await client.StoreConsumerPositionAsync(Consumer.New(1), streamId, topicId, checkpoint);
+}
+
+var next = await client.PollMessagesAsync(streamId, topicId, 0, Consumer.New(1), polled.Continuation, options);
+```
+
+| Option     | Default  | Rule                                                                               |
+| ---------- | -------- | ---------------------------------------------------------------------------------- |
+| `Count`    | `1000`   | Most messages of one batch                                                         |
+| `MaxWait`  | `0`      | 0 to 600 seconds. Zero replies at once. The call ends at `MaxWait` plus 10 seconds |
+| `MinCount` | `1`      | 1 to `Count`, else status 4009                                                     |
+| `MaxBytes` | `1 MiB`  | At least 344 bytes, else status 80                                                 |
+| `Commit`   | `Manual` | Who stores the offset, see below                                                   |
+
+- `CommitPolicy.Manual` stores nothing. Commit `PolledMessages.Checkpoint`, or `CheckpointAt(index)` for a part
+  of the batch, with `StoreConsumerPositionAsync`. A checkpoint keeps the partition history that delivered it,
+  so a commit after the partition was deleted and recreated fails with status 87.
+- `CommitPolicy.OnRead` makes the server store the offset of the last message of the read, as part of the read,
+  in one round trip. While the primary stays up a message is delivered at most once. A failover before the store
+  replicates can deliver the last batch again. A one-shot poll refuses `CommitPolicy.Auto` with status 2
+  (`InvalidConfiguration`): it has nothing to count.
+- `PolledMessages.Continuation` continues the partition after the batch, under its context. An empty batch keeps
+  the start of its read, so `Next` stays `Next`. A poll of another partition refuses the continuation with
+  status 3.
+- A first message larger than `MaxBytes` comes alone in its read. A `Last` read of more than one message that
+  starts with such a message fails with status 80 and stores nothing.
+- A client keeps at most 64 polls parked on the server. A poll over the limit waits in the client for a free
+  place, within its own request timeout.
+- If you cancel a parked poll, the client sends a cancel on the same link. A poll that stores nothing ends with
+  `OperationCanceledException`, or with `VsrRequestOutcomeUnknownException` for a `Next` poll. A poll that
+  commits keeps its reply: the server answers it once, refused when the cancel won and nothing was stored, so
+  the call ends with `OperationCanceledException`, or with the messages it stored before the cancel arrived,
+  which the call returns.
+- A parked poll that can move an offset holds the progress lane of its partition for up to `MaxWait`. The offset
+  stores and deletes of the same client on that partition wait for it, and its sends do not.
+- Over HTTP, a poll uses `.../messages/deferred`, and a poll that commits is a `POST`, which no client or proxy
+  sends again. The server refuses a `GET` that asks for auto commit. The SDK `HttpClient` times out after 640
+  seconds, above the longest request timeout. A committing poll whose outcome is unknown (a 5xx other than 503,
+  a 4xx whose error does not prove that nothing was stored, a transport failure after the connect, or a cancel
+  while it is on the wire) throws `VsrFetchProgressUncertainException`, and its partition refuses progress polls
+  and offset stores or deletes with `VsrPartitionLaneBlockedException` until `AbandonOperationAsync`. The block
+  covers calls that spell the stream and the topic the same way, and calls whose context names the same
+  incarnation.
+
+### Partition Reader
+
+`PartitionReader` reads many partitions in batches and keeps the position of each. Over TCP it reads the
+partitions of one primary in one request and finds a moved primary by itself. Over HTTP it reads each
+partition with its own poll.
+
+```c#
+await using var reader = await client.Reader(streamId, topicId, Consumer.New(1))
+    .WithPartitions(0, 1, 2)
+    .WithOptions(new PollOptions { MaxWait = TimeSpan.FromSeconds(5) })
+    .BuildAsync();
+
+while (true)
+{
+    try
+    {
+        var batch = await reader.NextAsync();
+        Process(batch);
+        await reader.CommitAsync(batch.Checkpoint!);
+    }
+    catch (ReadException error) when (error.Skip is { } skip)
+    {
+        // A message that does not decode. The reader reads it again until a seek passes it.
+        await reader.SeekAsync(error.PartitionId!.Value, skip);
+    }
+}
+```
+
+- A consumer group reads the assignment of its member: `client.Reader(streamId, topicId, Consumer.Group("g"))`
+  joins the group, follows rebalances and leaves the group in `CloseAsync`. Over HTTP a group reader is not
+  available.
+- `NextAsync` returns one batch of one partition, never empty. A canceled call leaves the batches in the
+  reader. `ReadException` names the partition of an error, and the reader retries by itself. Status 87 stops a
+  partition until `SeekAsync` when its position came from an earlier read, and so do status 80 and a refusal
+  that six retries did not change. Status 5009 (`ConsumerGroupPartitionNotOwned`) also drops the position of its
+  partition, which reads again from the start of the reader with the owner generation of a new route.
+- For a message that does not decode, `ReadException` carries `Skip`, `Offset` and `Record`. `Record` is the
+  record as the reply carried it, its 48-byte frame header, payload and user headers before decryption, so a
+  message that does not decrypt can be kept or inspected. When the framing of the reply is malformed, `Offset`
+  is null and `Record` holds the remaining reply bytes from where decoding stopped. Over HTTP a message that does
+  not decrypt has an `Offset` and no `Record`, since a JSON reply carries no records.
+- With `CommitPolicy.Manual`, `CommitAsync` stores a checkpoint. With `CommitPolicy.OnRead` the server stores the
+  offset of each read, the reader reads only while `NextAsync` waits, and `CloseAsync` moves the offset back to
+  the last batch the application took. A read whose reply was lost is repaired: the next read finds the messages
+  that it stored, and the reader reads them again by offset without a commit, up to the end that the server
+  stored. `CloseAsync` also moves the offset back before a lost read that nobody took. A batch that the server
+  stored is handed over even when its partition left the assignment of the member. A `SeekAsync` to an offset
+  above 0 first stores the offset before it, so a restart resumes at the sought offset, and a refused store fails
+  the seek and moves nothing. Every other `SeekAsync` stores nothing.
+- With `CommitPolicy.Auto(new AutoCommit { Interval, Every, After })` the reader counts each batch, when
+  `NextAsync` returns it (`CommitAfter.Delivery`), at the next `NextAsync` or `CloseAsync`
+  (`CommitAfter.Processing`), or on `Mark` (`CommitAfter.Marked`), and stores the latest counted position at the
+  interval, on the event (`CommitEvery.Message`, `Messages(n)` or `Batch`), and in `CloseAsync`.
+  `AutoCommit.Default` stores every 10,000 messages or 5 seconds after processing.
+- The reader reads ahead up to 16 MiB and 16,000 messages, set with `WithPrefetch`. Without options a read waits
+  up to 5 seconds. A fetch puts the partitions that it served last at its end, so each partition gets its turn.
+  After a refused read or a failed refresh the reader waits its retry interval.
+- A partition whose last read ended before its head reads in a fetch of its own, with the full `Count` and
+  `MaxBytes`, while the prefetch holds that many reads (up to 16 at once). The quiet partitions of a primary share
+  one fetch, which waits on the server. With `CommitPolicy.OnRead` the reader starts those fetches only while
+  `NextAsync` waits, and each partition is in one committing read at a time.
+
 ## Offset Management
 
 ### Storing Offsets
@@ -686,6 +842,10 @@
 );
 ```
 
+`StoreConsumerPositionAsync` stores the offset of a `Checkpoint` under the partition history that delivered its
+message, so a store after the partition was deleted and recreated fails with status 87 instead of landing on
+the new partition.
+
 ### Retrieving Offsets
 
 Get the current stored offset:
@@ -861,7 +1021,7 @@
 
 ### Segment Management
 
-Delete up to N of the oldest sealed segments from a partition; the active segment is retained:
+Delete up to N of the oldest sealed segments from a partition. The active segment is retained:
 
 ```c#
 await client.DeleteSegmentsAsync(
@@ -952,12 +1112,14 @@
 
 ## Advanced: IggyConsumer
 
-High-level consumer with automatic offset management and consumer groups:
+High-level consumer on a `PartitionReader`, one message at a time, standalone over partitions or as a member of
+a consumer group:
 
 ```c#
 using System.Text;
 using Apache.Iggy;
 using Apache.Iggy.Consumers;
+using Apache.Iggy.Contracts;
 using Apache.Iggy.Kinds;
 
 await using var consumer = IggyConsumerBuilder.Create(
@@ -966,9 +1128,9 @@
     Identifier.String("my-topic"),
     Consumer.New(1)
 )
+.WithPartitions(0, 1)
 .WithPollingStrategy(PollingStrategy.Next())
-.WithBatchSize(10)
-.WithAutoCommitMode(AutoCommitMode.Auto)
+.WithPollOptions(new PollOptions { MaxWait = TimeSpan.FromSeconds(5) })
 .Build();
 
 await consumer.InitAsync();
@@ -977,10 +1139,11 @@
 {
     var payload = Encoding.UTF8.GetString(message.Message.Payload);
     Console.WriteLine($"Offset {message.CurrentOffset}: {payload}");
+    await consumer.CommitAsync(message.Checkpoint!);
 }
 ```
 
-For consumer groups (load-balanced across multiple consumers):
+For consumer groups (load-balanced across multiple consumers), with the client storing the offsets:
 
 ```c#
 await using var consumer = IggyConsumerBuilder.Create(
@@ -989,27 +1152,36 @@
     Identifier.String("my-topic"),
     Consumer.Group("my-group")
 )
-.WithConsumerGroup("my-group", createIfNotExists: true, joinGroup: true)
+.WithConsumerGroup("my-group", createIfNotExists: true)
 .WithPollingStrategy(PollingStrategy.Next())
-.WithAutoCommitMode(AutoCommitMode.AfterReceive)
+.WithPollOptions(new PollOptions { MaxWait = TimeSpan.FromSeconds(5), Commit = CommitPolicy.Auto() })
 .Build();
 
 await consumer.InitAsync();
 
-await foreach (var message in consumer.ReceiveAsync())
+await consumer.ConsumeAsync(async (message, token) =>
 {
-    var payload = Encoding.UTF8.GetString(message.Message.Payload);
-    Console.WriteLine($"Partition {message.PartitionId}: {payload}");
-}
+    await HandleAsync(message, token);
+});
 ```
 
-Disposal cancels and drains pending offset stores, then checkpoints the last message delivered
-from each partition before leaving the group. Buffered messages are excluded, and each checkpoint
-keeps the delivered message's partition context. `AutoCommitMode.Disabled` skips this final
-checkpoint. The final stores share one 30-second budget; failures are logged and cleanup continues.
-Disposing from inside a receive loop is supported, including rented and deserialized messages.
+`CommitPolicy` decides who stores the offsets:
 
-Under `AutoCommitMode.AfterReceive` a message counts as delivered once its loop body completes. A message whose body threw, broke or was still running when the consumer was disposed is not checkpointed and is delivered again.
+- `CommitPolicy.Manual` (default): the application commits `ReceivedMessage.Checkpoint` with `CommitAsync`.
+- `CommitPolicy.OnRead`: the server stores the offset of each read. The consumer reads only while the loop
+  waits, and `ShutdownAsync` moves the offset back to the last message handed over. A consumer with an encryptor
+  refuses it, because a batch that does not decrypt would be stored.
+- `CommitPolicy.Auto`: the consumer counts each message and stores the latest counted position at the interval
+  or the event of the policy, before a rebalance takes a partition away, and on shutdown. With
+  `CommitAfter.Delivery` a message counts when it is handed over. With `CommitAfter.Processing` it counts once
+  `ReceiveAsync` is read again, or once the handler of `ConsumeAsync` returns with success. A handler that throws
+  ends `ConsumeAsync` with its error, and its message does not count, so it is delivered again. With
+  `CommitAfter.Marked` only `Mark(checkpoint)` counts.
+
+A read error does not end the loop: the reader retries by itself and the error reaches `SubscribeToErrorEvents`.
+`DisposeAsync` calls `ShutdownAsync`. It waits for a handler of `ConsumeAsync` that runs, unless that handler calls
+it, counts the last message of `ReceiveAsync` under `CommitAfter.Processing`, stores or rewinds the offsets, and
+leaves the group.
 
 For automatic deserialization:
 
@@ -1022,7 +1194,7 @@
     new OrderDeserializer()
 );
 builder.WithPollingStrategy(PollingStrategy.Next());
-builder.WithAutoCommitMode(AutoCommitMode.AfterReceive);
+builder.WithPollOptions(new PollOptions { Commit = CommitPolicy.Auto() });
 
 await using var consumer = builder.Build();
 await consumer.InitAsync();
diff --git a/foreign/go/README.md b/foreign/go/README.md
index 561769e..3f0d27d 100644
--- a/foreign/go/README.md
+++ b/foreign/go/README.md
@@ -205,8 +205,10 @@
 
 A sign-in, a logout or a disconnect by the application ends every call that
 started before it. A call that did not change server state fails with 40. A
-mutation that possibly changed server state ends uncertain. No call goes out
-under a session that the application opened after the call started.
+mutation that possibly changed server state ends uncertain. A fetch with
+auto-commit whose reply arrived returns its sections, because the server stored
+their offsets. No call goes out under a session that the application opened
+after the call started.
 
 A reply with status 30 (`StaleClient`) can deny one attempt, because the server
 no longer attaches the link to the session. Usually the session ended, for
@@ -234,7 +236,7 @@
 
 `SendBinaryRequestToReplica(ctx, code, payload, replicaID)` sends a raw command to one node of a cluster, named by the replica id that cluster metadata returns.
 
-Each node answers non-replicated commands such as `GetStats` from its own state, so this reads the state of that node. Replicated commands, polls, consumer-offset reads and session-control codes fail with `ErrInvalidCommand` before anything is sent. An id that cluster metadata does not name fails with `ErrInvalidIdentifier`. An unreachable node is retried until the request deadline, and `ErrNotPrimary` is returned rather than sent to another node.
+Each node answers non-replicated commands such as `GetStats` from its own state, so this reads the state of that node. Replicated commands, polls, fetches, deferred-request cancellations, consumer-offset reads and session-control codes fail with `ErrInvalidCommand` before anything is sent. An id that cluster metadata does not name fails with `ErrInvalidIdentifier`. An unreachable node is retried until the request deadline, and `ErrNotPrimary` is returned rather than sent to another node.
 
 If a mutation remains uncertain when its call ends, `errors.As` exposes
 `errors.OperationUncertain` with an exact `uint64` `OperationID`. The SDK retains
@@ -308,14 +310,105 @@
 this wire-contract cutover. The server refuses the previous storage format;
 there is no implicit migration or fallback to retired routing commands.
 
-`Next` and auto-commit polls can advance progress before replying. A 57 reply,
-a lost reply or an eviction after sending ends that call immediately with 57,
-releases its transient lane barrier, and creates no retained operation. A later
-application poll is a new operation and may skip messages whose progress already
-advanced. Pure reads retry within their deadline. Known non-admission replies 58
+`Next` polls and polls with `iggcon.OnReadCommit()` can advance progress before replying. A 57 reply, a lost reply or an eviction after sending ends a `Next` poll that stores nothing with 57, and a poll that stores its offset with `errors.FetchProgressUncertain` (14010) naming the operation, because the server can have stored it.
+
+An error reply of a poll or fetch that stores its offset is uncertain too, unless its status proves that nothing was stored (the `commit_outcomes` table of the shared deferred polling vectors).
+
+A `Next` poll that stores nothing releases its lane barrier and creates no retained operation. A poll or fetch that stores its offset and ends uncertain keeps the progress of its partitions blocked: the next poll or fetch of the partition that stores its offset and every offset store or delete of it fail with `errors.PartitionLaneBlocked` (14009) naming the operation, also after a reconnect and a new session of the same client, until `AbandonOperation(operation)`.
+
+A later poll from `Next` starts after what the server stored. Pure reads retry within their deadline. Known non-admission replies 58
 and 89 can retry without changing the frozen request. Manual offset writes are
 replicated mutations and use the exact-ID recovery API above.
 
+Each partition also has a progress lane, which admits one call at a time in
+arrival order. Progress polls and consumer offset reads, stores and deletes
+take it. Sends take only the mutation lane of the partition, so a parked
+progress poll does not delay a send of the same client. An offset store or
+delete takes both lanes. If it stays uncertain, it blocks the later progress
+polls and offset calls of its partition with `errors.LaneBlocked` until you
+resolve or abandon it. An uncertain send blocks only later mutations.
+
+## Reading messages
+
+The SDK reads messages at three levels: `PollMessages` reads one partition, a `reader.PartitionReader` reads many partitions in batches, and a `reader.IggyConsumer` hands over one message at a time. All three take `iggcon.PollOptions`, whose `CommitPolicy` says who stores the consumer offsets.
+
+### Poll one partition
+
+`PollMessages` lets the server hold the request until messages arrive. It waits up to `MaxWait` for `MinCount` messages and returns at most `Count` messages and `MaxBytes` of records. The call ends after `MaxWait` plus 10 s, or at the deadline of its context.
+
+```go
+options := iggcon.WaitPollOptions(5 * time.Second) // 1000 messages, 1 MiB, nothing stored
+options.Commit = iggcon.OnReadCommit()
+polled, err := client.PollMessages(ctx, stream, topic, 0, iggcon.DefaultConsumer(),
+    iggcon.NextPollingStrategy(), options)
+```
+
+`iggcon.ManualCommit()` (the default) stores nothing, and the application stores a checkpoint with `StoreConsumerPosition`. `iggcon.OnReadCommit()` makes the server store the offset of the last message it returns as part of the read, in one round trip. While the primary stays up a message is delivered at most once. A failover before the store replicates can deliver the last batch again.
+
+A poll of one partition refuses `iggcon.AutoCommitPolicy(...)` with `errors.ErrInvalidConfiguration`, because it has nothing to count. A reader and a consumer accept it.
+
+A first message larger than `MaxBytes` comes alone in the reply, up to `iggcon.MaxRecordSize`. A `Last` poll of more than one message whose first message is larger than `MaxBytes` fails with `errors.ErrInvalidSizeBytes`. `PolledMessage.Checkpoint` and `CheckpointAt` name the position after a message with the history of its partition.
+
+`PolledMessage.Continuation` is the strategy after the last message, bound to its partition, and a poll or a seek of another partition refuses it with `errors.ErrInvalidCommand`.
+
+When the context of a poll with `OnReadCommit` is cancelled, the SDK cancels the poll on the server and waits for its one reply: refused with `errors.ErrTransientNotAccepted` (58) when the cancel won and nothing was stored, or with what the server read and stored, which the call returns. Until then the offset calls of the partition wait, so they never race a late store.
+
+A reply that the connection loses ends the call with `errors.FetchProgressUncertain` (14010) and blocks the progress of the partition until `AbandonOperation`, as above. After the abandon the next `Next` poll starts after what the lost read stored. The client holds at most 64 deferred polls at once, a call waits for a free slot within its deadline, and `Close` waits for the reply of each poll that stores its offset.
+
+### Partition reader
+
+`reader.NewPartitionReader` reads the partitions of a topic in batches.
+
+It groups the partitions by their primary and reads the partitions of a group together.
+
+It puts the partitions that a read served last at the end of the next one so a busy partition does not starve the others, and spreads the partitions that hold more messages than their last read returned over the free prefetch slots, one read each with the whole `Count` and `MaxBytes`, while the quiet partitions of a group share one read that waits on the server.
+
+Under `OnReadCommit` every read stores, so a group holds one read that waits at a time, and a waiting `Next` fills the free slots with the backlogged partitions, one committing read per partition.
+
+It regroups after a primary moves, keeps a prefetch, and stays usable after an error.
+
+A standalone consumer names its partitions with `reader.WithPartitions`. A consumer group reader joins its group, follows its assignment and leaves the group in `Close`. A later or concurrent `Close` waits for the first one and returns its result.
+
+```go
+partitionReader, err := reader.NewPartitionReader(ctx, client, stream, topic, iggcon.DefaultConsumer(),
+    reader.WithPartitions(0, 1, 2), reader.WithStart(iggcon.FirstPollingStrategy()))
+batch, err := partitionReader.Next(ctx)
+checkpoint, _ := batch.Checkpoint()
+err = partitionReader.Commit(ctx, checkpoint)
+err = partitionReader.Close(ctx)
+```
+
+`Next` returns the next batch of one partition, never empty, in offset order within one generation of the partition: from its first read, from each `Seek`, and from each new assignment. A cancelled `Next` leaves its batch in the reader. The default wait of a reader is 5 s (`reader.DefaultMaxWait`).
+
+- `ManualCommit`: `Commit` stores the offset of a delivered checkpoint, forward only within a generation.
+- `OnReadCommit`: the server stores each read, and the reader reads only for a waiting `Next`. After a read whose reply was lost, the reader abandons it, waits its retry interval, and its next read starts after what the lost read stored. The reader reads those messages again without a store and delivers them first. A forward jump without such a lost read is a store of the application or of another session, and the reader goes on from there.
+
+  A partition that leaves the assignment still hands over the reads that the server stored for it. A `Seek` to an offset above 0 stores the offset before it once the read in flight ends, also when `Close` starts first, and a seek to offset 0 or to another strategy stores nothing. A refused store fails the seek, or the close when it ran there. `Close` moves the offset of each partition with undelivered batches back to its last delivered message. `Commit` and `Mark` are refused.
+- `AutoCommitPolicy(auto)`: the reader counts each batch as `auto.After` says (`CommitAfterProcessing` at the next `Next` or `Close`, `CommitAfterDelivery` when `Next` returns it, `CommitAfterMarked` on `Mark`), and stores the latest counted position at `auto.Interval`, on `auto.Every`, before a rebalance takes a partition away, and in `Close`. `iggcon.DefaultAutoCommit()` stores every 10,000 messages or 5 s after processing.
+
+A `*reader.ReadError` names its partition. `errors.ErrHistoryUnavailable` stops a partition until `Seek` when its position came from an earlier read of a history that is gone. A section that does not decode is read again one message at a time: every good message is delivered, and a message that does not decode stops its partition with `Skip`, the continuation after it.
+
+`errors.ErrConsumerGroupPartitionNotOwned` reads the partition again from its stored offset, and six in a row are reported. A refusal that a retry does not change is retried with a doubling wait and stops its partition after six in a row.
+
+### Consumer
+
+`reader.NewIggyConsumer` hands over one message at a time from a reader, as `Next`, as an iterator from `Messages`, or to a handler in `Consume`. Under `CommitAfterProcessing` a message counts when the next one is asked for, or after its handler succeeds in `Consume`. A handler that fails leaves its message uncounted and ends `Consume` with its error. `Consume` also ends when its context ends, between the messages of a batch too.
+
+`Shutdown` stops `Consume`, counts the last message, stores, moves `OnRead` offsets back and leaves the group.
+
+```go
+options := iggcon.WaitPollOptions(5 * time.Second)
+options.Commit = iggcon.AutoCommitPolicy(iggcon.DefaultAutoCommit())
+consumer, err := reader.NewIggyConsumer(ctx, client, stream, topic, iggcon.NewGroupConsumer(group),
+    reader.WithPollOptions(options))
+err = consumer.Consume(ctx, func(ctx context.Context, message reader.ReceivedMessage) error {
+    return process(message.Message)
+})
+err = consumer.Shutdown(ctx)
+```
+
+`SendBinaryRequest` sends codes 106 and 109 as they are and refuses code 108. External implementations, wrappers and mocks of `contracts.Client` implement `PollMessages` with its options.
+
 ## Testing
 
 Unit tests need nothing running:
diff --git a/foreign/go/binary_serialization/binary_response_deserializer.go b/foreign/go/binary_serialization/binary_response_deserializer.go
index bde3b5a..080bf74 100644
--- a/foreign/go/binary_serialization/binary_response_deserializer.go
+++ b/foreign/go/binary_serialization/binary_response_deserializer.go
@@ -201,43 +201,145 @@
 	if err := partitionContext.UnmarshalBinary(payload[16:pollPrefixLength]); err != nil {
 		return nil, err
 	}
-	position := pollPrefixLength
+	messages, err := decodeRecords("poll response", payload[pollPrefixLength:], messagesCount, compression)
+	if err != nil {
+		return nil, err
+	}
 
+	return &iggcon.PolledMessage{
+		PartitionId:   partitionId,
+		CurrentOffset: currentOffset,
+		Messages:      messages,
+		MessageCount:  messagesCount,
+		Context:       partitionContext,
+	}, nil
+}
+
+// The fields of a section header of a fetch reply, by offset: partition id,
+// status, current offset, message count, partition context, next offset and
+// records length.
+const (
+	sectionStatusAt        = 4
+	sectionCurrentOffsetAt = 8
+	sectionMessageCountAt  = 16
+	sectionContextAt       = 20
+	sectionNextOffsetAt    = sectionContextAt + iggcon.PartitionContextSize
+	sectionRecordsLengthAt = sectionNextOffsetAt + 8
+	fetchSectionHeaderSize = sectionRecordsLengthAt + 4
+	fetchSectionsCountSize = 4
+)
+
+// DeserializeFetchSections decodes a fetch reply,
+// [sections_count u32][section header x n][records of each section], into
+// its sections in reply order. The records of each section stay encoded and
+// alias payload, and each section keeps compression for their decode. A
+// refused section carries a nonzero status and no records, and the records
+// of all sections fill the body exactly.
+func DeserializeFetchSections(payload []byte, compression iggcon.IggyMessageCompression) ([]iggcon.FetchMessagesSection, error) {
+	if len(payload) < fetchSectionsCountSize {
+		return nil, fmt.Errorf("fetch response: %d bytes is short of the section count", len(payload))
+	}
+	count := binary.LittleEndian.Uint32(payload)
+	if count > iggcon.MaxPartitionsPerRequest {
+		return nil, fmt.Errorf("fetch response: %d sections exceed %d", count, iggcon.MaxPartitionsPerRequest)
+	}
+	records := fetchSectionsCountSize + int(count)*fetchSectionHeaderSize
+	if len(payload) < records {
+		return nil, fmt.Errorf("fetch response: %d section headers do not fit %d bytes", count, len(payload))
+	}
+	sections := make([]iggcon.FetchMessagesSection, 0, count)
+	for header := payload[fetchSectionsCountSize:records]; len(header) > 0; header = header[fetchSectionHeaderSize:] {
+		section := iggcon.FetchMessagesSection{
+			PartitionId:   binary.LittleEndian.Uint32(header),
+			CurrentOffset: binary.LittleEndian.Uint64(header[sectionCurrentOffsetAt:]),
+			MessageCount:  binary.LittleEndian.Uint32(header[sectionMessageCountAt:]),
+			NextOffset:    binary.LittleEndian.Uint64(header[sectionNextOffsetAt:]),
+			Compression:   compression,
+		}
+		if err := section.Context.UnmarshalBinary(header[sectionContextAt:sectionNextOffsetAt]); err != nil {
+			return nil, err
+		}
+		length := binary.LittleEndian.Uint32(header[sectionRecordsLengthAt:])
+		if status := binary.LittleEndian.Uint32(header[sectionStatusAt:]); status != 0 {
+			if length != 0 {
+				return nil, fmt.Errorf("fetch response: refused partition %d carries %d record bytes", section.PartitionId, length)
+			}
+			section.Err = ierror.FromCode(ierror.Code(status))
+		}
+		if uint64(length) > uint64(len(payload)-records) {
+			return nil, fmt.Errorf("fetch response: %d record bytes of partition %d overrun the body", length, section.PartitionId)
+		}
+		section.Records = payload[records : records+int(length)]
+		records += int(length)
+		sections = append(sections, section)
+	}
+	if records != len(payload) {
+		return nil, fmt.Errorf("fetch response: %d bytes follow the last section", len(payload)-records)
+	}
+	return sections, nil
+}
+
+// DeserializeSectionMessages decodes the records of a served fetch section
+// as DeserializeFetchMessagesResponse decodes a poll reply, with the
+// compression of the section and the same aliasing. A refused section
+// returns its Err.
+func DeserializeSectionMessages(section iggcon.FetchMessagesSection) (*iggcon.PolledMessage, error) {
+	if section.Err != nil {
+		return nil, section.Err
+	}
+	messages, err := decodeRecords("fetch section", section.Records, section.MessageCount, section.Compression)
+	if err != nil {
+		return nil, err
+	}
+	return &iggcon.PolledMessage{
+		PartitionId:   section.PartitionId,
+		CurrentOffset: section.CurrentOffset,
+		Messages:      messages,
+		MessageCount:  section.MessageCount,
+		Context:       section.Context,
+	}, nil
+}
+
+// decodeRecords decodes the batch records of the reply named reply, which
+// declares messagesCount messages.
+func decodeRecords(reply string, records []byte, messagesCount uint32, compression iggcon.IggyMessageCompression) ([]iggcon.IggyMessage, error) {
+	length := len(records)
 	// The declared count is server-controlled; the allocation hint is capped
 	// by what the body could possibly hold.
-	maxMessages := (length - pollPrefixLength) / batch.MessageHeaderSize
+	maxMessages := length / batch.MessageHeaderSize
 	if int(messagesCount) < maxMessages {
 		maxMessages = int(messagesCount)
 	}
 	messages := make([]iggcon.IggyMessage, 0, maxMessages)
+	position := 0
 	for position < length {
-		record, err := batch.DecodeHeader(payload[position:])
+		record, err := batch.DecodeHeader(records[position:])
 		if err != nil {
-			return nil, fmt.Errorf("poll response: %w", err)
+			return nil, fmt.Errorf("%s: %w", reply, err)
 		}
 		if record.BatchLength > uint64(length-position) {
 			return nil, fmt.Errorf(
-				"poll response: batch record of %d bytes overruns the body", record.BatchLength)
+				"%s: batch record of %d bytes overruns the body", reply, record.BatchLength)
 		}
 		recordEnd := position + int(record.BatchLength)
 		cursor := position + batch.HeaderSize
 		for cursor < recordEnd {
-			frame, err := batch.DecodeMessageHeader(payload[cursor:recordEnd])
+			frame, err := batch.DecodeMessageHeader(records[cursor:recordEnd])
 			if err != nil {
-				return nil, fmt.Errorf("poll response: %w", err)
+				return nil, fmt.Errorf("%s: %w", reply, err)
 			}
 			payloadStart := cursor + batch.MessageHeaderSize
 			payloadEnd := payloadStart + int(frame.PayloadLength)
 			userHeadersEnd := payloadEnd + int(frame.UserHeadersLength)
 			if userHeadersEnd > recordEnd {
 				return nil, fmt.Errorf(
-					"poll response: message of %d payload and %d user-header bytes overruns the batch record",
-					frame.PayloadLength, frame.UserHeadersLength)
+					"%s: message of %d payload and %d user-header bytes overruns the batch record",
+					reply, frame.PayloadLength, frame.UserHeadersLength)
 			}
-			payloadSlice := payload[payloadStart:payloadEnd]
+			payloadSlice := records[payloadStart:payloadEnd]
 			var userHeaders []byte
 			if frame.UserHeadersLength > 0 {
-				userHeaders = payload[payloadEnd:userHeadersEnd]
+				userHeaders = records[payloadEnd:userHeadersEnd]
 			}
 			cursor = userHeadersEnd
 
@@ -272,17 +374,10 @@
 	}
 	if uint32(len(messages)) != messagesCount {
 		return nil, fmt.Errorf(
-			"poll response: %d decoded messages do not match the declared %d",
-			len(messages), messagesCount)
+			"%s: %d decoded messages do not match the declared %d",
+			reply, len(messages), messagesCount)
 	}
-
-	return &iggcon.PolledMessage{
-		PartitionId:   partitionId,
-		CurrentOffset: currentOffset,
-		Messages:      messages,
-		MessageCount:  messagesCount,
-		Context:       partitionContext,
-	}, nil
+	return messages, nil
 }
 
 // optionSpecMinimumSize is the smallest catalog entry: a one-character name
@@ -969,19 +1064,25 @@
 		return nil, fmt.Errorf("group assignment: %d bytes is short of the header", len(payload))
 	}
 
-	count := binary.LittleEndian.Uint32(payload[8:12])
-	body := payload[assignmentHeaderLength:]
-	if uint64(len(body)) != uint64(count)*4 {
-		return nil, fmt.Errorf(
-			"group assignment: %d partitions do not fill %d body bytes", count, len(body))
-	}
-
-	partitions := make([]uint32, 0, count)
-	for offset := 0; offset < len(body); offset += 4 {
-		partitions = append(partitions, binary.LittleEndian.Uint32(body[offset:offset+4]))
+	partitions, err := partitionIds("group assignment",
+		binary.LittleEndian.Uint32(payload[8:12]), payload[assignmentHeaderLength:])
+	if err != nil {
+		return nil, err
 	}
 	return &iggcon.ConsumerGroupAssignment{
 		Generation: binary.LittleEndian.Uint64(payload[0:8]),
 		Partitions: partitions,
 	}, nil
 }
+
+// partitionIds decodes the count partition ids that fill body.
+func partitionIds(reply string, count uint32, body []byte) ([]uint32, error) {
+	if uint64(len(body)) != uint64(count)*4 {
+		return nil, fmt.Errorf("%s: %d partitions do not fill %d body bytes", reply, count, len(body))
+	}
+	partitions := make([]uint32, 0, count)
+	for offset := 0; offset < len(body); offset += 4 {
+		partitions = append(partitions, binary.LittleEndian.Uint32(body[offset:offset+4]))
+	}
+	return partitions, nil
+}
diff --git a/foreign/go/binary_serialization/deferred_polling_fixture_test.go b/foreign/go/binary_serialization/deferred_polling_fixture_test.go
new file mode 100644
index 0000000..6a3d6dc
--- /dev/null
+++ b/foreign/go/binary_serialization/deferred_polling_fixture_test.go
@@ -0,0 +1,159 @@
+// 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.
+
+package binaryserialization
+
+import (
+	"encoding/hex"
+	"encoding/json"
+	"math/big"
+	"os"
+	"testing"
+
+	iggcon "github.com/apache/iggy/foreign/go/contracts"
+	"github.com/apache/iggy/foreign/go/internal/vsr"
+	"github.com/stretchr/testify/require"
+)
+
+// The replies and constants of the deferred polling vectors that the Rust
+// encoders generate, shared with every SDK.
+type deferredPollingFixture struct {
+	Constants struct {
+		MinDeferredPollMaxBytes uint32 `json:"min_deferred_poll_max_bytes"`
+		MaxPartitionsPerRequest int    `json:"max_partitions_per_request"`
+		MaxRecordSize           int    `json:"max_record_size"`
+		MaxRecordReplySize      int    `json:"max_record_reply_size"`
+	} `json:"constants"`
+	FetchReplies []struct {
+		Case     string `json:"case"`
+		BodyHex  string `json:"body_hex"`
+		Sections []struct {
+			PartitionId   uint32 `json:"partition_id"`
+			Status        uint32 `json:"status"`
+			CurrentOffset uint64 `json:"current_offset"`
+			MessagesCount uint32 `json:"messages_count"`
+			Context       struct {
+				Incarnation     uint64 `json:"incarnation"`
+				OwnerGeneration uint64 `json:"owner_generation"`
+				MetadataOp      uint64 `json:"metadata_op"`
+			} `json:"context"`
+			NextOffset uint64 `json:"next_offset"`
+			RecordsLen int    `json:"records_len"`
+			Messages   []struct {
+				Offset          uint64 `json:"offset"`
+				Id              string `json:"id"`
+				Timestamp       uint64 `json:"timestamp"`
+				OriginTimestamp uint64 `json:"origin_timestamp"`
+				Payload         string `json:"payload"`
+			} `json:"messages"`
+		} `json:"sections"`
+	} `json:"fetch_replies"`
+	Refused struct {
+		FetchReplies []struct {
+			Case    string `json:"case"`
+			BodyHex string `json:"body_hex"`
+		} `json:"fetch_replies"`
+	} `json:"refused"`
+}
+
+func loadDeferredPollingFixture(t *testing.T) deferredPollingFixture {
+	t.Helper()
+	data, err := os.ReadFile("../contracts/testdata/deferred_polling.json")
+	require.NoError(t, err)
+	var fixture deferredPollingFixture
+	require.NoError(t, json.Unmarshal(data, &fixture))
+	return fixture
+}
+
+func TestFixture_MatchesTheLimitsOfTheServer(t *testing.T) {
+	constants := loadDeferredPollingFixture(t).Constants
+
+	require.Equal(t, constants.MinDeferredPollMaxBytes, uint32(iggcon.MinDeferredPollMaxBytes))
+	require.Equal(t, constants.MaxPartitionsPerRequest, iggcon.MaxPartitionsPerRequest)
+	require.Equal(t, constants.MaxRecordSize, iggcon.MaxRecordSize)
+	require.GreaterOrEqual(t, vsr.MaxFrameSize, constants.MaxRecordReplySize,
+		"a reply of the largest record fits a frame of the client")
+}
+
+func TestFixture_DecodesEachFetchReplyOfTheSharedVectors(t *testing.T) {
+	for _, vector := range loadDeferredPollingFixture(t).FetchReplies {
+		t.Run(vector.Case, func(t *testing.T) {
+			body, err := hex.DecodeString(vector.BodyHex)
+			require.NoError(t, err)
+
+			sections, err := DeserializeFetchSections(body, iggcon.MESSAGE_COMPRESSION_NONE)
+
+			require.NoError(t, err)
+			require.Len(t, sections, len(vector.Sections))
+			for index, want := range vector.Sections {
+				section := sections[index]
+				require.Equal(t, want.PartitionId, section.PartitionId)
+				require.Equal(t, want.CurrentOffset, section.CurrentOffset)
+				require.Equal(t, want.MessagesCount, section.MessageCount)
+				require.Equal(t, want.NextOffset, section.NextOffset)
+				require.Len(t, section.Records, want.RecordsLen)
+				require.Equal(t, iggcon.PartitionContext{Incarnation: want.Context.Incarnation,
+					OwnerGeneration: want.Context.OwnerGeneration, MetadataOp: want.Context.MetadataOp}, section.Context)
+				if want.Status != 0 {
+					require.Error(t, section.Err)
+					continue
+				}
+				require.NoError(t, section.Err)
+				polled, err := DeserializeSectionMessages(section)
+				require.NoError(t, err)
+				require.Len(t, polled.Messages, len(want.Messages))
+				for at, message := range want.Messages {
+					got := polled.Messages[at]
+					require.Equal(t, message.Offset, got.Header.Offset)
+					require.Equal(t, message.Id, idString(got.Header.Id))
+					require.Equal(t, message.Timestamp, got.Header.Timestamp)
+					require.Equal(t, message.OriginTimestamp, got.Header.OriginTimestamp)
+					require.Equal(t, message.Payload, string(got.Payload))
+				}
+			}
+		})
+	}
+}
+
+func TestFixture_RefusesEachRefusedFetchReplyOfTheSharedVectors(t *testing.T) {
+	for _, vector := range loadDeferredPollingFixture(t).Refused.FetchReplies {
+		t.Run(vector.Case, func(t *testing.T) {
+			body, err := hex.DecodeString(vector.BodyHex)
+			require.NoError(t, err)
+
+			sections, err := DeserializeFetchSections(body, iggcon.MESSAGE_COMPRESSION_NONE)
+			if err == nil {
+				for _, section := range sections {
+					if _, decodeErr := DeserializeSectionMessages(section); decodeErr != nil && section.Err == nil {
+						err = decodeErr
+					}
+				}
+			}
+
+			require.Error(t, err)
+		})
+	}
+}
+
+// idString is the decimal value of a little endian 128-bit message id.
+func idString(id iggcon.MessageID) string {
+	bigEndian := make([]byte, len(id))
+	for index, value := range id {
+		bigEndian[len(id)-1-index] = value
+	}
+	return new(big.Int).SetBytes(bigEndian).String()
+}
diff --git a/foreign/go/binary_serialization/vsr_response_deserializer_test.go b/foreign/go/binary_serialization/vsr_response_deserializer_test.go
index e8c88dc..63eca92 100644
--- a/foreign/go/binary_serialization/vsr_response_deserializer_test.go
+++ b/foreign/go/binary_serialization/vsr_response_deserializer_test.go
@@ -26,6 +26,7 @@
 	iggcon "github.com/apache/iggy/foreign/go/contracts"
 	ierror "github.com/apache/iggy/foreign/go/errors"
 	"github.com/apache/iggy/foreign/go/internal/batch"
+	"github.com/klauspost/compress/s2"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
@@ -329,6 +330,131 @@
 	assert.Equal(t, []byte("user-header-bytes"), second.UserHeaders)
 }
 
+// goldenFetchReply is a fetch reply generated by the Rust encoder. Partition
+// 1 is served: current offset 41, context (3, 9, 12), next offset 42 and one
+// batch record that carries the message "hello" at offset 41, with id 1,
+// base timestamp 1700000000000100 and origin timestamp 1700000000000000.
+// Partition 2 is refused with NotPrimary.
+const goldenFetchReply = "020000000100000000000000290000000000000001000000030000000000000009000000000000000c000000000000002a000000000000003501000002000000590000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000290000000000000064401e18240a060000401e18240a06003501000000000000bd2d787d3cdb745c010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008e5ca59edbbdbfb90100000000000000000000000000000000000000000000000000000005000000000000000000000068656c6c6f"
+
+func TestDeserializeFetchSections_DecodesTheGoldenVector(t *testing.T) {
+	body, err := hex.DecodeString(goldenFetchReply)
+	require.NoError(t, err)
+
+	sections, err := DeserializeFetchSections(body, iggcon.MESSAGE_COMPRESSION_NONE)
+	require.NoError(t, err)
+
+	require.Len(t, sections, 2)
+	served, refused := sections[0], sections[1]
+	assert.Equal(t, uint32(1), served.PartitionId)
+	require.NoError(t, served.Err)
+	assert.Equal(t, uint64(41), served.CurrentOffset)
+	assert.Equal(t, uint32(1), served.MessageCount)
+	assert.Equal(t, iggcon.PartitionContext{Incarnation: 3, OwnerGeneration: 9, MetadataOp: 12}, served.Context)
+	assert.Equal(t, uint64(42), served.NextOffset)
+	assert.Len(t, served.Records, 309)
+	assert.Equal(t, uint32(2), refused.PartitionId)
+	require.ErrorIs(t, refused.Err, ierror.ErrNotPrimary)
+	assert.Empty(t, refused.Records)
+
+	polled, err := DeserializeSectionMessages(served)
+	require.NoError(t, err)
+	assert.Equal(t, uint32(1), polled.PartitionId)
+	assert.Equal(t, uint64(41), polled.CurrentOffset)
+	assert.Equal(t, served.Context, polled.Context)
+	require.Len(t, polled.Messages, 1)
+	message := polled.Messages[0]
+	assert.Equal(t, uint64(41), message.Header.Offset)
+	assert.Equal(t, iggcon.MessageID{1}, message.Header.Id)
+	assert.Equal(t, uint64(1700000000000100), message.Header.Timestamp)
+	assert.Equal(t, uint64(1700000000000000), message.Header.OriginTimestamp)
+	assert.Equal(t, []byte("hello"), message.Payload)
+
+	_, err = DeserializeSectionMessages(refused)
+	require.ErrorIs(t, err, ierror.ErrNotPrimary, "a refused section decodes to its refusal")
+}
+
+// fetchSectionHeader is a section header of a fetch reply for partition with
+// status and records_len, and every other field zero.
+func fetchSectionHeader(partition, status, recordsLength uint32) []byte {
+	header := make([]byte, fetchSectionHeaderSize)
+	binary.LittleEndian.PutUint32(header, partition)
+	binary.LittleEndian.PutUint32(header[sectionStatusAt:], status)
+	binary.LittleEndian.PutUint32(header[sectionRecordsLengthAt:], recordsLength)
+	return header
+}
+
+// fetchReply is a fetch reply with the section headers and records.
+func fetchReply(headers [][]byte, records []byte) []byte {
+	reply := binary.LittleEndian.AppendUint32(nil, uint32(len(headers)))
+	for _, header := range headers {
+		reply = append(reply, header...)
+	}
+	return append(reply, records...)
+}
+
+func TestDeserializeFetchSections_AnEmptyTableHasNoSections(t *testing.T) {
+	sections, err := DeserializeFetchSections(fetchReply(nil, nil), iggcon.MESSAGE_COMPRESSION_NONE)
+
+	require.NoError(t, err)
+	assert.Empty(t, sections)
+}
+
+func TestDeserializeFetchSections_RejectsATableThatDisagreesWithItsBody(t *testing.T) {
+	served := fetchSectionHeader(1, 0, 4)
+	tooMany := binary.LittleEndian.AppendUint32(nil, iggcon.MaxPartitionsPerRequest+1)
+	tooMany = append(tooMany, make([]byte, (iggcon.MaxPartitionsPerRequest+1)*fetchSectionHeaderSize)...)
+	for _, tc := range []struct {
+		name  string
+		reply []byte
+	}{
+		{"a body short of the section count", []byte{1, 0, 0}},
+		{"a table that does not fit the body", fetchReply([][]byte{served}, nil)[:fetchSectionsCountSize+fetchSectionHeaderSize-1]},
+		{"more sections than one fetch has targets", tooMany},
+		{"a refused section with records", fetchReply([][]byte{fetchSectionHeader(1, uint32(ierror.NotPrimaryCode), 4)}, make([]byte, 4))},
+		{"records that run past the body", fetchReply([][]byte{served}, make([]byte, 3))},
+		{"bytes after the last section", fetchReply([][]byte{served}, make([]byte, 5))},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			_, err := DeserializeFetchSections(tc.reply, iggcon.MESSAGE_COMPRESSION_NONE)
+
+			require.Error(t, err)
+		})
+	}
+}
+
+func TestDeserializeFetchSections_SplitsTheRecordsInTableOrder(t *testing.T) {
+	reply := fetchReply(
+		[][]byte{fetchSectionHeader(4, 0, 2), fetchSectionHeader(7, uint32(ierror.HistoryUnavailableCode), 0), fetchSectionHeader(9, 0, 3)},
+		[]byte{1, 2, 3, 4, 5})
+
+	sections, err := DeserializeFetchSections(reply, iggcon.MESSAGE_COMPRESSION_NONE)
+
+	require.NoError(t, err)
+	require.Len(t, sections, 3)
+	assert.Equal(t, []byte{1, 2}, sections[0].Records)
+	require.ErrorIs(t, sections[1].Err, ierror.ErrHistoryUnavailable)
+	assert.Empty(t, sections[1].Records)
+	assert.Equal(t, []byte{3, 4, 5}, sections[2].Records)
+}
+
+func TestDeserializeSectionMessages_UndoesTheCompressionOfTheFetch(t *testing.T) {
+	payload := []byte("compressed-payload")
+	records := appendBatchRecord(nil, 7, 0, 0, batchFrame{payload: s2.Encode(nil, payload)})
+	header := fetchSectionHeader(1, 0, uint32(len(records)))
+	binary.LittleEndian.PutUint32(header[sectionMessageCountAt:], 1)
+	sections, err := DeserializeFetchSections(fetchReply([][]byte{header}, records), iggcon.MESSAGE_COMPRESSION_S2)
+	require.NoError(t, err)
+	require.Len(t, sections, 1)
+
+	polled, err := DeserializeSectionMessages(sections[0])
+
+	require.NoError(t, err)
+	require.Len(t, polled.Messages, 1)
+	assert.Equal(t, uint64(7), polled.Messages[0].Header.Offset)
+	assert.Equal(t, payload, polled.Messages[0].Payload)
+}
+
 func TestDeserializeToTopic_PinsTheFieldOrderOfTheWireLayout(t *testing.T) {
 	const nameLenOffset = 32
 	name := "orders"
diff --git a/foreign/go/client/iggy_client.go b/foreign/go/client/iggy_client.go
index 5f79400..b7c9c35 100644
--- a/foreign/go/client/iggy_client.go
+++ b/foreign/go/client/iggy_client.go
@@ -63,6 +63,9 @@
 
 type IggyClient struct{ iggcon.Client }
 
+// Unwrap is the client of the protocol, which a reader.PartitionReader reads through.
+func (c *IggyClient) Unwrap() iggcon.Client { return c.Client }
+
 // NewIggyClient creates the IggyClient instance without connecting.
 // Call Connect to establish the connection to the server.
 // If no Option is provided, NewIggyClient will create a default TCP client.
diff --git a/foreign/go/client/reader/consumer.go b/foreign/go/client/reader/consumer.go
new file mode 100644
index 0000000..9c09c84
--- /dev/null
+++ b/foreign/go/client/reader/consumer.go
@@ -0,0 +1,229 @@
+// 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.
+
+package reader
+
+import (
+	"context"
+	"iter"
+	"sync"
+	"sync/atomic"
+
+	iggcon "github.com/apache/iggy/foreign/go/contracts"
+	ierror "github.com/apache/iggy/foreign/go/errors"
+)
+
+// ReceivedMessage is one message that an IggyConsumer handed over.
+type ReceivedMessage struct {
+	PartitionID uint32
+	Message     iggcon.IggyMessage
+	// Checkpoint is the position after the message, for a commit or a mark.
+	Checkpoint iggcon.Checkpoint
+	// EndsBatch marks the last message of the batch that the reader read.
+	EndsBatch bool
+}
+
+// IggyConsumer hands over one message at a time from a PartitionReader, as a
+// standalone consumer or a consumer group member. Under CommitAuto with
+// CommitAfterProcessing a message counts when the next one is asked for or at
+// Shutdown, and in Consume once its handler succeeds. A handler that fails
+// leaves its message uncounted and ends Consume with its error. Under
+// CommitAfterDelivery a message counts when it is handed over, and under
+// CommitAfterMarked only Mark counts. One caller at a time calls Next or runs
+// Consume.
+type IggyConsumer struct {
+	reader    *PartitionReader
+	after     iggcon.CommitAfter
+	auto      bool
+	mu        sync.Mutex
+	batch     *iggcon.PolledMessage
+	index     int
+	uncounted *ReceivedMessage
+	stopping  atomic.Bool
+}
+
+// NewIggyConsumer starts the reader of a consumer, which a group member joins
+// its group for. It takes the options of NewPartitionReader.
+func NewIggyConsumer(
+	ctx context.Context,
+	client iggcon.Client,
+	streamId, topicId iggcon.Identifier,
+	consumer iggcon.Consumer,
+	options ...Option,
+) (*IggyConsumer, error) {
+	reader, err := NewPartitionReader(ctx, client, streamId, topicId, consumer, options...)
+	if err != nil {
+		return nil, err
+	}
+	c := &IggyConsumer{reader: reader}
+	if reader.auto != nil {
+		c.auto = true
+		c.after = reader.auto.After
+	}
+	return c, nil
+}
+
+// Next returns the next message. It fails with a *ReadError for an error of a
+// read, after which the consumer goes on.
+func (c *IggyConsumer) Next(ctx context.Context) (ReceivedMessage, error) {
+	c.mu.Lock()
+	c.countUncountedLocked()
+	c.mu.Unlock()
+	message, err := c.take(ctx)
+	if err == nil && c.auto && c.after == iggcon.CommitAfterProcessing {
+		c.mu.Lock()
+		c.uncounted = &message
+		c.mu.Unlock()
+	}
+	return message, err
+}
+
+// Messages yields the messages until ctx ends, Shutdown, or an error, which it yields last.
+func (c *IggyConsumer) Messages(ctx context.Context) iter.Seq2[ReceivedMessage, error] {
+	return func(yield func(ReceivedMessage, error) bool) {
+		for !c.stopping.Load() {
+			message, err := c.Next(ctx)
+			if !yield(message, err) || err != nil {
+				return
+			}
+		}
+	}
+}
+
+// Consume runs handler on each message, one after another, until ctx ends,
+// Shutdown, or the first failure of a handler or a read, which it returns.
+func (c *IggyConsumer) Consume(ctx context.Context, handler func(context.Context, ReceivedMessage) error) error {
+	c.mu.Lock()
+	c.countUncountedLocked()
+	c.mu.Unlock()
+	for !c.stopping.Load() {
+		if err := ctx.Err(); err != nil {
+			return err
+		}
+		message, err := c.take(ctx)
+		if err != nil {
+			if c.stopping.Load() {
+				return nil
+			}
+			return err
+		}
+		if err := handler(ctx, message); err != nil {
+			return err
+		}
+		if c.auto && c.after == iggcon.CommitAfterProcessing {
+			c.reader.countDelivered(message.Checkpoint, 1, message.EndsBatch)
+		}
+	}
+	return nil
+}
+
+// Commit stores the offset of a checkpoint that the consumer handed over, see PartitionReader.Commit.
+func (c *IggyConsumer) Commit(ctx context.Context, checkpoint iggcon.Checkpoint) error {
+	return c.reader.Commit(ctx, checkpoint)
+}
+
+// Mark counts a checkpoint that the consumer handed over, see PartitionReader.Mark.
+func (c *IggyConsumer) Mark(checkpoint iggcon.Checkpoint) error {
+	return c.reader.Mark(checkpoint)
+}
+
+// Seek sets the next position of a partition, see PartitionReader.Seek. The
+// messages of the partition that the consumer still holds are dropped.
+func (c *IggyConsumer) Seek(ctx context.Context, partitionID uint32, start iggcon.PollingStrategy) error {
+	c.mu.Lock()
+	store, err := c.reader.startSeek(partitionID, start)
+	if err == nil {
+		if c.batch != nil && c.batch.PartitionId == partitionID {
+			c.batch = nil
+		}
+		if c.uncounted != nil && c.uncounted.PartitionID == partitionID {
+			c.uncounted = nil
+		}
+	}
+	c.mu.Unlock()
+	if err != nil || store == nil {
+		return err
+	}
+	return store(ctx)
+}
+
+// Partitions are the partitions of the reader.
+func (c *IggyConsumer) Partitions() []uint32 {
+	return c.reader.Partitions()
+}
+
+// Shutdown stops a running Consume after its current handler, counts the last
+// message under CommitAfterProcessing, and closes the reader, which stores,
+// moves offsets back and leaves its group as its commit policy says.
+func (c *IggyConsumer) Shutdown(ctx context.Context) error {
+	c.stopping.Store(true)
+	c.mu.Lock()
+	c.countUncountedLocked()
+	c.mu.Unlock()
+	return c.reader.Close(ctx)
+}
+
+func (c *IggyConsumer) take(ctx context.Context) (ReceivedMessage, error) {
+	for {
+		if c.stopping.Load() {
+			return ReceivedMessage{}, ierror.ErrClientShutdown
+		}
+		c.mu.Lock()
+		if c.batch != nil && c.index < len(c.batch.Messages) {
+			message := c.handOverLocked()
+			c.mu.Unlock()
+			return message, nil
+		}
+		c.mu.Unlock()
+		batch, generation, err := c.reader.next(ctx, false)
+		if err != nil {
+			return ReceivedMessage{}, err
+		}
+		c.mu.Lock()
+		// A seek of the partition that started after the reader handed the batch over drops it.
+		if c.reader.keeps(batch.PartitionId, generation) {
+			c.batch = batch
+			c.index = 0
+		}
+		c.mu.Unlock()
+	}
+}
+
+func (c *IggyConsumer) handOverLocked() ReceivedMessage {
+	at := c.index
+	c.index++
+	checkpoint, _ := c.batch.CheckpointAt(at)
+	message := ReceivedMessage{
+		PartitionID: c.batch.PartitionId,
+		Message:     c.batch.Messages[at],
+		Checkpoint:  checkpoint,
+		EndsBatch:   at == len(c.batch.Messages)-1,
+	}
+	c.reader.deliverMessage(checkpoint)
+	if c.auto && c.after == iggcon.CommitAfterDelivery {
+		c.reader.countDelivered(checkpoint, 1, message.EndsBatch)
+	}
+	return message
+}
+
+func (c *IggyConsumer) countUncountedLocked() {
+	if c.uncounted != nil {
+		message := c.uncounted
+		c.uncounted = nil
+		c.reader.countDelivered(message.Checkpoint, 1, message.EndsBatch)
+	}
+}
diff --git a/foreign/go/client/reader/consumer_test.go b/foreign/go/client/reader/consumer_test.go
new file mode 100644
index 0000000..b99159a
--- /dev/null
+++ b/foreign/go/client/reader/consumer_test.go
@@ -0,0 +1,208 @@
+// 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.
+
+package reader
+
+import (
+	"context"
+	"errors"
+	"testing"
+	"time"
+
+	iggcon "github.com/apache/iggy/foreign/go/contracts"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func autoConsumer(t *testing.T, client *fakeClient, after iggcon.CommitAfter) *IggyConsumer {
+	t.Helper()
+	auto := iggcon.AutoCommit{Every: iggcon.CommitEvery{Kind: iggcon.CommitEveryMessage}, After: after}
+	consumer, err := NewIggyConsumer(context.Background(), client, identifier(t, 1), identifier(t, 2),
+		iggcon.NewSingleConsumer(identifier(t, 3)), WithPartitions(1),
+		WithPollOptions(withCommit(waitOneSecond, iggcon.AutoCommitPolicy(auto))), WithRetryInterval(100*time.Millisecond))
+	require.NoError(t, err)
+	return consumer
+}
+
+func TestIggyConsumer_CountsAMessageWhenTheNextIsAskedForAndTheLastAtShutdown(t *testing.T) {
+	client := newFakeClient()
+	consumer := autoConsumer(t, client, iggcon.CommitAfterProcessing)
+	go func() { client.nextRead(t).answer(section(1, 0, 1)) }()
+
+	first, err := consumer.Next(context.Background())
+	require.NoError(t, err)
+	assert.Equal(t, uint64(0), first.Checkpoint.Offset())
+	assert.Empty(t, client.recordedStores())
+	_, err = consumer.Next(context.Background())
+	require.NoError(t, err)
+	require.Eventually(t, func() bool { return len(client.recordedStores()) == 1 }, testTimeout, 5*time.Millisecond)
+	assert.Equal(t, []store{{1, 0}}, client.recordedStores())
+
+	require.NoError(t, consumer.Shutdown(context.Background()))
+
+	assert.Equal(t, []store{{1, 0}, {1, 1}}, client.recordedStores())
+}
+
+// A handler that fails leaves its message uncounted and ends the loop with its error.
+func TestIggyConsumer_CountsAfterASuccessfulHandlerAndEndsOnAFailedOne(t *testing.T) {
+	client := newFakeClient()
+	consumer := autoConsumer(t, client, iggcon.CommitAfterProcessing)
+	go func() { client.nextRead(t).answer(section(1, 0, 1, 2)) }()
+	failure := errors.New("handler failed")
+	var handled []uint64
+
+	err := consumer.Consume(context.Background(), func(_ context.Context, message ReceivedMessage) error {
+		handled = append(handled, message.Checkpoint.Offset())
+		if message.Checkpoint.Offset() == 2 {
+			return failure
+		}
+		return nil
+	})
+
+	require.ErrorIs(t, err, failure)
+	assert.Equal(t, []uint64{0, 1, 2}, handled)
+	require.NoError(t, consumer.Shutdown(context.Background()))
+	assert.Equal(t, uint64(1), client.recordedStores()[len(client.recordedStores())-1].offset,
+		"the failed message stays uncounted")
+}
+
+// Under OnRead the server stored the whole batch, so Shutdown moves the offset back to the last message handed over.
+func TestIggyConsumer_MovesTheOffsetBackToTheLastMessageHandedOverAtShutdownUnderOnRead(t *testing.T) {
+	client := newFakeClient()
+	consumer, err := NewIggyConsumer(context.Background(), client, identifier(t, 1), identifier(t, 2),
+		iggcon.NewSingleConsumer(identifier(t, 3)), WithPartitions(1),
+		WithPollOptions(withCommit(waitOneSecond, iggcon.OnReadCommit())))
+	require.NoError(t, err)
+	go func() { client.nextRead(t).answer(section(1, 0, 1, 2, 3)) }()
+	_, err = consumer.Next(context.Background())
+	require.NoError(t, err)
+	_, err = consumer.Next(context.Background())
+	require.NoError(t, err)
+
+	require.NoError(t, consumer.Shutdown(context.Background()))
+
+	assert.Equal(t, []store{{1, 1}}, client.recordedStores())
+}
+
+func TestIggyConsumer_ShutdownWaitsForAnAutomaticStoreWithoutCancellingIt(t *testing.T) {
+	client := newFakeClient()
+	client.storeStarted = make(chan context.Context, 4)
+	client.storeRelease = make(chan struct{})
+	consumer := autoConsumer(t, client, iggcon.CommitAfterDelivery)
+	client.nextRead(t).answer(section(1, 0))
+	_, err := consumer.Next(context.Background())
+	require.NoError(t, err)
+	var storeContext context.Context
+	select {
+	case storeContext = <-client.storeStarted:
+	case <-time.After(testTimeout):
+		t.Fatal("the automatic store did not start")
+	}
+	closed := make(chan error, 1)
+	go func() { closed <- consumer.Shutdown(context.Background()) }()
+	<-consumer.reader.run.Done()
+	assert.NoError(t, storeContext.Err(), "shutdown must let an admitted automatic store settle")
+	close(client.storeRelease)
+	select {
+	case err := <-closed:
+		require.NoError(t, err)
+	case <-time.After(testTimeout):
+		t.Fatal("shutdown did not finish after the store settled")
+	}
+}
+
+// A seek of another partition leaves the batch that the consumer took from the reader meanwhile.
+func TestIggyConsumer_HandsOverABatchTakenWhileAnotherPartitionIsSought(t *testing.T) {
+	client := newFakeClient()
+	consumer := manualConsumer(t, client, 1, 2)
+	read := client.nextRead(t)
+	received := receiveInBackground(consumer)
+	require.Eventually(t, func() bool { return waiting(consumer.reader) == 1 }, testTimeout, time.Millisecond)
+
+	require.NoError(t, consumer.Seek(context.Background(), 2, iggcon.OffsetPollingStrategy(0)))
+	read.answer(section(1, 0))
+
+	result := <-received
+	require.NoError(t, result.err)
+	assert.Equal(t, uint32(1), result.message.PartitionID)
+	assert.Equal(t, uint64(0), result.message.Checkpoint.Offset())
+}
+
+// The first batch that the reader reads after a seek reaches the consumer that waited during the seek.
+func TestIggyConsumer_HandsOverTheBatchReadAfterASeekOfItsPartition(t *testing.T) {
+	client := newFakeClient()
+	consumer := manualConsumer(t, client, 1)
+	stale := client.nextRead(t)
+	received := receiveInBackground(consumer)
+	require.Eventually(t, func() bool { return waiting(consumer.reader) == 1 }, testTimeout, time.Millisecond)
+
+	require.NoError(t, consumer.Seek(context.Background(), 1, iggcon.OffsetPollingStrategy(5)))
+	sought := client.nextRead(t)
+	require.Equal(t, uint64(5), sought.targets[0].Strategy.Value)
+	sought.answer(section(1, 5))
+	stale.answer(section(1, 0))
+
+	result := <-received
+	require.NoError(t, result.err)
+	assert.Equal(t, uint64(5), result.message.Checkpoint.Offset())
+}
+
+// A handler that ends the context of Consume gets no further message of the batch.
+func TestIggyConsumer_ConsumeEndsWhenItsContextEndsBetweenMessages(t *testing.T) {
+	client := newFakeClient()
+	consumer := manualConsumer(t, client, 1)
+	client.nextRead(t).answer(section(1, 0, 1, 2))
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+	var handled []uint64
+
+	err := consumer.Consume(ctx, func(_ context.Context, message ReceivedMessage) error {
+		handled = append(handled, message.Checkpoint.Offset())
+		cancel()
+		return nil
+	})
+
+	require.ErrorIs(t, err, context.Canceled)
+	assert.Equal(t, []uint64{0}, handled)
+}
+
+func manualConsumer(t *testing.T, client *fakeClient, partitions ...uint32) *IggyConsumer {
+	t.Helper()
+	consumer, err := NewIggyConsumer(context.Background(), client, identifier(t, 1), identifier(t, 2),
+		iggcon.NewSingleConsumer(identifier(t, 3)), WithPartitions(partitions...), WithPollOptions(waitOneSecond),
+		WithRetryInterval(testRetryInterval))
+	require.NoError(t, err)
+	t.Cleanup(func() { _ = consumer.Shutdown(context.Background()) })
+	return consumer
+}
+
+type receivedOutcome struct {
+	message ReceivedMessage
+	err     error
+}
+
+// receiveInBackground starts a Next of consumer whose outcome arrives on the returned channel.
+func receiveInBackground(consumer *IggyConsumer) <-chan receivedOutcome {
+	received := make(chan receivedOutcome, 1)
+	go func() {
+		ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
+		defer cancel()
+		message, err := consumer.Next(ctx)
+		received <- receivedOutcome{message, err}
+	}()
+	return received
+}
diff --git a/foreign/go/client/reader/reader.go b/foreign/go/client/reader/reader.go
new file mode 100644
index 0000000..de36ce5
--- /dev/null
+++ b/foreign/go/client/reader/reader.go
@@ -0,0 +1,1565 @@
+// 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.
+
+// Package reader reads the partitions of a topic with a PartitionReader, which
+// groups them by primary and reads the partitions of a group together, and
+// hands over one message at a time with an IggyConsumer.
+package reader
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"log/slog"
+	"sort"
+	"sync"
+	"time"
+
+	binaryserialization "github.com/apache/iggy/foreign/go/binary_serialization"
+	iggcon "github.com/apache/iggy/foreign/go/contracts"
+	ierror "github.com/apache/iggy/foreign/go/errors"
+)
+
+const (
+	// DefaultMaxWait is the wait of a read of a reader unless its options say otherwise.
+	DefaultMaxWait = 5 * time.Second
+	// DefaultPrefetchMessages bounds the messages of the reads in flight and the queued batches.
+	DefaultPrefetchMessages = 16_000
+	// DefaultRetryInterval is the pause before a refused or failed read is sent again.
+	DefaultRetryInterval      = time.Second
+	lastingRefusalsBeforeStop = 6
+	maxBackoffShift           = 5
+	wholeReader               = -1
+)
+
+// ReadError is an error of a read. The reader stays usable after it and retries by itself.
+type ReadError struct {
+	// PartitionID is the partition of the error, or nil for an error of the whole reader.
+	PartitionID *uint32
+	Err         error
+	// Skip is, for a message that does not decode, the continuation after it.
+	// The partition stops until a Seek, to Skip to step over the message.
+	Skip *iggcon.PollingStrategy
+}
+
+func (e *ReadError) Error() string {
+	if e.PartitionID != nil {
+		return fmt.Sprintf("partition %d: %v", *e.PartitionID, e.Err)
+	}
+	return e.Err.Error()
+}
+
+func (e *ReadError) Unwrap() error { return e.Err }
+
+// transport is what the reader needs of the client: a read of the partitions
+// of one primary in one request, and the grouping of partitions by primary.
+type transport interface {
+	FetchMessages(ctx context.Context, streamId, topicId iggcon.Identifier, consumer iggcon.Consumer,
+		targets []iggcon.FetchMessagesTarget, count uint32, autoCommit bool, options iggcon.DeferredPollOptions,
+	) ([]iggcon.FetchMessagesSection, error)
+	ReadinessGroups(ctx context.Context, streamId, topicId iggcon.Identifier, partitions []uint32) ([][]uint32, error)
+}
+
+// Option configures a PartitionReader.
+type Option func(*config)
+
+type config struct {
+	partitions       []uint32
+	start            iggcon.PollingStrategy
+	options          iggcon.PollOptions
+	prefetchMessages uint32
+	retryInterval    time.Duration
+	logger           *slog.Logger
+}
+
+// WithPartitions sets the partitions of a standalone consumer, which requires
+// them. A consumer group reads the assignment of its member.
+func WithPartitions(partitions ...uint32) Option {
+	return func(c *config) { c.partitions = append([]uint32(nil), partitions...) }
+}
+
+// WithStart sets the start of a partition without a position, NextPollingStrategy by default.
+func WithStart(start iggcon.PollingStrategy) Option {
+	return func(c *config) { c.start = start }
+}
+
+// WithPollOptions sets the options of each read and the commit policy,
+// WaitPollOptions(DefaultMaxWait) by default, which stores nothing.
+func WithPollOptions(options iggcon.PollOptions) Option {
+	return func(c *config) { c.options = options }
+}
+
+// WithPrefetchMessages bounds the messages of the reads in flight and the
+// queued batches, the larger of DefaultPrefetchMessages and the count by default.
+func WithPrefetchMessages(messages uint32) Option {
+	return func(c *config) { c.prefetchMessages = messages }
+}
+
+// WithRetryInterval sets the pause before a refused or failed read is sent again.
+func WithRetryInterval(interval time.Duration) Option {
+	return func(c *config) { c.retryInterval = interval }
+}
+
+// WithLogger sets the logger of the retries.
+func WithLogger(logger *slog.Logger) Option {
+	return func(c *config) { c.logger = logger }
+}
+
+type partition struct {
+	id         uint32
+	generation uint64
+	position   iggcon.PollingStrategy
+	// fromRead marks a position that a read gave, so it carries the history of the partition.
+	fromRead bool
+	inFlight bool
+	// parked marks a partition whose read in flight holds quiet partitions, so it may wait.
+	parked  bool
+	retryAt time.Time
+	// servedAt is the read turn that last returned messages of the partition.
+	servedAt uint64
+	// backlogged marks a partition whose last section ended before its last
+	// message, so its next read returns at once.
+	backlogged      bool
+	lastingRefusals int
+	stopped         *ReadError
+	context         iggcon.PartitionContext
+	// received is the last offset that a read of this generation returned.
+	received       *uint64
+	baseline       *uint64
+	readStarted    bool
+	firstDelivered *uint64
+	delivered      *iggcon.Checkpoint
+	committed      *uint64
+	// lostRead marks a committing read whose outcome is unknown, until a later
+	// committing read returns messages without a gap. Only then does a forward
+	// jump mean that the lost read stored.
+	lostRead bool
+	// gapFrom and held: under OnRead, the start of the messages that a lost
+	// read stored, and the batch that was read after them.
+	gapFrom *uint64
+	gapEnd  uint64
+	held    *iggcon.PolledMessage
+	// aloneUntil is the end of a section that did not decode, which the
+	// partition reads one message at a time.
+	aloneUntil  *uint64
+	pendingSeek *iggcon.PollingStrategy
+	counted     *iggcon.Checkpoint
+	stored      *iggcon.Checkpoint
+	sinceStore  uint64
+}
+
+func (p *partition) deliveredHere(checkpoint iggcon.Checkpoint) bool {
+	return p.delivered != nil && p.firstDelivered != nil && sameIncarnationAndOwner(checkpoint.Context(), p.delivered.Context()) &&
+		checkpoint.Offset() >= *p.firstDelivered && checkpoint.Offset() <= p.delivered.Offset()
+}
+
+type entry struct {
+	partition  int64
+	generation uint64
+	batch      *iggcon.PolledMessage
+	err        *ReadError
+}
+
+type uncounted struct {
+	partition  uint32
+	generation uint64
+	checkpoint iggcon.Checkpoint
+	messages   uint64
+}
+
+// PartitionReader reads the partitions of a topic in batches, with routing,
+// prefetch and the position of each partition inside. Next returns the next
+// batch of one partition, in offset order within one generation of the
+// partition: from its first read, from each Seek, and from each new assignment.
+//
+// With CommitManual, Commit stores the offset of a delivered checkpoint. With
+// CommitOnRead the server stores each read, the reader reads only when Next
+// waits, and after a read whose reply was lost it reads again by offset,
+// without a store, the messages that the lost read stored, and delivers them
+// first. A partition that leaves the assignment still hands over the reads
+// that the server stored for it. Close moves the offset of each partition with
+// undelivered batches back to its last delivered message. With CommitAuto the
+// reader counts each batch as the policy says, or each Mark, and stores the
+// latest counted position at its interval or event, before a rebalance takes a
+// partition away, and in Close.
+type PartitionReader struct {
+	client    iggcon.Client
+	transport transport
+	stream    iggcon.Identifier
+	topic     iggcon.Identifier
+	consumer  iggcon.Consumer
+	config    config
+	slots     int
+	group     bool
+	onRead    bool
+	auto      *iggcon.AutoCommit
+
+	mu              sync.Mutex
+	partitions      map[uint32]*partition
+	seekGenerations map[uint32]uint64
+	queue           []entry
+	waiting         int
+	changed         chan struct{}
+	groups          [][]uint32
+	regroup         bool
+	grouping        bool
+	regroupAt       time.Time
+	groupCursor     int
+	serves          uint64
+	reads           int
+	refreshing      bool
+	refreshAt       time.Time
+	lastGen         uint64
+	uncounted       *uncounted
+	retiring        []iggcon.Checkpoint
+	closed          bool
+
+	storeMu  sync.Mutex
+	storeDue chan struct{}
+	wake     chan struct{}
+	run      context.Context
+	stop     context.CancelFunc
+	inFlight sync.WaitGroup
+	loopDone chan struct{}
+	// closeDone closes when the first Close ends, with its result in closeErr.
+	closeDone chan struct{}
+	closeErr  error
+	// seekStores are the stores of the seeks that a read applied after Close started.
+	seekStores []func(context.Context) error
+}
+
+// NewPartitionReader starts a reader. A consumer group reader joins its group first.
+func NewPartitionReader(
+	ctx context.Context,
+	client iggcon.Client,
+	streamId, topicId iggcon.Identifier,
+	consumer iggcon.Consumer,
+	options ...Option,
+) (*PartitionReader, error) {
+	for {
+		wrapper, wraps := client.(interface{ Unwrap() iggcon.Client })
+		if _, reads := client.(transport); reads || !wraps {
+			break
+		}
+		client = wrapper.Unwrap()
+	}
+	fetcher, ok := client.(transport)
+	if !ok {
+		return nil, fmt.Errorf("%w: the client cannot read partitions in groups", ierror.ErrFeatureUnavailable)
+	}
+	cfg := config{
+		start:         iggcon.NextPollingStrategy(),
+		options:       iggcon.WaitPollOptions(DefaultMaxWait),
+		retryInterval: DefaultRetryInterval,
+		logger:        slog.New(slog.DiscardHandler),
+	}
+	for _, option := range options {
+		option(&cfg)
+	}
+	if err := cfg.options.Validate(); err != nil {
+		return nil, err
+	}
+	group := consumer.Kind == iggcon.ConsumerKindGroup
+	if _, bound := cfg.start.Partition(); bound || group == (len(cfg.partitions) > 0) || cfg.retryInterval <= 0 {
+		return nil, fmt.Errorf("%w: a standalone reader needs its partitions, a group reader reads its assignment, "+
+			"and the start cannot be the continuation of one partition", ierror.ErrInvalidCommand)
+	}
+	if cfg.start.Kind == iggcon.POLLING_FIRST {
+		cfg.start = iggcon.OffsetPollingStrategy(0)
+	}
+	prefetch := uint64(cfg.prefetchMessages)
+	if prefetch == 0 {
+		prefetch = max(DefaultPrefetchMessages, uint64(cfg.options.Count))
+	}
+	run, stop := context.WithCancel(context.WithoutCancel(ctx))
+	r := &PartitionReader{
+		client: client, transport: fetcher, stream: streamId, topic: topicId, consumer: consumer, config: cfg,
+		slots: int(max(1, prefetch/uint64(cfg.options.Count))), group: group,
+		onRead:     cfg.options.Commit.Kind == iggcon.CommitOnRead,
+		partitions: make(map[uint32]*partition), changed: make(chan struct{}), regroup: true,
+		storeDue: make(chan struct{}, 1), wake: make(chan struct{}, 1), run: run, stop: stop,
+		loopDone: make(chan struct{}), closeDone: make(chan struct{}),
+	}
+	if cfg.options.Commit.Kind == iggcon.CommitAuto {
+		auto := cfg.options.Commit.Auto
+		r.auto = &auto
+	}
+	if group {
+		if err := client.JoinConsumerGroup(ctx, streamId, topicId, consumer.Id); err != nil {
+			stop()
+			return nil, err
+		}
+	} else {
+		for _, id := range cfg.partitions {
+			r.addPartition(id)
+		}
+	}
+	go r.loop()
+	r.signal()
+	return r, nil
+}
+
+// Next returns the next batch of one partition, never empty. A cancelled call
+// leaves its batch in the reader. It fails with a *ReadError for an error of a read.
+func (r *PartitionReader) Next(ctx context.Context) (*iggcon.PolledMessage, error) {
+	batch, _, err := r.next(ctx, true)
+	return batch, err
+}
+
+// next returns the next batch and the generation of its partition that read it.
+func (r *PartitionReader) next(ctx context.Context, delivers bool) (*iggcon.PolledMessage, uint64, error) {
+	r.mu.Lock()
+	if delivers {
+		r.countUncountedLocked()
+	}
+	for {
+		if r.closed {
+			r.mu.Unlock()
+			return nil, 0, ierror.ErrClientShutdown
+		}
+		for len(r.queue) > 0 {
+			head := r.queue[0]
+			r.queue = r.queue[1:]
+			if !r.currentLocked(head) {
+				continue
+			}
+			if head.err != nil {
+				r.mu.Unlock()
+				r.signal()
+				return nil, 0, head.err
+			}
+			if p := r.partitions[uint32(head.partition)]; delivers && p != nil {
+				r.deliveredLocked(p, head.batch)
+			}
+			r.mu.Unlock()
+			r.signal()
+			return head.batch, head.generation, nil
+		}
+		r.waiting++
+		changed := r.changed
+		r.mu.Unlock()
+		r.signal()
+		select {
+		case <-changed:
+		case <-ctx.Done():
+			r.mu.Lock()
+			r.waiting--
+			r.mu.Unlock()
+			return nil, 0, ctx.Err()
+		}
+		r.mu.Lock()
+		r.waiting--
+	}
+}
+
+// Mark counts a checkpoint that this reader delivered, under CommitAuto. With
+// CommitAfterMarked only marks count. It fails with ierror.ErrInvalidCommand
+// without CommitAuto, and for a checkpoint of another generation.
+func (r *PartitionReader) Mark(checkpoint iggcon.Checkpoint) error {
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	if r.auto == nil {
+		return ierror.ErrInvalidCommand
+	}
+	p := r.partitions[checkpoint.PartitionID()]
+	if p == nil || !p.deliveredHere(checkpoint) {
+		return ierror.ErrInvalidCommand
+	}
+	r.countLocked(p, checkpoint, 1, false)
+	return nil
+}
+
+// Commit stores the offset of a checkpoint that this reader delivered. A
+// checkpoint of an older generation, or at or below the last commit of its
+// generation, stores nothing. It fails with ierror.ErrInvalidCommand under
+// CommitOnRead and for a partition that the reader does not read.
+func (r *PartitionReader) Commit(ctx context.Context, checkpoint iggcon.Checkpoint) error {
+	r.mu.Lock()
+	if r.onRead {
+		r.mu.Unlock()
+		return ierror.ErrInvalidCommand
+	}
+	p := r.partitions[checkpoint.PartitionID()]
+	if p == nil {
+		r.mu.Unlock()
+		return ierror.ErrInvalidCommand
+	}
+	if !p.deliveredHere(checkpoint) || (p.committed != nil && checkpoint.Offset() <= *p.committed) {
+		r.mu.Unlock()
+		return nil
+	}
+	generation := p.generation
+	r.mu.Unlock()
+	if err := r.client.StoreConsumerPosition(ctx, r.consumer, r.stream, r.topic, checkpointPosition(checkpoint)); err != nil {
+		return err
+	}
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	if p.generation == generation && (p.committed == nil || checkpoint.Offset() > *p.committed) {
+		offset := checkpoint.Offset()
+		p.committed = &offset
+	}
+	return nil
+}
+
+// Seek sets the next read position of a partition and starts a new generation
+// of it. It drops the queued batches of the partition and resumes a stopped
+// one. Under CommitOnRead, a seek to an offset stores the offset before it
+// once the read in flight ended, so the next read of the server starts there.
+func (r *PartitionReader) Seek(ctx context.Context, partitionID uint32, start iggcon.PollingStrategy) error {
+	store, err := r.startSeek(partitionID, start)
+	if err != nil || store == nil {
+		return err
+	}
+	return store(ctx)
+}
+
+// startSeek starts the new generation of a seek, and returns the store that
+// must go before its next read under OnRead.
+func (r *PartitionReader) startSeek(partitionID uint32, start iggcon.PollingStrategy) (func(context.Context) error, error) {
+	if err := start.RequirePartition(partitionID); err != nil {
+		return nil, err
+	}
+	if start.Kind == iggcon.POLLING_FIRST {
+		start = iggcon.OffsetPollingStrategy(0)
+	}
+	r.mu.Lock()
+	p := r.partitions[partitionID]
+	if p == nil || r.closed {
+		r.mu.Unlock()
+		return nil, ierror.ErrInvalidCommand
+	}
+	r.lastGen++
+	if r.seekGenerations == nil {
+		r.seekGenerations = make(map[uint32]uint64)
+	}
+	r.seekGenerations[partitionID] = r.lastGen
+	r.dropQueuedLocked(partitionID)
+	if r.onRead && p.inFlight {
+		p.pendingSeek = &start
+		r.mu.Unlock()
+		return nil, nil
+	}
+	store := r.applySeekLocked(p, start)
+	r.mu.Unlock()
+	r.signal()
+	return store, nil
+}
+
+// Partitions are the partitions that the reader reads now.
+func (r *PartitionReader) Partitions() []uint32 {
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	ids := make([]uint32, 0, len(r.partitions))
+	for id := range r.partitions {
+		ids = append(ids, id)
+	}
+	sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
+	return ids
+}
+
+// Close stops the reader and waits for its reads, which it lets end. With
+// CommitAuto it counts the last batch under CommitAfterProcessing and stores
+// the latest counted position of each partition. With CommitOnRead it first
+// stores the offset of each seek that waited for one of those reads, then
+// moves the offset of each partition with undelivered batches back to its
+// last delivered message. A group reader then leaves its group. It returns the
+// first failed store or leave. A later Close waits for the first one and
+// returns its result.
+func (r *PartitionReader) Close(ctx context.Context) error {
+	r.mu.Lock()
+	if r.closed {
+		r.mu.Unlock()
+		select {
+		case <-r.closeDone:
+			return r.closeErr
+		case <-ctx.Done():
+			return ctx.Err()
+		}
+	}
+	r.closed = true
+	r.countUncountedLocked()
+	r.broadcastLocked()
+	r.mu.Unlock()
+	r.stop()
+	<-r.loopDone
+	r.inFlight.Wait()
+	var failure error
+	for _, store := range r.seekStores {
+		if err := store(ctx); err != nil && failure == nil {
+			failure = err
+		}
+	}
+	if r.onRead {
+		if err := r.rewind(ctx); err != nil && failure == nil {
+			failure = err
+		}
+	}
+	if r.auto != nil {
+		if err := r.flush(ctx); err != nil && failure == nil {
+			failure = err
+		}
+	}
+	if r.group {
+		// A member that the server no longer knows, after a lost session, has left already.
+		err := r.client.LeaveConsumerGroup(ctx, r.stream, r.topic, r.consumer.Id)
+		if err != nil && !errors.Is(err, ierror.ErrConsumerGroupMemberNotFound) && failure == nil {
+			failure = err
+		}
+	}
+	r.closeErr = failure
+	close(r.closeDone)
+	return failure
+}
+
+func (r *PartitionReader) addPartition(id uint32) {
+	r.lastGen++
+	r.partitions[id] = &partition{id: id, generation: r.lastGen, position: r.config.start, fromRead: r.config.start.Context != nil}
+}
+
+func (r *PartitionReader) signal() {
+	select {
+	case r.wake <- struct{}{}:
+	default:
+	}
+}
+
+func (r *PartitionReader) broadcastLocked() {
+	close(r.changed)
+	r.changed = make(chan struct{})
+}
+
+func (r *PartitionReader) loop() {
+	defer close(r.loopDone)
+	timer := time.NewTimer(r.config.retryInterval)
+	defer timer.Stop()
+	var ticks <-chan time.Time
+	if r.auto != nil && r.auto.Interval > 0 {
+		ticker := time.NewTicker(r.auto.Interval)
+		defer ticker.Stop()
+		ticks = ticker.C
+	}
+	for {
+		select {
+		case <-r.run.Done():
+			return
+		case <-r.wake:
+		case <-timer.C:
+		case <-ticks:
+			r.flushInBackground()
+		case <-r.storeDue:
+			r.flushInBackground()
+		}
+		next := r.pump()
+		if !timer.Stop() {
+			select {
+			case <-timer.C:
+			default:
+			}
+		}
+		timer.Reset(next)
+	}
+}
+
+// pump sends what the budget and the demand allow, and returns when it wants
+// to be called again at the latest.
+func (r *PartitionReader) pump() time.Duration {
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	if r.closed {
+		return r.config.retryInterval
+	}
+	now := time.Now()
+	if r.group && !r.refreshing && !now.Before(r.refreshAt) {
+		r.refreshing = true
+		r.inFlight.Add(1)
+		go r.refreshAssignment()
+	}
+	if r.regroup && !r.grouping && len(r.partitions) > 0 && !now.Before(r.regroupAt) {
+		r.grouping = true
+		r.regroup = false
+		ids := make([]uint32, 0, len(r.partitions))
+		for id := range r.partitions {
+			ids = append(ids, id)
+		}
+		sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
+		r.inFlight.Add(1)
+		go r.regroupPartitions(ids)
+	}
+	if !r.grouping {
+		// These reads commit nothing, so they go without demand.
+		for _, p := range r.partitions {
+			if p.inFlight || p.stopped != nil || p.retryAt.After(now) {
+				continue
+			}
+			if p.gapFrom != nil {
+				r.readGapLocked(p)
+			} else if p.aloneUntil != nil {
+				r.readLocked([]iggcon.FetchMessagesTarget{{PartitionId: p.id, Strategy: p.position}}, 1, false, false)
+			}
+		}
+	}
+	// Under OnRead each read stores its batch, so the reads go only on demand.
+	if !r.grouping && len(r.groups) > 0 && (!r.onRead || (r.waiting > 0 && len(r.queue) == 0)) {
+		r.readGroupsLocked(now)
+	}
+	wait := r.config.retryInterval
+	for _, p := range r.partitions {
+		if !p.inFlight && p.stopped == nil && p.retryAt.After(now) {
+			wait = min(wait, p.retryAt.Sub(now))
+		}
+	}
+	return max(wait, time.Millisecond)
+}
+
+// readGroupsLocked gives every group with ready partitions one read, the
+// groups without a read that may wait in flight first, and deals the free
+// slots left to the backlogged partitions, one read each with the whole count
+// and bytes. The quiet partitions of a group share one read, which may wait on
+// the server. Under OnRead that read stores, so a group holds one at a time.
+func (r *PartitionReader) readGroupsLocked(now time.Time) {
+	budget := r.slots - len(r.queue) - r.reads
+	if budget <= 0 {
+		return
+	}
+	type groupRead struct {
+		turn              int
+		index             int
+		ready             []iggcon.FetchMessagesTarget
+		quiet, backlogged []iggcon.FetchMessagesTarget
+		extra             int
+	}
+	var idle, reading []*groupRead
+	first := r.groupCursor
+	for turn := range len(r.groups) {
+		index := (first + turn) % len(r.groups)
+		parked := r.groupParksLocked(r.groups[index])
+		ready := r.readyTargetsLocked(r.groups[index], now, parked)
+		if len(ready) == 0 {
+			continue
+		}
+		read := &groupRead{turn: turn, index: index, ready: ready}
+		for _, target := range ready {
+			if r.partitions[target.PartitionId].backlogged {
+				read.backlogged = append(read.backlogged, target)
+			} else {
+				read.quiet = append(read.quiet, target)
+			}
+		}
+		if parked {
+			reading = append(reading, read)
+		} else {
+			idle = append(idle, read)
+		}
+	}
+	chosen := append(idle, reading...)
+	chosen = chosen[:min(len(chosen), budget)]
+	sort.Slice(chosen, func(i, j int) bool { return chosen[i].turn < chosen[j].turn })
+	// Every read holds at least one partition.
+	for extra, dealt := budget-len(chosen), true; extra > 0 && dealt; {
+		dealt = false
+		for _, read := range chosen {
+			limit := len(read.backlogged)
+			if len(read.quiet) == 0 {
+				limit--
+			}
+			if extra > 0 && read.extra < limit {
+				read.extra++
+				extra--
+				dealt = true
+			}
+		}
+	}
+	for _, read := range chosen {
+		var reads [][]iggcon.FetchMessagesTarget
+		switch {
+		case read.extra == 0:
+			reads = [][]iggcon.FetchMessagesTarget{read.ready}
+		case len(read.quiet) == 0:
+			reads = dealTargets(read.backlogged, read.extra+1)
+		default:
+			reads = append([][]iggcon.FetchMessagesTarget{read.quiet}, dealTargets(read.backlogged, read.extra)...)
+		}
+		for at, targets := range reads {
+			r.readLocked(targets, r.config.options.Count, r.onRead, at == 0 && len(read.quiet) > 0)
+		}
+		r.groupCursor = read.index + 1
+	}
+}
+
+// groupParksLocked reports whether a read of the group that may wait is in flight.
+func (r *PartitionReader) groupParksLocked(group []uint32) bool {
+	for _, id := range group {
+		if p := r.partitions[id]; p != nil && p.inFlight && p.parked {
+			return true
+		}
+	}
+	return false
+}
+
+// readyTargetsLocked puts the partitions served last at the end, since a fetch
+// that reaches its count serves its first targets. Under OnRead a quiet
+// partition waits for the read of the group that may wait, since a second one
+// would store.
+func (r *PartitionReader) readyTargetsLocked(group []uint32, now time.Time, parked bool) []iggcon.FetchMessagesTarget {
+	var ready []*partition
+	for _, id := range group {
+		p := r.partitions[id]
+		if p != nil && !p.inFlight && p.stopped == nil && p.gapFrom == nil && p.aloneUntil == nil && !p.retryAt.After(now) &&
+			(p.backlogged || !r.onRead || !parked) {
+			ready = append(ready, p)
+		}
+	}
+	sort.SliceStable(ready, func(i, j int) bool { return ready[i].servedAt < ready[j].servedAt })
+	targets := make([]iggcon.FetchMessagesTarget, 0, len(ready))
+	for _, p := range ready {
+		targets = append(targets, iggcon.FetchMessagesTarget{PartitionId: p.id, Strategy: p.position})
+	}
+	return targets
+}
+
+func (r *PartitionReader) readLocked(targets []iggcon.FetchMessagesTarget, count uint32, commit, parked bool) {
+	generations := make(map[uint32]uint64, len(targets))
+	for _, target := range targets {
+		p := r.partitions[target.PartitionId]
+		p.inFlight = true
+		p.parked = parked
+		generations[target.PartitionId] = p.generation
+	}
+	r.reads++
+	r.inFlight.Add(1)
+	options := r.config.options.Deferred()
+	// A read of a lost range or of one message asks for fewer messages than the reader.
+	options.MinCount = min(options.MinCount, count)
+	// A fetch needs the reply margin also without a wait.
+	options.RequestTimeout = max(options.RequestTimeout, options.MaxWait+iggcon.MinDeferredPollReplyMargin+time.Second)
+	go func() {
+		defer r.inFlight.Done()
+		prepared := targets
+		var err error
+		if commit {
+			prepared, err = r.prepareRead(targets, generations, options)
+		}
+		var sections []iggcon.FetchMessagesSection
+		if err == nil && len(prepared) > 0 {
+			sections, err = r.transport.FetchMessages(r.run, r.stream, r.topic, r.consumer, prepared, count, commit, options)
+		}
+		r.mu.Lock()
+		r.reads--
+		for id, generation := range generations {
+			// A seek or a new assignment started a new generation, whose own read can be in flight.
+			if p := r.partitions[id]; p != nil && p.generation == generation {
+				p.inFlight = false
+			}
+		}
+		switch {
+		case !r.closed:
+			if err == nil {
+				r.acceptLocked(generations, commit, sections)
+			} else {
+				r.refuseLocked(generations, err)
+			}
+		case commit:
+			r.settleOnCloseLocked(generations, sections, err)
+		}
+		r.applyPendingSeeksLocked(generations)
+		r.broadcastLocked()
+		r.mu.Unlock()
+		r.signal()
+	}()
+}
+
+func (r *PartitionReader) prepareRead(targets []iggcon.FetchMessagesTarget, generations map[uint32]uint64, options iggcon.DeferredPollOptions) ([]iggcon.FetchMessagesTarget, error) {
+	type baselineResult struct {
+		target iggcon.FetchMessagesTarget
+		batch  *iggcon.PolledMessage
+		err    error
+	}
+	results := make(chan baselineResult, len(targets))
+	probes := 0
+	r.mu.Lock()
+	for _, target := range targets {
+		p := r.partitions[target.PartitionId]
+		if p == nil || p.generation != generations[target.PartitionId] || p.received != nil || p.baseline != nil || target.Strategy.Kind != iggcon.POLLING_NEXT {
+			continue
+		}
+		probes++
+		go func() {
+			probe := options
+			probe.MinCount = 1
+			sections, err := r.transport.FetchMessages(r.run, r.stream, r.topic, r.consumer, []iggcon.FetchMessagesTarget{target}, 1, false, probe)
+			var polled *iggcon.PolledMessage
+			for _, section := range sections {
+				if section.PartitionId != target.PartitionId {
+					continue
+				}
+				if section.Err != nil {
+					err = section.Err
+					break
+				}
+				polled, err = binaryserialization.DeserializeSectionMessages(section)
+				break
+			}
+			results <- baselineResult{target: target, batch: polled, err: err}
+		}()
+	}
+	r.mu.Unlock()
+	var failure error
+	for range probes {
+		result := <-results
+		r.mu.Lock()
+		p := r.partitions[result.target.PartitionId]
+		if result.err != nil && failure == nil {
+			failure = result.err
+		}
+		if p != nil && p.generation == generations[result.target.PartitionId] && result.err == nil {
+			if result.batch != nil && len(result.batch.Messages) > 0 {
+				first := result.batch.Messages[0].Header.Offset
+				p.baseline = &first
+				p.context = result.batch.Context
+				p.position = iggcon.NextPollingStrategy().WithContext(p.context)
+			} else {
+				p.retryAt = time.Now().Add(r.config.retryInterval)
+			}
+		}
+		r.mu.Unlock()
+	}
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	if failure != nil || r.closed || r.waiting == 0 {
+		return nil, failure
+	}
+	prepared := make([]iggcon.FetchMessagesTarget, 0, len(targets))
+	for _, target := range targets {
+		p := r.partitions[target.PartitionId]
+		if p == nil || p.generation != generations[target.PartitionId] || p.pendingSeek != nil {
+			continue
+		}
+		if target.Strategy.Kind == iggcon.POLLING_NEXT && p.received == nil && p.baseline == nil {
+			continue
+		}
+		p.readStarted = true
+		prepared = append(prepared, iggcon.FetchMessagesTarget{PartitionId: p.id, Strategy: p.position})
+	}
+	return prepared, nil
+}
+
+func (r *PartitionReader) acceptLocked(generations map[uint32]uint64, commit bool, sections []iggcon.FetchMessagesSection) {
+	now := time.Now()
+	answered := make(map[uint32]bool, len(sections))
+	var abandoned uint64
+	for _, section := range sections {
+		answered[section.PartitionId] = true
+		var unknown ierror.FetchProgressUncertain
+		lost := errors.As(section.Err, &unknown)
+		if lost && unknown.OperationID != abandoned {
+			// The lost read holds the progress of its partition, also after a seek or a new assignment.
+			r.abandon(unknown.OperationID)
+			abandoned = unknown.OperationID
+		}
+		p := r.partitions[section.PartitionId]
+		generation, read := generations[section.PartitionId]
+		if !read || !r.keepsLocked(section.PartitionId, generation) {
+			continue
+		}
+		if commit && (p == nil || p.generation != generation) {
+			// The server stored this read, so a partition that left the assignment still hands it over.
+			r.receiveStoredLocked(section, generation)
+			continue
+		}
+		if p == nil || p.generation != generations[section.PartitionId] || p.pendingSeek != nil {
+			continue
+		}
+		if lost {
+			// The next read of the server shows what the lost read stored, and the gap check reads it again.
+			p.lostRead = true
+			p.retryAt = now.Add(r.config.retryInterval)
+			continue
+		}
+		if section.Err != nil {
+			r.refuseSectionLocked(p, section.Err, now)
+			continue
+		}
+		p.lastingRefusals = 0
+		p.context = section.Context
+		r.receiveLocked(p, section, commit, now)
+	}
+	for id := range generations {
+		if p := r.partitions[id]; p != nil && !answered[id] && r.config.options.MaxWait == 0 {
+			p.retryAt = now.Add(r.config.retryInterval)
+		}
+	}
+}
+
+func (r *PartitionReader) receiveLocked(p *partition, section iggcon.FetchMessagesSection, commit bool, now time.Time) {
+	// An empty partition reports a current offset of 0.
+	p.backlogged = section.NextOffset <= section.CurrentOffset && (section.MessageCount > 0 || section.CurrentOffset > 0)
+	polled, err := binaryserialization.DeserializeSectionMessages(section)
+	if err != nil {
+		r.refuseUndecodedLocked(p, section, commit, err)
+		return
+	}
+	expected := p.baseline
+	if p.received != nil {
+		next := *p.received + 1
+		expected = &next
+	}
+	if len(polled.Messages) == 0 {
+		if commit && p.lostRead && p.gapFrom == nil && expected != nil && section.NextOffset > *expected {
+			gap := *expected
+			p.gapFrom = &gap
+			p.gapEnd = section.NextOffset
+			p.held = polled
+			last := section.NextOffset - 1
+			p.received = &last
+			r.readGapLocked(p)
+			return
+		}
+		if !commit && p.gapFrom != nil {
+			switch {
+			case section.NextOffset >= p.gapEnd:
+				r.acceptGapLocked(p, polled)
+			case section.NextOffset > *p.gapFrom:
+				// The messages before the next offset are gone.
+				next := section.NextOffset
+				p.gapFrom = &next
+			default:
+				p.retryAt = now.Add(r.config.retryInterval)
+			}
+			return
+		}
+		if r.config.options.MaxWait == 0 {
+			p.retryAt = now.Add(r.config.retryInterval)
+		}
+		return
+	}
+	r.serves++
+	p.servedAt = r.serves
+	first := polled.Messages[0].Header.Offset
+	last := polled.Messages[len(polled.Messages)-1].Header.Offset
+	switch {
+	case commit && p.lostRead && p.gapFrom == nil && expected != nil && first > *expected:
+		// A read whose reply was lost stored up to here, so the messages in between are read again first.
+		gap := *expected
+		p.gapFrom = &gap
+		p.gapEnd = first
+		p.held = polled
+		p.received = &last
+		r.readGapLocked(p)
+	case p.gapFrom != nil && !commit:
+		r.acceptGapLocked(p, polled)
+	default:
+		if commit {
+			// This store fences out any older read that is still parked on the server.
+			p.lostRead = false
+		}
+		// A read alone goes over messages that an earlier read returned.
+		if p.received == nil || last > *p.received {
+			p.received = &last
+		}
+		r.queue = append(r.queue, entry{partition: int64(p.id), generation: p.generation, batch: polled})
+	}
+	if p.aloneUntil != nil && (p.gapFrom == nil && last+1 >= *p.aloneUntil || p.gapFrom != nil && *p.gapFrom >= *p.aloneUntil) {
+		p.aloneUntil = nil
+	}
+	switch continuation, _ := polled.Continuation(); {
+	case p.aloneUntil != nil && p.gapFrom == nil:
+		p.position = continuation
+	case r.onRead:
+		p.position = iggcon.NextPollingStrategy()
+	default:
+		p.position = continuation
+	}
+	p.fromRead = p.position.Context != nil
+}
+
+// refuseUndecodedLocked reads a section that does not decode again one
+// message at a time, so each good message reaches the application. A message
+// that does not decode stops the partition with the continuation after it.
+func (r *PartitionReader) refuseUndecodedLocked(p *partition, section iggcon.FetchMessagesSection, commit bool, err error) {
+	start := section.NextOffset - min(uint64(section.MessageCount), section.NextOffset)
+	if commit && section.NextOffset > 0 {
+		// The server stored the offset of the section, so Close moves it back over what was not delivered.
+		last := section.NextOffset - 1
+		if p.received == nil || last > *p.received {
+			p.received = &last
+		}
+	}
+	if section.MessageCount > 1 {
+		until := section.NextOffset
+		p.aloneUntil = &until
+		if p.gapFrom == nil {
+			p.position = iggcon.OffsetPollingStrategy(start).WithContext(section.Context)
+			p.fromRead = true
+		}
+		return
+	}
+	skip := iggcon.OffsetPollingStrategy(start + 1).WithContext(section.Context)
+	failure := &ReadError{PartitionID: &p.id, Err: err, Skip: &skip}
+	p.stopped = failure
+	r.queue = append(r.queue, entry{partition: int64(p.id), generation: p.generation, err: failure})
+}
+
+// receiveStoredLocked queues the messages of a stored read of a partition that left the assignment.
+func (r *PartitionReader) receiveStoredLocked(section iggcon.FetchMessagesSection, generation uint64) {
+	if section.Err != nil {
+		return
+	}
+	polled, err := binaryserialization.DeserializeSectionMessages(section)
+	if err == nil && len(polled.Messages) > 0 {
+		r.queue = append(r.queue, entry{partition: int64(section.PartitionId), generation: generation, batch: polled})
+	}
+}
+
+// settleOnCloseLocked records what a committing read that ended during Close
+// stored, so that the rewind moves the offset back over it.
+func (r *PartitionReader) settleOnCloseLocked(generations map[uint32]uint64, sections []iggcon.FetchMessagesSection, err error) {
+	var unknown ierror.FetchProgressUncertain
+	if errors.As(err, &unknown) {
+		r.abandon(unknown.OperationID)
+		for id, generation := range generations {
+			if p := r.partitions[id]; p != nil && p.generation == generation {
+				p.lostRead = true
+			}
+		}
+		return
+	}
+	var abandoned uint64
+	for _, section := range sections {
+		p := r.partitions[section.PartitionId]
+		generation, read := generations[section.PartitionId]
+		if p == nil || !read || p.generation != generation {
+			continue
+		}
+		switch {
+		case errors.As(section.Err, &unknown):
+			if unknown.OperationID != abandoned {
+				r.abandon(unknown.OperationID)
+				abandoned = unknown.OperationID
+			}
+			p.lostRead = true
+		case section.Err == nil && section.MessageCount > 0 && section.NextOffset > 0:
+			last := section.NextOffset - 1
+			if p.received == nil || last > *p.received {
+				p.received = &last
+			}
+		}
+	}
+}
+
+func (r *PartitionReader) readGapLocked(p *partition) {
+	missing := p.gapEnd - *p.gapFrom
+	count := uint32(min(uint64(r.config.options.Count), missing))
+	if p.aloneUntil != nil && *p.gapFrom < *p.aloneUntil {
+		count = 1
+	}
+	strategy := iggcon.OffsetPollingStrategy(*p.gapFrom).WithContext(p.context)
+	r.readLocked([]iggcon.FetchMessagesTarget{{PartitionId: p.id, Strategy: strategy}}, count, false, false)
+}
+
+func (r *PartitionReader) acceptGapLocked(p *partition, read *iggcon.PolledMessage) {
+	heldFirst := p.gapEnd
+	var missing []iggcon.IggyMessage
+	for _, message := range read.Messages {
+		if message.Header.Offset < heldFirst {
+			missing = append(missing, message)
+		}
+	}
+	if len(missing) > 0 {
+		batch := *read
+		batch.Messages = missing
+		batch.MessageCount = uint32(len(missing))
+		r.queue = append(r.queue, entry{partition: int64(p.id), generation: p.generation, batch: &batch})
+		next := missing[len(missing)-1].Header.Offset + 1
+		p.gapFrom = &next
+	}
+	if len(missing) == 0 || *p.gapFrom >= heldFirst {
+		if len(p.held.Messages) > 0 {
+			r.queue = append(r.queue, entry{partition: int64(p.id), generation: p.generation, batch: p.held})
+		}
+		p.gapFrom = nil
+		p.held = nil
+		return
+	}
+	r.readGapLocked(p)
+}
+
+func (r *PartitionReader) refuseSectionLocked(p *partition, err error, now time.Time) {
+	p.backlogged = false
+	if p.gapFrom != nil && errors.Is(err, ierror.ErrConsumerGroupPartitionNotOwned) {
+		r.giveUpGapLocked(p)
+	}
+	r.handleRefusalLocked(p, err, now)
+}
+
+// giveUpGapLocked reports what a lost read stored as lost, since another member
+// owns the partition now, and queues the batch read after it.
+func (r *PartitionReader) giveUpGapLocked(p *partition) {
+	lost := &ReadError{PartitionID: &p.id, Err: fmt.Errorf("%w: offsets %d to %d were stored by a read whose reply was lost",
+		ierror.FetchProgressUncertain{}, *p.gapFrom, p.gapEnd-1)}
+	r.queue = append(r.queue, entry{partition: int64(p.id), generation: p.generation, err: lost})
+	if len(p.held.Messages) > 0 {
+		r.queue = append(r.queue, entry{partition: int64(p.id), generation: p.generation, batch: p.held})
+	}
+	p.gapFrom = nil
+	p.held = nil
+}
+
+func (r *PartitionReader) refuseLocked(generations map[uint32]uint64, err error) {
+	now := time.Now()
+	var unknown ierror.FetchProgressUncertain
+	switch {
+	case errors.Is(err, context.Canceled) && r.run.Err() != nil:
+		return
+	case errors.As(err, &unknown):
+		// The next read of the server starts after what the lost read stored, and the gap check reads it again.
+		r.abandon(unknown.OperationID)
+		for id := range generations {
+			if p := r.partitions[id]; p != nil {
+				p.lostRead = true
+				p.retryAt = now.Add(r.config.retryInterval)
+			}
+		}
+		return
+	case errors.Is(err, ierror.ErrNotPrimary):
+		r.regroup = true
+		return
+	}
+	var coded interface{ Code() ierror.Code }
+	if errors.As(err, &coded) {
+		for id := range generations {
+			if p := r.partitions[id]; p != nil {
+				r.handleRefusalLocked(p, err, now)
+			}
+		}
+		return
+	}
+	for id := range generations {
+		if p := r.partitions[id]; p != nil {
+			p.retryAt = now.Add(r.config.retryInterval)
+		}
+	}
+	r.config.logger.Warn("A read of the reader failed, the reader retries it", "error", err)
+	if len(r.queue) == 0 || r.queue[len(r.queue)-1].partition != wholeReader {
+		r.queue = append(r.queue, entry{partition: wholeReader, err: &ReadError{Err: err}})
+	}
+}
+
+func (r *PartitionReader) handleRefusalLocked(p *partition, err error, now time.Time) {
+	switch {
+	case errors.Is(err, ierror.ErrNotPrimary):
+		r.regroup = true
+		return
+	case errors.Is(err, ierror.ErrConsumerGroupPartitionNotOwned):
+		// A rejoin gives the partition a new owner generation, so its next read starts from the stored offset.
+		p.position = r.config.start
+		p.fromRead = r.config.start.Context != nil
+		p.context = iggcon.PartitionContext{}
+		p.received = nil
+		p.baseline = nil
+		p.lostRead = false
+		r.refreshAt = now
+		p.retryAt = now.Add(r.config.retryInterval)
+		p.lastingRefusals++
+		if p.lastingRefusals >= lastingRefusalsBeforeStop {
+			p.lastingRefusals = 0
+			r.queue = append(r.queue, entry{partition: int64(p.id), generation: p.generation, err: &ReadError{PartitionID: &p.id, Err: err}})
+		}
+		return
+	case errors.Is(err, ierror.ErrTransientNotAccepted), errors.Is(err, ierror.ErrTransientNotCommitted),
+		errors.Is(err, ierror.ErrDeferredPollCapacityExceeded):
+		p.retryAt = now.Add(r.config.retryInterval)
+		return
+	case errors.Is(err, ierror.ErrHistoryUnavailable) && !p.fromRead:
+		r.config.logger.Warn("A partition has a new history, the reader reads it again", "partition", p.id)
+		r.regroup = true
+		p.retryAt = now.Add(r.config.retryInterval)
+		return
+	}
+	p.lastingRefusals++
+	if errors.Is(err, ierror.ErrHistoryUnavailable) || errors.Is(err, ierror.ErrInvalidSizeBytes) ||
+		p.lastingRefusals >= lastingRefusalsBeforeStop {
+		p.stopped = &ReadError{PartitionID: &p.id, Err: err}
+		r.queue = append(r.queue, entry{partition: int64(p.id), generation: p.generation, err: p.stopped})
+		return
+	}
+	shift := min(p.lastingRefusals-1, maxBackoffShift)
+	p.retryAt = now.Add(r.config.retryInterval << shift)
+	r.config.logger.Warn("A partition refused a read, the reader retries it", "partition", p.id, "error", err)
+}
+
+func (r *PartitionReader) applyPendingSeeksLocked(generations map[uint32]uint64) {
+	for id := range generations {
+		p := r.partitions[id]
+		if p == nil || p.pendingSeek == nil {
+			continue
+		}
+		start := *p.pendingSeek
+		p.pendingSeek = nil
+		r.dropQueuedLocked(id)
+		if store := r.applySeekLocked(p, start); store != nil {
+			if r.closed {
+				// Close runs it after the reads ended and before its rewind.
+				r.seekStores = append(r.seekStores, store)
+				continue
+			}
+			r.inFlight.Add(1)
+			go func() {
+				defer r.inFlight.Done()
+				if err := store(r.run); err != nil {
+					r.config.logger.Error("Failed to store the offset before a seek", "partition", id, "error", err)
+				}
+			}()
+		}
+	}
+}
+
+// applySeekLocked starts a new generation of p at start, and returns the store
+// that must go before its next read under OnRead.
+func (r *PartitionReader) applySeekLocked(p *partition, start iggcon.PollingStrategy) func(context.Context) error {
+	r.lastGen++
+	*p = partition{id: p.id, generation: r.lastGen, position: start, fromRead: start.Context != nil, context: p.context}
+	if !r.onRead || start.Kind != iggcon.POLLING_OFFSET || start.Value == 0 {
+		return nil
+	}
+	// The server stores forward only on a read, so a store moves the offset that the next read starts after.
+	if start.Context != nil {
+		p.context = *start.Context
+	}
+	first := start.Value
+	p.baseline = &first
+	p.position = iggcon.NextPollingStrategy()
+	history := p.context
+	id := p.id
+	return func(ctx context.Context) error {
+		return r.client.StoreConsumerPosition(ctx, r.consumer, r.stream, r.topic, iggcon.ConsumerPosition{
+			PartitionId: id, Offset: first - 1, Context: history})
+	}
+}
+
+func (r *PartitionReader) dropQueuedLocked(partitionID uint32) {
+	kept := r.queue[:0]
+	for _, queued := range r.queue {
+		if queued.partition != int64(partitionID) {
+			kept = append(kept, queued)
+		}
+	}
+	r.queue = kept
+}
+
+// currentLocked reports whether a queued entry still counts. A seek or a new
+// assignment of its partition drops it. A partition that left the assignment
+// keeps the reads that the server stored under OnRead.
+func (r *PartitionReader) currentLocked(queued entry) bool {
+	if queued.partition == wholeReader {
+		return true
+	}
+	return r.keepsLocked(uint32(queued.partition), queued.generation)
+}
+
+// keeps reports whether a batch that the generation of its partition read still counts.
+func (r *PartitionReader) keeps(partitionID uint32, generation uint64) bool {
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	return r.keepsLocked(partitionID, generation)
+}
+
+func (r *PartitionReader) keepsLocked(partitionID uint32, generation uint64) bool {
+	p := r.partitions[partitionID]
+	return (p != nil && p.generation == generation) ||
+		(r.onRead && generation >= r.seekGenerations[partitionID])
+}
+
+func (r *PartitionReader) deliveredLocked(p *partition, batch *iggcon.PolledMessage) {
+	last, _ := batch.Checkpoint()
+	if p.firstDelivered == nil {
+		first := batch.Messages[0].Header.Offset
+		p.firstDelivered = &first
+	}
+	p.delivered = &last
+	if r.auto == nil {
+		return
+	}
+	switch r.auto.After {
+	case iggcon.CommitAfterDelivery:
+		r.countLocked(p, last, uint64(len(batch.Messages)), true)
+	case iggcon.CommitAfterProcessing:
+		r.uncounted = &uncounted{partition: p.id, generation: p.generation, checkpoint: last, messages: uint64(len(batch.Messages))}
+	}
+}
+
+// deliverMessage records one message that an IggyConsumer handed over.
+func (r *PartitionReader) deliverMessage(checkpoint iggcon.Checkpoint) {
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	if p := r.partitions[checkpoint.PartitionID()]; p != nil {
+		if p.firstDelivered == nil {
+			offset := checkpoint.Offset()
+			p.firstDelivered = &offset
+		}
+		p.delivered = &checkpoint
+	}
+}
+
+// countDelivered counts messages up to a delivered checkpoint, for an IggyConsumer.
+func (r *PartitionReader) countDelivered(checkpoint iggcon.Checkpoint, messages uint64, endsBatch bool) {
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	if p := r.partitions[checkpoint.PartitionID()]; p != nil && p.deliveredHere(checkpoint) {
+		r.countLocked(p, checkpoint, messages, endsBatch)
+	}
+}
+
+func (r *PartitionReader) countUncountedLocked() {
+	if r.uncounted == nil {
+		return
+	}
+	batch := r.uncounted
+	r.uncounted = nil
+	if p := r.partitions[batch.partition]; p != nil && p.generation == batch.generation {
+		r.countLocked(p, batch.checkpoint, batch.messages, true)
+	}
+}
+
+func (r *PartitionReader) countLocked(p *partition, checkpoint iggcon.Checkpoint, messages uint64, endsBatch bool) {
+	if r.auto == nil {
+		return
+	}
+	if p.counted == nil || checkpoint.Offset() > p.counted.Offset() {
+		p.counted = &checkpoint
+	}
+	p.sinceStore += messages
+	due := false
+	switch r.auto.Every.Kind {
+	case iggcon.CommitEveryMessage:
+		due = true
+	case iggcon.CommitEveryMessages:
+		due = p.sinceStore >= uint64(r.auto.Every.Messages)
+	case iggcon.CommitEveryBatch:
+		due = endsBatch
+	}
+	if due {
+		select {
+		case r.storeDue <- struct{}{}:
+		default:
+		}
+	}
+}
+
+func (r *PartitionReader) flushInBackground() {
+	r.inFlight.Add(1)
+	go func() {
+		defer r.inFlight.Done()
+		if err := r.flush(context.WithoutCancel(r.run)); err != nil {
+			r.config.logger.Error("Failed to store the counted offsets of a reader", "error", err)
+		}
+	}()
+}
+
+// flush stores the retired positions, then each counted position ahead of its
+// last store, one at a time, and returns the first failure.
+func (r *PartitionReader) flush(ctx context.Context) error {
+	r.storeMu.Lock()
+	defer r.storeMu.Unlock()
+	r.mu.Lock()
+	stores := append([]iggcon.Checkpoint(nil), r.retiring...)
+	r.retiring = nil
+	for _, p := range r.partitions {
+		if p.counted != nil && (p.stored == nil || p.counted.Offset() > p.stored.Offset()) {
+			stores = append(stores, *p.counted)
+		}
+	}
+	r.mu.Unlock()
+	var failure error
+	for _, checkpoint := range stores {
+		if err := r.client.StoreConsumerPosition(ctx, r.consumer, r.stream, r.topic, checkpointPosition(checkpoint)); err != nil {
+			if failure == nil {
+				failure = err
+			}
+			continue
+		}
+		r.mu.Lock()
+		if p := r.partitions[checkpoint.PartitionID()]; p != nil && p.deliveredHere(checkpoint) {
+			stored := checkpoint
+			p.stored = &stored
+			p.sinceStore = 0
+		}
+		r.mu.Unlock()
+	}
+	return failure
+}
+
+// rewind moves the offset of each partition with undelivered messages back to
+// its last delivered message.
+func (r *PartitionReader) rewind(ctx context.Context) error {
+	r.mu.Lock()
+	firstQueued := make(map[uint32]uint64)
+	for _, queued := range r.queue {
+		if queued.batch != nil && len(queued.batch.Messages) > 0 {
+			if _, ok := firstQueued[uint32(queued.partition)]; !ok {
+				firstQueued[uint32(queued.partition)] = queued.batch.Messages[0].Header.Offset
+			}
+		}
+	}
+	r.queue = nil
+	var positions []iggcon.ConsumerPosition
+	var deletes []iggcon.ConsumerPosition
+	for _, p := range r.partitions {
+		first, queued := firstQueued[p.id]
+		undelivered := queued || p.lostRead || (p.readStarted && p.delivered == nil) ||
+			(p.received != nil && (p.delivered == nil || *p.received > p.delivered.Offset()))
+		switch {
+		case !undelivered:
+		case p.delivered != nil:
+			positions = append(positions, checkpointPosition(*p.delivered))
+		case p.baseline != nil && p.readStarted:
+			position := iggcon.ConsumerPosition{PartitionId: p.id, Context: p.context}
+			if *p.baseline == 0 {
+				deletes = append(deletes, position)
+			} else {
+				position.Offset = *p.baseline - 1
+				positions = append(positions, position)
+			}
+		case r.config.start.Kind == iggcon.POLLING_NEXT && queued && first > 0:
+			positions = append(positions, iggcon.ConsumerPosition{PartitionId: p.id, Offset: first - 1, Context: p.context})
+		default:
+			r.config.logger.Warn("Cannot move the offset of a partition back, no message of it was delivered", "partition", p.id)
+		}
+	}
+	r.mu.Unlock()
+	var failure error
+	for _, position := range deletes {
+		if err := r.client.DeleteConsumerPosition(ctx, r.consumer, r.stream, r.topic, position); err != nil && failure == nil {
+			failure = err
+		}
+	}
+	for _, position := range positions {
+		if err := r.client.StoreConsumerPosition(ctx, r.consumer, r.stream, r.topic, position); err != nil && failure == nil {
+			failure = err
+		}
+	}
+	return failure
+}
+
+func (r *PartitionReader) regroupPartitions(ids []uint32) {
+	defer r.inFlight.Done()
+	groups, err := r.transport.ReadinessGroups(r.run, r.stream, r.topic, ids)
+	r.mu.Lock()
+	r.grouping = false
+	if err != nil {
+		r.regroup = true
+		r.regroupAt = time.Now().Add(r.config.retryInterval)
+		r.config.logger.Warn("Failed to group the partitions of a reader, it tries again", "error", err)
+		for _, p := range r.partitions {
+			p.retryAt = r.regroupAt
+		}
+	} else {
+		r.groups = groups
+	}
+	r.mu.Unlock()
+	r.signal()
+}
+
+func (r *PartitionReader) refreshAssignment() {
+	defer r.inFlight.Done()
+	assignment, err := r.client.SyncConsumerGroup(r.run, r.stream, r.topic, r.consumer.Id)
+	if err != nil && errors.Is(err, ierror.ErrConsumerGroupMemberNotFound) {
+		if err = r.client.JoinConsumerGroup(r.run, r.stream, r.topic, r.consumer.Id); err == nil {
+			assignment, err = r.client.SyncConsumerGroup(r.run, r.stream, r.topic, r.consumer.Id)
+		}
+	}
+	r.mu.Lock()
+	r.refreshing = false
+	r.refreshAt = time.Now().Add(r.config.retryInterval)
+	if err != nil {
+		r.config.logger.Warn("Failed to read the group assignment of a reader, it tries again", "error", err)
+	} else if assignment != nil {
+		r.assignLocked(assignment.Partitions)
+	}
+	r.broadcastLocked()
+	r.mu.Unlock()
+	r.signal()
+}
+
+func (r *PartitionReader) assignLocked(assigned []uint32) {
+	next := make(map[uint32]bool, len(assigned))
+	for _, id := range assigned {
+		next[id] = true
+	}
+	changed := false
+	for id, p := range r.partitions {
+		if next[id] {
+			continue
+		}
+		delete(r.partitions, id)
+		if !r.onRead {
+			r.dropQueuedLocked(id)
+		} else if p.gapFrom != nil {
+			r.giveUpGapLocked(p)
+		}
+		if p.counted != nil && (p.stored == nil || p.counted.Offset() > p.stored.Offset()) {
+			// The partition goes to another member, so its counted position is stored first.
+			r.retiring = append(r.retiring, *p.counted)
+		}
+		changed = true
+	}
+	for _, id := range assigned {
+		if _, ok := r.partitions[id]; !ok {
+			r.addPartition(id)
+			changed = true
+		}
+	}
+	if changed {
+		r.regroup = true
+		if len(r.retiring) > 0 {
+			select {
+			case r.storeDue <- struct{}{}:
+			default:
+			}
+		}
+	}
+}
+
+func (r *PartitionReader) abandon(operationID uint64) {
+	r.inFlight.Add(1)
+	go func() {
+		defer r.inFlight.Done()
+		// Close waits for it, so the rewind after it finds the partition open.
+		if err := r.client.AbandonOperation(context.WithoutCancel(r.run), operationID); err != nil {
+			r.config.logger.Warn("Failed to abandon a lost read", "operation", operationID, "error", err)
+		}
+	}()
+}
+
+// dealTargets deals targets round robin into count reads.
+func dealTargets(targets []iggcon.FetchMessagesTarget, count int) [][]iggcon.FetchMessagesTarget {
+	reads := make([][]iggcon.FetchMessagesTarget, count)
+	for index, target := range targets {
+		reads[index%count] = append(reads[index%count], target)
+	}
+	return reads
+}
+
+// sameIncarnationAndOwner reports whether offsets of two contexts compare. The
+// metadata op is left out, because the server raises it per read.
+func sameIncarnationAndOwner(left, right iggcon.PartitionContext) bool {
+	return left.Incarnation == right.Incarnation && left.OwnerGeneration == right.OwnerGeneration
+}
+
+func checkpointPosition(checkpoint iggcon.Checkpoint) iggcon.ConsumerPosition {
+	return iggcon.ConsumerPosition{PartitionId: checkpoint.PartitionID(), Offset: checkpoint.Offset(), Context: checkpoint.Context()}
+}
diff --git a/foreign/go/client/reader/reader_test.go b/foreign/go/client/reader/reader_test.go
new file mode 100644
index 0000000..a4b8eea
--- /dev/null
+++ b/foreign/go/client/reader/reader_test.go
@@ -0,0 +1,1374 @@
+// 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.
+
+package reader
+
+import (
+	"context"
+	"encoding/binary"
+	"errors"
+	"fmt"
+	"slices"
+	"sync"
+	"testing"
+	"time"
+
+	iggcon "github.com/apache/iggy/foreign/go/contracts"
+	ierror "github.com/apache/iggy/foreign/go/errors"
+	"github.com/apache/iggy/foreign/go/internal/batch"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+const (
+	testTimeout       = 5 * time.Second
+	testRetryInterval = 100 * time.Millisecond
+)
+
+var (
+	testContext    = iggcon.PartitionContext{Incarnation: 7, OwnerGeneration: 1, MetadataOp: 1}
+	errStoreFailed = errors.New("the store failed")
+)
+
+type fakeRead struct {
+	targets []iggcon.FetchMessagesTarget
+	count   uint32
+	commit  bool
+	reply   chan fakeReply
+}
+
+type fakeReply struct {
+	sections []iggcon.FetchMessagesSection
+	err      error
+}
+
+type store struct {
+	partition uint32
+	offset    uint64
+}
+
+// fakeClient answers the reads of a reader one by one and records its stores.
+type fakeClient struct {
+	iggcon.Client
+	mu          sync.Mutex
+	reads       chan *fakeRead
+	stores      []store
+	abandoned   []uint64
+	membership  []string
+	assignments chan []uint32
+	grouper     func([]uint32) [][]uint32
+	// failedGroupings is the number of the next groupings that fail.
+	failedGroupings int
+	groupings       []time.Time
+	starts          map[uint32]uint64
+	deleted         []uint32
+	storeStarted    chan context.Context
+	storeRelease    chan struct{}
+	// failedStores is the number of the next stores that fail.
+	failedStores int
+	// holdsCommits makes a committing read wait for its reply after its
+	// caller stops waiting, as the client does.
+	holdsCommits bool
+	leaveErr     error
+}
+
+func newFakeClient() *fakeClient {
+	return &fakeClient{
+		reads:       make(chan *fakeRead, 16),
+		starts:      make(map[uint32]uint64),
+		assignments: make(chan []uint32, 4),
+		grouper:     func(partitions []uint32) [][]uint32 { return [][]uint32{partitions} },
+	}
+}
+
+func (f *fakeClient) FetchMessages(ctx context.Context, _, _ iggcon.Identifier, _ iggcon.Consumer,
+	targets []iggcon.FetchMessagesTarget, count uint32, autoCommit bool, options iggcon.DeferredPollOptions,
+) ([]iggcon.FetchMessagesSection, error) {
+	// The client refuses before the send what the server refuses.
+	if err := options.ValidateFetch(count); err != nil {
+		return nil, err
+	}
+	if !autoCommit && count == 1 && len(targets) == 1 && targets[0].Strategy.Kind == iggcon.POLLING_NEXT {
+		return []iggcon.FetchMessagesSection{section(targets[0].PartitionId, f.starts[targets[0].PartitionId])}, nil
+	}
+	read := &fakeRead{targets: targets, count: count, commit: autoCommit, reply: make(chan fakeReply, 1)}
+	f.reads <- read
+	if autoCommit && f.holdsCommits {
+		reply := <-read.reply
+		return reply.sections, reply.err
+	}
+	select {
+	case reply := <-read.reply:
+		return reply.sections, reply.err
+	case <-ctx.Done():
+		return nil, ctx.Err()
+	}
+}
+
+func (f *fakeClient) ReadinessGroups(_ context.Context, _, _ iggcon.Identifier, partitions []uint32) ([][]uint32, error) {
+	f.mu.Lock()
+	defer f.mu.Unlock()
+	f.groupings = append(f.groupings, time.Now())
+	if f.failedGroupings > 0 {
+		f.failedGroupings--
+		return nil, errors.New("no route to the partition yet")
+	}
+	return f.grouper(partitions), nil
+}
+
+func (f *fakeClient) StoreConsumerPosition(ctx context.Context, _ iggcon.Consumer, _, _ iggcon.Identifier, position iggcon.ConsumerPosition) error {
+	if f.storeStarted != nil {
+		f.storeStarted <- ctx
+		select {
+		case <-f.storeRelease:
+		case <-ctx.Done():
+			return ctx.Err()
+		}
+	}
+	f.mu.Lock()
+	defer f.mu.Unlock()
+	if f.failedStores > 0 {
+		f.failedStores--
+		return errStoreFailed
+	}
+	f.stores = append(f.stores, store{partition: position.PartitionId, offset: position.Offset})
+	return nil
+}
+
+func (f *fakeClient) DeleteConsumerPosition(_ context.Context, _ iggcon.Consumer, _, _ iggcon.Identifier, position iggcon.ConsumerPosition) error {
+	f.mu.Lock()
+	defer f.mu.Unlock()
+	f.deleted = append(f.deleted, position.PartitionId)
+	return nil
+}
+
+func (f *fakeClient) AbandonOperation(_ context.Context, operationID uint64) error {
+	f.mu.Lock()
+	defer f.mu.Unlock()
+	f.abandoned = append(f.abandoned, operationID)
+	return nil
+}
+
+func (f *fakeClient) JoinConsumerGroup(context.Context, iggcon.Identifier, iggcon.Identifier, iggcon.Identifier) error {
+	f.mu.Lock()
+	defer f.mu.Unlock()
+	f.membership = append(f.membership, "join")
+	return nil
+}
+
+func (f *fakeClient) LeaveConsumerGroup(context.Context, iggcon.Identifier, iggcon.Identifier, iggcon.Identifier) error {
+	f.mu.Lock()
+	defer f.mu.Unlock()
+	f.membership = append(f.membership, "leave")
+	return f.leaveErr
+}
+
+func (f *fakeClient) SyncConsumerGroup(ctx context.Context, _, _ iggcon.Identifier, _ iggcon.Identifier) (*iggcon.ConsumerGroupAssignment, error) {
+	select {
+	case partitions := <-f.assignments:
+		return &iggcon.ConsumerGroupAssignment{Generation: 1, Partitions: partitions}, nil
+	case <-ctx.Done():
+		return nil, ctx.Err()
+	}
+}
+
+func (f *fakeClient) nextRead(t *testing.T) *fakeRead {
+	t.Helper()
+	select {
+	case read := <-f.reads:
+		return read
+	case <-time.After(testTimeout):
+		t.Fatal("the reader sent no read")
+		return nil
+	}
+}
+
+func (f *fakeClient) readsNothingFor(wait time.Duration) bool {
+	select {
+	case read := <-f.reads:
+		f.reads <- read
+		return false
+	case <-time.After(wait):
+		return true
+	}
+}
+
+func (f *fakeClient) recordedStores() []store {
+	f.mu.Lock()
+	defer f.mu.Unlock()
+	return append([]store(nil), f.stores...)
+}
+
+func (f *fakeClient) recordedAbandons() []uint64 {
+	f.mu.Lock()
+	defer f.mu.Unlock()
+	return append([]uint64(nil), f.abandoned...)
+}
+
+func (f *fakeClient) recordedGroupings() []time.Time {
+	f.mu.Lock()
+	defer f.mu.Unlock()
+	return append([]time.Time(nil), f.groupings...)
+}
+
+func (r *fakeRead) answer(sections ...iggcon.FetchMessagesSection) {
+	r.reply <- fakeReply{sections: sections}
+}
+
+// section is an OK section of partition with one message at each offset, the last one of the partition.
+func section(partition uint32, offsets ...uint64) iggcon.FetchMessagesSection {
+	var records []byte
+	for _, offset := range offsets {
+		records = append(records, record(offset)...)
+	}
+	next := offsets[len(offsets)-1] + 1
+	return iggcon.FetchMessagesSection{PartitionId: partition, CurrentOffset: next - 1, MessageCount: uint32(len(offsets)),
+		Context: testContext, NextOffset: next, Records: records}
+}
+
+// behind is an empty section of a partition whose read starts at offset 0
+// while the partition holds messages up to head, as a fetch that reached its
+// count before the partition returns.
+func behind(partition uint32, head uint64) iggcon.FetchMessagesSection {
+	return iggcon.FetchMessagesSection{PartitionId: partition, CurrentOffset: head, Context: testContext}
+}
+
+// ahead is an OK section with one message at each offset of a partition whose
+// head is at head, past the last of them, so the partition is backlogged.
+func ahead(partition uint32, head uint64, offsets ...uint64) iggcon.FetchMessagesSection {
+	backlogged := section(partition, offsets...)
+	backlogged.CurrentOffset = head
+	return backlogged
+}
+
+func partitionsOf(read *fakeRead) []uint32 {
+	partitions := make([]uint32, 0, len(read.targets))
+	for _, target := range read.targets {
+		partitions = append(partitions, target.PartitionId)
+	}
+	slices.Sort(partitions)
+	return partitions
+}
+
+// record is one batch record with one message at offset.
+func record(offset uint64) []byte {
+	return batchRecord(offset, frame(0, []byte(fmt.Sprintf("message %d", offset)), 0))
+}
+
+// undecodable is one batch record at offset whose message claims more payload than the record holds.
+func undecodable(offset uint64) []byte {
+	message := frame(0, []byte("message"), 0)
+	binary.LittleEndian.PutUint32(message[36:], 1024)
+	return batchRecord(offset, message)
+}
+
+// sectionOf is an OK section of partition that holds records of count messages up to next.
+func sectionOf(partition uint32, next uint64, count uint32, records ...[]byte) iggcon.FetchMessagesSection {
+	return iggcon.FetchMessagesSection{PartitionId: partition, CurrentOffset: next - 1, MessageCount: count,
+		Context: testContext, NextOffset: next, Records: slices.Concat(records...)}
+}
+
+// batchRecord is one batch record based at baseOffset holding frames.
+func batchRecord(baseOffset uint64, frames ...[]byte) []byte {
+	blob := slices.Concat(frames...)
+	header := make([]byte, batch.HeaderSize)
+	binary.LittleEndian.PutUint64(header[8:], baseOffset)
+	binary.LittleEndian.PutUint64(header[32:], uint64(batch.HeaderSize+len(blob)))
+	binary.LittleEndian.PutUint32(header[48:], uint32(len(frames)))
+	return append(header, blob...)
+}
+
+// frame is one message frame at offsetDelta of its batch, with reserved in the reserved field.
+func frame(offsetDelta uint32, payload []byte, reserved uint64) []byte {
+	message := make([]byte, batch.MessageHeaderSize+len(payload))
+	binary.LittleEndian.PutUint32(message[24:], offsetDelta)
+	binary.LittleEndian.PutUint32(message[36:], uint32(len(payload)))
+	binary.LittleEndian.PutUint64(message[40:], reserved)
+	copy(message[batch.MessageHeaderSize:], payload)
+	return message
+}
+
+func identifier(t *testing.T, value uint32) iggcon.Identifier {
+	t.Helper()
+	id, err := iggcon.NewIdentifier(value)
+	require.NoError(t, err)
+	return id
+}
+
+func standalone(t *testing.T, client *fakeClient, options iggcon.PollOptions, partitions ...uint32) *PartitionReader {
+	t.Helper()
+	reader, err := NewPartitionReader(context.Background(), client, identifier(t, 1), identifier(t, 2),
+		iggcon.NewSingleConsumer(identifier(t, 3)), WithPartitions(partitions...), WithPollOptions(options),
+		WithRetryInterval(testRetryInterval))
+	require.NoError(t, err)
+	t.Cleanup(func() { _ = reader.Close(context.Background()) })
+	return reader
+}
+
+// member is a group reader whose first refresh reads assigned.
+func member(t *testing.T, client *fakeClient, options iggcon.PollOptions, assigned ...uint32) *PartitionReader {
+	t.Helper()
+	group, err := iggcon.NewIdentifier(uint32(9))
+	require.NoError(t, err)
+	client.assignments <- assigned
+	reader, err := NewPartitionReader(context.Background(), client, identifier(t, 1), identifier(t, 2),
+		iggcon.NewGroupConsumer(group), WithPollOptions(options), WithRetryInterval(testRetryInterval))
+	require.NoError(t, err)
+	t.Cleanup(func() { _ = reader.Close(context.Background()) })
+	return reader
+}
+
+// assign hands assigned to the next refresh of reader and waits until the reader follows it.
+func assign(t *testing.T, client *fakeClient, reader *PartitionReader, assigned ...uint32) {
+	t.Helper()
+	client.assignments <- assigned
+	require.Eventually(t, func() bool { return slices.Equal(reader.Partitions(), assigned) }, testTimeout, 5*time.Millisecond)
+}
+
+// nextInBackground starts a Next whose outcome arrives on the returned channel.
+func nextInBackground(reader *PartitionReader) <-chan outcome {
+	polled := make(chan outcome, 1)
+	go func() {
+		ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
+		defer cancel()
+		batch, err := reader.Next(ctx)
+		polled <- outcome{batch, err}
+	}()
+	return polled
+}
+
+type outcome struct {
+	polled *iggcon.PolledMessage
+	err    error
+}
+
+func queued(reader *PartitionReader) int {
+	reader.mu.Lock()
+	defer reader.mu.Unlock()
+	return len(reader.queue)
+}
+
+func waiting(reader *PartitionReader) int {
+	reader.mu.Lock()
+	defer reader.mu.Unlock()
+	return reader.waiting
+}
+
+func next(t *testing.T, reader *PartitionReader) *iggcon.PolledMessage {
+	t.Helper()
+	ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
+	defer cancel()
+	polled, err := reader.Next(ctx)
+	require.NoError(t, err)
+	return polled
+}
+
+func offsets(polled *iggcon.PolledMessage) []uint64 {
+	values := make([]uint64, 0, len(polled.Messages))
+	for _, message := range polled.Messages {
+		values = append(values, message.Header.Offset)
+	}
+	return values
+}
+
+var waitOneSecond = iggcon.WaitPollOptions(time.Second)
+
+func withCommit(options iggcon.PollOptions, commit iggcon.CommitPolicy) iggcon.PollOptions {
+	options.Commit = commit
+	return options
+}
+
+func TestPartitionReader_ReadsEachGroupWithOneRequestAndContinuesAfterItsLastMessage(t *testing.T) {
+	client := newFakeClient()
+	client.grouper = func([]uint32) [][]uint32 { return [][]uint32{{1, 3}, {2}} }
+	reader := standalone(t, client, waitOneSecond, 1, 2, 3)
+	first, second := client.nextRead(t), client.nextRead(t)
+	groupOfTwo, groupOfOneAndThree := first, second
+	if first.targets[0].PartitionId != 2 {
+		groupOfTwo, groupOfOneAndThree = second, first
+	}
+	require.Len(t, groupOfOneAndThree.targets, 2)
+
+	groupOfTwo.answer(section(2, 5, 6))
+	assert.Equal(t, []uint64{5, 6}, offsets(next(t, reader)))
+	groupOfOneAndThree.answer(section(3, 0), section(1, 9))
+	assert.Equal(t, []uint64{0}, offsets(next(t, reader)))
+	assert.Equal(t, []uint64{9}, offsets(next(t, reader)))
+
+	again := client.nextRead(t)
+	if again.targets[0].PartitionId != 2 {
+		again = client.nextRead(t)
+	}
+	partition, bound := again.targets[0].Strategy.Partition()
+	assert.True(t, bound)
+	assert.Equal(t, uint32(2), partition)
+	assert.Equal(t, uint64(7), again.targets[0].Strategy.Value, "a partition continues after its last message")
+}
+
+// Under OnRead a read stores its offset, so the reader reads only for a waiting
+// Next. After a lost reply the next read starts after what the lost read
+// stored, and the reader reads those messages again without a store.
+func TestPartitionReader_ReadsOnDemandUnderOnReadAndReadsAgainWhatALostReplyStored(t *testing.T) {
+	client := newFakeClient()
+	reader := standalone(t, client, withCommit(waitOneSecond, iggcon.OnReadCommit()), 1)
+	assert.True(t, client.readsNothingFor(300*time.Millisecond))
+
+	type outcome struct {
+		polled *iggcon.PolledMessage
+		err    error
+	}
+	polled := make(chan outcome, 1)
+	go func() {
+		batch, err := reader.Next(context.Background())
+		polled <- outcome{batch, err}
+	}()
+	read := client.nextRead(t)
+	assert.True(t, read.commit)
+	read.answer(section(1, 0, 1))
+	assert.Equal(t, []uint64{0, 1}, offsets((<-polled).polled))
+
+	go func() {
+		batch, err := reader.Next(context.Background())
+		polled <- outcome{batch, err}
+	}()
+	client.nextRead(t).reply <- fakeReply{err: ierror.FetchProgressUncertain{OperationID: 42}}
+	afterLoss := client.nextRead(t)
+	assert.Equal(t, iggcon.POLLING_NEXT, afterLoss.targets[0].Strategy.Kind)
+	afterLoss.answer(section(1, 5, 6))
+	gap := client.nextRead(t)
+	assert.False(t, gap.commit)
+	assert.Equal(t, iggcon.POLLING_OFFSET, gap.targets[0].Strategy.Kind)
+	assert.Equal(t, uint64(2), gap.targets[0].Strategy.Value)
+	assert.Equal(t, uint32(3), gap.count)
+	gap.answer(section(1, 2, 3, 4, 5))
+
+	result := <-polled
+	require.NoError(t, result.err)
+	assert.Equal(t, []uint64{2, 3, 4}, offsets(result.polled))
+	assert.Equal(t, []uint64{5, 6}, offsets(next(t, reader)))
+	client.mu.Lock()
+	assert.Equal(t, []uint64{42}, client.abandoned)
+	client.mu.Unlock()
+}
+
+func TestPartitionReader_CommitsADeliveredCheckpointUnderManualAndRefusesOneUnderOnRead(t *testing.T) {
+	client := newFakeClient()
+	reader := standalone(t, client, waitOneSecond, 1)
+	client.nextRead(t).answer(section(1, 3, 4))
+	batch := next(t, reader)
+	checkpoint, _ := batch.CheckpointAt(0)
+
+	require.NoError(t, reader.Commit(context.Background(), checkpoint))
+	require.NoError(t, reader.Commit(context.Background(), checkpoint))
+	assert.Equal(t, []store{{1, 3}}, client.recordedStores())
+
+	onReadClient := newFakeClient()
+	onReadClient.starts[1] = 3
+	onRead := standalone(t, onReadClient, withCommit(waitOneSecond, iggcon.OnReadCommit()), 1)
+	go func() { onReadClient.nextRead(t).answer(section(1, 3)) }()
+	delivered, _ := next(t, onRead).Checkpoint()
+	require.ErrorIs(t, onRead.Commit(context.Background(), delivered), ierror.ErrInvalidCommand)
+	require.ErrorIs(t, onRead.Mark(delivered), ierror.ErrInvalidCommand)
+}
+
+// Under OnRead the server stored what the reader holds undelivered, so Close moves the offset back.
+func TestPartitionReader_MovesTheOffsetOfUndeliveredBatchesBackOnCloseUnderOnRead(t *testing.T) {
+	client := newFakeClient()
+	client.starts[2] = 4
+	client.grouper = func([]uint32) [][]uint32 { return [][]uint32{{1}, {2}} }
+	reader := standalone(t, client, withCommit(waitOneSecond, iggcon.OnReadCommit()), 1, 2)
+	polled := make(chan *iggcon.PolledMessage, 1)
+	go func() {
+		batch, _ := reader.Next(context.Background())
+		polled <- batch
+	}()
+	first, second := client.nextRead(t), client.nextRead(t)
+	partitionOne, partitionTwo := first, second
+	if first.targets[0].PartitionId != 1 {
+		partitionOne, partitionTwo = second, first
+	}
+	partitionOne.answer(section(1, 0, 1))
+	assert.Equal(t, []uint64{0, 1}, offsets(<-polled))
+	partitionTwo.answer(section(2, 4, 5))
+	require.Eventually(t, func() bool {
+		reader.mu.Lock()
+		defer reader.mu.Unlock()
+		return len(reader.queue) == 1
+	}, testTimeout, 5*time.Millisecond)
+
+	require.NoError(t, reader.Close(context.Background()))
+
+	assert.Equal(t, []store{{2, 3}}, client.recordedStores(), "partition 2 started at its next offset and delivered nothing")
+}
+
+func TestPartitionReader_StopsAPartitionWhoseHistoryIsGoneUntilASeek(t *testing.T) {
+	client := newFakeClient()
+	reader := standalone(t, client, waitOneSecond, 1)
+	client.nextRead(t).answer(section(1, 0))
+	next(t, reader)
+	client.nextRead(t).answer(iggcon.FetchMessagesSection{PartitionId: 1, Err: ierror.ErrHistoryUnavailable})
+
+	_, err := reader.Next(context.Background())
+	var readError *ReadError
+	require.ErrorAs(t, err, &readError)
+	require.ErrorIs(t, err, ierror.ErrHistoryUnavailable)
+	assert.Equal(t, uint32(1), *readError.PartitionID)
+	assert.True(t, client.readsNothingFor(300*time.Millisecond))
+
+	require.NoError(t, reader.Seek(context.Background(), 1, iggcon.FirstPollingStrategy()))
+	assert.Equal(t, uint64(0), client.nextRead(t).targets[0].Strategy.Value)
+}
+
+func TestPartitionReader_RefusesAContinuationOfAnotherPartition(t *testing.T) {
+	client := newFakeClient()
+	reader := standalone(t, client, waitOneSecond, 1, 2)
+	client.nextRead(t).answer(section(1, 0))
+	continuation, _ := next(t, reader).Continuation()
+
+	require.ErrorIs(t, reader.Seek(context.Background(), 2, continuation), ierror.ErrInvalidCommand)
+	require.ErrorIs(t, reader.Seek(context.Background(), 3, iggcon.FirstPollingStrategy()), ierror.ErrInvalidCommand)
+}
+
+// A rebalance takes a partition away only after its counted position is stored.
+func TestPartitionReader_FollowsTheAssignmentAndStoresTheCountedPositionOfAPartitionThatLeaves(t *testing.T) {
+	client := newFakeClient()
+	client.grouper = func(partitions []uint32) [][]uint32 {
+		groups := make([][]uint32, 0, len(partitions))
+		for _, partition := range partitions {
+			groups = append(groups, []uint32{partition})
+		}
+		return groups
+	}
+	auto := iggcon.AutoCommit{Every: iggcon.CommitEvery{Kind: iggcon.CommitEveryMessages, Messages: 1000}, After: iggcon.CommitAfterDelivery}
+	group, err := iggcon.NewIdentifier(uint32(9))
+	require.NoError(t, err)
+	client.assignments <- []uint32{1, 2}
+	reader, err := NewPartitionReader(context.Background(), client, identifier(t, 1), identifier(t, 2),
+		iggcon.NewGroupConsumer(group), WithPollOptions(withCommit(waitOneSecond, iggcon.AutoCommitPolicy(auto))),
+		WithRetryInterval(100*time.Millisecond))
+	require.NoError(t, err)
+	first, second := client.nextRead(t), client.nextRead(t)
+	partitionOne := first
+	if first.targets[0].PartitionId != 1 {
+		partitionOne = second
+	}
+	partitionOne.answer(section(1, 7))
+	next(t, reader)
+	assert.Equal(t, []uint32{1, 2}, reader.Partitions())
+
+	client.assignments <- []uint32{2}
+
+	require.Eventually(t, func() bool { return len(client.recordedStores()) == 1 }, testTimeout, 5*time.Millisecond)
+	assert.Equal(t, []store{{1, 7}}, client.recordedStores())
+	assert.Equal(t, []uint32{2}, reader.Partitions())
+	client.assignments <- []uint32{2}
+	require.NoError(t, reader.Close(context.Background()))
+	assert.Equal(t, []string{"join", "leave"}, client.membership)
+}
+
+// Under OnRead the server stored the batches that the reader holds, so a
+// partition that leaves the assignment still hands them over.
+func TestPartitionReader_HandsOverAQueuedBatchOfAPartitionThatLeavesUnderOnRead(t *testing.T) {
+	client := newFakeClient()
+	client.starts[2] = 4
+	reader := member(t, client, withCommit(waitOneSecond, iggcon.OnReadCommit()), 1, 2)
+	polled := nextInBackground(reader)
+	client.nextRead(t).answer(section(1, 0, 1), section(2, 4, 5))
+	assert.Equal(t, []uint64{0, 1}, offsets((<-polled).polled))
+
+	assign(t, client, reader, 1)
+
+	require.Equal(t, 1, queued(reader), "the queued batch of the partition that left stays")
+	batch := next(t, reader)
+	assert.Equal(t, uint32(2), batch.PartitionId)
+	assert.Equal(t, []uint64{4, 5}, offsets(batch))
+}
+
+// Under OnRead the server stored a read in flight, so its reply reaches the
+// application after its partition left the assignment.
+func TestPartitionReader_HandsOverAReadThatEndsAfterItsPartitionLeftUnderOnRead(t *testing.T) {
+	client := newFakeClient()
+	client.starts[2] = 4
+	client.grouper = func(partitions []uint32) [][]uint32 {
+		groups := make([][]uint32, 0, len(partitions))
+		for _, partition := range partitions {
+			groups = append(groups, []uint32{partition})
+		}
+		return groups
+	}
+	reader := member(t, client, withCommit(waitOneSecond, iggcon.OnReadCommit()), 1, 2)
+	polled := nextInBackground(reader)
+	first, second := client.nextRead(t), client.nextRead(t)
+	partitionOne, partitionTwo := first, second
+	if first.targets[0].PartitionId != 1 {
+		partitionOne, partitionTwo = second, first
+	}
+	partitionOne.answer(section(1, 0, 1))
+	assert.Equal(t, []uint64{0, 1}, offsets((<-polled).polled))
+
+	assign(t, client, reader, 1)
+	partitionTwo.answer(section(2, 4, 5))
+
+	require.Eventually(t, func() bool { return queued(reader) == 1 }, testTimeout, 5*time.Millisecond,
+		"the reply of the partition that left is queued")
+	batch := next(t, reader)
+	assert.Equal(t, uint32(2), batch.PartitionId)
+	assert.Equal(t, []uint64{4, 5}, offsets(batch))
+}
+
+// Under OnRead a partition that leaves while it reads again what a lost read
+// stored reports those messages as lost and still hands over the batch that
+// the server stored after them.
+func TestPartitionReader_HandsOverTheBatchAfterALostReadWhenItsPartitionLeavesUnderOnRead(t *testing.T) {
+	client := newFakeClient()
+	reader := member(t, client, withCommit(waitOneSecond, iggcon.OnReadCommit()), 1)
+	polled := nextInBackground(reader)
+	client.nextRead(t).answer(section(1, 0, 1))
+	require.NoError(t, (<-polled).err)
+	polled = nextInBackground(reader)
+	client.nextRead(t).reply <- fakeReply{err: ierror.FetchProgressUncertain{OperationID: 42}}
+	client.nextRead(t).answer(section(1, 5, 6))
+	assert.False(t, client.nextRead(t).commit)
+
+	assign(t, client, reader, 2)
+
+	select {
+	case result := <-polled:
+		var lost ierror.FetchProgressUncertain
+		require.ErrorAs(t, result.err, &lost)
+	case <-time.After(testTimeout):
+		t.Fatal("the reader reported no loss for the partition that left")
+	}
+	assert.Equal(t, []uint64{5, 6}, offsets(next(t, reader)))
+}
+
+// A failed grouping waits the retry interval, however often the application
+// asks for a batch.
+func TestPartitionReader_WaitsTheRetryIntervalBeforeItGroupsAgainAfterAFailedGrouping(t *testing.T) {
+	client := newFakeClient()
+	client.failedGroupings = 2
+	reader := standalone(t, client, waitOneSecond, 1)
+	for range 3 {
+		ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond)
+		_, _ = reader.Next(ctx)
+		cancel()
+	}
+
+	read := client.nextRead(t)
+
+	groupings := client.recordedGroupings()
+	require.Len(t, groupings, 3)
+	assert.GreaterOrEqual(t, groupings[1].Sub(groupings[0]), testRetryInterval)
+	assert.GreaterOrEqual(t, groupings[2].Sub(groupings[1]), testRetryInterval)
+	read.answer(section(1, 0))
+	assert.Equal(t, []uint64{0}, offsets(next(t, reader)))
+}
+
+// A fetch that reaches its count serves its first targets, so the partitions
+// served last go last in the next fetch and each partition gets its turn.
+func TestPartitionReader_PutsThePartitionsServedLastAtTheEndOfTheNextFetch(t *testing.T) {
+	client := newFakeClient()
+	reader := standalone(t, client, waitOneSecond, 1, 3, 5)
+	var delivered []uint32
+	for range 3 {
+		read := client.nextRead(t)
+		first := read.targets[0]
+		read.answer(section(first.PartitionId, first.Strategy.Value))
+		delivered = append(delivered, next(t, reader).PartitionId)
+	}
+
+	assert.Equal(t, []uint32{1, 3, 5}, delivered)
+}
+
+func TestPartitionReader_HandsOverAQueuedBatchOfAPartitionAssignedAgainUnderOnRead(t *testing.T) {
+	client := newFakeClient()
+	client.starts[2] = 4
+	reader := member(t, client, withCommit(waitOneSecond, iggcon.OnReadCommit()), 1, 2)
+	polled := nextInBackground(reader)
+	client.nextRead(t).answer(section(1, 0, 1), section(2, 4, 5))
+	assert.Equal(t, []uint64{0, 1}, offsets((<-polled).polled))
+
+	assign(t, client, reader, 1)
+	assign(t, client, reader, 1, 2)
+
+	require.Equal(t, 1, queued(reader), "the queued batch of the partition that left stays")
+	batch := next(t, reader)
+	assert.Equal(t, uint32(2), batch.PartitionId)
+	assert.Equal(t, []uint64{4, 5}, offsets(batch))
+}
+
+func TestPartitionReader_HandsOverAReadThatEndsAfterItsPartitionIsAssignedAgainUnderOnRead(t *testing.T) {
+	client := newFakeClient()
+	client.starts[2] = 4
+	client.grouper = func(partitions []uint32) [][]uint32 {
+		groups := make([][]uint32, 0, len(partitions))
+		for _, partition := range partitions {
+			groups = append(groups, []uint32{partition})
+		}
+		return groups
+	}
+	reader := member(t, client, withCommit(waitOneSecond, iggcon.OnReadCommit()), 1, 2)
+	polled := nextInBackground(reader)
+	first, second := client.nextRead(t), client.nextRead(t)
+	partitionOne, partitionTwo := first, second
+	if first.targets[0].PartitionId != 1 {
+		partitionOne, partitionTwo = second, first
+	}
+	partitionOne.answer(section(1, 0, 1))
+	assert.Equal(t, []uint64{0, 1}, offsets((<-polled).polled))
+
+	assign(t, client, reader, 1)
+	assign(t, client, reader, 1, 2)
+	partitionTwo.answer(section(2, 4, 5))
+
+	require.Eventually(t, func() bool { return queued(reader) == 1 }, testTimeout, 5*time.Millisecond,
+		"the reply of the partition that left is queued")
+	batch := next(t, reader)
+	assert.Equal(t, uint32(2), batch.PartitionId)
+	assert.Equal(t, []uint64{4, 5}, offsets(batch))
+}
+
+func TestPartitionReader_WaitsTheRetryIntervalAfterASectionlessReplyWithZeroWait(t *testing.T) {
+	client := newFakeClient()
+	reader := standalone(t, client, iggcon.DefaultPollOptions(), 1)
+	read := client.nextRead(t)
+	started := time.Now()
+	read.answer()
+	client.nextRead(t)
+	assert.GreaterOrEqual(t, time.Since(started), testRetryInterval)
+	assert.Zero(t, queued(reader))
+}
+
+func TestPartitionReader_DropsAnOldStoredReplyAfterASeekAndReassignment(t *testing.T) {
+	client := newFakeClient()
+	reader := member(t, client, withCommit(waitOneSecond, iggcon.OnReadCommit()), 1)
+	ctx, cancel := context.WithCancel(context.Background())
+	polled := make(chan error, 1)
+	go func() {
+		_, err := reader.Next(ctx)
+		polled <- err
+	}()
+	old := client.nextRead(t)
+	cancel()
+	require.ErrorIs(t, <-polled, context.Canceled)
+	assign(t, client, reader, 2)
+	assign(t, client, reader, 1)
+	require.NoError(t, reader.Seek(context.Background(), 1, iggcon.OffsetPollingStrategy(10)))
+	client.starts[1] = 10
+	assign(t, client, reader, 2)
+	assign(t, client, reader, 1)
+	old.answer(section(1, 0, 1))
+	resumed := nextInBackground(reader)
+	client.nextRead(t).answer(section(1, 10))
+	result := <-resumed
+	require.NoError(t, result.err)
+	assert.Equal(t, []uint64{10}, offsets(result.polled))
+}
+
+func TestPartitionReader_RecoversTheFirstLostOnReadReply(t *testing.T) {
+	for _, start := range []uint64{0, 5} {
+		t.Run(fmt.Sprint(start), func(t *testing.T) {
+			client := newFakeClient()
+			client.starts[1] = start
+			reader := standalone(t, client, withCommit(waitOneSecond, iggcon.OnReadCommit()), 1)
+			polled := nextInBackground(reader)
+			first := client.nextRead(t)
+			require.True(t, first.commit)
+			first.reply <- fakeReply{err: ierror.FetchProgressUncertain{OperationID: 42}}
+			client.nextRead(t).answer(section(1, start+2))
+			gap := client.nextRead(t)
+			require.False(t, gap.commit)
+			require.Equal(t, start, gap.targets[0].Strategy.Value)
+			gap.answer(section(1, start, start+1))
+			result := <-polled
+			require.NoError(t, result.err)
+			assert.Equal(t, []uint64{start, start + 1}, offsets(result.polled))
+			assert.Equal(t, []uint64{start + 2}, offsets(next(t, reader)))
+		})
+	}
+}
+
+func TestPartitionReader_RewindsTheFirstUndeliveredOnReadToItsBaseline(t *testing.T) {
+	for _, start := range []uint64{0, 5} {
+		t.Run(fmt.Sprint(start), func(t *testing.T) {
+			client := newFakeClient()
+			client.starts[1] = start
+			reader := standalone(t, client, withCommit(waitOneSecond, iggcon.OnReadCommit()), 1)
+			polled := nextInBackground(reader)
+			client.nextRead(t)
+			require.NoError(t, reader.Close(context.Background()))
+			require.Error(t, (<-polled).err)
+			if start == 0 {
+				assert.Equal(t, []uint32{1}, client.deleted)
+			} else {
+				assert.Equal(t, []store{{1, start - 1}}, client.recordedStores())
+			}
+		})
+	}
+}
+
+func TestPartitionReader_RecoversTheFirstLostOnReadReplyWhenItConsumedTheWholeBacklog(t *testing.T) {
+	for _, start := range []uint64{0, 5} {
+		t.Run(fmt.Sprint(start), func(t *testing.T) {
+			client := newFakeClient()
+			client.starts[1] = start
+			reader := standalone(t, client, withCommit(waitOneSecond, iggcon.OnReadCommit()), 1)
+			polled := nextInBackground(reader)
+			client.nextRead(t).reply <- fakeReply{err: ierror.FetchProgressUncertain{OperationID: 42}}
+			client.nextRead(t).answer(iggcon.FetchMessagesSection{PartitionId: 1, Context: testContext,
+				CurrentOffset: start + 2, NextOffset: start + 2})
+			gap := client.nextRead(t)
+			require.False(t, gap.commit)
+			require.Equal(t, start, gap.targets[0].Strategy.Value)
+			gap.answer(section(1, start, start+1))
+			result := <-polled
+			require.NoError(t, result.err)
+			assert.Equal(t, []uint64{start, start + 1}, offsets(result.polled))
+		})
+	}
+}
+
+// A read again of what a lost reply stored that the server refuses is sent
+// again once its retry is due, so the partition does not stall.
+func TestPartitionReader_SendsAgainARefusedReadOfWhatALostReplyStored(t *testing.T) {
+	client := newFakeClient()
+	reader := standalone(t, client, withCommit(waitOneSecond, iggcon.OnReadCommit()), 1)
+	polled := nextInBackground(reader)
+	client.nextRead(t).answer(section(1, 0, 1))
+	require.NoError(t, (<-polled).err)
+	polled = nextInBackground(reader)
+	client.nextRead(t).reply <- fakeReply{err: ierror.FetchProgressUncertain{OperationID: 42}}
+	client.nextRead(t).answer(section(1, 5, 6))
+	refused := client.nextRead(t)
+	require.False(t, refused.commit)
+	refused.answer(iggcon.FetchMessagesSection{PartitionId: 1, Err: ierror.ErrDeferredPollCapacityExceeded})
+
+	again := client.nextRead(t)
+
+	assert.False(t, again.commit)
+	assert.Equal(t, uint64(2), again.targets[0].Strategy.Value)
+	again.answer(section(1, 2, 3, 4))
+	result := <-polled
+	require.NoError(t, result.err)
+	assert.Equal(t, []uint64{2, 3, 4}, offsets(result.polled))
+	assert.Equal(t, []uint64{5, 6}, offsets(next(t, reader)))
+}
+
+// A read again of what a lost reply stored that returns nothing is sent again
+// after the retry interval, so the partition does not stall.
+func TestPartitionReader_SendsAgainAnEmptyReadOfWhatALostReplyStored(t *testing.T) {
+	client := newFakeClient()
+	reader := standalone(t, client, withCommit(waitOneSecond, iggcon.OnReadCommit()), 1)
+	polled := nextInBackground(reader)
+	client.nextRead(t).answer(section(1, 0, 1))
+	require.NoError(t, (<-polled).err)
+	polled = nextInBackground(reader)
+	client.nextRead(t).reply <- fakeReply{err: ierror.FetchProgressUncertain{OperationID: 42}}
+	client.nextRead(t).answer(section(1, 5, 6))
+	empty := client.nextRead(t)
+	require.False(t, empty.commit)
+	empty.answer(iggcon.FetchMessagesSection{PartitionId: 1, Context: testContext, CurrentOffset: 7, NextOffset: 2})
+
+	again := client.nextRead(t)
+
+	assert.False(t, again.commit)
+	assert.Equal(t, uint64(2), again.targets[0].Strategy.Value)
+	again.answer(section(1, 2, 3, 4))
+	result := <-polled
+	require.NoError(t, result.err)
+	assert.Equal(t, []uint64{2, 3, 4}, offsets(result.polled))
+}
+
+// A member refused as not the owner reads the partition again from its stored
+// offset, since a rejoin gives the partition a new owner generation.
+func TestPartitionReader_ReadsAPartitionRefusedAsNotOwnedAgainWithoutItsOwnerGeneration(t *testing.T) {
+	client := newFakeClient()
+	reader := member(t, client, waitOneSecond, 1)
+	client.nextRead(t).answer(section(1, 0))
+	next(t, reader)
+	continued := client.nextRead(t)
+	require.Equal(t, testContext, *continued.targets[0].Strategy.Context)
+	continued.answer(iggcon.FetchMessagesSection{PartitionId: 1, Err: ierror.ErrConsumerGroupPartitionNotOwned})
+
+	retry := client.nextRead(t)
+
+	assert.Equal(t, iggcon.POLLING_NEXT, retry.targets[0].Strategy.Kind)
+	assert.Nil(t, retry.targets[0].Strategy.Context)
+}
+
+func TestPartitionReader_ReportsAPartitionThatStaysRefusedAsNotOwned(t *testing.T) {
+	client := newFakeClient()
+	reader := member(t, client, waitOneSecond, 1)
+	for range lastingRefusalsBeforeStop {
+		client.nextRead(t).answer(iggcon.FetchMessagesSection{PartitionId: 1, Err: ierror.ErrConsumerGroupPartitionNotOwned})
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
+	defer cancel()
+	_, err := reader.Next(ctx)
+
+	var readError *ReadError
+	require.ErrorAs(t, err, &readError)
+	require.ErrorIs(t, err, ierror.ErrConsumerGroupPartitionNotOwned)
+	assert.Equal(t, uint32(1), *readError.PartitionID)
+	client.nextRead(t).answer(section(1, 0))
+	assert.Equal(t, []uint64{0}, offsets(next(t, reader)), "the partition is read again after the report")
+}
+
+// A section that does not decode is read again one message at a time, so
+// only the message that does not decode is skipped.
+func TestPartitionReader_ReadsAnUndecodableSectionOneMessageAtATimeAndSkipsOnlyTheBadMessage(t *testing.T) {
+	client := newFakeClient()
+	reader := standalone(t, client, waitOneSecond, 1)
+	client.nextRead(t).answer(sectionOf(1, 3, 3, record(0), undecodable(1), record(2)))
+
+	first := client.nextRead(t)
+	assert.Equal(t, uint32(1), first.count)
+	assert.Equal(t, iggcon.OffsetPollingStrategy(0).WithContext(testContext), first.targets[0].Strategy)
+	first.answer(section(1, 0))
+	assert.Equal(t, []uint64{0}, offsets(next(t, reader)))
+	bad := client.nextRead(t)
+	assert.Equal(t, uint32(1), bad.count)
+	assert.Equal(t, uint64(1), bad.targets[0].Strategy.Value)
+	bad.answer(sectionOf(1, 2, 1, undecodable(1)))
+	_, err := reader.Next(context.Background())
+	var readError *ReadError
+	require.ErrorAs(t, err, &readError)
+	require.NotNil(t, readError.Skip)
+	assert.Equal(t, uint64(2), readError.Skip.Value, "only the message that does not decode is skipped")
+
+	require.NoError(t, reader.Seek(context.Background(), 1, *readError.Skip))
+	after := client.nextRead(t)
+	assert.Equal(t, uint64(2), after.targets[0].Strategy.Value)
+	after.answer(section(1, 2))
+	assert.Equal(t, []uint64{2}, offsets(next(t, reader)))
+}
+
+func TestPartitionReader_StoresAgainACommitWhoseStoreFailed(t *testing.T) {
+	client := newFakeClient()
+	client.failedStores = 1
+	reader := standalone(t, client, waitOneSecond, 1)
+	client.nextRead(t).answer(section(1, 3, 4))
+	checkpoint, _ := next(t, reader).Checkpoint()
+
+	require.ErrorIs(t, reader.Commit(context.Background(), checkpoint), errStoreFailed)
+	require.NoError(t, reader.Commit(context.Background(), checkpoint))
+
+	assert.Equal(t, []store{{1, 4}}, client.recordedStores())
+}
+
+// The server raises the metadata op of a partition context per read, so a
+// checkpoint delivered before that still counts.
+func TestPartitionReader_CommitsACheckpointDeliveredBeforeTheMetadataOpMoved(t *testing.T) {
+	client := newFakeClient()
+	reader := standalone(t, client, waitOneSecond, 1)
+	client.nextRead(t).answer(section(1, 3))
+	earlier, _ := next(t, reader).Checkpoint()
+	later := section(1, 4)
+	later.Context.MetadataOp++
+	client.nextRead(t).answer(later)
+	next(t, reader)
+
+	require.NoError(t, reader.Commit(context.Background(), earlier))
+
+	assert.Equal(t, []store{{1, 3}}, client.recordedStores())
+}
+
+func TestPartitionReader_MarksACheckpointDeliveredBeforeTheMetadataOpMoved(t *testing.T) {
+	client := newFakeClient()
+	auto := iggcon.AutoCommit{Every: iggcon.CommitEvery{Kind: iggcon.CommitEveryMessage}, After: iggcon.CommitAfterMarked}
+	reader := standalone(t, client, withCommit(waitOneSecond, iggcon.AutoCommitPolicy(auto)), 1)
+	client.nextRead(t).answer(section(1, 3))
+	earlier, _ := next(t, reader).Checkpoint()
+	later := section(1, 4)
+	later.Context.MetadataOp++
+	client.nextRead(t).answer(later)
+	next(t, reader)
+
+	require.NoError(t, reader.Mark(earlier))
+
+	require.Eventually(t, func() bool { return len(client.recordedStores()) == 1 }, testTimeout, 5*time.Millisecond)
+	assert.Equal(t, []store{{1, 3}}, client.recordedStores())
+}
+
+func TestPartitionReader_CloseCalledAgainWaitsForTheFirstAndReturnsItsResult(t *testing.T) {
+	client := newFakeClient()
+	client.starts[1] = 5
+	client.storeStarted = make(chan context.Context, 4)
+	client.storeRelease = make(chan struct{})
+	client.failedStores = 1
+	reader := standalone(t, client, withCommit(waitOneSecond, iggcon.OnReadCommit()), 1)
+	nextInBackground(reader)
+	client.nextRead(t)
+	first := make(chan error, 1)
+	go func() { first <- reader.Close(context.Background()) }()
+	select {
+	case <-client.storeStarted:
+	case <-time.After(testTimeout):
+		t.Fatal("the close stored nothing")
+	}
+
+	second := make(chan error, 1)
+	go func() { second <- reader.Close(context.Background()) }()
+
+	select {
+	case <-second:
+		t.Fatal("the second close returned before the first one finished")
+	case <-time.After(300 * time.Millisecond):
+	}
+	close(client.storeRelease)
+	require.ErrorIs(t, <-first, errStoreFailed)
+	require.ErrorIs(t, <-second, errStoreFailed)
+}
+
+// After a lost reply the reader waits the retry interval before it reads the
+// partition again, instead of reading it at the round trip rate.
+func TestPartitionReader_WaitsTheRetryIntervalAfterALostReplyBeforeItReadsThePartitionAgain(t *testing.T) {
+	for _, whole := range []bool{true, false} {
+		t.Run(fmt.Sprintf("whole_%t", whole), func(t *testing.T) {
+			client := newFakeClient()
+			reader := standalone(t, client, withCommit(waitOneSecond, iggcon.OnReadCommit()), 1)
+			polled := nextInBackground(reader)
+			client.nextRead(t).answer(section(1, 0, 1))
+			require.NoError(t, (<-polled).err)
+			nextInBackground(reader)
+			lost := client.nextRead(t)
+			started := time.Now()
+			if whole {
+				lost.reply <- fakeReply{err: ierror.FetchProgressUncertain{OperationID: 42}}
+			} else {
+				lost.answer(iggcon.FetchMessagesSection{PartitionId: 1, Err: ierror.FetchProgressUncertain{OperationID: 42}})
+			}
+
+			client.nextRead(t)
+
+			assert.GreaterOrEqual(t, time.Since(started), testRetryInterval)
+		})
+	}
+}
+
+// A forward jump of a committing read without a lost read of the partition is
+// a store of the application or of another session, so the reader goes on from
+// there and reads nothing again.
+func TestPartitionReader_GoesOnFromAForwardJumpWithoutALostRead(t *testing.T) {
+	for _, empty := range []bool{false, true} {
+		t.Run(fmt.Sprintf("empty_%t", empty), func(t *testing.T) {
+			client := newFakeClient()
+			reader := standalone(t, client, withCommit(waitOneSecond, iggcon.OnReadCommit()), 1)
+			polled := nextInBackground(reader)
+			client.nextRead(t).answer(section(1, 0, 1))
+			require.NoError(t, (<-polled).err)
+			polled = nextInBackground(reader)
+			jumped := client.nextRead(t)
+			require.True(t, jumped.commit)
+
+			if empty {
+				jumped.answer(iggcon.FetchMessagesSection{PartitionId: 1, Context: testContext, CurrentOffset: 5, NextOffset: 5})
+			} else {
+				jumped.answer(section(1, 5, 6))
+				result := <-polled
+				require.NoError(t, result.err)
+				assert.Equal(t, []uint64{5, 6}, offsets(result.polled))
+				nextInBackground(reader)
+			}
+
+			after := client.nextRead(t)
+			assert.True(t, after.commit, "the skipped offsets are not read again")
+			assert.Equal(t, iggcon.POLLING_NEXT, after.targets[0].Strategy.Kind)
+		})
+	}
+}
+
+// The lost range can hold fewer messages than the minimum count of the reader,
+// and its read still goes out.
+func TestPartitionReader_ReadsALostRangeShorterThanTheMinimumCount(t *testing.T) {
+	client := newFakeClient()
+	options := withCommit(waitOneSecond, iggcon.OnReadCommit())
+	options.MinCount = 5
+	reader := standalone(t, client, options, 1)
+	polled := nextInBackground(reader)
+	client.nextRead(t).answer(section(1, 0, 1, 2, 3, 4))
+	require.NoError(t, (<-polled).err)
+	polled = nextInBackground(reader)
+	client.nextRead(t).reply <- fakeReply{err: ierror.FetchProgressUncertain{OperationID: 42}}
+	client.nextRead(t).answer(section(1, 8, 9, 10, 11, 12))
+
+	gap := client.nextRead(t)
+
+	assert.False(t, gap.commit)
+	assert.Equal(t, uint32(3), gap.count)
+	gap.answer(section(1, 5, 6, 7))
+	result := <-polled
+	require.NoError(t, result.err)
+	assert.Equal(t, []uint64{5, 6, 7}, offsets(result.polled))
+}
+
+// Under OnRead the server can have stored what a lost read returned, so Close
+// moves the offset back to the last delivered message.
+func TestPartitionReader_MovesTheOffsetBackOverALostReadOnClose(t *testing.T) {
+	client := newFakeClient()
+	reader := standalone(t, client, withCommit(waitOneSecond, iggcon.OnReadCommit()), 1)
+	polled := nextInBackground(reader)
+	client.nextRead(t).answer(section(1, 0, 1))
+	require.NoError(t, (<-polled).err)
+	nextInBackground(reader)
+	client.nextRead(t).reply <- fakeReply{err: ierror.FetchProgressUncertain{OperationID: 42}}
+	require.Eventually(t, func() bool { return len(client.recordedAbandons()) == 1 }, testTimeout, 5*time.Millisecond)
+
+	require.NoError(t, reader.Close(context.Background()))
+
+	assert.Equal(t, []store{{1, 1}}, client.recordedStores())
+}
+
+// A committing read that ends during Close stored its messages, which nobody
+// receives, so Close moves the offset back over them.
+func TestPartitionReader_MovesTheOffsetBackOverAReadThatEndsDuringClose(t *testing.T) {
+	client := newFakeClient()
+	client.holdsCommits = true
+	reader := standalone(t, client, withCommit(waitOneSecond, iggcon.OnReadCommit()), 1)
+	polled := nextInBackground(reader)
+	client.nextRead(t).answer(section(1, 0, 1))
+	require.NoError(t, (<-polled).err)
+	nextInBackground(reader)
+	parked := client.nextRead(t)
+	closed := make(chan error, 1)
+	go func() { closed <- reader.Close(context.Background()) }()
+	require.Eventually(t, func() bool { return reader.run.Err() != nil }, testTimeout, time.Millisecond)
+
+	parked.answer(section(1, 2, 3))
+
+	require.NoError(t, <-closed)
+	assert.Equal(t, []store{{1, 1}}, client.recordedStores())
+}
+
+// A lost read of a partition that a seek or a new assignment replaced still
+// holds the progress of the partition, so the reader abandons it.
+func TestPartitionReader_AbandonsALostReadOfAPartitionThatChangedMeanwhile(t *testing.T) {
+	for _, change := range []string{"seek", "revoke"} {
+		t.Run(change, func(t *testing.T) {
+			client := newFakeClient()
+			options := withCommit(waitOneSecond, iggcon.OnReadCommit())
+			var reader *PartitionReader
+			if change == "seek" {
+				reader = standalone(t, client, options, 1)
+			} else {
+				reader = member(t, client, options, 1)
+			}
+			polled := nextInBackground(reader)
+			client.nextRead(t).answer(section(1, 0, 1))
+			require.NoError(t, (<-polled).err)
+			nextInBackground(reader)
+			inFlight := client.nextRead(t)
+			if change == "seek" {
+				require.NoError(t, reader.Seek(context.Background(), 1, iggcon.OffsetPollingStrategy(10)))
+			} else {
+				assign(t, client, reader, 2)
+			}
+
+			inFlight.answer(iggcon.FetchMessagesSection{PartitionId: 1, Err: ierror.FetchProgressUncertain{OperationID: 42}})
+
+			require.Eventually(t, func() bool { return slices.Equal(client.recordedAbandons(), []uint64{42}) },
+				testTimeout, 5*time.Millisecond)
+		})
+	}
+}
+
+// The end of a read of an older generation leaves the read of the new
+// generation in flight, so no second read of the same position goes out.
+func TestPartitionReader_KeepsTheReadOfANewGenerationInFlightWhenAnOlderReadEnds(t *testing.T) {
+	client := newFakeClient()
+	reader := standalone(t, client, waitOneSecond, 1)
+	old := client.nextRead(t)
+	require.NoError(t, reader.Seek(context.Background(), 1, iggcon.OffsetPollingStrategy(5)))
+	sought := client.nextRead(t)
+	require.Equal(t, uint64(5), sought.targets[0].Strategy.Value)
+
+	old.answer(section(1, 0))
+
+	assert.True(t, client.readsNothingFor(300*time.Millisecond), "the read of the new generation is still in flight")
+	sought.answer(section(1, 5))
+	assert.Equal(t, []uint64{5}, offsets(next(t, reader)))
+}
+
+// A member that the server no longer knows has left its group already.
+func TestPartitionReader_ClosesAMemberThatTheServerNoLongerKnows(t *testing.T) {
+	client := newFakeClient()
+	client.leaveErr = ierror.ErrConsumerGroupMemberNotFound
+	reader := member(t, client, waitOneSecond, 1)
+
+	require.NoError(t, reader.Close(context.Background()))
+
+	assert.Equal(t, []string{"join", "leave"}, client.membership)
+}
+
+// A reader that commits nothing reads each backlogged partition of a group in
+// a read of its own while slots are free, each with the whole count, and the
+// quiet partitions of the group share one read that may wait.
+func TestPartitionReader_ReadsEachBackloggedPartitionOfAGroupInItsOwnRead(t *testing.T) {
+	client := newFakeClient()
+	reader := standalone(t, client, waitOneSecond, 1, 2, 3, 4, 5)
+	first := client.nextRead(t)
+	require.Len(t, first.targets, 5)
+	ahead := section(1, 0)
+	ahead.CurrentOffset = 100
+
+	first.answer(ahead, behind(2, 50), behind(3, 50), behind(4, 50), iggcon.FetchMessagesSection{PartitionId: 5, Context: testContext})
+
+	reads := make([][]uint32, 0, 5)
+	for range 5 {
+		read := client.nextRead(t)
+		assert.Equal(t, waitOneSecond.Count, read.count)
+		reads = append(reads, partitionsOf(read))
+	}
+	assert.ElementsMatch(t, [][]uint32{{1}, {2}, {3}, {4}, {5}}, reads)
+	assert.Equal(t, []uint64{0}, offsets(next(t, reader)))
+}
+
+// More backlogged partitions than free slots are dealt over the slots, least
+// recently served first.
+func TestPartitionReader_DealsMoreBackloggedPartitionsThanFreeSlotsOverTheSlots(t *testing.T) {
+	client := newFakeClient()
+	reader, err := NewPartitionReader(context.Background(), client, identifier(t, 1), identifier(t, 2),
+		iggcon.NewSingleConsumer(identifier(t, 3)), WithPartitions(1, 2, 3, 4), WithPollOptions(waitOneSecond),
+		WithPrefetchMessages(3*waitOneSecond.Count), WithRetryInterval(testRetryInterval))
+	require.NoError(t, err)
+	t.Cleanup(func() { _ = reader.Close(context.Background()) })
+	ahead := section(1, 0)
+	ahead.CurrentOffset = 100
+
+	client.nextRead(t).answer(ahead, behind(2, 50), behind(3, 50), behind(4, 50))
+
+	reads := [][]uint32{partitionsOf(client.nextRead(t)), partitionsOf(client.nextRead(t))}
+	assert.ElementsMatch(t, [][]uint32{{2, 4}, {1, 3}}, reads, "the queued batch holds one of three slots")
+	assert.True(t, client.readsNothingFor(300*time.Millisecond))
+}
+
+// Under OnRead a seek waits for the read in flight before it stores the offset
+// before its start, and still stores it when Close starts before that read ends.
+func TestPartitionReader_StoresAPendingSeekWhenCloseStartsBeforeTheReadInFlightEnds(t *testing.T) {
+	for _, lost := range []bool{false, true} {
+		t.Run(fmt.Sprintf("lost_%t", lost), func(t *testing.T) {
+			client := newFakeClient()
+			client.holdsCommits = true
+			reader := standalone(t, client, withCommit(waitOneSecond, iggcon.OnReadCommit()), 1)
+			polled := nextInBackground(reader)
+			client.nextRead(t).answer(section(1, 0, 1))
+			require.NoError(t, (<-polled).err)
+			nextInBackground(reader)
+			inFlight := client.nextRead(t)
+			require.NoError(t, reader.Seek(context.Background(), 1, iggcon.OffsetPollingStrategy(10)))
+			closed := make(chan error, 1)
+			go func() { closed <- reader.Close(context.Background()) }()
+			require.Eventually(t, func() bool { return reader.run.Err() != nil }, testTimeout, time.Millisecond)
+
+			if lost {
+				inFlight.reply <- fakeReply{err: ierror.FetchProgressUncertain{OperationID: 42}}
+			} else {
+				inFlight.answer(section(1, 2, 3))
+			}
+
+			require.NoError(t, <-closed)
+			assert.Equal(t, []store{{1, 9}}, client.recordedStores(), "the seek decides where the next session starts")
+			if lost {
+				assert.Equal(t, []uint64{42}, client.recordedAbandons())
+			}
+		})
+	}
+}
+
+// The offset before a seek is the last position of the new generation, so
+// Close moves a partition with a read in flight back to it.
+func TestPartitionReader_MovesTheOffsetBackBeforeTheSeekOffsetOnCloseWhileAReadIsInFlight(t *testing.T) {
+	client := newFakeClient()
+	reader := standalone(t, client, withCommit(waitOneSecond, iggcon.OnReadCommit()), 1)
+	require.NoError(t, reader.Seek(context.Background(), 1, iggcon.OffsetPollingStrategy(10)))
+	nextInBackground(reader)
+	assert.Equal(t, iggcon.POLLING_NEXT, client.nextRead(t).targets[0].Strategy.Kind)
+
+	require.NoError(t, reader.Close(context.Background()))
+
+	assert.Equal(t, []store{{1, 9}, {1, 9}}, client.recordedStores())
+}
+
+// There is no offset before 0, and the other strategies name no offset, so
+// such a seek stores nothing under OnRead.
+func TestPartitionReader_StoresNothingForASeekThatNamesNoOffsetAboveZeroUnderOnRead(t *testing.T) {
+	for _, start := range []iggcon.PollingStrategy{
+		iggcon.FirstPollingStrategy(), iggcon.OffsetPollingStrategy(0), iggcon.LastPollingStrategy(),
+		iggcon.NextPollingStrategy(), iggcon.TimestampPollingStrategy(10),
+	} {
+		t.Run(fmt.Sprintf("%v", start.Kind), func(t *testing.T) {
+			client := newFakeClient()
+			reader := standalone(t, client, withCommit(waitOneSecond, iggcon.OnReadCommit()), 1)
+			require.NoError(t, reader.Seek(context.Background(), 1, start))
+			nextInBackground(reader)
+
+			read := client.nextRead(t)
+			kind := start.Kind
+			if kind == iggcon.POLLING_FIRST {
+				kind = iggcon.POLLING_OFFSET
+			}
+			assert.Equal(t, kind, read.targets[0].Strategy.Kind)
+			assert.True(t, read.commit)
+			assert.Empty(t, client.recordedStores())
+		})
+	}
+}
+
+// Under OnRead a waiting Next fills the free slots too: the backlogged
+// partitions of a group take committing reads of their own, each with the
+// whole count, beside the one read of its quiet partitions that may wait, and
+// a partition is in one committing read at a time.
+func TestPartitionReader_SpreadsTheBacklogOfAGroupOverCommittingReadsOfTheirOwnUnderOnRead(t *testing.T) {
+	client := newFakeClient()
+	reader := standalone(t, client, withCommit(waitOneSecond, iggcon.OnReadCommit()), 1, 2, 3, 4)
+	polled := nextInBackground(reader)
+	first := client.nextRead(t)
+	require.Len(t, first.targets, 4)
+	first.answer(ahead(1, 100, 0), ahead(2, 100, 0), ahead(3, 100, 0), section(4, 0))
+	require.NoError(t, (<-polled).err)
+	for range 3 {
+		next(t, reader)
+	}
+	assert.True(t, client.readsNothingFor(300*time.Millisecond), "the reader reads for a waiting Next only")
+
+	polled = nextInBackground(reader)
+	spread := make([]*fakeRead, 0, 4)
+	reads := make([][]uint32, 0, 4)
+	for range 4 {
+		read := client.nextRead(t)
+		assert.True(t, read.commit)
+		assert.Equal(t, waitOneSecond.Count, read.count)
+		spread = append(spread, read)
+		reads = append(reads, partitionsOf(read))
+	}
+	assert.ElementsMatch(t, [][]uint32{{1}, {2}, {3}, {4}}, reads)
+
+	for _, read := range spread {
+		if partition := read.targets[0].PartitionId; partition != 4 {
+			read.answer(ahead(partition, 100, 1))
+		}
+	}
+	require.NoError(t, (<-polled).err)
+	next(t, reader)
+	next(t, reader)
+	nextInBackground(reader)
+
+	again := [][]uint32{partitionsOf(client.nextRead(t)), partitionsOf(client.nextRead(t)), partitionsOf(client.nextRead(t))}
+	assert.ElementsMatch(t, [][]uint32{{1}, {2}, {3}}, again, "the backlog reads again beside the read that waits, which keeps its partition")
+	assert.True(t, client.readsNothingFor(300*time.Millisecond))
+}
diff --git a/foreign/go/client/tcp/deferred_polling_fixture_test.go b/foreign/go/client/tcp/deferred_polling_fixture_test.go
new file mode 100644
index 0000000..531d6b3
--- /dev/null
+++ b/foreign/go/client/tcp/deferred_polling_fixture_test.go
@@ -0,0 +1,97 @@
+// 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.
+
+package tcp
+
+import (
+	"encoding/hex"
+	"encoding/json"
+	"fmt"
+	"os"
+	"testing"
+
+	ierror "github.com/apache/iggy/foreign/go/errors"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// The refused fetches of the deferred polling vectors that the Rust encoders
+// generate: a raw fetch that the server would refuse fails before it is sent.
+func TestFixture_RefusesEachRefusedFetchOfTheSharedVectors(t *testing.T) {
+	data, err := os.ReadFile("../../contracts/testdata/deferred_polling.json")
+	require.NoError(t, err)
+	var fixture struct {
+		Refused struct {
+			Fetches []struct {
+				Case    string `json:"case"`
+				BodyHex string `json:"body_hex"`
+			} `json:"fetches"`
+		} `json:"refused"`
+	}
+	require.NoError(t, json.Unmarshal(data, &fixture))
+	require.NotEmpty(t, fixture.Refused.Fetches)
+	for _, vector := range fixture.Refused.Fetches {
+		t.Run(vector.Case, func(t *testing.T) {
+			body, err := hex.DecodeString(vector.BodyHex)
+			require.NoError(t, err)
+
+			_, err = parseFetch(body)
+
+			require.Error(t, err)
+		})
+	}
+}
+
+// A committing poll or fetch that fails with a status stored nothing exactly
+// when the shared vectors call that status certain, for a whole reply and for
+// a fetch section.
+func TestFixture_ClassifiesEachCommitOutcomeOfTheSharedVectors(t *testing.T) {
+	requests, sections := commitOutcomes(t)
+	for _, scope := range []struct {
+		section  bool
+		outcomes []commitOutcome
+	}{{false, requests}, {true, sections}} {
+		require.NotEmpty(t, scope.outcomes)
+		for _, vector := range scope.outcomes {
+			t.Run(fmt.Sprintf("section_%t/%s", scope.section, vector.Error), func(t *testing.T) {
+				assert.Equal(t, vector.Certain, fetchRefusedBeforeAdmission(ierror.FromCode(vector.Status), scope.section))
+			})
+		}
+	}
+}
+
+// commitOutcome is one status of the shared commit outcome vectors.
+type commitOutcome struct {
+	Status  ierror.Code `json:"status"`
+	Error   string      `json:"error"`
+	Certain bool        `json:"certain"`
+}
+
+// commitOutcomes reads the whole reply and the section lists of the shared commit outcome vectors.
+func commitOutcomes(t *testing.T) (requests, sections []commitOutcome) {
+	t.Helper()
+	data, err := os.ReadFile("../../contracts/testdata/deferred_polling.json")
+	require.NoError(t, err)
+	var fixture struct {
+		CommitOutcomes struct {
+			Request []commitOutcome `json:"request"`
+			Section []commitOutcome `json:"section"`
+		} `json:"commit_outcomes"`
+	}
+	require.NoError(t, json.Unmarshal(data, &fixture))
+	return fixture.CommitOutcomes.Request, fixture.CommitOutcomes.Section
+}
diff --git a/foreign/go/client/tcp/tcp_core.go b/foreign/go/client/tcp/tcp_core.go
index 66cf874..337a346 100644
--- a/foreign/go/client/tcp/tcp_core.go
+++ b/foreign/go/client/tcp/tcp_core.go
@@ -68,6 +68,13 @@
 	polls              pollRouter
 	groups             groupAssignmentCache
 	topics             topicCache
+	// deferred holds one slot for each deferred poll and fetch
+	// in flight.
+	deferred chan struct{}
+	// commits counts the fetches with auto-commit that may be in flight, and
+	// Close waits for them. Each Add happens under mtx before the close of
+	// closed, or not at all.
+	commits sync.WaitGroup
 }
 
 type config struct {
@@ -236,6 +243,7 @@
 }
 
 // WithPendingOperations bounds active and retained mutation frames, including headers.
+// The same limits independently bound committing-fetch recovery IDs and target keys.
 func WithPendingOperations(maxOperations, maxBytes int) Option {
 	return func(opts *Options) {
 		opts.config.maxPendingOperations = maxOperations
@@ -259,7 +267,7 @@
 			opt(&opts)
 		}
 	}
-	client := &IggyTcpClient{config: opts.config, logger: logger, session: vsr.NewSession(), sessionFence: 1, registerGate: make(chan struct{}, 1), closed: make(chan struct{})}
+	client := &IggyTcpClient{config: opts.config, logger: logger, session: vsr.NewSession(), sessionFence: 1, registerGate: make(chan struct{}, 1), closed: make(chan struct{}), deferred: make(chan struct{}, maxDeferredRequests)}
 	client.operations = newOperationRegistry(opts.config.maxPendingOperations, opts.config.maxPendingBytes)
 	client.links = newReplicaLinks(client)
 	client.epoch.Store(newSignInEpoch())
@@ -273,6 +281,9 @@
 	replayInterval            = 50 * time.Millisecond
 	lifecycleRetryMaxInterval = time.Second
 	failoverDialTimeout       = 2 * time.Second
+	// maxDeferredRequests bounds the deferred polls and fetches that the client
+	// parks at once. The server parks at most as many for one session on one shard.
+	maxDeferredRequests = 64
 )
 
 // operationScope marks the context of a call. Its value is the sign-in epoch
@@ -287,6 +298,16 @@
 	return context.WithValue(timed, operationScope{}, c.epoch.Load()), cancel
 }
 
+// deferredContext bounds a deferred poll or a fetch by its
+// own request timeout instead of the default operation budget.
+func (c *IggyTcpClient) deferredContext(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
+	timed, cancel := context.WithTimeout(ctx, timeout)
+	if ctx.Value(operationScope{}) != nil {
+		return timed, cancel
+	}
+	return context.WithValue(timed, operationScope{}, c.epoch.Load()), cancel
+}
+
 // callEpoch is the sign-in epoch that the call of ctx started in.
 func (c *IggyTcpClient) callEpoch(ctx context.Context) *signInEpoch {
 	if epoch, ok := ctx.Value(operationScope{}).(*signInEpoch); ok {
@@ -353,6 +374,10 @@
 		if poll, ok := cmd.(*command.PollMessages); ok && poll.Strategy.Context != nil {
 			captured = poll.Strategy.Context
 		}
+		if cmd.Code() == command.FetchMessagesCode {
+			// Each target of a fetch carries its own fence.
+			captured = &iggcon.PartitionContext{}
+		}
 		return c.drive(ctx, cmd.Code(), query, frame, captured, nil)
 	}
 	return c.drive(ctx, cmd.Code(), nil, frame, nil, replica)
@@ -372,26 +397,54 @@
 func (r rawRequest) AppendBinary(b []byte) ([]byte, error) { return append(b, r.payload...), nil }
 
 // SendBinaryRequest sends a command code and payload and returns the raw response body.
-// Session-control codes return ierror.ErrInvalidCommand without writing to the connection.
-// Message sends, polls and consumer offset writes are stamped with the partition context
-// their typed methods would capture, and keep it across retries. A send must name an
-// explicit partition id: a balanced or key-partitioned send returns
-// ierror.ErrFeatureUnavailable without writing, so use SendMessages for those.
+// Session-control codes and deferred-request cancels return ierror.ErrInvalidCommand
+// without writing to the connection. Message sends, polls and consumer offset writes are
+// stamped with the partition context their typed methods would capture, and keep it
+// across retries. A send must name an explicit partition id: a balanced or
+// key-partitioned send returns ierror.ErrFeatureUnavailable without writing, so use
+// SendMessages for those. Deferred polls and fetches take the paths
+// of a deferred poll and a fetch, and limits that the
+// server refuses fail before anything is sent. A fetch preserves its target caps
+// and nonzero fences, resolving zero incarnations before progress admission.
 func (c *IggyTcpClient) SendBinaryRequest(ctx context.Context, code uint32, payload []byte) ([]byte, error) {
-	if isSessionControlCode(code) {
+	if isSessionControlCode(code) || code == uint32(command.CancelDeferredRequestCode) {
 		return nil, ierror.ErrInvalidCommand
 	}
+	if ctx == nil {
+		return nil, ierror.ErrNilContext
+	}
+	switch command.Code(code) {
+	case command.FetchMessagesCode:
+		fetch, err := parseFetch(payload)
+		if err != nil {
+			return nil, err
+		}
+		ctx, cancel := c.deferredContext(ctx, fetch.options.RequestTimeout)
+		defer cancel()
+		reply, _, err := c.fetchMessages(ctx, fetch)
+		return reply, err
+	case command.PollMessagesDeferredCode:
+		options, err := rawDeferredPollOptions(payload)
+		if err != nil {
+			return nil, err
+		}
+		var cancel context.CancelFunc
+		ctx, cancel = c.deferredContext(ctx, options.RequestTimeout)
+		defer cancel()
+	}
 	return c.do(ctx, rawRequest{code: command.Code(code), payload: payload})
 }
 
 // SendBinaryRequestToReplica sends a command code and payload to the replica with id
 // replicaID and returns that replica's raw response body. Session-control codes,
-// replicated commands, polls and consumer offset reads return ierror.ErrInvalidCommand
-// without writing to the connection. A replica the roster does not name, after one
-// roster read, returns ierror.ErrInvalidIdentifier. The replica's own refusal, such as
-// ierror.ErrNotPrimary, is returned as it is.
+// replicated commands, polls, fetches, deferred-request cancels and
+// consumer offset reads return ierror.ErrInvalidCommand without writing to the
+// connection. A replica the roster does not name, after one roster read, returns
+// ierror.ErrInvalidIdentifier. The replica's own refusal, such as ierror.ErrNotPrimary,
+// is returned as it is.
 func (c *IggyTcpClient) SendBinaryRequestToReplica(ctx context.Context, code uint32, payload []byte, replicaID uint8) ([]byte, error) {
-	if _, replicated := vsr.ReplicatedOperation(code); replicated || isSessionControlCode(code) || isPartitionRead(command.Code(code)) {
+	if _, replicated := vsr.ReplicatedOperation(code); replicated || isSessionControlCode(code) || isPartitionRead(command.Code(code)) ||
+		code == uint32(command.CancelDeferredRequestCode) {
 		return nil, ierror.ErrInvalidCommand
 	}
 	return c.doCaptured(ctx, rawRequest{code: command.Code(code), payload: payload}, nil, &replicaID)
@@ -408,7 +461,13 @@
 // isPartitionRead reports whether code reads a partition through its primary and
 // incarnation, which a request to a chosen replica does not carry.
 func isPartitionRead(code command.Code) bool {
-	return code == command.PollMessagesCode || code == command.GetOffsetCode
+	switch code {
+	case command.PollMessagesCode, command.PollMessagesDeferredCode, command.FetchMessagesCode,
+		command.GetOffsetCode:
+		return true
+	default:
+		return false
+	}
 }
 
 func isSessionControlCode(code uint32) bool {
@@ -530,6 +589,8 @@
 // ResolveOperation replays the retained bytes under their original identity.
 // A committed business rejection is returned unchanged and releases the lane.
 // For an operation that is no longer retained, it returns what a replay proved.
+// An unknown committing fetch returns FetchProgressUncertain without I/O, and its
+// recovery ID is local ownership, not a replayable server receipt.
 func (c *IggyTcpClient) ResolveOperation(ctx context.Context, operationID uint64) error {
 	if ctx == nil {
 		return ierror.ErrNilContext
@@ -582,9 +643,30 @@
 	return nil
 }
 
+// Close ends the client. A fetch with auto-commit is never cancelled,
+// because a cancel after its read drops what it stored, so Close waits for
+// the reply of each one in flight before it closes the links.
 func (c *IggyTcpClient) Close() error {
+	c.mtx.Lock()
 	c.closeOnce.Do(func() { close(c.closed) })
-	return c.Disconnect()
+	c.mtx.Unlock()
+	c.commits.Wait()
+	return errors.Join(c.Disconnect(), c.operations.unresolvedFetch())
+}
+
+// startCommit counts a fetch with auto-commit before it may send. It fails
+// once Close started, so Close waits only for the fetches that it lets
+// through.
+func (c *IggyTcpClient) startCommit() bool {
+	c.mtx.Lock()
+	defer c.mtx.Unlock()
+	select {
+	case <-c.closed:
+		return false
+	default:
+		c.commits.Add(1)
+		return true
+	}
 }
 
 func (c *IggyTcpClient) pause(ctx context.Context, interval time.Duration) error {
diff --git a/foreign/go/client/tcp/tcp_core_test.go b/foreign/go/client/tcp/tcp_core_test.go
index 9c3192f..8d1ebc2 100644
--- a/foreign/go/client/tcp/tcp_core_test.go
+++ b/foreign/go/client/tcp/tcp_core_test.go
@@ -528,7 +528,7 @@
 	}
 }
 
-func TestExchange_EvictedPollThatMovesItsConsumerEndsNotCommitted(t *testing.T) {
+func TestExchange_EvictedPollThatMovesItsConsumerEndsWithItsUnknownOutcome(t *testing.T) {
 	for _, test := range []struct {
 		name       string
 		reason     vsr.EvictionReason
@@ -536,10 +536,10 @@
 		autoCommit bool
 		want       error
 	}{
-		{"no_session/auto_commit", vsr.EvictionNoSession, iggcon.FirstPollingStrategy(), true, ierror.ErrTransientNotCommitted},
+		{"no_session/auto_commit", vsr.EvictionNoSession, iggcon.FirstPollingStrategy(), true, ierror.FetchProgressUncertain{}},
 		{"no_session/next", vsr.EvictionNoSession, iggcon.NextPollingStrategy(), false, ierror.ErrTransientNotCommitted},
 		{"no_session/read_only", vsr.EvictionNoSession, iggcon.FirstPollingStrategy(), false, ierror.ErrTransientNotCommitted},
-		{"stale_client/next", vsr.EvictionStaleClient, iggcon.NextPollingStrategy(), true, ierror.ErrTransientNotCommitted},
+		{"stale_client/auto_commit", vsr.EvictionStaleClient, iggcon.NextPollingStrategy(), true, ierror.FetchProgressUncertain{}},
 	} {
 		t.Run(test.name, func(t *testing.T) {
 			client, serverConn := newOfflinePipeClient(t)
@@ -548,7 +548,7 @@
 			})
 			partition := uint32(0)
 
-			_, err := client.PollMessages(context.Background(), numericIdentifier(t, 1), numericIdentifier(t, 2),
+			_, err := client.pollMessages(context.Background(), numericIdentifier(t, 1), numericIdentifier(t, 2),
 				iggcon.NewSingleConsumer(numericIdentifier(t, 3)), test.strategy, 1, test.autoCommit, &partition)
 
 			require.ErrorIs(t, err, test.want)
diff --git a/foreign/go/client/tcp/tcp_deferred.go b/foreign/go/client/tcp/tcp_deferred.go
new file mode 100644
index 0000000..35ab45c
--- /dev/null
+++ b/foreign/go/client/tcp/tcp_deferred.go
@@ -0,0 +1,523 @@
+// 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.
+
+package tcp
+
+import (
+	"cmp"
+	"context"
+	"encoding/binary"
+	"errors"
+	"maps"
+	"math"
+	"slices"
+	"sync"
+	"time"
+
+	binaryserialization "github.com/apache/iggy/foreign/go/binary_serialization"
+	iggcon "github.com/apache/iggy/foreign/go/contracts"
+	ierror "github.com/apache/iggy/foreign/go/errors"
+	"github.com/apache/iggy/foreign/go/internal/command"
+	"github.com/apache/iggy/foreign/go/internal/vsr"
+)
+
+// Layouts of the deferred requests. A deferred poll is a poll body followed by
+// [wait_us u64][min_count u32][max_bytes u32][request_timeout_us u64]. A fetch is
+// [consumer][stream][topic][wait_us u64][request_timeout_us u64]
+// [min_count u32][count u32][max_bytes u32][auto_commit u8][targets u32],
+// then that many targets of [partition_id u32][strategy kind u8]
+// [strategy value u64][max_bytes u32][incarnation u64][owner_generation u64].
+const (
+	wireDurationSize         = 8
+	deferredParametersSize   = wireDurationSize + 4 + 4 + wireDurationSize
+	fetchAutoCommitAt        = 2*wireDurationSize + 3*4
+	fetchParametersSize      = fetchAutoCommitAt + 1 + 4
+	fetchTargetMaxBytesAt    = partitionIdSize + 1 + 8
+	fetchTargetIncarnationAt = fetchTargetMaxBytesAt + 4
+	fetchTargetSize          = fetchTargetIncarnationAt + 2*8
+)
+
+// readinessLookups bounds the route lookups that one call runs at once, as in
+// the Rust SDK.
+const readinessLookups = 16
+
+// FetchMessages reads the partitions of one primary in one request, as a
+// reader reads one readiness group. Read partitions with reader.PartitionReader,
+// which groups them, keeps their positions and recovers lost replies.
+func (c *IggyTcpClient) FetchMessages(
+	ctx context.Context,
+	streamId iggcon.Identifier,
+	topicId iggcon.Identifier,
+	consumer iggcon.Consumer,
+	targets []iggcon.FetchMessagesTarget,
+	count uint32,
+	autoCommit bool,
+	options iggcon.DeferredPollOptions,
+) ([]iggcon.FetchMessagesSection, error) {
+	if ctx == nil {
+		return nil, ierror.ErrNilContext
+	}
+	// The encoding cuts the limits to whole microseconds, so they are checked
+	// before it.
+	if err := options.ValidateFetch(count); err != nil {
+		return nil, err
+	}
+	request := &command.FetchMessages{
+		StreamId: streamId, TopicId: topicId, Consumer: consumer, Targets: make([]command.FetchTarget, 0, len(targets)),
+		Count: count, AutoCommit: autoCommit, Options: options,
+	}
+	// Each target may take the whole budget, so that the server serves a
+	// ready target in full instead of slicing its batches.
+	for _, target := range targets {
+		request.Targets = append(request.Targets, command.FetchTarget{FetchMessagesTarget: target, MaxBytes: options.MaxBytes})
+	}
+	body, err := request.MarshalBinary()
+	if err != nil {
+		return nil, err
+	}
+	fetch, err := parseFetch(body)
+	if err != nil {
+		return nil, err
+	}
+	ctx, cancel := c.deferredContext(ctx, options.RequestTimeout)
+	defer cancel()
+	_, sections, err := c.fetchMessages(ctx, fetch)
+	return sections, err
+}
+
+// ReadinessGroups groups partitions by their primary for a reader, in
+// ascending primary id order, with at most MaxPartitionsPerRequest in a group.
+func (c *IggyTcpClient) ReadinessGroups(
+	ctx context.Context,
+	streamId iggcon.Identifier,
+	topicId iggcon.Identifier,
+	partitions []uint32,
+) ([][]uint32, error) {
+	if ctx == nil {
+		return nil, ierror.ErrNilContext
+	}
+	ctx, cancel := c.operationContext(ctx)
+	defer cancel()
+	topic, err := iggcon.MarshalIdentifiers(streamId, topicId)
+	if err != nil {
+		return nil, err
+	}
+	routes, err := c.partitionRoutes(ctx, topic, partitions)
+	if err != nil {
+		return nil, err
+	}
+	primaries := make(map[uint8][]uint32)
+	for index, route := range routes {
+		primaries[route.primary] = append(primaries[route.primary], partitions[index])
+	}
+	groups := make([][]uint32, 0, len(primaries))
+	for _, primary := range slices.Sorted(maps.Keys(primaries)) {
+		groups = slices.AppendSeq(groups, slices.Chunk(primaries[primary], iggcon.MaxPartitionsPerRequest))
+	}
+	return groups, nil
+}
+
+// fetchRequest is a fetch body that parseFetch accepted.
+type fetchRequest struct {
+	body []byte
+	// topic is the encoded stream and topic, the start of each route query.
+	topic   []byte
+	options iggcon.DeferredPollOptions
+	// targets is where the targets start in body.
+	targets int
+}
+
+// parseFetch refuses a fetch that the server refuses, before anything is
+// sent.
+func parseFetch(body []byte) (fetchRequest, error) {
+	topic, position, err := pollTarget(body)
+	if err != nil {
+		return fetchRequest{}, err
+	}
+	targets := position + fetchParametersSize
+	if len(body) < targets {
+		return fetchRequest{}, ierror.ErrInvalidCommand
+	}
+	count := binary.LittleEndian.Uint32(body[targets-4:])
+	if count == 0 || count > iggcon.MaxPartitionsPerRequest || uint64(len(body)-targets) != uint64(count)*fetchTargetSize {
+		return fetchRequest{}, ierror.ErrInvalidCommand
+	}
+	// The limits are [wait_us u64][request_timeout_us u64][min_count u32]
+	// [count u32][max_bytes u32].
+	limits := body[position:targets]
+	fetch := fetchRequest{body: body, topic: topic, targets: targets, options: iggcon.DeferredPollOptions{
+		MaxWait:        wireDuration(limits),
+		RequestTimeout: wireDuration(limits[wireDurationSize:]),
+		MinCount:       binary.LittleEndian.Uint32(limits[2*wireDurationSize:]),
+		MaxBytes:       binary.LittleEndian.Uint32(limits[2*wireDurationSize+8:]),
+	}}
+	if err := fetch.options.ValidateFetch(binary.LittleEndian.Uint32(limits[2*wireDurationSize+4:])); err != nil {
+		return fetchRequest{}, err
+	}
+	seen := make(map[uint32]struct{}, count)
+	for offset := targets; offset < len(body); offset += fetchTargetSize {
+		target := body[offset : offset+fetchTargetSize]
+		partition := binary.LittleEndian.Uint32(target)
+		if _, duplicate := seen[partition]; duplicate {
+			return fetchRequest{}, ierror.ErrInvalidCommand
+		}
+		seen[partition] = struct{}{}
+		// The server splits count over the targets, and another start point
+		// would loop or misread under a split count.
+		if kind := iggcon.MessagePolling(target[partitionIdSize]); kind != iggcon.POLLING_NEXT && kind != iggcon.POLLING_OFFSET {
+			return fetchRequest{}, ierror.ErrInvalidCommand
+		}
+		if limit := binary.LittleEndian.Uint32(target[fetchTargetMaxBytesAt:]); limit < iggcon.MinDeferredPollMaxBytes || limit > fetch.options.MaxBytes {
+			return fetchRequest{}, ierror.ErrInvalidSizeBytes
+		}
+	}
+	return fetch, nil
+}
+
+// fetchMessages sends fetch to the primary of its first target, and returns
+// the reply with its sections. The caller groups the targets by primary, so
+// a target that moved gets a NotPrimary section. A refused section marks the
+// route of its partition, and a refusal of the whole fetch, which names no
+// target, marks the routes of every target. So the next ReadinessGroups and
+// the next fetch look them up again.
+func (c *IggyTcpClient) fetchMessages(ctx context.Context, fetch fetchRequest) ([]byte, []iggcon.FetchMessagesSection, error) {
+	if err := c.fenceTargets(ctx, &fetch); err != nil {
+		return nil, nil, err
+	}
+	completion := &fetchCompletion{}
+	ctx = context.WithValue(ctx, fetchCompletionKey{}, completion)
+	reply, err := c.do(ctx, rawRequest{code: command.FetchMessagesCode, payload: fetch.body})
+	if err != nil {
+		for offset := fetch.targets; offset < len(fetch.body); offset += fetchTargetSize {
+			c.polls.refusedAt(routeQuery(fetch.topic, binary.LittleEndian.Uint32(fetch.body[offset:])), err)
+		}
+		return nil, nil, err
+	}
+	sections, err := binaryserialization.DeserializeFetchSections(reply, c.MessageCompression)
+	if err != nil {
+		return nil, nil, err
+	}
+	for index := range sections {
+		section := &sections[index]
+		if section.Err != nil {
+			c.polls.refusedAt(routeQuery(fetch.topic, section.PartitionId), section.Err)
+			if slices.Contains(completion.uncertain, section.PartitionId) {
+				section.Err = ierror.FetchProgressUncertain{OperationID: completion.operationID}
+			}
+		}
+	}
+	return reply, sections, nil
+}
+
+type fetchCompletionKey struct{}
+
+type fetchCompletion struct {
+	operationID uint64
+	uncertain   []uint32
+}
+
+// classifyFetchReply validates only the section table while all progress lanes
+// are still held. Record decoding remains the caller's responsibility.
+func classifyFetchReply(ctx context.Context, operation *pendingOperation, ticket *laneTicket, body []byte) error {
+	sections, err := binaryserialization.DeserializeFetchSections(body, "")
+	if err != nil {
+		return err
+	}
+	requested := make(map[uint32]laneKey, len(ticket.fetch.keys))
+	for _, key := range ticket.fetch.keys {
+		requested[key.partition] = key
+	}
+	unknown := make([]laneKey, 0, len(sections))
+	for _, section := range sections {
+		key, found := requested[section.PartitionId]
+		if !found {
+			return ierror.ErrInvalidCommand
+		}
+		delete(requested, section.PartitionId)
+		if section.Err != nil && !fetchRefusedBeforeAdmission(section.Err, true) {
+			unknown = append(unknown, key)
+		}
+	}
+	if len(unknown) > 0 {
+		_ = ticket.retainFetch(unknown)
+		if completion, ok := ctx.Value(fetchCompletionKey{}).(*fetchCompletion); ok {
+			completion.operationID = operation.id
+			for _, key := range unknown {
+				completion.uncertain = append(completion.uncertain, key.partition)
+			}
+		}
+	}
+	return nil
+}
+
+// fetchRefusedBeforeAdmission reports whether a committing poll or fetch that
+// failed with err, as its whole reply or as one fetch section, stored nothing.
+func fetchRefusedBeforeAdmission(err error, section bool) bool {
+	var coded ierror.IggyError
+	if !errors.As(err, &coded) {
+		return false
+	}
+	switch coded.Code() {
+	case ierror.TransientNotAcceptedCode, ierror.InvalidSizeBytesCode, ierror.HistoryUnavailableCode, ierror.NotPrimaryCode,
+		ierror.PartitionNotFoundCode, ierror.TooManyConsumerOffsetsCode, ierror.ConsumerGroupPartitionNotOwnedCode:
+		return true
+	case ierror.FeatureUnavailableCode, ierror.InvalidIdentifierCode, ierror.DeferredPollCapacityExceededCode,
+		ierror.StreamIdNotFoundCode, ierror.StreamNameNotFoundCode, ierror.TopicIdNotFoundCode, ierror.TopicNameNotFoundCode,
+		ierror.InvalidOffsetCode:
+		return !section
+	default:
+		return false
+	}
+}
+
+// Zero wire incarnations select the current history. Resolve them before
+// lane admission so raw and typed calls share the same progress identity.
+func (c *IggyTcpClient) fenceTargets(ctx context.Context, fetch *fetchRequest) error {
+	var unfenced []int
+	var partitions []uint32
+	for offset := fetch.targets; offset < len(fetch.body); offset += fetchTargetSize {
+		target := fetch.body[offset:]
+		if binary.LittleEndian.Uint64(target[fetchTargetIncarnationAt:]) == 0 {
+			unfenced = append(unfenced, offset)
+			partitions = append(partitions, binary.LittleEndian.Uint32(target))
+		}
+	}
+	if len(unfenced) == 0 {
+		return nil
+	}
+	routes, err := c.partitionRoutes(ctx, fetch.topic, partitions)
+	if err != nil {
+		return err
+	}
+	fetch.body = append([]byte(nil), fetch.body...)
+	for lookup, offset := range unfenced {
+		if routes[lookup].context.Incarnation == 0 {
+			return ierror.ErrTransientNotAccepted
+		}
+		incarnation := fetch.body[offset+fetchTargetIncarnationAt:]
+		binary.LittleEndian.PutUint64(incarnation, routes[lookup].context.Incarnation)
+	}
+	return nil
+}
+
+// fetchParameters returns where the parameters of a fetch body start, after
+// its consumer, stream and topic, or false for a body too short for them.
+func fetchParameters(body []byte) (int, bool) {
+	_, position, err := pollTarget(body)
+	return position, err == nil && len(body)-position >= fetchParametersSize
+}
+
+// isProgressFetch reports whether a fetch body commits or has a Next target:
+// either moves or reads the stored offset of its partitions.
+func isProgressFetch(body []byte) bool {
+	position, ok := fetchParameters(body)
+	if !ok {
+		return false
+	}
+	if body[position+fetchAutoCommitAt] != 0 {
+		return true
+	}
+	for target := position + fetchParametersSize; len(body)-target >= fetchTargetSize; target += fetchTargetSize {
+		if body[target+partitionIdSize] == byte(iggcon.POLLING_NEXT) {
+			return true
+		}
+	}
+	return false
+}
+
+// fetchLanes are the progress lanes of the partitions of a fetch body, in
+// ascending partition order, which enterLanes needs.
+func fetchLanes(body []byte) []laneKey {
+	position, ok := fetchParameters(body)
+	if !ok {
+		return nil
+	}
+	targets := body[position+fetchParametersSize:]
+	keys := make([]laneKey, 0, len(targets)/fetchTargetSize)
+	for ; len(targets) >= fetchTargetSize; targets = targets[fetchTargetSize:] {
+		keys = append(keys, laneKey{
+			incarnation: binary.LittleEndian.Uint64(targets[fetchTargetIncarnationAt:]),
+			partition:   binary.LittleEndian.Uint32(targets),
+		}.progressLane())
+	}
+	slices.SortFunc(keys, func(a, b laneKey) int { return cmp.Compare(a.partition, b.partition) })
+	return keys
+}
+
+// partitionRoutes looks up the routes of partitions of topic, readinessLookups
+// at once. The first failure in the order of partitions fails it.
+func (c *IggyTcpClient) partitionRoutes(ctx context.Context, topic []byte, partitions []uint32) ([]pollRoute, error) {
+	routes := make([]pollRoute, len(partitions))
+	failures := make([]error, len(partitions))
+	slots := make(chan struct{}, readinessLookups)
+	var lookups sync.WaitGroup
+	for index, partition := range partitions {
+		slots <- struct{}{}
+		lookups.Go(func() {
+			defer func() { <-slots }()
+			routes[index], failures[index] = c.readinessRoute(ctx, routeQuery(topic, partition))
+		})
+	}
+	lookups.Wait()
+	for _, failure := range failures {
+		if failure != nil {
+			return nil, failure
+		}
+	}
+	return routes, nil
+}
+
+// readinessRoute is the route of the partition of query. A lookup that fails
+// before admission is retried until the call ends.
+func (c *IggyTcpClient) readinessRoute(ctx context.Context, query []byte) (pollRoute, error) {
+	delay := replayInterval
+	for {
+		if err := c.waitForRecovery(ctx); err != nil {
+			return pollRoute{}, err
+		}
+		route, err := c.consumerRoute(ctx, query)
+		if err == nil {
+			return route, nil
+		}
+		if c.sessionRecovers(ctx, err) {
+			continue
+		}
+		if !retryBeforeAdmission(err) {
+			return pollRoute{}, err
+		}
+		if c.pause(ctx, nextLookupBackoff(&delay)) != nil {
+			return pollRoute{}, ierror.ErrTransientNotAccepted
+		}
+	}
+}
+
+// rawDeferredPollOptions reads the limits of a raw deferred poll and refuses
+// a poll that the server refuses, before anything is sent.
+func rawDeferredPollOptions(payload []byte) (iggcon.DeferredPollOptions, error) {
+	if _, err := partitionQuery(command.PollMessagesDeferredCode, payload); err != nil {
+		return iggcon.DeferredPollOptions{}, err
+	}
+	limits := payload[len(payload)-deferredParametersSize:]
+	// The poll parameters are [kind u8][value u64][count u32][auto_commit u8].
+	parameters := payload[len(payload)-deferredParametersSize-pollParametersSize:]
+	count := binary.LittleEndian.Uint32(parameters[1+8:])
+	options := iggcon.DeferredPollOptions{
+		MaxWait:        wireDuration(limits),
+		MinCount:       binary.LittleEndian.Uint32(limits[wireDurationSize:]),
+		MaxBytes:       binary.LittleEndian.Uint32(limits[wireDurationSize+4:]),
+		RequestTimeout: wireDuration(limits[deferredParametersSize-wireDurationSize:]),
+	}
+	return options, options.Validate(count)
+}
+
+// deferredBudget is the budget of a deferred poll or fetch when its call
+// reached the driver, and where its frame carries the budget.
+type deferredBudget struct {
+	started   time.Time
+	options   iggcon.DeferredPollOptions
+	waitAt    int
+	timeoutAt int
+	// minTimeout is the shortest request timeout that the server accepts.
+	minTimeout time.Duration
+	// storesOnRead marks a poll or fetch with auto-commit. A cancel of one
+	// waits for the single reply of the server, which tells whether it stored.
+	storesOnRead bool
+}
+
+// newDeferredBudget reads the budget of a deferred poll or fetch frame that
+// passed partitionQuery. It returns nil for any other request.
+func newDeferredBudget(code command.Code, frame []byte) *deferredBudget {
+	var waitAt int
+	switch code {
+	case command.PollMessagesDeferredCode:
+		waitAt = len(frame) - deferredParametersSize
+	case command.FetchMessagesCode:
+		_, position, err := pollTarget(frame[vsr.HeaderSize:])
+		if err != nil {
+			return nil
+		}
+		waitAt = vsr.HeaderSize + position
+	default:
+		return nil
+	}
+	timeoutAt := waitAt + wireDurationSize
+	if code == command.PollMessagesDeferredCode {
+		timeoutAt = len(frame) - wireDurationSize
+	}
+	budget := &deferredBudget{
+		started:    time.Now(),
+		options:    iggcon.DeferredPollOptions{MaxWait: wireDuration(frame[waitAt:]), RequestTimeout: wireDuration(frame[timeoutAt:])},
+		waitAt:     waitAt,
+		timeoutAt:  timeoutAt,
+		minTimeout: time.Microsecond,
+	}
+	if code == command.FetchMessagesCode {
+		budget.minTimeout = iggcon.MinDeferredPollReplyMargin
+	}
+	budget.storesOnRead = storesOnRead(code, frame[vsr.HeaderSize:])
+	return budget
+}
+
+// attempt is the frame of the next attempt: the request with what is left of
+// its budget, cut to the deadline of the call so that the server answers
+// before the caller stops waiting. It is a copy, because the writer of an
+// earlier attempt can still hold the frame.
+func (b *deferredBudget) attempt(ctx context.Context, frame []byte) (*operationFrame, error) {
+	options, err := b.options.Remaining(time.Since(b.started))
+	if err != nil {
+		return nil, err
+	}
+	if deadline, ok := ctx.Deadline(); ok {
+		options.RequestTimeout = min(options.RequestTimeout, time.Until(deadline))
+	}
+	if options.RequestTimeout < b.minTimeout {
+		return nil, ierror.ErrTransientNotAccepted
+	}
+	options.MaxWait = min(options.MaxWait, max(options.RequestTimeout-iggcon.MinDeferredPollReplyMargin, 0))
+	attempt := append([]byte(nil), frame...)
+	binary.LittleEndian.PutUint64(attempt[b.waitAt:], command.Micros(options.MaxWait))
+	binary.LittleEndian.PutUint64(attempt[b.timeoutAt:], command.Micros(options.RequestTimeout))
+	operation := newOperationFrame(attempt, nil)
+	operation.uncancellable = b.storesOnRead
+	return operation, nil
+}
+
+// storesOnRead reports whether the body of a poll or fetch asks the server to
+// store the offsets of what it reads.
+func storesOnRead(code command.Code, body []byte) bool {
+	switch code {
+	case command.PollMessagesCode:
+		return len(body) > 0 && body[len(body)-1] != 0
+	case command.PollMessagesDeferredCode:
+		return len(body) > deferredParametersSize && body[len(body)-deferredParametersSize-1] != 0
+	case command.FetchMessagesCode:
+		_, position, err := pollTarget(body)
+		return err == nil && len(body) > position+fetchAutoCommitAt && body[position+fetchAutoCommitAt] != 0
+	default:
+		return false
+	}
+}
+
+// wireDuration reads microseconds as the wire carries them, saturated at the
+// longest Duration.
+func wireDuration(micros []byte) time.Duration {
+	value := binary.LittleEndian.Uint64(micros)
+	if value > math.MaxInt64/uint64(time.Microsecond) {
+		return math.MaxInt64
+	}
+	return time.Duration(value) * time.Microsecond
+}
diff --git a/foreign/go/client/tcp/tcp_deferred_test.go b/foreign/go/client/tcp/tcp_deferred_test.go
new file mode 100644
index 0000000..e14bb31
--- /dev/null
+++ b/foreign/go/client/tcp/tcp_deferred_test.go
@@ -0,0 +1,2052 @@
+// 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.
+
+package tcp
+
+import (
+	"context"
+	"encoding/binary"
+	"fmt"
+	"net"
+	"sync"
+	"sync/atomic"
+	"testing"
+	"testing/synctest"
+	"time"
+
+	binaryserialization "github.com/apache/iggy/foreign/go/binary_serialization"
+	iggcon "github.com/apache/iggy/foreign/go/contracts"
+	ierror "github.com/apache/iggy/foreign/go/errors"
+	"github.com/apache/iggy/foreign/go/internal/command"
+	"github.com/apache/iggy/foreign/go/internal/vsr"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// Numeric consumer, stream and topic identifiers precede the fetch limits.
+const fetchLimitsAt = 19
+
+const deferredTestTimeout = 3 * time.Second
+
+func deferredOptions(wait, timeout time.Duration) iggcon.DeferredPollOptions {
+	return iggcon.DeferredPollOptions{MaxWait: wait, MinCount: 1, MaxBytes: 1 << 20, RequestTimeout: timeout}
+}
+
+// deferredLimits reads the wait and the request timeout that a recorded
+// deferred poll or fetch carries.
+func deferredLimits(t *testing.T, read request) (time.Duration, time.Duration) {
+	t.Helper()
+	if read.code() == uint32(command.FetchMessagesCode) {
+		limits := read.payload[fetchLimitsAt:]
+		return wireDuration(limits), wireDuration(limits[wireDurationSize:])
+	}
+	require.Equal(t, uint32(command.PollMessagesDeferredCode), read.code())
+	limits := read.payload[len(read.payload)-deferredParametersSize:]
+	return wireDuration(limits), wireDuration(limits[deferredParametersSize-wireDurationSize:])
+}
+
+// requireCancellationOf checks that cancellation cancels parked: a header
+// without a body that names the same client, session and request.
+func requireCancellationOf(t *testing.T, parked, cancellation request) {
+	t.Helper()
+	assert.Equal(t, uint32(command.CancelDeferredRequestCode), cancellation.code())
+	assert.Equal(t, vsr.OperationNonReplicated, cancellation.operation())
+	assert.Empty(t, cancellation.payload)
+	assert.Equal(t, uint32(vsr.HeaderSize), binary.LittleEndian.Uint32(cancellation.header[frameOffsetSize:]))
+	assert.Equal(t, parked.requestID(), cancellation.requestID())
+	assert.Equal(t, parked.sessionID(), cancellation.sessionID())
+	assert.Equal(t, parked.clientID(), cancellation.clientID())
+}
+
+func receive[T any](t *testing.T, channel <-chan T, what string) T {
+	t.Helper()
+	select {
+	case value := <-channel:
+		return value
+	case <-time.After(deferredTestTimeout):
+		t.Fatalf("timed out waiting for %s", what)
+		var zero T
+		return zero
+	}
+}
+
+func pollDeferred(ctx context.Context, t *testing.T, client *IggyTcpClient, partition uint32, options iggcon.DeferredPollOptions) (*iggcon.PolledMessage, error) {
+	consumer := iggcon.NewSingleConsumer(numericIdentifier(t, 3))
+	return client.pollMessagesDeferred(ctx, numericIdentifier(t, 1), numericIdentifier(t, 2), consumer,
+		iggcon.OffsetPollingStrategy(0), 1, false, &partition, options)
+}
+
+func TestPollMessagesDeferred_CancelSendsTheCancellationOnTheSameLink(t *testing.T) {
+	client, serverConn := newPipeClient(t)
+	parked := make(chan request, 1)
+	cancellations := make(chan request, 1)
+	servePartitionOperations(t, serverConn, func(_ int, read request) []byte {
+		switch read.code() {
+		case uint32(command.PollMessagesDeferredCode):
+			parked <- read
+			return nil
+		case uint32(command.CancelDeferredRequestCode):
+			cancellations <- read
+			// A reply that crossed the cancellation names the cancelled request.
+			return replyFrame(vsr.OperationNonReplicated, emptyBatchBody(1))
+		default:
+			return replyFrame(vsr.OperationNonReplicated, emptyBatchBody(polledPartition(t, read)))
+		}
+	})
+	ctx, cancel := context.WithCancel(context.Background())
+	polled := make(chan error, 1)
+	go func() {
+		_, err := pollDeferred(ctx, t, client, 1, deferredOptions(5*time.Second, 10*time.Second))
+		polled <- err
+	}()
+	deferred := receive(t, parked, "the deferred poll")
+
+	cancel()
+
+	require.ErrorIs(t, receive(t, polled, "the cancelled poll"), ierror.ErrTransientNotCommitted)
+	requireCancellationOf(t, deferred, receive(t, cancellations, "the cancellation"))
+	partition := uint32(2)
+	after, err := client.pollMessages(context.Background(), numericIdentifier(t, 1), numericIdentifier(t, 2),
+		iggcon.NewSingleConsumer(numericIdentifier(t, 3)), iggcon.OffsetPollingStrategy(0), 1, false, &partition)
+	require.NoError(t, err, "the link stays usable after a cancellation")
+	assert.Equal(t, partition, after.PartitionId, "the late reply of the cancelled poll is dropped")
+	link := client.links.bootstrap
+	require.Eventually(t, func() bool {
+		link.mu.Lock()
+		defer link.mu.Unlock()
+		return len(link.pending) == 0 && len(link.capacity) == 0
+	}, deferredTestTimeout, managedLinkPoll, "the cancelled poll keeps no pending entry or capacity slot")
+}
+
+// The server answers a cancelled poll that stores its offset once: refused when
+// the cancel won, or with what it read and stored. So the call waits for that
+// reply, which tells the caller whether the offset moved.
+func TestPollMessagesDeferred_ACancelledCommittingPollReturnsTheReplyToItsCancellation(t *testing.T) {
+	for _, tt := range []struct {
+		name   string
+		stored bool
+	}{
+		{"cancel_won", false},
+		{"read_stored_first", true},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+			client, serverConn := newPipeClient(t)
+			parked := make(chan request, 1)
+			cancellations := make(chan request, 1)
+			servePartitionOperations(t, serverConn, func(_ int, read request) []byte {
+				switch read.code() {
+				case uint32(command.PollMessagesDeferredCode):
+					parked <- read
+					return nil
+				case uint32(command.CancelDeferredRequestCode):
+					cancellations <- read
+					// The cancellation names the request id of the poll, so this answers the poll.
+					if tt.stored {
+						return replyFrame(vsr.OperationNonReplicated, emptyBatchBody(1))
+					}
+					return statusReplyFrame(vsr.OperationNonReplicated, uint32(ierror.TransientNotAcceptedCode), nil)
+				default:
+					t.Errorf("unexpected command %d", read.code())
+					return nil
+				}
+			})
+			ctx, cancel := context.WithCancel(context.Background())
+			type outcome struct {
+				polled *iggcon.PolledMessage
+				err    error
+			}
+			polled := make(chan outcome, 1)
+			go func() {
+				partition := uint32(1)
+				result, err := client.pollMessagesDeferred(ctx, numericIdentifier(t, 1), numericIdentifier(t, 2),
+					iggcon.NewSingleConsumer(numericIdentifier(t, 3)), iggcon.NextPollingStrategy(), 1, true,
+					&partition, deferredOptions(5*time.Second, 10*time.Second))
+				polled <- outcome{result, err}
+			}()
+			deferred := receive(t, parked, "the committing poll")
+
+			cancel()
+
+			requireCancellationOf(t, deferred, receive(t, cancellations, "the cancellation"))
+			result := receive(t, polled, "the cancelled committing poll")
+			if tt.stored {
+				require.NoError(t, result.err)
+				assert.Equal(t, uint32(1), result.polled.PartitionId)
+				return
+			}
+			require.ErrorIs(t, result.err, ierror.ErrTransientNotAccepted)
+		})
+	}
+}
+
+// The server can have stored the offset of a committing poll whose reply is
+// lost, which only a later read shows.
+func TestPollMessages_ALostReplyOfACommittingPollIsUncertainProgress(t *testing.T) {
+	for _, deferred := range []bool{false, true} {
+		t.Run(fmt.Sprintf("deferred_%t", deferred), func(t *testing.T) {
+			client, serverConn := newPipeClient(t)
+			servePartitionOperations(t, serverConn, func(_ int, read request) []byte {
+				if read.code() == uint32(command.PollMessagesCode) || read.code() == uint32(command.PollMessagesDeferredCode) {
+					_ = serverConn.Close()
+					return nil
+				}
+				t.Errorf("unexpected command %d", read.code())
+				return nil
+			})
+			partition := uint32(1)
+			consumer := iggcon.NewSingleConsumer(numericIdentifier(t, 3))
+			var err error
+			if deferred {
+				_, err = client.pollMessagesDeferred(context.Background(), numericIdentifier(t, 1), numericIdentifier(t, 2),
+					consumer, iggcon.NextPollingStrategy(), 1, true, &partition, deferredOptions(time.Second, 3*time.Second))
+			} else {
+				_, err = client.pollMessages(context.Background(), numericIdentifier(t, 1), numericIdentifier(t, 2),
+					consumer, iggcon.NextPollingStrategy(), 1, true, &partition)
+			}
+
+			require.ErrorIs(t, err, ierror.FetchProgressUncertain{})
+		})
+	}
+}
+
+func TestPollMessages_CancelOfAnImmediatePollSendsNoCancellation(t *testing.T) {
+	client, serverConn := newPipeClient(t)
+	parked := make(chan struct{}, 1)
+	var polls atomic.Int32
+	server := servePartitionOperations(t, serverConn, func(_ int, read request) []byte {
+		if polls.Add(1) == 1 {
+			parked <- struct{}{}
+			return nil
+		}
+		return replyFrame(vsr.OperationNonReplicated, emptyBatchBody(polledPartition(t, read)))
+	})
+	partition := uint32(1)
+	poll := func(ctx context.Context) error {
+		_, err := client.pollMessages(ctx, numericIdentifier(t, 1), numericIdentifier(t, 2),
+			iggcon.NewSingleConsumer(numericIdentifier(t, 3)), iggcon.OffsetPollingStrategy(0), 1, false, &partition)
+		return err
+	}
+	ctx, cancel := context.WithCancel(context.Background())
+	polled := make(chan error, 1)
+	go func() { polled <- poll(ctx) }()
+	receive(t, parked, "the poll")
+
+	cancel()
+
+	require.Error(t, receive(t, polled, "the cancelled poll"))
+	require.NoError(t, poll(context.Background()))
+	assert.Zero(t, requestCount(server.recorded(), command.CancelDeferredRequestCode))
+}
+
+func TestPollMessagesDeferred_RetriesACapacityRefusalWithinItsBudget(t *testing.T) {
+	client, serverConn := newPipeClient(t)
+	var attempts atomic.Int32
+	server := servePartitionOperations(t, serverConn, func(_ int, read request) []byte {
+		if attempts.Add(1) == 1 {
+			return statusReplyFrame(vsr.OperationNonReplicated, uint32(ierror.DeferredPollCapacityExceededCode), nil)
+		}
+		return replyFrame(vsr.OperationNonReplicated, emptyBatchBody(1))
+	})
+	options := deferredOptions(2*time.Second, 5*time.Second)
+
+	polled, err := pollDeferred(context.Background(), t, client, 1, options)
+
+	require.NoError(t, err)
+	assert.Equal(t, uint32(1), polled.PartitionId)
+	var sent []request
+	for _, read := range server.recorded() {
+		if read.code() == uint32(command.PollMessagesDeferredCode) {
+			sent = append(sent, read)
+		}
+	}
+	require.Len(t, sent, 2, "the refused poll is sent again")
+	assert.Equal(t, sent[0].requestID(), sent[1].requestID(), "an admission refusal keeps the request")
+	firstWait, firstTimeout := deferredLimits(t, sent[0])
+	secondWait, secondTimeout := deferredLimits(t, sent[1])
+	assert.LessOrEqual(t, firstTimeout, options.RequestTimeout)
+	assert.Less(t, secondTimeout, firstTimeout, "the retry spends the budget of the call")
+	assert.Less(t, secondWait, firstWait)
+	assert.GreaterOrEqual(t, secondTimeout-secondWait, iggcon.MinDeferredPollReplyMargin)
+}
+
+func TestPollMessagesDeferred_EndsAtItsRequestTimeoutAndCancelsThePoll(t *testing.T) {
+	client, serverConn := newPipeClient(t)
+	parked := make(chan request, 1)
+	cancellations := make(chan request, 1)
+	servePartitionOperations(t, serverConn, func(_ int, read request) []byte {
+		switch read.code() {
+		case uint32(command.PollMessagesDeferredCode):
+			parked <- read
+		case uint32(command.CancelDeferredRequestCode):
+			cancellations <- read
+		}
+		return nil
+	})
+	options := deferredOptions(0, 300*time.Millisecond)
+	started := time.Now()
+
+	_, err := pollDeferred(context.Background(), t, client, 1, options)
+
+	require.ErrorIs(t, err, ierror.ErrTransientNotCommitted)
+	elapsed := time.Since(started)
+	assert.GreaterOrEqual(t, elapsed, options.RequestTimeout)
+	assert.Less(t, elapsed, responseReadTimeout/10, "the request timeout, not the operation budget, bounds the call")
+	deferred := receive(t, parked, "the deferred poll")
+	_, timeout := deferredLimits(t, deferred)
+	assert.LessOrEqual(t, timeout, options.RequestTimeout)
+	requireCancellationOf(t, deferred, receive(t, cancellations, "the cancellation"))
+}
+
+func TestPollMessagesDeferred_ParksAtMostTheDeferredLimit(t *testing.T) {
+	client, serverConn := newPipeClient(t)
+	parked := make(chan request, maxDeferredRequests+1)
+	cancellations := make(chan request, maxDeferredRequests+1)
+	servePartitionOperations(t, serverConn, func(_ int, read request) []byte {
+		switch read.code() {
+		case uint32(command.PollMessagesDeferredCode):
+			parked <- read
+		case uint32(command.CancelDeferredRequestCode):
+			cancellations <- read
+		}
+		return nil
+	})
+	ctx, cancelAll := context.WithCancel(context.Background())
+	var polls sync.WaitGroup
+	defer func() {
+		cancelAll()
+		polls.Wait()
+	}()
+	start := func(ctx context.Context) {
+		polls.Go(func() { _, _ = pollDeferred(ctx, t, client, 1, deferredOptions(10*time.Second, 15*time.Second)) })
+	}
+	first, cancelFirst := context.WithCancel(ctx)
+	start(first)
+	firstPoll := receive(t, parked, "the first deferred poll")
+	for range maxDeferredRequests - 1 {
+		start(ctx)
+		receive(t, parked, "a deferred poll under the limit")
+	}
+
+	start(ctx)
+	select {
+	case read := <-parked:
+		t.Fatalf("deferred poll %d went past the limit of %d", read.requestID(), maxDeferredRequests)
+	case <-time.After(200 * time.Millisecond):
+	}
+	cancelFirst()
+
+	requireCancellationOf(t, firstPoll, receive(t, cancellations, "the cancellation"))
+	receive(t, parked, "the deferred poll that waited for a free slot")
+}
+
+// Progress polls of one partition queue behind its parked poll. They take no
+// deferred slot while they wait, so a poll of another partition still parks.
+func TestPollMessagesDeferred_PollsQueuedOnAProgressLaneLeaveTheSlotsFree(t *testing.T) {
+	client, serverConn := newPipeClient(t)
+	parked := make(chan request, maxDeferredRequests+1)
+	servePartitionOperations(t, serverConn, func(_ int, read request) []byte {
+		if read.code() == uint32(command.PollMessagesDeferredCode) {
+			parked <- read
+		}
+		return nil
+	}, 3)
+	calls := newLaneCalls(t)
+	ctx, cancelAll := context.WithCancel(context.Background())
+	var polls sync.WaitGroup
+	defer func() {
+		cancelAll()
+		polls.Wait()
+	}()
+	options := deferredOptions(10*time.Second, 15*time.Second)
+	for range maxDeferredRequests {
+		polls.Go(func() {
+			_, _ = client.pollMessagesDeferred(ctx, calls.stream, calls.topic, calls.consumer,
+				iggcon.NextPollingStrategy(), 1, false, &calls.partition, options)
+		})
+	}
+	receive(t, parked, "the progress poll that holds the lane")
+	require.Eventually(t, func() bool { return progressLaneWaiters(client) == maxDeferredRequests-1 },
+		deferredTestTimeout, managedLinkPoll, "the other progress polls wait for the lane")
+
+	polls.Go(func() { _, _ = pollDeferred(ctx, t, client, 2, options) })
+
+	read := receive(t, parked, "the poll of another partition")
+	query, err := partitionQuery(command.PollMessagesDeferredCode, read.payload)
+	require.NoError(t, err)
+	assert.Equal(t, uint32(2), binary.LittleEndian.Uint32(query[len(query)-partitionIdSize:]))
+}
+
+// A parked Next or auto-commit poll holds the progress lane of its partition.
+// The offset calls of that partition wait for it, and a send does not.
+func TestPollMessagesDeferred_AParkedProgressPollHoldsOffsetCallsButNotSends(t *testing.T) {
+	calls := newLaneCalls(t)
+	for _, offset := range []struct {
+		name string
+		code command.Code
+		call func(context.Context, *IggyTcpClient) error
+	}{
+		{"get", command.GetOffsetCode, calls.getOffset},
+		{"store", command.StoreOffsetCode, calls.storeOffset},
+		{"delete", command.DeleteConsumerOffsetCode, calls.deleteOffset},
+	} {
+		for _, progress := range []struct {
+			name       string
+			strategy   iggcon.PollingStrategy
+			autoCommit bool
+		}{
+			{"next", iggcon.NextPollingStrategy(), false},
+			{"auto_commit", iggcon.OffsetPollingStrategy(0), true},
+		} {
+			t.Run(offset.name+"/"+progress.name, func(t *testing.T) {
+				client, serverConn := newPipeClient(t)
+				parked := make(chan struct{}, 1)
+				server := servePartitionOperations(t, serverConn, func(_ int, read request) []byte {
+					switch read.code() {
+					case uint32(command.PollMessagesDeferredCode):
+						parked <- struct{}{}
+						return nil
+					case uint32(command.CancelDeferredRequestCode):
+						if progress.autoCommit {
+							// A cancelled poll that stores its offset gets one reply: the cancel won.
+							return statusReplyFrame(vsr.OperationNonReplicated, uint32(ierror.TransientNotAcceptedCode), nil)
+						}
+						return nil
+					case uint32(command.SendMessagesCode):
+						return replyFrame(vsr.OperationSendMessages, zeroConfirmations())
+					case uint32(command.GetOffsetCode):
+						return replyFrame(vsr.OperationNonReplicated, consumerOffsetBody(calls.partition))
+					default:
+						return replyFrame(read.operation(), resultSection())
+					}
+				})
+				// A replicated request carries its operation instead of its code.
+				offsetsSent := func() int {
+					if operation := vsr.OperationForCode(uint32(offset.code)); operation != vsr.OperationNonReplicated {
+						return len(requestsFor(server.recorded(), operation))
+					}
+					return requestCount(server.recorded(), offset.code)
+				}
+				ctx, cancel := context.WithCancel(context.Background())
+				defer cancel()
+				polled := make(chan error, 1)
+				go func() {
+					_, err := client.pollMessagesDeferred(ctx, calls.stream, calls.topic, calls.consumer, progress.strategy, 1,
+						progress.autoCommit, &calls.partition, deferredOptions(5*time.Second, 10*time.Second))
+					polled <- err
+				}()
+				receive(t, parked, "the parked poll")
+				offsetDone := make(chan error, 1)
+				go func() { offsetDone <- offset.call(context.Background(), client) }()
+				require.Eventually(t, func() bool { return progressLaneWaiters(client) == 1 },
+					deferredTestTimeout, managedLinkPoll, "the offset call waits for the progress lane")
+
+				sendCtx, cancelSend := context.WithTimeout(context.Background(), deferredTestTimeout)
+				defer cancelSend()
+				require.NoError(t, calls.send(sendCtx, client), "a send does not wait for the parked poll")
+				assert.Zero(t, offsetsSent(), "the offset call is not sent while the poll is parked")
+
+				cancel()
+				var cancelled error = ierror.ErrTransientNotCommitted
+				if progress.autoCommit {
+					cancelled = ierror.ErrTransientNotAccepted
+				}
+				require.ErrorIs(t, receive(t, polled, "the cancelled poll"), cancelled)
+				require.NoError(t, receive(t, offsetDone, "the offset call"))
+				assert.Equal(t, 1, offsetsSent())
+			})
+		}
+	}
+}
+
+func TestSendBinaryRequest_TakesTheDeferredPathsAndRefusesACancellation(t *testing.T) {
+	client, serverConn := newPipeClient(t)
+	server := servePartitionOperations(t, serverConn, func(_ int, read request) []byte {
+		return replyFrame(vsr.OperationNonReplicated, nil)
+	})
+	streamId, topicId, consumer := groupConsumer(t)
+	ctx := context.Background()
+	partition := uint32(1)
+	invalid := deferredOptions(time.Second, 2*time.Second)
+	invalid.MinCount = 0
+	refusedPoll, err := (&command.PollMessages{StreamId: streamId, TopicId: topicId, Consumer: consumer, PartitionId: &partition,
+		Strategy: iggcon.NextPollingStrategy(), Count: 1, Deferred: &invalid}).MarshalBinary()
+	require.NoError(t, err)
+
+	_, err = client.SendBinaryRequest(ctx, uint32(command.PollMessagesDeferredCode), refusedPoll)
+	require.ErrorIs(t, err, ierror.ErrInvalidMessagesCount)
+	_, err = client.SendBinaryRequest(ctx, uint32(command.CancelDeferredRequestCode), nil)
+	require.ErrorIs(t, err, ierror.ErrInvalidCommand)
+	for _, code := range []command.Code{command.PollMessagesDeferredCode, command.FetchMessagesCode, command.CancelDeferredRequestCode} {
+		_, err = client.SendBinaryRequestToReplica(ctx, uint32(code), nil, 0)
+		require.ErrorIs(t, err, ierror.ErrInvalidCommand, "code %d", code)
+	}
+	assert.Zero(t, requestCount(server.recorded(), command.PollMessagesDeferredCode))
+	assert.Zero(t, requestCount(server.recorded(), command.CancelDeferredRequestCode))
+}
+
+// A deferred group poll reads one partition. ReadinessGroups and FetchMessages
+// read the whole assignment.
+func TestPollMessagesDeferred_RefusesAGroupPollWithoutAPartition(t *testing.T) {
+	client, serverConn := newPipeClient(t)
+	server := servePartitionOperations(t, serverConn, func(_ int, read request) []byte {
+		t.Errorf("unexpected command %d", read.code())
+		return nil
+	})
+	streamId, topicId, consumer := groupConsumer(t)
+
+	_, err := client.pollMessagesDeferred(context.Background(), streamId, topicId, consumer,
+		iggcon.NextPollingStrategy(), 1, true, nil, deferredOptions(time.Second, 2*time.Second))
+
+	require.ErrorIs(t, err, ierror.ErrInvalidCommand)
+	assert.Empty(t, server.recorded())
+}
+
+// The targets of a recorded fetch follow its parameters.
+const fetchTargetsAt = fetchLimitsAt + fetchParametersSize
+
+// fetchSection is a section of a fetch reply that holds no records.
+type fetchSection struct {
+	partition uint32
+	status    ierror.Code
+	next      uint64
+	count     uint32
+	records   []byte
+	context   iggcon.PartitionContext
+}
+
+// fetchBody builds the reply of a fetch. Each section header is [partition
+// u32][status u32][current_offset u64][messages_count u32][context]
+// [next_offset u64][records_len u32].
+func fetchBody(sections ...fetchSection) []byte {
+	body := binary.LittleEndian.AppendUint32(nil, uint32(len(sections)))
+	for _, section := range sections {
+		body = binary.LittleEndian.AppendUint32(body, section.partition)
+		body = binary.LittleEndian.AppendUint32(body, uint32(section.status))
+		body = binary.LittleEndian.AppendUint64(body, 0)
+		body = binary.LittleEndian.AppendUint32(body, section.count)
+		body = binary.LittleEndian.AppendUint64(body, section.context.Incarnation)
+		body = binary.LittleEndian.AppendUint64(body, section.context.OwnerGeneration)
+		body = binary.LittleEndian.AppendUint64(body, section.context.MetadataOp)
+		body = binary.LittleEndian.AppendUint64(body, section.next)
+		body = binary.LittleEndian.AppendUint32(body, uint32(len(section.records)))
+	}
+	for _, section := range sections {
+		body = append(body, section.records...)
+	}
+	return body
+}
+
+// fetchTarget is a target of a recorded fetch.
+type fetchTarget struct {
+	partition uint32
+	strategy  iggcon.MessagePolling
+	value     uint64
+	maxBytes  uint32
+	fence     iggcon.PartitionContext
+}
+
+func fetchTargets(t *testing.T, read request) []fetchTarget {
+	t.Helper()
+	require.Equal(t, uint32(command.FetchMessagesCode), read.code())
+	count := binary.LittleEndian.Uint32(read.payload[fetchTargetsAt-4:])
+	require.Len(t, read.payload, fetchTargetsAt+int(count)*fetchTargetSize)
+	targets := make([]fetchTarget, 0, count)
+	for offset := fetchTargetsAt; offset < len(read.payload); offset += fetchTargetSize {
+		target := read.payload[offset:]
+		targets = append(targets, fetchTarget{
+			partition: binary.LittleEndian.Uint32(target),
+			strategy:  iggcon.MessagePolling(target[partitionIdSize]),
+			value:     binary.LittleEndian.Uint64(target[partitionIdSize+1:]),
+			maxBytes:  binary.LittleEndian.Uint32(target[fetchTargetMaxBytesAt:]),
+			fence: iggcon.PartitionContext{
+				Incarnation:     binary.LittleEndian.Uint64(target[fetchTargetIncarnationAt:]),
+				OwnerGeneration: binary.LittleEndian.Uint64(target[fetchTargetIncarnationAt+8:]),
+			},
+		})
+	}
+	return targets
+}
+
+// recordedFetch is the one fetch of reads.
+func recordedFetch(t *testing.T, reads []request) request {
+	t.Helper()
+	var fetches []request
+	for _, read := range reads {
+		if read.code() == uint32(command.FetchMessagesCode) {
+			fetches = append(fetches, read)
+		}
+	}
+	require.Len(t, fetches, 1)
+	return fetches[0]
+}
+
+type fetchOutcome struct {
+	sections []iggcon.FetchMessagesSection
+	err      error
+}
+
+// startFetch fetches partitions from strategy in the background.
+func startFetch(ctx context.Context, client *IggyTcpClient, calls laneCalls, strategy iggcon.PollingStrategy, autoCommit bool, partitions ...uint32) <-chan fetchOutcome {
+	targets := make([]iggcon.FetchMessagesTarget, 0, len(partitions))
+	for _, partition := range partitions {
+		targets = append(targets, iggcon.FetchMessagesTarget{PartitionId: partition, Strategy: strategy})
+	}
+	fetched := make(chan fetchOutcome, 1)
+	go func() {
+		sections, err := client.FetchMessages(ctx, calls.stream, calls.topic, calls.consumer, targets, 10, autoCommit,
+			deferredOptions(5*time.Second, 10*time.Second))
+		fetched <- fetchOutcome{sections: sections, err: err}
+	}()
+	return fetched
+}
+
+// answerParked writes reply to the request that the server left parked.
+func answerParked(t *testing.T, conn net.Conn, parked request, reply []byte) {
+	t.Helper()
+	echoReplyRequest(reply, parked)
+	_, err := conn.Write(reply)
+	require.NoError(t, err)
+}
+
+// A fetch goes to the primary of its first target in one request, because
+// its caller groups the targets. Its header carries no partition context,
+// because each target carries its own.
+func TestFetchMessages_SendsOneRequestToThePrimaryOfItsFirstTarget(t *testing.T) {
+	fixture := newPrimaryPollFixture(t, func(_, _ int, read request) ([]byte, bool) {
+		if read.code() != uint32(command.FetchMessagesCode) {
+			return nil, false
+		}
+		return replyFrame(vsr.OperationNonReplicated, fetchBody(fetchSection{partition: 1, next: 1},
+			fetchSection{partition: 0, status: ierror.NotPrimaryCode})), true
+	}, nil)
+	streamId, topicId, consumer := groupConsumer(t)
+	next := iggcon.NextPollingStrategy()
+
+	sections, err := fixture.client.FetchMessages(context.Background(), streamId, topicId, consumer,
+		[]iggcon.FetchMessagesTarget{{PartitionId: 1, Strategy: next}, {PartitionId: 0, Strategy: next}},
+		10, false, deferredOptions(0, 2*time.Second))
+
+	require.NoError(t, err)
+	require.Len(t, sections, 2)
+	assert.ErrorIs(t, sections[1].Err, ierror.ErrNotPrimary)
+	fetch := recordedFetch(t, fixture.primaries[1].recorded())
+	assert.Equal(t, make([]byte, iggcon.PartitionContextSize), fetch.header[frameOffsetContext:frameOffsetContext+iggcon.PartitionContextSize])
+	targets := fetchTargets(t, fetch)
+	require.Len(t, targets, 2)
+	assert.Equal(t, []uint32{1, 0}, []uint32{targets[0].partition, targets[1].partition})
+	assert.Zero(t, requestCount(fixture.primaries[0].recorded(), command.FetchMessagesCode))
+	assert.Zero(t, requestCount(fixture.coordinator.recorded(), command.FetchMessagesCode))
+}
+
+// Each target of a fetch may take the whole byte budget, and a target without
+// a context is fenced to the incarnation of its route. The call returns the
+// sections in reply order.
+func TestFetchMessages_FillsTheCapAndTheFenceOfEachTarget(t *testing.T) {
+	client, serverConn := newPipeClient(t)
+	server := servePartitionOperations(t, serverConn, func(_ int, read request) []byte {
+		if read.code() != uint32(command.FetchMessagesCode) {
+			t.Errorf("unexpected command %d", read.code())
+			return nil
+		}
+		return replyFrame(vsr.OperationNonReplicated, fetchBody(fetchSection{partition: 2, status: ierror.NotPrimaryCode},
+			fetchSection{partition: 1, next: 42}))
+	})
+	calls := newLaneCalls(t)
+	fence := iggcon.PartitionContext{Incarnation: 5, OwnerGeneration: 9, MetadataOp: 3}
+	options := deferredOptions(time.Second, 3*time.Second)
+
+	sections, err := client.FetchMessages(context.Background(), calls.stream, calls.topic, calls.consumer,
+		[]iggcon.FetchMessagesTarget{
+			{PartitionId: 1, Strategy: iggcon.OffsetPollingStrategy(41)},
+			{PartitionId: 2, Strategy: iggcon.NextPollingStrategy().WithContext(fence)},
+		}, 10, false, options)
+
+	require.NoError(t, err)
+	assert.Equal(t, []fetchTarget{
+		{partition: 1, strategy: iggcon.POLLING_OFFSET, value: 41, maxBytes: options.MaxBytes, fence: iggcon.PartitionContext{Incarnation: 17}},
+		{partition: 2, strategy: iggcon.POLLING_NEXT, maxBytes: options.MaxBytes, fence: iggcon.PartitionContext{Incarnation: 5, OwnerGeneration: 9}},
+	}, fetchTargets(t, recordedFetch(t, server.recorded())))
+	assert.Equal(t, 1, requestCount(server.recorded(), command.GetPartitionRouteCode), "a target with a context needs no route")
+	require.Len(t, sections, 2)
+	assert.Equal(t, uint32(2), sections[0].PartitionId)
+	assert.ErrorIs(t, sections[0].Err, ierror.ErrNotPrimary)
+	assert.Equal(t, uint32(1), sections[1].PartitionId)
+	assert.NoError(t, sections[1].Err)
+	assert.Equal(t, uint64(42), sections[1].NextOffset)
+}
+
+// A fetch that commits or starts at Next holds the progress lane of each of
+// its partitions while it is parked, so the offset calls of those partitions
+// wait for it. A fetch from offsets that does not commit holds no lane. Each
+// takes one deferred slot.
+func TestFetchMessages_AProgressFetchHoldsTheProgressLanesOfItsPartitions(t *testing.T) {
+	calls := newLaneCalls(t)
+	for _, tt := range []struct {
+		name       string
+		strategy   iggcon.PollingStrategy
+		autoCommit bool
+		progress   bool
+	}{
+		{"auto_commit", iggcon.OffsetPollingStrategy(0), true, true},
+		{"next", iggcon.NextPollingStrategy(), false, true},
+		{"offset", iggcon.OffsetPollingStrategy(0), false, false},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+			client, serverConn := newPipeClient(t)
+			parked := make(chan request, 1)
+			server := servePartitionOperations(t, serverConn, func(_ int, read request) []byte {
+				if read.code() == uint32(command.FetchMessagesCode) {
+					parked <- read
+					return nil
+				}
+				return replyFrame(read.operation(), resultSection())
+			})
+			storesSent := func() int {
+				return len(requestsFor(server.recorded(), vsr.OperationForCode(uint32(command.StoreOffsetCode))))
+			}
+			fetched := startFetch(context.Background(), client, calls, tt.strategy, tt.autoCommit, 2, 1)
+			fetch := receive(t, parked, "the parked fetch")
+			assert.Len(t, client.deferred, 1, "a fetch takes one deferred slot")
+			stored := make(chan error, 2)
+			for _, partition := range []uint32{1, 2} {
+				store := calls
+				store.partition = partition
+				go func() { stored <- store.storeOffset(context.Background(), client) }()
+			}
+
+			if tt.progress {
+				require.Eventually(t, func() bool { return progressLaneWaiters(client) == 2 },
+					deferredTestTimeout, managedLinkPoll, "the store of each partition waits for the fetch")
+				assert.Zero(t, storesSent())
+			} else {
+				require.NoError(t, receive(t, stored, "a store"))
+				require.NoError(t, receive(t, stored, "a store"))
+			}
+			answerParked(t, serverConn, fetch, replyFrame(vsr.OperationNonReplicated, fetchBody()))
+
+			require.NoError(t, receive(t, fetched, "the fetch").err)
+			if tt.progress {
+				require.NoError(t, receive(t, stored, "a store"))
+				require.NoError(t, receive(t, stored, "a store"))
+			}
+			assert.Equal(t, 2, storesSent())
+		})
+	}
+}
+
+// A fetch takes the progress lanes of its partitions in ascending partition
+// order, so a fetch that waits for a lane holds none of the lanes above it.
+func TestFetchMessages_TakesTheProgressLanesInPartitionOrder(t *testing.T) {
+	calls := newLaneCalls(t)
+	client, serverConn := newPipeClient(t)
+	parked := make(chan request, 2)
+	servePartitionOperations(t, serverConn, func(_ int, read request) []byte {
+		if read.code() == uint32(command.FetchMessagesCode) {
+			parked <- read
+			return nil
+		}
+		return replyFrame(read.operation(), resultSection())
+	})
+	next := iggcon.NextPollingStrategy()
+	first := startFetch(context.Background(), client, calls, next, false, 2, 1)
+	firstFetch := receive(t, parked, "the first fetch")
+	second := startFetch(context.Background(), client, calls, next, false, 3, 1)
+	require.Eventually(t, func() bool { return progressLaneWaiters(client) == 1 },
+		deferredTestTimeout, managedLinkPoll, "the second fetch waits for the lane of partition 1")
+
+	store := calls
+	store.partition = 3
+	storeCtx, cancelStore := context.WithTimeout(context.Background(), deferredTestTimeout)
+	defer cancelStore()
+	require.NoError(t, store.storeOffset(storeCtx, client), "the waiting fetch holds no lane of partition 3")
+
+	answerParked(t, serverConn, firstFetch, replyFrame(vsr.OperationNonReplicated, fetchBody()))
+	require.NoError(t, receive(t, first, "the first fetch").err)
+	answerParked(t, serverConn, receive(t, parked, "the second fetch"), replyFrame(vsr.OperationNonReplicated, fetchBody()))
+	require.NoError(t, receive(t, second, "the second fetch").err)
+}
+
+func TestFetchMessages_NamesTheBlockedPartitionAndReleasesEarlierLanes(t *testing.T) {
+	calls := newLaneCalls(t)
+	calls.partition = 2
+	client, serverConn := newPipeClient(t)
+	attempts := make(map[uint64]int)
+	server := servePartitionOperations(t, serverConn, func(_ int, read request) []byte {
+		if read.code() == uint32(command.FetchMessagesCode) {
+			return replyFrame(vsr.OperationNonReplicated, fetchBody(fetchSection{partition: 2, next: 8}))
+		}
+		if read.code() == uint32(command.GetOffsetCode) {
+			return replyFrame(vsr.OperationNonReplicated, consumerOffsetBody(1))
+		}
+		attempts[read.requestID()]++
+		return uncertainAnswer(attempts[read.requestID()]-1, read)
+	})
+	ctx, cancel := context.WithTimeout(context.Background(), deferredTestTimeout)
+	defer cancel()
+	var uncertain ierror.OperationUncertain
+	require.ErrorAs(t, calls.storeOffset(ctx, client), &uncertain)
+
+	for _, autoCommit := range []bool{false, true} {
+		outcome := receive(t, startFetch(ctx, client, calls, iggcon.NextPollingStrategy(), autoCommit, 3, 2, 1), "the blocked fetch")
+		var blocked ierror.PartitionLaneBlocked
+		require.ErrorAs(t, outcome.err, &blocked)
+		assert.Equal(t, uint32(2), blocked.PartitionID)
+		assert.Equal(t, uncertain.OperationID, blocked.OperationID)
+		assert.Equal(t, ierror.PartitionLaneBlockedCode, blocked.Code())
+		assert.Equal(t, ierror.Code(14009), blocked.Code())
+		assert.ErrorIs(t, outcome.err, ierror.ErrPartitionLaneBlocked)
+		assert.ErrorIs(t, outcome.err, ierror.ErrLaneBlocked)
+		var generic ierror.LaneBlocked
+		require.ErrorAs(t, outcome.err, &generic)
+		assert.Equal(t, uncertain.OperationID, generic.OperationID)
+		assert.Empty(t, client.deferred, "a blocked fetch releases its deferred slot")
+	}
+	assert.Zero(t, requestCount(server.recorded(), command.FetchMessagesCode), "a blocked fetch is never sent")
+	var single ierror.LaneBlocked
+	err := calls.getOffset(ctx, client)
+	require.ErrorAs(t, err, &single)
+	assert.IsType(t, ierror.LaneBlocked{}, err, "single-partition calls keep their error type")
+	assert.Equal(t, ierror.Code(14007), single.Code())
+
+	other := calls
+	other.partition = 1
+	require.NoError(t, other.getOffset(ctx, client), "the fetch releases the lane acquired before the blocked target")
+	require.NoError(t, client.AbandonOperation(ctx, uncertain.OperationID))
+	require.NoError(t, receive(t, startFetch(ctx, client, calls, iggcon.NextPollingStrategy(), false, 3, 2, 1), "the repaired fetch").err)
+	assert.Equal(t, 1, requestCount(server.recorded(), command.FetchMessagesCode))
+}
+
+// The outcome of a fetch that commits or starts at Next is unknown once it is
+// sent, so it is never sent again. A fetch from offsets that does not commit
+// changes nothing, so it is sent again.
+func TestFetchMessages_SendsAgainOnlyAFetchThatMovesNoProgress(t *testing.T) {
+	calls := newLaneCalls(t)
+	for _, tt := range []struct {
+		name       string
+		strategy   iggcon.PollingStrategy
+		autoCommit bool
+		fetches    int32
+	}{
+		{"auto_commit", iggcon.OffsetPollingStrategy(0), true, 1},
+		{"next", iggcon.NextPollingStrategy(), false, 1},
+		{"offset", iggcon.OffsetPollingStrategy(0), false, 2},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+			client, serverConn := newPipeClient(t)
+			var fetches atomic.Int32
+			servePartitionOperations(t, serverConn, func(_ int, read request) []byte {
+				if read.code() != uint32(command.FetchMessagesCode) {
+					t.Errorf("unexpected command %d", read.code())
+					return nil
+				}
+				if fetches.Add(1) == 1 {
+					return statusReplyFrame(vsr.OperationNonReplicated, uint32(ierror.TransientNotCommittedCode), nil)
+				}
+				return replyFrame(vsr.OperationNonReplicated, fetchBody(fetchSection{partition: 1, next: 1}))
+			})
+
+			outcome := receive(t, startFetch(context.Background(), client, calls, tt.strategy, tt.autoCommit, 1), "the fetch")
+
+			if tt.autoCommit {
+				require.ErrorIs(t, outcome.err, ierror.ErrFetchProgressUncertain)
+			} else if tt.fetches == 1 {
+				require.ErrorIs(t, outcome.err, ierror.ErrTransientNotCommitted)
+			} else {
+				require.NoError(t, outcome.err)
+			}
+			assert.Equal(t, tt.fetches, fetches.Load())
+		})
+	}
+}
+
+func TestFetchMessages_UnknownCommitBlocksEveryTargetUntilExplicitRecovery(t *testing.T) {
+	calls := newLaneCalls(t)
+	client, serverConn := newPipeClient(t)
+	server := servePartitionOperations(t, serverConn, func(_ int, read request) []byte {
+		switch command.Code(read.code()) {
+		case command.FetchMessagesCode:
+			return statusReplyFrame(vsr.OperationNonReplicated, uint32(ierror.TransientNotCommittedCode), nil)
+		case command.GetOffsetCode:
+			return replyFrame(vsr.OperationNonReplicated, consumerOffsetBody(1))
+		default:
+			return replyFrame(read.operation(), resultSection())
+		}
+	})
+	ctx, cancel := context.WithTimeout(context.Background(), deferredTestTimeout)
+	defer cancel()
+	outcome := receive(t, startFetch(ctx, client, calls, iggcon.NextPollingStrategy(), true, 3, 2, 1), "the unknown fetch")
+	var coded interface{ Code() ierror.Code }
+	require.ErrorAs(t, outcome.err, &coded)
+	require.Equal(t, ierror.Code(14010), coded.Code())
+	var recoveryID uint64
+	for _, partition := range []uint32{1, 2, 3} {
+		calls.partition = partition
+		var blocked ierror.PartitionLaneBlocked
+		require.ErrorAs(t, calls.storeOffset(ctx, client), &blocked)
+		assert.Equal(t, partition, blocked.PartitionID)
+		if recoveryID == 0 {
+			recoveryID = blocked.OperationID
+		}
+		assert.Equal(t, recoveryID, blocked.OperationID)
+		require.NoError(t, calls.getOffset(ctx, client), "offset inspection stays available")
+	}
+	assert.Zero(t, requestCount(server.recorded(), command.StoreOffsetCode))
+	require.ErrorAs(t, client.ResolveOperation(ctx, recoveryID), &coded)
+	assert.Equal(t, ierror.Code(14010), coded.Code())
+	assert.Equal(t, 1, requestCount(server.recorded(), command.FetchMessagesCode), "resolution never replays a fetch")
+	var blocked ierror.PartitionLaneBlocked
+	require.ErrorAs(t, calls.storeOffset(ctx, client), &blocked)
+	require.NoError(t, client.AbandonOperation(ctx, recoveryID))
+	require.NoError(t, calls.storeOffset(ctx, client))
+	assert.ErrorIs(t, client.AbandonOperation(ctx, recoveryID), ierror.ErrOperationNotRetained)
+}
+
+// A committing poll whose outcome is unknown blocks the progress of its
+// partition as a committing fetch does, until the caller abandons it. A
+// refusal before the store blocks nothing.
+func TestPollMessages_UnknownCommitBlocksItsPartitionUntilAbandoned(t *testing.T) {
+	for _, deferred := range []bool{false, true} {
+		for _, refusal := range []ierror.Code{ierror.TransientNotCommittedCode, ierror.ConsumerGroupPartitionNotOwnedCode} {
+			t.Run(fmt.Sprintf("deferred_%t/status_%d", deferred, refusal), func(t *testing.T) {
+				calls := newLaneCalls(t)
+				client, serverConn := newPipeClient(t)
+				var polls atomic.Int32
+				servePartitionOperations(t, serverConn, func(_ int, read request) []byte {
+					switch command.Code(read.code()) {
+					case command.PollMessagesCode, command.PollMessagesDeferredCode:
+						if polls.Add(1) == 1 {
+							return statusReplyFrame(vsr.OperationNonReplicated, uint32(refusal), nil)
+						}
+						return replyFrame(vsr.OperationNonReplicated, emptyBatchBody(calls.partition))
+					default:
+						return replyFrame(read.operation(), resultSection())
+					}
+				})
+				ctx, cancel := context.WithTimeout(context.Background(), deferredTestTimeout)
+				defer cancel()
+				poll := func() error {
+					if !deferred {
+						return calls.poll(ctx, client, iggcon.NextPollingStrategy(), true)
+					}
+					_, err := client.pollMessagesDeferred(ctx, calls.stream, calls.topic, calls.consumer,
+						iggcon.NextPollingStrategy(), 1, true, &calls.partition, deferredOptions(0, 2*time.Second))
+					return err
+				}
+
+				err := poll()
+
+				if refusal != ierror.TransientNotCommittedCode {
+					require.ErrorIs(t, err, ierror.FromCode(refusal))
+					require.NoError(t, poll(), "a refusal before the store leaves the partition open")
+					return
+				}
+				var unknown ierror.FetchProgressUncertain
+				require.ErrorAs(t, err, &unknown)
+				var blocked ierror.PartitionLaneBlocked
+				require.ErrorAs(t, poll(), &blocked)
+				assert.Equal(t, calls.partition, blocked.PartitionID)
+				assert.Equal(t, unknown.OperationID, blocked.OperationID)
+				require.ErrorAs(t, calls.storeOffset(ctx, client), &blocked)
+				assert.Equal(t, unknown.OperationID, blocked.OperationID)
+				assert.Equal(t, int32(1), polls.Load(), "a blocked poll is never sent")
+				require.NoError(t, client.AbandonOperation(ctx, unknown.OperationID))
+				require.NoError(t, poll())
+				assert.Equal(t, int32(2), polls.Load())
+			})
+		}
+	}
+}
+
+// A disconnect of the caller while a committing poll waits on the server
+// leaves the outcome of the poll unknown, and its partition blocked.
+func TestPollMessages_ADisconnectDuringACommittingPollLeavesItUncertain(t *testing.T) {
+	calls := newLaneCalls(t)
+	client, serverConn := newPipeClient(t)
+	parked := make(chan request, 1)
+	servePartitionOperations(t, serverConn, func(_ int, read request) []byte {
+		if read.code() == uint32(command.PollMessagesDeferredCode) {
+			parked <- read
+			return nil
+		}
+		return replyFrame(read.operation(), resultSection())
+	})
+	polled := make(chan error, 1)
+	go func() {
+		_, err := client.pollMessagesDeferred(context.Background(), calls.stream, calls.topic, calls.consumer,
+			iggcon.NextPollingStrategy(), 1, true, &calls.partition, deferredOptions(10*time.Second, 20*time.Second))
+		polled <- err
+	}()
+	receive(t, parked, "the parked poll")
+
+	require.NoError(t, client.Disconnect())
+
+	var unknown ierror.FetchProgressUncertain
+	require.ErrorAs(t, receive(t, polled, "the poll"), &unknown)
+	var retained ierror.FetchProgressUncertain
+	require.ErrorAs(t, client.operations.unresolvedFetch(), &retained, "the block outlives the session")
+	assert.Equal(t, unknown.OperationID, retained.OperationID)
+}
+
+func TestFetchMessages_UnknownCommitResolvesZeroIncarnationsBeforeAdmission(t *testing.T) {
+	for _, raw := range []bool{false, true} {
+		t.Run(fmt.Sprintf("raw_%t", raw), func(t *testing.T) {
+			client, serverConn := newPipeClient(t)
+			server := servePartitionOperations(t, serverConn, func(_ int, read request) []byte {
+				if read.code() == uint32(command.FetchMessagesCode) {
+					return statusReplyFrame(vsr.OperationNonReplicated, uint32(ierror.TransientNotCommittedCode), nil)
+				}
+				return replyFrame(read.operation(), resultSection())
+			})
+			calls := newLaneCalls(t)
+			options := deferredOptions(0, 2*time.Second)
+			target := iggcon.FetchMessagesTarget{PartitionId: 1, Strategy: iggcon.NextPollingStrategy()}
+			target.Strategy.Context = &iggcon.PartitionContext{OwnerGeneration: 9}
+			var err error
+			if raw {
+				body, encodeErr := (&command.FetchMessages{StreamId: calls.stream, TopicId: calls.topic, Consumer: calls.consumer,
+					Targets: []command.FetchTarget{{FetchMessagesTarget: target, MaxBytes: options.MaxBytes}}, Count: 10, AutoCommit: true, Options: options}).MarshalBinary()
+				require.NoError(t, encodeErr)
+				original := append([]byte(nil), body...)
+				_, err = client.SendBinaryRequest(context.Background(), uint32(command.FetchMessagesCode), body)
+				assert.Equal(t, original, body, "raw caller bytes remain owned by the caller")
+			} else {
+				_, err = client.FetchMessages(context.Background(), calls.stream, calls.topic, calls.consumer,
+					[]iggcon.FetchMessagesTarget{target}, 10, true, options)
+			}
+			var unknown ierror.FetchProgressUncertain
+			require.ErrorAs(t, err, &unknown)
+			fence := fetchTargets(t, recordedFetch(t, server.recorded()))[0].fence
+			assert.Equal(t, uint64(17), fence.Incarnation)
+			assert.Equal(t, uint64(9), fence.OwnerGeneration, "resolve only the missing incarnation")
+			var blocked ierror.PartitionLaneBlocked
+			require.ErrorAs(t, calls.storeOffset(context.Background(), client), &blocked)
+			assert.Equal(t, unknown.OperationID, blocked.OperationID)
+			assert.Zero(t, requestCount(server.recorded(), command.StoreOffsetCode))
+		})
+	}
+}
+
+func TestFetchMessages_UnknownCommitBlocksProgressWithAnExplicitZeroContext(t *testing.T) {
+	client, serverConn := newPipeClient(t)
+	server := servePartitionOperations(t, serverConn, func(_ int, read request) []byte {
+		if read.code() == uint32(command.FetchMessagesCode) {
+			return statusReplyFrame(vsr.OperationNonReplicated, uint32(ierror.TransientNotCommittedCode), nil)
+		}
+		if read.code() == uint32(command.PollMessagesCode) || read.code() == uint32(command.PollMessagesDeferredCode) {
+			return replyFrame(vsr.OperationNonReplicated, emptyBatchBody(1))
+		}
+		return replyFrame(read.operation(), resultSection())
+	})
+	calls := newLaneCalls(t)
+	outcome := receive(t, startFetch(context.Background(), client, calls, iggcon.NextPollingStrategy(), true, 1), "the unknown fetch")
+	var unknown ierror.FetchProgressUncertain
+	require.ErrorAs(t, outcome.err, &unknown)
+	fence := iggcon.PartitionContext{OwnerGeneration: 9}
+	strategy := iggcon.NextPollingStrategy().WithContext(fence)
+	for _, progress := range []struct {
+		name string
+		call func() error
+	}{
+		{"store", func() error {
+			return client.StoreConsumerPosition(t.Context(), calls.consumer, calls.stream, calls.topic,
+				iggcon.ConsumerPosition{PartitionId: 1, Offset: 7, Context: fence})
+		}},
+		{"poll", func() error { return calls.poll(t.Context(), client, strategy, false) }},
+		{"deferred", func() error {
+			_, err := client.pollMessagesDeferred(t.Context(), calls.stream, calls.topic, calls.consumer,
+				strategy, 1, false, &calls.partition, deferredOptions(0, 2*time.Second))
+			return err
+		}},
+	} {
+		t.Run(progress.name, func(t *testing.T) {
+			var blocked ierror.PartitionLaneBlocked
+			require.ErrorAs(t, progress.call(), &blocked)
+			assert.Equal(t, unknown.OperationID, blocked.OperationID)
+		})
+	}
+	assert.Empty(t, requestsFor(server.recorded(), vsr.OperationForCode(uint32(command.StoreOffsetCode))))
+	assert.Zero(t, requestCount(server.recorded(), command.PollMessagesCode))
+	assert.Zero(t, requestCount(server.recorded(), command.PollMessagesDeferredCode))
+}
+
+func TestFetchMessages_ExplicitZeroContextKeepsItsWireFenceAcrossAStaleRoute(t *testing.T) {
+	client, serverConn := newPipeClient(t)
+	var applied atomic.Bool
+	server := servePartitionOperations(t, serverConn, func(_ int, read request) []byte {
+		if read.code() == uint32(command.FetchMessagesCode) {
+			return statusReplyFrame(vsr.OperationNonReplicated, uint32(ierror.TransientNotCommittedCode), nil)
+		}
+		if read.operation() == vsr.OperationForCode(uint32(command.StoreOffsetCode)) {
+			if read.incarnation() == 18 {
+				applied.Store(true)
+				return committedReply(read, 0)
+			}
+			return committedReply(read, ierror.HistoryUnavailableCode)
+		}
+		return replyFrame(read.operation(), nil)
+	})
+	calls := newLaneCalls(t)
+	strategy := iggcon.NextPollingStrategy().WithContext(iggcon.PartitionContext{Incarnation: 18})
+	outcome := receive(t, startFetch(context.Background(), client, calls, strategy, true, 1), "the unknown fetch from new history")
+	var unknown ierror.FetchProgressUncertain
+	require.ErrorAs(t, outcome.err, &unknown)
+	position := iggcon.ConsumerPosition{PartitionId: 1, Offset: 7, Context: iggcon.PartitionContext{OwnerGeneration: 9, MetadataOp: 100}}
+	err := client.StoreConsumerPosition(context.Background(), calls.consumer, calls.stream, calls.topic, position)
+	require.ErrorIs(t, err, ierror.ErrHistoryUnavailable)
+	assert.False(t, applied.Load(), "the server rejects a zero header incarnation before progress admission")
+	stores := requestsFor(server.recorded(), vsr.OperationForCode(uint32(command.StoreOffsetCode)))
+	require.Len(t, stores, 1)
+	assert.Zero(t, stores[0].incarnation(), "local lane normalization preserves the caller's explicit wire fence")
+	assert.Equal(t, position.Context.OwnerGeneration, binary.LittleEndian.Uint64(stores[0].header[frameOffsetContext+8:]))
+	assert.Equal(t, position.Context.MetadataOp, binary.LittleEndian.Uint64(stores[0].header[frameOffsetContext+16:]))
+	position.Context.Incarnation = 18
+	var blocked ierror.PartitionLaneBlocked
+	require.ErrorAs(t, client.StoreConsumerPosition(context.Background(), calls.consumer, calls.stream, calls.topic, position), &blocked)
+	assert.Equal(t, unknown.OperationID, blocked.OperationID)
+}
+
+func TestFetchMessages_LiveUnknownCommitBlocksAcrossConnectionsAndSessionChanges(t *testing.T) {
+	for _, cancelAfterSend := range []bool{false, true} {
+		t.Run(fmt.Sprintf("cancel_%t", cancelAfterSend), func(t *testing.T) {
+			parked := make(chan request, 1)
+			release := make(chan struct{})
+			finished := make(chan struct{})
+			var releaseOnce sync.Once
+			defer releaseOnce.Do(func() { close(release) })
+			var fetches atomic.Int32
+			fixture := newPrimaryPollFixture(t, func(primary, _ int, read request) ([]byte, bool) {
+				if read.operation() == vsr.OperationSendMessages {
+					return replyFrame(read.operation(), zeroConfirmations()), true
+				}
+				if read.operation() == vsr.OperationForCode(uint32(command.StoreOffsetCode)) ||
+					read.operation() == vsr.OperationForCode(uint32(command.DeleteConsumerOffsetCode)) {
+					return replyFrame(read.operation(), resultSection()), true
+				}
+				switch command.Code(read.code()) {
+				case command.FetchMessagesCode:
+					if fetches.Add(1) == 1 {
+						assert.Equal(t, 0, primary)
+						parked <- read
+						<-release
+						close(finished)
+						return replyFrame(vsr.OperationNonReplicated, fetchBody(fetchSection{partition: 0, next: 1})), true
+					}
+					return statusReplyFrame(vsr.OperationNonReplicated, uint32(ierror.TransientNotCommittedCode), nil), true
+				case command.GetOffsetCode:
+					return replyFrame(vsr.OperationNonReplicated, consumerOffsetBody(1)), true
+				case command.CancelDeferredRequestCode:
+					// The cancel reaches the server after the fetch held its reply, so nothing answers it.
+					return nil, true
+				}
+				return nil, false
+			}, func(_ int, read request) ([]byte, bool) {
+				if read.code() != uint32(command.GetPartitionRouteCode) {
+					return nil, false
+				}
+				reply := routeReply(t, read, uint8(routedPollPartition(t, read)+1))
+				binary.LittleEndian.PutUint64(reply[vsr.HeaderSize:], 17)
+				return reply, true
+			})
+			awaitReplicaLinks(t, fixture)
+			client := fixture.client
+			calls := newLaneCalls(t)
+			ctx, cancel := context.WithCancel(context.Background())
+			defer cancel()
+			fetched := make(chan error, 1)
+			go func() {
+				_, err := client.FetchMessages(ctx, calls.stream, calls.topic, calls.consumer,
+					[]iggcon.FetchMessagesTarget{{PartitionId: 0, Strategy: iggcon.NextPollingStrategy()}, {PartitionId: 1, Strategy: iggcon.NextPollingStrategy()}},
+					10, true, deferredOptions(0, 2*time.Second))
+				fetched <- err
+			}()
+			old := receive(t, parked, "the live committing fetch")
+			stored := make(chan error, 1)
+			go func() { stored <- calls.storeOffset(context.Background(), client) }()
+			require.Eventually(t, func() bool { return progressLaneWaiters(client) == 1 }, deferredTestTimeout, time.Millisecond)
+			if cancelAfterSend {
+				cancel()
+			}
+			var unknown ierror.FetchProgressUncertain
+			require.ErrorAs(t, receive(t, fetched, "the bounded unknown fetch"), &unknown)
+			var blocked ierror.PartitionLaneBlocked
+			require.ErrorAs(t, receive(t, stored, "the already queued store"), &blocked)
+			assert.Equal(t, uint32(1), blocked.PartitionID)
+			assert.Equal(t, unknown.OperationID, blocked.OperationID)
+			assert.Equal(t, old.requestID(), unknown.OperationID)
+			select {
+			case <-finished:
+				t.Fatal("the old server read must still be live")
+			default:
+			}
+			for _, partition := range []uint32{0, 1} {
+				alias := calls
+				alias.partition = partition
+				alias.stream, _ = iggcon.NewIdentifier("stream")
+				alias.topic, _ = iggcon.NewIdentifier("topic")
+				require.ErrorAs(t, alias.storeOffset(context.Background(), client), &blocked)
+				assert.Equal(t, unknown.OperationID, blocked.OperationID)
+				assert.Equal(t, partition, blocked.PartitionID)
+			}
+			require.NoError(t, calls.getOffset(context.Background(), client))
+			require.NoError(t, calls.poll(context.Background(), client, iggcon.OffsetPollingStrategy(0), false))
+			require.NoError(t, calls.send(context.Background(), client))
+			connections := fixture.primaries[1].connections()
+			client.links.replica(2).close(ierror.ErrNotConnected)
+			require.NoError(t, calls.getOffset(context.Background(), client))
+			assert.Greater(t, fixture.primaries[1].connections(), connections)
+			_, err := client.LoginUser(context.Background(), "iggy", "secret")
+			require.NoError(t, err)
+			require.ErrorAs(t, calls.deleteOffset(context.Background(), client), &blocked)
+			assert.Equal(t, unknown.OperationID, blocked.OperationID)
+			require.ErrorIs(t, client.ResolveOperation(context.Background(), unknown.OperationID), ierror.ErrFetchProgressUncertain)
+			assert.Empty(t, requestsFor(fixture.primaries[1].recorded(), vsr.OperationForCode(uint32(command.StoreOffsetCode))))
+			assert.Empty(t, requestsFor(fixture.primaries[1].recorded(), vsr.OperationForCode(uint32(command.DeleteConsumerOffsetCode))))
+			assert.Equal(t, int32(1), fetches.Load(), "reconnect, sign-in and resolve never replay")
+			require.NoError(t, client.StoreConsumerPosition(context.Background(), calls.consumer, calls.stream, calls.topic,
+				iggcon.ConsumerPosition{PartitionId: 1, Offset: 7, Context: iggcon.PartitionContext{Incarnation: 18}}), "a new history is independent")
+			require.NoError(t, client.AbandonOperation(context.Background(), unknown.OperationID))
+			require.NoError(t, calls.storeOffset(context.Background(), client), "explicit recovery permits progress while the old read is live")
+			select {
+			case <-finished:
+				t.Fatal("abandon cannot complete the old server read")
+			default:
+			}
+			second := receive(t, startFetch(context.Background(), client, calls, iggcon.NextPollingStrategy(), true, 1), "the new unknown fetch")
+			var nextUnknown ierror.FetchProgressUncertain
+			require.ErrorAs(t, second.err, &nextUnknown)
+			assert.Greater(t, nextUnknown.OperationID, unknown.OperationID, "recovery IDs survive session replacement without reuse")
+			var blockedUnknown ierror.FetchProgressUncertain
+			require.ErrorAs(t, client.Close(), &blockedUnknown)
+			assert.Equal(t, nextUnknown.OperationID, blockedUnknown.OperationID)
+			releaseOnce.Do(func() { close(release) })
+			receive(t, finished, "the old read released by the test")
+		})
+	}
+}
+
+func TestFetchMessages_PartialUnknownKeepsHealthyRecordsAndRawBytes(t *testing.T) {
+	calls := newLaneCalls(t)
+	encoded, err := (&command.SendMessages{StreamId: calls.stream, TopicId: calls.topic,
+		Partitioning: iggcon.PartitionId(1), Messages: []iggcon.IggyMessage{calls.message}}).MarshalBinary()
+	require.NoError(t, err)
+	const metadataLengthSize = 4
+	records := encoded[metadataLengthSize+int(binary.LittleEndian.Uint32(encoded)):]
+	for _, raw := range []bool{false, true} {
+		t.Run(fmt.Sprintf("raw_%t", raw), func(t *testing.T) {
+			client, serverConn := newPipeClient(t)
+			body := fetchBody(fetchSection{partition: 3, status: ierror.TransientNotCommittedCode},
+				fetchSection{partition: 1, next: 1, count: 1, records: records, context: iggcon.PartitionContext{Incarnation: 17}},
+				fetchSection{partition: 2, status: ierror.RequestAlreadyAppliedCode},
+				fetchSection{partition: 4, status: ierror.DeferredPollCapacityExceededCode})
+			server := servePartitionOperations(t, serverConn, func(_ int, read request) []byte {
+				if read.code() == uint32(command.FetchMessagesCode) {
+					return replyFrame(vsr.OperationNonReplicated, body)
+				}
+				return replyFrame(read.operation(), resultSection())
+			})
+			var sections []iggcon.FetchMessagesSection
+			var fetchErr error
+			if raw {
+				options := deferredOptions(0, 2*time.Second)
+				targets := make([]command.FetchTarget, 0, 4)
+				for _, partition := range []uint32{1, 2, 3, 4} {
+					targets = append(targets, command.FetchTarget{FetchMessagesTarget: iggcon.FetchMessagesTarget{
+						PartitionId: partition, Strategy: iggcon.NextPollingStrategy()}, MaxBytes: options.MaxBytes})
+				}
+				requestBody, encodeErr := (&command.FetchMessages{StreamId: calls.stream, TopicId: calls.topic, Consumer: calls.consumer,
+					Targets: targets, Count: 10, AutoCommit: true, Options: options}).MarshalBinary()
+				require.NoError(t, encodeErr)
+				var reply []byte
+				reply, fetchErr = client.SendBinaryRequest(context.Background(), uint32(command.FetchMessagesCode), requestBody)
+				require.NoError(t, fetchErr)
+				assert.Equal(t, body, reply)
+				sections, fetchErr = binaryserialization.DeserializeFetchSections(reply, iggcon.MESSAGE_COMPRESSION_NONE)
+			} else {
+				outcome := receive(t, startFetch(context.Background(), client, calls, iggcon.NextPollingStrategy(), true, 1, 2, 3, 4), "the partial fetch")
+				sections, fetchErr = outcome.sections, outcome.err
+			}
+			require.NoError(t, fetchErr)
+			require.Len(t, sections, 4)
+			healthy, decodeErr := binaryserialization.DeserializeSectionMessages(sections[1])
+			require.NoError(t, decodeErr)
+			require.Len(t, healthy.Messages, 1)
+			assert.Equal(t, calls.message.Payload, healthy.Messages[0].Payload)
+			assert.Equal(t, uint64(1), sections[1].NextOffset)
+			require.NoError(t, calls.storeOffset(context.Background(), client), "healthy committed data is independently usable")
+			var handle uint64
+			for _, section := range []iggcon.FetchMessagesSection{sections[0], sections[2], sections[3]} {
+				blockedCalls := calls
+				blockedCalls.partition = section.PartitionId
+				var blocked ierror.PartitionLaneBlocked
+				require.ErrorAs(t, blockedCalls.storeOffset(context.Background(), client), &blocked)
+				if handle == 0 {
+					handle = blocked.OperationID
+				}
+				assert.Equal(t, handle, blocked.OperationID)
+				assert.Equal(t, section.PartitionId, blocked.PartitionID)
+				if !raw {
+					var unknown ierror.FetchProgressUncertain
+					require.ErrorAs(t, section.Err, &unknown)
+					assert.Equal(t, handle, unknown.OperationID)
+				}
+			}
+			assert.Len(t, requestsFor(server.recorded(), vsr.OperationForCode(uint32(command.StoreOffsetCode))), 1)
+			require.ErrorIs(t, client.Close(), ierror.ErrFetchProgressUncertain)
+			var unknown ierror.FetchProgressUncertain
+			require.ErrorAs(t, client.ResolveOperation(context.Background(), handle), &unknown)
+			assert.Equal(t, handle, unknown.OperationID, "close does not abandon recovery")
+		})
+	}
+}
+
+func TestFetchMessages_RecoveryCapacityRefusesBeforeSendAndAbandonReleasesIt(t *testing.T) {
+	client, serverConn := newPipeClient(t)
+	client.operations = newOperationRegistry(2, DefaultMaxPendingOperationBytes)
+	server := servePartitionOperations(t, serverConn, func(_ int, read request) []byte {
+		if read.code() == uint32(command.FetchMessagesCode) {
+			return statusReplyFrame(vsr.OperationNonReplicated, uint32(ierror.TransientNotCommittedCode), nil)
+		}
+		return replyFrame(read.operation(), resultSection())
+	})
+	calls := newLaneCalls(t)
+	var ids []uint64
+	for _, partition := range []uint32{1, 2} {
+		outcome := receive(t, startFetch(context.Background(), client, calls, iggcon.NextPollingStrategy(), true, partition), "a retained fetch")
+		var unknown ierror.FetchProgressUncertain
+		require.ErrorAs(t, outcome.err, &unknown)
+		ids = append(ids, unknown.OperationID)
+	}
+	refused := receive(t, startFetch(context.Background(), client, calls, iggcon.NextPollingStrategy(), true, 3), "the capacity refusal")
+	require.ErrorIs(t, refused.err, ierror.ErrTransientNotAccepted)
+	assert.Equal(t, 2, requestCount(server.recorded(), command.FetchMessagesCode))
+	noncommitting := receive(t, startFetch(context.Background(), client, calls, iggcon.NextPollingStrategy(), false, 3), "noncommitting Next at capacity")
+	require.ErrorIs(t, noncommitting.err, ierror.ErrTransientNotCommitted)
+	calls.partition = 3
+	require.NoError(t, calls.storeOffset(context.Background(), client), "noncommitting Next creates no retained block")
+	require.NoError(t, client.AbandonOperation(context.Background(), ids[0]))
+	after := receive(t, startFetch(context.Background(), client, calls, iggcon.NextPollingStrategy(), true, 3), "the fetch after capacity release")
+	var unknown ierror.FetchProgressUncertain
+	require.ErrorAs(t, after.err, &unknown)
+	assert.Greater(t, unknown.OperationID, ids[1])
+	require.NoError(t, client.AbandonOperation(context.Background(), ids[1]))
+	require.NoError(t, client.AbandonOperation(context.Background(), unknown.OperationID))
+	client.operations.mu.Lock()
+	assert.Empty(t, client.operations.fetchRetained)
+	assert.Empty(t, client.operations.fetchBlocked)
+	assert.Empty(t, client.operations.fetchReservations)
+	assert.Empty(t, client.operations.lanes)
+	assert.Zero(t, client.operations.fetchBytes)
+	client.operations.maxBytes = 1
+	client.operations.mu.Unlock()
+	before := requestCount(server.recorded(), command.FetchMessagesCode)
+	refused = receive(t, startFetch(context.Background(), client, calls, iggcon.NextPollingStrategy(), true, 4), "the byte capacity refusal")
+	require.ErrorIs(t, refused.err, ierror.ErrTransientNotAccepted)
+	assert.Equal(t, before, requestCount(server.recorded(), command.FetchMessagesCode))
+	require.NoError(t, client.Close())
+}
+
+func TestFetchMessages_CancelBeforeSendReleasesEveryAcquiredLane(t *testing.T) {
+	client, serverConn := newPipeClient(t)
+	parked := make(chan request, 1)
+	server := servePartitionOperations(t, serverConn, func(_ int, read request) []byte {
+		if read.code() == uint32(command.PollMessagesDeferredCode) {
+			parked <- read
+			return nil
+		}
+		return replyFrame(read.operation(), resultSection())
+	})
+	calls := newLaneCalls(t)
+	polled := make(chan error, 1)
+	go func() {
+		partition := uint32(2)
+		_, err := client.pollMessagesDeferred(context.Background(), calls.stream, calls.topic, calls.consumer,
+			iggcon.NextPollingStrategy(), 1, false, &partition, deferredOptions(5*time.Second, 10*time.Second))
+		polled <- err
+	}()
+	poll := receive(t, parked, "the active progress poll")
+	ctx, cancel := context.WithCancel(context.Background())
+	fetched := startFetch(ctx, client, calls, iggcon.NextPollingStrategy(), true, 2, 1)
+	require.Eventually(t, func() bool { return progressLaneWaiters(client) == 1 }, deferredTestTimeout, time.Millisecond)
+	cancel()
+	require.ErrorIs(t, receive(t, fetched, "the cancelled partially acquired fetch").err, ierror.ErrTransientNotAccepted)
+	require.NoError(t, calls.storeOffset(context.Background(), client), "the acquired lower partition was released")
+	assert.Zero(t, requestCount(server.recorded(), command.FetchMessagesCode))
+	answerParked(t, serverConn, poll, replyFrame(vsr.OperationNonReplicated, emptyBatchBody(2)))
+	require.NoError(t, receive(t, polled, "the original progress poll"))
+	client.operations.mu.Lock()
+	assert.Empty(t, client.operations.fetchReservations)
+	assert.Empty(t, client.operations.lanes)
+	assert.Zero(t, client.operations.fetchBytes)
+	client.operations.mu.Unlock()
+}
+
+func TestFetchMessages_WholeAndSectionRefusalsRetainOnlyUncertainProgress(t *testing.T) {
+	requests, sections := commitOutcomes(t)
+	// A status that the shared vectors do not list leaves the outcome unknown.
+	unlisted := []commitOutcome{{Status: ierror.RequestAlreadyAppliedCode}, {Status: ierror.InvalidCommandCode}}
+	for _, scope := range []struct {
+		section  bool
+		outcomes []commitOutcome
+	}{{false, requests}, {true, sections}} {
+		section := scope.section
+		for _, vector := range append(append([]commitOutcome(nil), scope.outcomes...), unlisted...) {
+			status := vector.Status
+			t.Run(fmt.Sprintf("section_%t/status_%d", section, status), func(t *testing.T) {
+				var attempts atomic.Int32
+				fixture := newPrimaryPollFixture(t, func(_, _ int, read request) ([]byte, bool) {
+					if read.operation() == vsr.OperationForCode(uint32(command.StoreOffsetCode)) {
+						return replyFrame(read.operation(), resultSection()), true
+					}
+					if read.code() != uint32(command.FetchMessagesCode) {
+						return nil, false
+					}
+					if attempts.Add(1) > 1 {
+						return replyFrame(vsr.OperationNonReplicated, fetchBody(fetchSection{partition: 1, next: 1})), true
+					}
+					if section {
+						return replyFrame(vsr.OperationNonReplicated, fetchBody(fetchSection{partition: 1, status: status})), true
+					}
+					return statusReplyFrame(vsr.OperationNonReplicated, uint32(status), nil), true
+				}, nil)
+				calls := newLaneCalls(t)
+				outcome := receive(t, startFetch(context.Background(), fixture.client, calls, iggcon.NextPollingStrategy(), true, 1), "the classified reply")
+				if vector.Certain {
+					if outcome.err != nil {
+						require.ErrorIs(t, outcome.err, ierror.FromCode(status))
+					}
+					require.NoError(t, calls.storeOffset(context.Background(), fixture.client))
+					return
+				}
+				uncertain := outcome.err
+				if section {
+					require.NoError(t, outcome.err)
+					require.Len(t, outcome.sections, 1)
+					uncertain = outcome.sections[0].Err
+				}
+				var recovery ierror.FetchProgressUncertain
+				require.ErrorAs(t, uncertain, &recovery)
+				var blocked ierror.PartitionLaneBlocked
+				require.ErrorAs(t, calls.storeOffset(context.Background(), fixture.client), &blocked)
+				assert.Equal(t, recovery.OperationID, blocked.OperationID)
+				assert.Equal(t, int32(1), attempts.Load(), "a correlated unknown reply cannot be graded into a retry")
+			})
+		}
+	}
+}
+
+func TestFetchMessages_MalformedTablesBlockAllTargetsButRecordDecodeDoesNot(t *testing.T) {
+	for _, test := range []struct {
+		name      string
+		body      []byte
+		uncertain bool
+	}{
+		{"truncated", []byte{1}, true},
+		{"duplicate", fetchBody(fetchSection{partition: 1}, fetchSection{partition: 1}), true},
+		{"unrequested", fetchBody(fetchSection{partition: 3}), true},
+		{"trailing", append(fetchBody(fetchSection{partition: 1}), 0), true},
+		{"records", fetchBody(fetchSection{partition: 1, count: 1, next: 6, records: []byte{1}, context: iggcon.PartitionContext{Incarnation: 17}}), false},
+	} {
+		t.Run(test.name, func(t *testing.T) {
+			client, serverConn := newPipeClient(t)
+			server := servePartitionOperations(t, serverConn, func(_ int, read request) []byte {
+				if read.code() == uint32(command.FetchMessagesCode) {
+					return replyFrame(vsr.OperationNonReplicated, test.body)
+				}
+				return replyFrame(read.operation(), resultSection())
+			})
+			calls := newLaneCalls(t)
+			outcome := receive(t, startFetch(context.Background(), client, calls, iggcon.NextPollingStrategy(), true, 1, 2), "the malformed reply")
+			if test.uncertain {
+				var unknown ierror.FetchProgressUncertain
+				require.ErrorAs(t, outcome.err, &unknown)
+				for _, partition := range []uint32{1, 2} {
+					calls.partition = partition
+					var blocked ierror.PartitionLaneBlocked
+					require.ErrorAs(t, calls.storeOffset(context.Background(), client), &blocked)
+					assert.Equal(t, unknown.OperationID, blocked.OperationID)
+				}
+			} else {
+				require.NoError(t, outcome.err)
+				require.Len(t, outcome.sections, 1)
+				section := outcome.sections[0]
+				_, err := binaryserialization.DeserializeSectionMessages(section)
+				require.Error(t, err)
+				assert.Equal(t, uint64(6), section.NextOffset)
+				assert.Equal(t, uint64(17), section.Context.Incarnation)
+				require.NoError(t, client.StoreConsumerPosition(context.Background(), calls.consumer, calls.stream, calls.topic,
+					iggcon.ConsumerPosition{PartitionId: 1, Offset: 4, Context: section.Context}), "decode failure leaves checkpoint repair usable")
+			}
+			assert.Equal(t, 1, requestCount(server.recorded(), command.FetchMessagesCode))
+		})
+	}
+}
+
+func TestFetchMessages_ClassifiesCorrelatedReplyBeforeSessionReset(t *testing.T) {
+	for _, explicit := range []bool{false, true} {
+		for _, partial := range []bool{false, true} {
+			t.Run(fmt.Sprintf("explicit_%t/partial_%t", explicit, partial), func(t *testing.T) {
+				client, serverConn := newPipeClient(t)
+				parked := make(chan request, 1)
+				server := servePartitionOperations(t, serverConn, func(_ int, read request) []byte {
+					parked <- read
+					return nil
+				})
+				calls := newLaneCalls(t)
+				fetched := startFetch(context.Background(), client, calls, iggcon.NextPollingStrategy(), true, 1, 2)
+				fetch := receive(t, parked, "the committing fetch")
+				sections := []fetchSection{{partition: 1, next: 7}}
+				if partial {
+					sections = append(sections, fetchSection{partition: 2, status: ierror.TransientNotCommittedCode})
+				}
+				func() {
+					client.mtx.Lock()
+					defer client.mtx.Unlock()
+					answerParked(t, serverConn, fetch, replyFrame(vsr.OperationNonReplicated, fetchBody(sections...)))
+					link := client.links.replica(0)
+					require.Eventually(t, func() bool {
+						link.mu.Lock()
+						defer link.mu.Unlock()
+						return link.pending[fetch.requestID()] == nil
+					}, deferredTestTimeout, time.Millisecond, "the complete correlated reply is queued before session replacement")
+					if explicit {
+						client.resetSessionLocked(false)
+					} else {
+						// Automatic recovery replaces the session but keeps the caller epoch.
+						client.session.Reset()
+						client.invalidateSessionLocked()
+					}
+				}()
+				// The server stored the offsets of the served sections, so the
+				// caller gets them even when its sign-in ended.
+				outcome := receive(t, fetched, "the classified old-session reply")
+				require.NoError(t, outcome.err)
+				require.Len(t, outcome.sections, len(sections))
+				assert.Equal(t, uint64(7), outcome.sections[0].NextOffset)
+				assert.NoError(t, outcome.sections[0].Err)
+				client.operations.mu.Lock()
+				_, healthyBlocked := client.operations.fetchBlocked[laneKey{partition: 1, incarnation: 17, progress: true}]
+				unknown := client.operations.fetchBlocked[laneKey{partition: 2, incarnation: 17, progress: true}]
+				client.operations.mu.Unlock()
+				assert.False(t, healthyBlocked, "known progress must not turn into uncertainty")
+				if partial {
+					require.NotNil(t, unknown)
+					require.ErrorIs(t, client.ResolveOperation(context.Background(), unknown.id), ierror.ErrFetchProgressUncertain)
+				} else {
+					assert.Nil(t, unknown, "an omitted sparse target stays unblocked")
+					require.NoError(t, client.Close())
+				}
+				assert.Equal(t, 1, requestCount(server.recorded(), command.FetchMessagesCode))
+			})
+		}
+	}
+}
+
+// A fetch refused with NotPrimary, as a whole or for some partitions, marks
+// the routes of those partitions as moved, so that the next ReadinessGroups
+// looks them up again.
+func TestFetchMessages_NotPrimaryMarksTheRoutesOfThosePartitionsMoved(t *testing.T) {
+	calls := newLaneCalls(t)
+	for _, tt := range []struct {
+		name    string
+		reply   []byte
+		err     error
+		lookups int
+	}{
+		{"the_fetch", statusReplyFrame(vsr.OperationNonReplicated, uint32(ierror.NotPrimaryCode), nil), ierror.ErrNotPrimary, 2},
+		{"a_section", replyFrame(vsr.OperationNonReplicated, fetchBody(fetchSection{partition: 1, next: 1},
+			fetchSection{partition: 2, status: ierror.NotPrimaryCode})), nil, 1},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+			client, serverConn := newPipeClient(t)
+			server := servePartitionOperations(t, serverConn, func(_ int, read request) []byte {
+				if read.code() != uint32(command.FetchMessagesCode) {
+					t.Errorf("unexpected command %d", read.code())
+					return nil
+				}
+				return append([]byte(nil), tt.reply...)
+			})
+			outcome := receive(t, startFetch(context.Background(), client, calls, iggcon.NextPollingStrategy(), false, 1, 2), "the fetch")
+			if tt.err != nil {
+				require.ErrorIs(t, outcome.err, tt.err)
+			} else {
+				require.NoError(t, outcome.err)
+			}
+			lookups := requestCount(server.recorded(), command.GetPartitionRouteCode)
+
+			_, err := client.ReadinessGroups(context.Background(), calls.stream, calls.topic, []uint32{1, 2})
+
+			require.NoError(t, err)
+			assert.Equal(t, tt.lookups, requestCount(server.recorded(), command.GetPartitionRouteCode)-lookups)
+			assert.Equal(t, 1, requestCount(server.recorded(), command.FetchMessagesCode), "the fetch is not sent again")
+		})
+	}
+}
+
+// A section that lost the history, the group owner or the namespace of its
+// partition leaves the route of that partition behind, and a refusal of the
+// whole fetch names no partition, so it leaves every route behind. The next
+// fetch fences those partitions to the incarnations of their new routes. The
+// call returns the sections that the server sent, in reply order.
+func TestFetchMessages_FencesAgainThePartitionsThatLostTheirRoutes(t *testing.T) {
+	calls := newLaneCalls(t)
+	refusedSection := func(status ierror.Code) []byte {
+		return replyFrame(vsr.OperationNonReplicated, fetchBody(fetchSection{partition: 3, next: 1},
+			fetchSection{partition: 2, status: status}))
+	}
+	for _, tt := range []struct {
+		name         string
+		reply        []byte
+		fetchErr     error
+		sectionErr   error
+		incarnations []uint64
+	}{
+		{"a_section_that_lost_its_history", refusedSection(ierror.HistoryUnavailableCode), nil, ierror.ErrHistoryUnavailable, []uint64{17, 18, 17}},
+		{"a_section_that_lost_its_owner", refusedSection(ierror.ConsumerGroupPartitionNotOwnedCode), nil, ierror.ErrConsumerGroupPartitionNotOwned, []uint64{17, 18, 17}},
+		{"a_section_that_lost_its_partition", refusedSection(ierror.PartitionNotFoundCode), nil, ierror.ErrPartitionNotFound, []uint64{17, 18, 17}},
+		{"the_fetch_that_lost_a_history", statusReplyFrame(vsr.OperationNonReplicated, uint32(ierror.HistoryUnavailableCode), nil), ierror.ErrHistoryUnavailable, nil, []uint64{18, 18, 18}},
+		{"the_fetch_that_lost_an_owner", statusReplyFrame(vsr.OperationNonReplicated, uint32(ierror.ConsumerGroupPartitionNotOwnedCode), nil), ierror.ErrConsumerGroupPartitionNotOwned, nil, []uint64{18, 18, 18}},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+			client, serverConn := newPipeClient(t)
+			var refused atomic.Bool
+			server := serve(serverConn, func(_ int, read request) []byte {
+				switch read.code() {
+				case uint32(command.GetPartitionRouteCode):
+					body := make([]byte, iggcon.PartitionRouteSize)
+					binary.LittleEndian.PutUint64(body, 17)
+					if refused.Load() {
+						binary.LittleEndian.PutUint64(body, 18)
+					}
+					binary.LittleEndian.PutUint32(body[20:], 4)
+					return replyFrame(vsr.OperationNonReplicated, body)
+				case uint32(command.FetchMessagesCode):
+					if refused.Swap(true) {
+						return replyFrame(vsr.OperationNonReplicated, fetchBody())
+					}
+					return append([]byte(nil), tt.reply...)
+				}
+				t.Errorf("unexpected command %d", read.code())
+				return nil
+			})
+			fetch := func() fetchOutcome {
+				return receive(t, startFetch(context.Background(), client, calls, iggcon.OffsetPollingStrategy(0), false, 1, 2, 3), "the fetch")
+			}
+
+			first := fetch()
+			if tt.fetchErr != nil {
+				require.ErrorIs(t, first.err, tt.fetchErr)
+			} else {
+				require.NoError(t, first.err)
+				require.Len(t, first.sections, 2)
+				assert.Equal(t, []uint32{3, 2}, []uint32{first.sections[0].PartitionId, first.sections[1].PartitionId})
+				assert.NoError(t, first.sections[0].Err)
+				assert.ErrorIs(t, first.sections[1].Err, tt.sectionErr)
+			}
+
+			require.NoError(t, fetch().err)
+
+			var fetches []request
+			for _, read := range server.recorded() {
+				if read.code() == uint32(command.FetchMessagesCode) {
+					fetches = append(fetches, read)
+				}
+			}
+			require.Len(t, fetches, 2)
+			var incarnations []uint64
+			for _, target := range fetchTargets(t, fetches[1]) {
+				incarnations = append(incarnations, target.fence.Incarnation)
+			}
+			assert.Equal(t, tt.incarnations, incarnations)
+		})
+	}
+}
+
+// A cancel after the read of a fetch with auto-commit drops what it stored,
+// so the client tells the server to drop only a fetch without auto-commit. A
+// fetch with auto-commit returns its reply also after its context ended.
+func TestFetchMessages_CancelSendsTheCancellationAndACommittingFetchWaitsForItsReply(t *testing.T) {
+	calls := newLaneCalls(t)
+	for _, tt := range []struct {
+		name       string
+		autoCommit bool
+	}{
+		{"without_auto_commit", false},
+		{"auto_commit", true},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+			client, serverConn := newPipeClient(t)
+			parked := make(chan request, 1)
+			cancellations := make(chan request, 1)
+			servePartitionOperations(t, serverConn, func(_ int, read request) []byte {
+				switch read.code() {
+				case uint32(command.FetchMessagesCode):
+					parked <- read
+				case uint32(command.CancelDeferredRequestCode):
+					cancellations <- read
+				default:
+					t.Errorf("unexpected command %d", read.code())
+				}
+				return nil
+			})
+			ctx, cancel := context.WithCancel(context.Background())
+			fetched := startFetch(ctx, client, calls, iggcon.OffsetPollingStrategy(0), tt.autoCommit, 1)
+			fetch := receive(t, parked, "the parked fetch")
+
+			cancel()
+
+			requireCancellationOf(t, fetch, receive(t, cancellations, "the cancellation"))
+			if !tt.autoCommit {
+				require.ErrorIs(t, receive(t, fetched, "the cancelled fetch").err, ierror.ErrTransientNotCommitted)
+				return
+			}
+			select {
+			case <-fetched:
+				t.Fatal("the fetch with auto-commit ended before its reply")
+			case <-time.After(200 * time.Millisecond):
+			}
+			answerParked(t, serverConn, fetch, replyFrame(vsr.OperationNonReplicated, fetchBody(fetchSection{partition: 1, next: 5})))
+			outcome := receive(t, fetched, "the fetch with auto-commit")
+			require.NoError(t, outcome.err)
+			require.Len(t, outcome.sections, 1)
+			assert.Equal(t, uint64(5), outcome.sections[0].NextOffset)
+		})
+	}
+}
+
+// A fetch without a reply ends at its request timeout, not at the operation
+// budget, also with auto-commit. Only a fetch without auto-commit is then
+// cancelled on the server.
+func TestFetchMessages_EndsAtItsRequestTimeoutAndCancelsOnlyAFetchWithoutAutoCommit(t *testing.T) {
+	calls := newLaneCalls(t)
+	for _, tt := range []struct {
+		name       string
+		autoCommit bool
+	}{
+		{"without_auto_commit", false},
+		{"auto_commit", true},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+			client, serverConn := newPipeClient(t)
+			parked := make(chan request, 1)
+			cancellations := make(chan request, 1)
+			servePartitionOperations(t, serverConn, func(_ int, read request) []byte {
+				switch read.code() {
+				case uint32(command.FetchMessagesCode):
+					parked <- read
+				case uint32(command.CancelDeferredRequestCode):
+					cancellations <- read
+				default:
+					t.Errorf("unexpected command %d", read.code())
+				}
+				return nil
+			})
+			// The lookup before the fetch takes some of the timeout, and the
+			// fetch needs the reply margin of what is left.
+			options := deferredOptions(0, iggcon.MinDeferredPollReplyMargin+500*time.Millisecond)
+			started := time.Now()
+
+			_, err := client.FetchMessages(context.Background(), calls.stream, calls.topic, calls.consumer,
+				[]iggcon.FetchMessagesTarget{{PartitionId: 1, Strategy: iggcon.OffsetPollingStrategy(0)}}, 10, tt.autoCommit, options)
+
+			if tt.autoCommit {
+				require.ErrorIs(t, err, ierror.ErrFetchProgressUncertain)
+			} else {
+				require.ErrorIs(t, err, ierror.ErrTransientNotCommitted)
+			}
+			elapsed := time.Since(started)
+			assert.GreaterOrEqual(t, elapsed, options.RequestTimeout)
+			assert.Less(t, elapsed, responseReadTimeout/10, "the request timeout, not the operation budget, bounds the call")
+			fetch := receive(t, parked, "the fetch")
+			if !tt.autoCommit {
+				requireCancellationOf(t, fetch, receive(t, cancellations, "the cancellation"))
+				return
+			}
+			select {
+			case <-cancellations:
+				t.Fatal("the fetch with auto-commit was cancelled")
+			case <-time.After(200 * time.Millisecond):
+			}
+		})
+	}
+}
+
+// Close waits for the reply of a fetch with auto-commit, because a cancel
+// after its read drops what it stored.
+func TestClose_WaitsForTheReplyOfAFetchWithAutoCommit(t *testing.T) {
+	calls := newLaneCalls(t)
+	client, serverConn := newPipeClient(t)
+	parked := make(chan request, 1)
+	server := servePartitionOperations(t, serverConn, func(_ int, read request) []byte {
+		if read.code() == uint32(command.FetchMessagesCode) {
+			parked <- read
+		}
+		return nil
+	})
+	fetched := startFetch(context.Background(), client, calls, iggcon.OffsetPollingStrategy(0), true, 1)
+	fetch := receive(t, parked, "the parked fetch")
+	closed := make(chan error, 1)
+	go func() { closed <- client.Close() }()
+	select {
+	case err := <-closed:
+		t.Fatalf("Close returned %v before the reply of the fetch", err)
+	case <-time.After(200 * time.Millisecond):
+	}
+
+	answerParked(t, serverConn, fetch, replyFrame(vsr.OperationNonReplicated, fetchBody(fetchSection{partition: 1, next: 5})))
+
+	require.NoError(t, receive(t, fetched, "the fetch").err)
+	require.NoError(t, receive(t, closed, "Close"))
+	assert.Zero(t, requestCount(server.recorded(), command.CancelDeferredRequestCode))
+}
+
+// A raw fetch takes the path of FetchMessages with the caps and the fences of
+// its targets as they are, and a fetch that the server refuses is not sent.
+func TestSendBinaryRequest_SendsAFetchAsItIsAndRefusesWhatTheServerRefuses(t *testing.T) {
+	client, serverConn := newPipeClient(t)
+	reply := fetchBody(fetchSection{partition: 1, next: 4})
+	server := servePartitionOperations(t, serverConn, func(_ int, read request) []byte {
+		if read.code() != uint32(command.FetchMessagesCode) {
+			t.Errorf("unexpected command %d", read.code())
+			return nil
+		}
+		return replyFrame(vsr.OperationNonReplicated, reply)
+	})
+	calls := newLaneCalls(t)
+	fetchOf := func(options iggcon.DeferredPollOptions, targets ...command.FetchTarget) []byte {
+		body, err := (&command.FetchMessages{StreamId: calls.stream, TopicId: calls.topic, Consumer: calls.consumer,
+			Targets: targets, Count: 10, Options: options}).MarshalBinary()
+		require.NoError(t, err)
+		return body
+	}
+	target := func(partition uint32, strategy iggcon.PollingStrategy, maxBytes uint32) command.FetchTarget {
+		return command.FetchTarget{FetchMessagesTarget: iggcon.FetchMessagesTarget{PartitionId: partition, Strategy: strategy}, MaxBytes: maxBytes}
+	}
+	options := deferredOptions(0, 2*time.Second)
+	offset := iggcon.OffsetPollingStrategy(3).WithContext(iggcon.PartitionContext{Incarnation: 5, OwnerGeneration: 9})
+	fetch := fetchOf(options, target(1, offset, iggcon.MinDeferredPollMaxBytes))
+
+	answer, err := client.SendBinaryRequest(context.Background(), uint32(command.FetchMessagesCode), fetch)
+
+	require.NoError(t, err)
+	assert.Equal(t, reply, answer)
+	assert.Equal(t, fetch[fetchTargetsAt:], recordedFetch(t, server.recorded()).payload[fetchTargetsAt:],
+		"the targets keep their caps and fences")
+	tooMany := make([]command.FetchTarget, iggcon.MaxPartitionsPerRequest+1)
+	for index := range tooMany {
+		tooMany[index] = target(uint32(index), offset, options.MaxBytes)
+	}
+	for _, tt := range []struct {
+		name string
+		body []byte
+		err  error
+	}{
+		{"no targets", fetchOf(options), ierror.ErrInvalidCommand},
+		{"more targets than one request takes", fetchOf(options, tooMany...), ierror.ErrInvalidCommand},
+		{"a partition named twice", fetchOf(options, target(1, offset, options.MaxBytes), target(1, offset, options.MaxBytes)), ierror.ErrInvalidCommand},
+		{"a start point other than Next or Offset", fetchOf(options, target(1, iggcon.FirstPollingStrategy(), options.MaxBytes)), ierror.ErrInvalidCommand},
+		{"a timeout without the reply margin at zero wait", fetchOf(deferredOptions(0, 500*time.Millisecond), target(1, offset, options.MaxBytes)), ierror.ErrInvalidCommand},
+		{"exactly the reply margin at zero wait", fetchOf(deferredOptions(0, time.Second), target(1, offset, options.MaxBytes)), ierror.ErrInvalidCommand},
+		{"a fractional microsecond above the reply margin", fetchOf(deferredOptions(0, time.Second+999*time.Nanosecond), target(1, offset, options.MaxBytes)), ierror.ErrInvalidCommand},
+		{"a target cap below the smallest", fetchOf(options, target(1, offset, iggcon.MinDeferredPollMaxBytes-1)), ierror.ErrInvalidSizeBytes},
+		{"a target cap over the cap of the fetch", fetchOf(options, target(1, offset, options.MaxBytes+1)), ierror.ErrInvalidSizeBytes},
+		{"a cut target", fetch[:len(fetch)-1], ierror.ErrInvalidCommand},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+			_, err := client.SendBinaryRequest(context.Background(), uint32(command.FetchMessagesCode), tt.body)
+			require.ErrorIs(t, err, tt.err)
+		})
+	}
+	_, err = client.SendBinaryRequestToReplica(context.Background(), uint32(command.FetchMessagesCode), fetch, 0)
+	require.ErrorIs(t, err, ierror.ErrInvalidCommand)
+	assert.Equal(t, 1, requestCount(server.recorded(), command.FetchMessagesCode))
+}
+
+func TestFetchMessages_RefusesWhatTheServerRefusesWithoutSending(t *testing.T) {
+	client, serverConn := newPipeClient(t)
+	server := servePartitionOperations(t, serverConn, func(_ int, read request) []byte {
+		t.Errorf("unexpected command %d", read.code())
+		return nil
+	})
+	calls := newLaneCalls(t)
+	next := iggcon.NextPollingStrategy()
+	one := []iggcon.FetchMessagesTarget{{PartitionId: 1, Strategy: next}}
+	tooMany := make([]iggcon.FetchMessagesTarget, iggcon.MaxPartitionsPerRequest+1)
+	for index := range tooMany {
+		tooMany[index] = iggcon.FetchMessagesTarget{PartitionId: uint32(index), Strategy: next}
+	}
+	options := deferredOptions(0, 2*time.Second)
+	tooSmall := options
+	tooSmall.MaxBytes = iggcon.MinDeferredPollMaxBytes - 1
+	for _, tt := range []struct {
+		name    string
+		targets []iggcon.FetchMessagesTarget
+		count   uint32
+		options iggcon.DeferredPollOptions
+		err     error
+	}{
+		{"no targets", nil, 10, options, ierror.ErrInvalidCommand},
+		{"more targets than one request takes", tooMany, 10, options, ierror.ErrInvalidCommand},
+		{"a partition named twice", []iggcon.FetchMessagesTarget{{PartitionId: 1, Strategy: next}, {PartitionId: 1, Strategy: next}}, 10, options, ierror.ErrInvalidCommand},
+		{"a start point other than Next or Offset", []iggcon.FetchMessagesTarget{{PartitionId: 1, Strategy: iggcon.LastPollingStrategy()}}, 10, options, ierror.ErrInvalidCommand},
+		{"a timeout without the reply margin when it does not wait", one, 10, deferredOptions(0, 500*time.Millisecond), ierror.ErrInvalidCommand},
+		{"exactly the reply margin when it does not wait", one, 10, deferredOptions(0, time.Second), ierror.ErrInvalidCommand},
+		{"a fractional microsecond above the reply margin", one, 10, deferredOptions(0, time.Second+999*time.Nanosecond), ierror.ErrInvalidCommand},
+		{"a count below the minimum count", one, 0, options, ierror.ErrInvalidMessagesCount},
+		{"a byte budget below the smallest", one, 10, tooSmall, ierror.ErrInvalidSizeBytes},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+			_, err := client.FetchMessages(context.Background(), calls.stream, calls.topic, calls.consumer, tt.targets, tt.count, true, tt.options)
+			require.ErrorIs(t, err, tt.err)
+		})
+	}
+	assert.Empty(t, server.recorded(), "a refused fetch sends nothing, not even a lookup")
+}
+
+// The server ends the reads of a fetch within the reply margin before its
+// timeout, so a fetch cut to the deadline of its call keeps the margin, and a
+// fetch whose call ends within the margin is not sent.
+func TestFetchMessages_KeepsTheReplyMarginWithinTheDeadlineOfItsCall(t *testing.T) {
+	client, serverConn := newPipeClient(t)
+	server := servePartitionOperations(t, serverConn, func(_ int, read request) []byte {
+		return replyFrame(vsr.OperationNonReplicated, fetchBody(fetchSection{partition: 1, next: 1}))
+	})
+	calls := newLaneCalls(t)
+	fetchWithin := func(deadline time.Duration) error {
+		ctx, cancel := context.WithTimeout(context.Background(), deadline)
+		defer cancel()
+		_, err := client.FetchMessages(ctx, calls.stream, calls.topic, calls.consumer,
+			[]iggcon.FetchMessagesTarget{{PartitionId: 1, Strategy: iggcon.OffsetPollingStrategy(0)}}, 10, false,
+			deferredOptions(5*time.Second, 10*time.Second))
+		return err
+	}
+
+	require.NoError(t, fetchWithin(3*time.Second))
+	require.ErrorIs(t, fetchWithin(iggcon.MinDeferredPollReplyMargin/2), ierror.ErrTransientNotAccepted)
+
+	wait, timeout := deferredLimits(t, recordedFetch(t, server.recorded()))
+	assert.LessOrEqual(t, timeout, 3*time.Second)
+	assert.Equal(t, timeout-iggcon.MinDeferredPollReplyMargin, wait)
+}
+
+func TestFetchMessages_SpendsSetupTimeWithoutRevalidatingTheInitialTimeout(t *testing.T) {
+	for _, raw := range []bool{false, true} {
+		for _, elapsed := range []time.Duration{500 * time.Millisecond, time.Second, time.Second + time.Microsecond} {
+			t.Run(fmt.Sprintf("raw_%t/setup_%s", raw, elapsed), func(t *testing.T) {
+				synctest.Test(t, func(t *testing.T) {
+					client, serverConn := newPipeClient(t)
+					server := servePartitionOperations(t, serverConn, func(_ int, _ request) []byte {
+						return replyFrame(vsr.OperationNonReplicated, fetchBody(fetchSection{partition: 1, next: 1}))
+					})
+					calls := newLaneCalls(t)
+					options := deferredOptions(0, 2*time.Second)
+					target := iggcon.FetchMessagesTarget{PartitionId: 1, Strategy: iggcon.OffsetPollingStrategy(0)}
+					body, err := (&command.FetchMessages{StreamId: calls.stream, TopicId: calls.topic, Consumer: calls.consumer,
+						Targets: []command.FetchTarget{{FetchMessagesTarget: target, MaxBytes: options.MaxBytes}}, Count: 10, Options: options}).MarshalBinary()
+					require.NoError(t, err)
+					for range cap(client.deferred) {
+						client.deferred <- struct{}{}
+					}
+					fetched := make(chan error, 1)
+					go func() {
+						if raw {
+							_, err := client.SendBinaryRequest(context.Background(), uint32(command.FetchMessagesCode), body)
+							fetched <- err
+							return
+						}
+						_, err := client.FetchMessages(context.Background(), calls.stream, calls.topic, calls.consumer,
+							[]iggcon.FetchMessagesTarget{target}, 10, false, options)
+						fetched <- err
+					}()
+					synctest.Wait()
+					time.Sleep(elapsed)
+					for range cap(client.deferred) {
+						<-client.deferred
+					}
+
+					err = receive(t, fetched, "the fetch after deferred admission")
+
+					if elapsed > time.Second {
+						require.ErrorIs(t, err, ierror.ErrTransientNotAccepted)
+						assert.Zero(t, requestCount(server.recorded(), command.FetchMessagesCode))
+						return
+					}
+					require.NoError(t, err)
+					wait, timeout := deferredLimits(t, recordedFetch(t, server.recorded()))
+					assert.Zero(t, wait)
+					assert.Equal(t, options.RequestTimeout-elapsed, timeout)
+				})
+			})
+		}
+	}
+}
+
+// Limits that are not whole microseconds keep the reply margin that the wire
+// carries, so the client neither refuses the fetch nor sends one that the
+// server refuses.
+func TestFetchMessages_KeepsTheReplyMarginOfFractionalLimits(t *testing.T) {
+	client, serverConn := newPipeClient(t)
+	server := servePartitionOperations(t, serverConn, func(_ int, read request) []byte {
+		return replyFrame(vsr.OperationNonReplicated, fetchBody(fetchSection{partition: 1, next: 1}))
+	})
+	calls := newLaneCalls(t)
+
+	_, err := client.FetchMessages(context.Background(), calls.stream, calls.topic, calls.consumer,
+		[]iggcon.FetchMessagesTarget{{PartitionId: 1, Strategy: iggcon.OffsetPollingStrategy(0)}}, 10, false,
+		deferredOptions(time.Second+999*time.Nanosecond, 2*time.Second))
+
+	require.NoError(t, err)
+	wait, timeout := deferredLimits(t, recordedFetch(t, server.recorded()))
+	assert.Positive(t, wait)
+	assert.GreaterOrEqual(t, timeout-wait, iggcon.MinDeferredPollReplyMargin)
+}
+
+// ReadinessGroups groups the partitions by primary in primary order, keeps
+// their order within a group, and splits a group that one request cannot
+// take.
+func TestReadinessGroups_GroupsThePartitionsByPrimaryInRequestSizedGroups(t *testing.T) {
+	t.Run("by_primary", func(t *testing.T) {
+		fixture := newPrimaryPollFixture(t, nil, func(_ int, read request) ([]byte, bool) {
+			if read.code() != uint32(command.GetPartitionRouteCode) {
+				return nil, false
+			}
+			return routeReply(t, read, uint8(routedPollPartition(t, read)%2+1)), true
+		})
+		streamId, topicId, _ := groupConsumer(t)
+
+		groups, err := fixture.client.ReadinessGroups(context.Background(), streamId, topicId, []uint32{5, 2, 3, 4})
+
+		require.NoError(t, err)
+		assert.Equal(t, [][]uint32{{2, 4}, {5, 3}}, groups)
+	})
+	t.Run("request_sized", func(t *testing.T) {
+		client, serverConn := newPipeClient(t)
+		servePartitionOperations(t, serverConn, func(_ int, read request) []byte {
+			t.Errorf("unexpected command %d", read.code())
+			return nil
+		})
+		calls := newLaneCalls(t)
+		partitions := make([]uint32, iggcon.MaxPartitionsPerRequest+1)
+		for index := range partitions {
+			partitions[index] = uint32(index)
+		}
+
+		groups, err := client.ReadinessGroups(context.Background(), calls.stream, calls.topic, partitions)
+
+		require.NoError(t, err)
+		assert.Equal(t, [][]uint32{partitions[:iggcon.MaxPartitionsPerRequest], partitions[iggcon.MaxPartitionsPerRequest:]}, groups)
+	})
+}
diff --git a/foreign/go/client/tcp/tcp_driver.go b/foreign/go/client/tcp/tcp_driver.go
index 83cd545..2f9264a 100644
--- a/foreign/go/client/tcp/tcp_driver.go
+++ b/foreign/go/client/tcp/tcp_driver.go
@@ -40,11 +40,24 @@
 	epoch := c.callEpoch(ctx)
 	_, mutation := vsr.ReplicatedOperation(uint32(code))
 	progress := isProgressPoll(code, frame[vsr.HeaderSize:])
+	// Offset calls queue with the progress polls of their partition, so an
+	// offset read does not overtake a poll that moves the offset.
+	progressLane := progress || code == command.GetOffsetCode || changesProgress(code)
 	// A plain read reports a context that is done before its first attempt.
 	// Later, also when a session recovery restarts the call, it ends with 58.
 	if err := ctx.Err(); err != nil && !mutation && !progress {
 		return nil, err
 	}
+	budget := newDeferredBudget(code, frame)
+	// A poll or fetch that stores its offset blocks the progress of its
+	// partitions until the caller abandons it when its outcome is unknown.
+	committing := storesOnRead(code, frame[vsr.HeaderSize:])
+	slotTaken := false
+	defer func() {
+		if slotTaken {
+			<-c.deferred
+		}
+	}()
 	historyRefreshed := false
 	keptContext := exact
 	lifecycleDelay := replayInterval
@@ -94,17 +107,45 @@
 			saved := captured
 			keptContext = &saved
 		}
-		if ticket == nil && (mutation || progress) {
+		if ticket == nil && (mutation || progressLane) {
 			key := laneKey{}
 			if query != nil {
 				key = laneKey{incarnation: captured.Incarnation, partition: route.key.partition}
+				if progressLane && code != command.FetchMessagesCode && key.incarnation == 0 {
+					key.incarnation = route.context.Incarnation
+					if key.incarnation == 0 {
+						return nil, ierror.ErrTransientNotAccepted
+					}
+				}
 			}
 			var err error
-			ticket, err = c.operations.enter(ctx, key)
+			switch {
+			case code == command.FetchMessagesCode:
+				// Each target of a fetch carries its own incarnation.
+				ticket, err = c.operations.enterLanes(ctx, fetchLanes(frame[vsr.HeaderSize:]))
+			case code == command.GetOffsetCode:
+				ticket, err = c.operations.enterLane(ctx, key.progressLane(), true)
+			case progressLane:
+				ticket, err = c.operations.enterProgress(ctx, key, mutation)
+			default:
+				ticket, err = c.operations.enter(ctx, key)
+			}
 			if err != nil {
 				return nil, err
 			}
 		}
+		// After the lanes, so that a poll queued behind a parked poll of its
+		// partition leaves the slot to deferred calls of other partitions.
+		if budget != nil && !slotTaken {
+			select {
+			case c.deferred <- struct{}{}:
+				slotTaken = true
+			case <-ctx.Done():
+				return nil, ierror.ErrTransientNotAccepted
+			case <-c.closed:
+				return nil, ierror.ErrClientShutdown
+			}
+		}
 		if mutation {
 			if err := ticket.reserve(ctx, len(frame)); err != nil {
 				if c.sessionRecovers(ctx, err) {
@@ -113,6 +154,11 @@
 				return nil, err
 			}
 		}
+		if committing {
+			if err := ticket.reserveFetch(); err != nil {
+				return nil, err
+			}
+		}
 		frozen := append([]byte(nil), frame...)
 		c.mtx.Lock()
 		fence := c.sessionFence
@@ -146,7 +192,15 @@
 		} else {
 			operation = &pendingOperation{id: vsr.StampedRequestID(frozen), code: code, query: append([]byte(nil), query...), fence: fence, frame: newOperationFrame(frozen, nil), replica: replica}
 		}
+		operation.budget = budget
+		operation.frame.uncancellable = committing
+		if committing && !c.startCommit() {
+			return nil, ierror.ErrClientShutdown
+		}
 		response, err := c.driveOperation(ctx, operation, ticket, false, mutation)
+		if committing {
+			c.commits.Done()
+		}
 		var completed *completedResultError
 		if errors.As(err, &completed) {
 			return nil, completed.err
@@ -186,7 +240,12 @@
 	notPrimarySeen := false
 	lookupDelay := replayInterval
 	progress := isProgressPoll(operation.code, operation.frame.data[vsr.HeaderSize:])
+	committing := storesOnRead(operation.code, operation.frame.data[vsr.HeaderSize:])
+	possiblySent := false
 	defer func() {
+		if committing && possiblySent && err != nil {
+			err = ticket.retainFetch(ticket.fetch.keys)
+		}
 		if ticket == nil {
 			operation.frame.release()
 			return
@@ -332,14 +391,27 @@
 				continue
 			}
 		}
-		reply, sent := link.exchange(ctx, operation.frame)
+		attempt := operation.frame
+		if operation.budget != nil {
+			if attempt, err = operation.budget.attempt(ctx, operation.frame.data); err != nil {
+				if uncertain {
+					return nil, ierror.ErrTransientNotCommitted
+				}
+				return nil, err
+			}
+		}
+		reply, sent := link.exchange(ctx, attempt)
+		possiblySent = possiblySent || sent
+		if attempt != operation.frame {
+			attempt.release()
+		}
 		c.mtx.Lock()
 		current = c.sessionFence == operation.fence
 		c.mtx.Unlock()
 		if ticket != nil {
 			current = current && ticket.current()
 		}
-		if !current {
+		if !current && (!committing || reply.err != nil) {
 			return nil, sessionChanged(sent)
 		}
 		if reply.err != nil {
@@ -361,7 +433,11 @@
 				return nil, reply.err
 			}
 			if operation.query != nil {
-				c.polls.invalidate(route, false)
+				// A caller that stopped waiting for a parked deferred request
+				// says nothing about its route.
+				if operation.budget == nil || ctx.Err() == nil {
+					c.polls.invalidate(route, false)
+				}
 			} else if sent && linkReady(link) && operation.replica == nil {
 				c.links.invalidateMetadataPrimary(link)
 			}
@@ -409,7 +485,10 @@
 			return nil, ierror.ErrInvalidCommand
 		}
 		publishCredentials := outcome.Err == nil && (!uncertain || !mutation || outcome.Committed)
-		if !c.acceptOperationReply(c.callEpoch(ctx), operation, &reply.header, publishCredentials) {
+		// A correlated fetch table settles progress even if recovery replaced
+		// its session or the caller's sign-in ended.
+		accepted := c.acceptOperationReply(c.callEpoch(ctx), operation, &reply.header, publishCredentials)
+		if !accepted && !committing {
 			return nil, sessionChanged(sent)
 		}
 		if outcome.Err == nil {
@@ -419,9 +498,32 @@
 				}
 				continue
 			}
+			if committing {
+				if operation.code == command.FetchMessagesCode {
+					if err = classifyFetchReply(ctx, operation, ticket, outcome.Body); err != nil {
+						return nil, err
+					}
+				}
+				possiblySent = false
+			}
 			c.refreshMembership(operation.code, operation.frame.data[vsr.HeaderSize:])
 			return outcome.Body, nil
 		}
+		if committing {
+			if !fetchRefusedBeforeAdmission(outcome.Err, false) {
+				if !closeIfDetached(link, outcome, bound) && isSessionError(outcome.Err) {
+					c.recoverSession(operation.fence)
+				}
+				return nil, outcome.Err
+			}
+			possiblySent = false
+			if !accepted {
+				if ended := c.callEpoch(ctx).err(); ended != nil {
+					return nil, ended
+				}
+				return nil, outcome.Err
+			}
+		}
 		if uncertain && outcome.Committed {
 			return nil, &completedResultError{err: outcome.Err}
 		}
@@ -438,9 +540,18 @@
 			} else if operation.code != command.GetClusterMetadataCode && operation.replica == nil {
 				_ = c.refreshRoster(ctx, link)
 			}
+		case errors.Is(err, ierror.ErrDeferredPollCapacityExceeded):
+			// The primary parks as many deferred requests of the session as it
+			// admits. The route is right, so the same primary is asked again.
 		case errors.Is(err, ierror.ErrNotPrimary) && operation.replica != nil:
 			// The caller chose the replica, so its refusal is the answer.
 			return nil, err
+		case errors.Is(err, ierror.ErrNotPrimary) &&
+			operation.code == command.FetchMessagesCode:
+			// The refusal does not name the partition that moved, so the
+			// sender of the wait or the fetch marks the route of each of its
+			// partitions.
+			return nil, err
 		case errors.Is(err, ierror.ErrNotPrimary):
 			firstRefusal := !notPrimarySeen
 			notPrimarySeen = true
@@ -469,7 +580,7 @@
 			} else if isSessionError(err) {
 				c.recoverSession(operation.fence)
 			}
-			if operation.query != nil && (errors.Is(err, ierror.ErrHistoryUnavailable) || errors.Is(err, ierror.ErrConsumerGroupPartitionNotOwned) || isNamespaceMiss(err)) {
+			if operation.query != nil && needsRelookup(err) {
 				c.polls.relookup(route)
 			}
 			if uncertain && mutation {
@@ -503,7 +614,19 @@
 }
 
 func isProgressPoll(code command.Code, body []byte) bool {
-	if code != command.PollMessagesCode || len(body) < pollParametersSize {
+	switch code {
+	case command.PollMessagesCode:
+	case command.PollMessagesDeferredCode:
+		if len(body) < deferredParametersSize {
+			return false
+		}
+		body = body[:len(body)-deferredParametersSize]
+	case command.FetchMessagesCode:
+		return isProgressFetch(body)
+	default:
+		return false
+	}
+	if len(body) < pollParametersSize {
 		return false
 	}
 	parameters := body[len(body)-pollParametersSize:]
diff --git a/foreign/go/client/tcp/tcp_driver_test.go b/foreign/go/client/tcp/tcp_driver_test.go
index 72be7da..2a12a7d 100644
--- a/foreign/go/client/tcp/tcp_driver_test.go
+++ b/foreign/go/client/tcp/tcp_driver_test.go
@@ -352,7 +352,7 @@
 			stream, topic, group := groupConsumer(t)
 			partition := uint32(0)
 			consumer := iggcon.NewSingleConsumer(group.Id)
-			_, err := client.PollMessages(context.Background(), stream, topic, consumer, iggcon.FirstPollingStrategy(), 1, false, &partition)
+			_, err := client.pollMessages(context.Background(), stream, topic, consumer, iggcon.FirstPollingStrategy(), 1, false, &partition)
 			require.NoError(t, err)
 			before := len(server.recorded())
 			link := client.links.metadata()
@@ -372,7 +372,7 @@
 			case "read":
 				err = client.Ping(ctx)
 			case "progress":
-				_, err = client.PollMessages(ctx, stream, topic, consumer, iggcon.NextPollingStrategy(), 1, true, &partition)
+				_, err = client.pollMessages(ctx, stream, topic, consumer, iggcon.NextPollingStrategy(), 1, true, &partition)
 			}
 			require.ErrorIs(t, err, ierror.ErrTransientNotAccepted)
 			require.Len(t, server.recorded(), before)
@@ -390,8 +390,8 @@
 	})
 	stream, topic, group := groupConsumer(t)
 	partition := uint32(0)
-	_, err := client.PollMessages(context.Background(), stream, topic, iggcon.NewSingleConsumer(group.Id), iggcon.NextPollingStrategy(), 1, true, &partition)
-	require.ErrorIs(t, err, ierror.ErrTransientNotCommitted)
+	_, err := client.pollMessages(context.Background(), stream, topic, iggcon.NewSingleConsumer(group.Id), iggcon.NextPollingStrategy(), 1, true, &partition)
+	require.ErrorIs(t, err, ierror.FetchProgressUncertain{})
 	require.Equal(t, uint32(1), polls.Load())
 	require.Empty(t, client.operations.retained)
 	require.Empty(t, client.operations.lanes)
@@ -554,7 +554,7 @@
 		return err
 	}
 	poll := func() error {
-		_, err := client.PollMessages(context.Background(), stream, topic, iggcon.NewSingleConsumer(group.Id), iggcon.FirstPollingStrategy(), 1, false, &partition)
+		_, err := client.pollMessages(context.Background(), stream, topic, iggcon.NewSingleConsumer(group.Id), iggcon.FirstPollingStrategy(), 1, false, &partition)
 		return err
 	}
 	created, polled := make(chan error, 1), make(chan error, 1)
@@ -671,17 +671,26 @@
 					strategy = iggcon.OffsetPollingStrategy(0)
 				}
 				ctx, cancel := context.WithCancel(context.Background())
+				if lostReply && autoCommit {
+					// A poll that stores its offset waits for its reply after a cancel, so its reply is lost at the deadline.
+					ctx, cancel = context.WithTimeout(context.Background(), 200*time.Millisecond)
+				}
 				defer cancel()
 				done := make(chan error, 1)
 				go func() {
-					_, err := client.PollMessages(ctx, stream, topic, iggcon.NewSingleConsumer(consumer), strategy, 1, autoCommit, &partition)
+					_, err := client.pollMessages(ctx, stream, topic, iggcon.NewSingleConsumer(consumer), strategy, 1, autoCommit, &partition)
 					done <- err
 				}()
 				<-received
-				if lostReply {
+				if lostReply && !autoCommit {
 					cancel()
 				}
-				require.ErrorIs(t, <-done, ierror.ErrTransientNotCommitted)
+				var want error = ierror.ErrTransientNotCommitted
+				if autoCommit {
+					// The server can have stored the offset, which only a read shows.
+					want = ierror.FetchProgressUncertain{}
+				}
+				require.ErrorIs(t, <-done, want)
 				require.Equal(t, uint32(1), polls.Load())
 				require.Equal(t, 1, requestCount(server.recorded(), command.PollMessagesCode))
 				client.operations.mu.Lock()
@@ -695,6 +704,139 @@
 	}
 }
 
+// laneCalls makes the calls of the lane tests, all on one partition.
+type laneCalls struct {
+	stream, topic iggcon.Identifier
+	consumer      iggcon.Consumer
+	partition     uint32
+	message       iggcon.IggyMessage
+}
+
+func newLaneCalls(t *testing.T) laneCalls {
+	t.Helper()
+	message, err := iggcon.NewIggyMessage([]byte("payload"))
+	require.NoError(t, err)
+	return laneCalls{
+		stream:    numericIdentifier(t, 1),
+		topic:     numericIdentifier(t, 2),
+		consumer:  iggcon.NewSingleConsumer(numericIdentifier(t, 3)),
+		partition: 1,
+		message:   message,
+	}
+}
+
+func (c laneCalls) send(ctx context.Context, client *IggyTcpClient) error {
+	_, err := client.SendMessages(ctx, c.stream, c.topic, iggcon.PartitionId(c.partition), []iggcon.IggyMessage{c.message})
+	return err
+}
+
+func (c laneCalls) poll(ctx context.Context, client *IggyTcpClient, strategy iggcon.PollingStrategy, autoCommit bool) error {
+	_, err := client.pollMessages(ctx, c.stream, c.topic, c.consumer, strategy, 1, autoCommit, &c.partition)
+	return err
+}
+
+func (c laneCalls) getOffset(ctx context.Context, client *IggyTcpClient) error {
+	_, err := client.GetConsumerOffset(ctx, c.consumer, c.stream, c.topic, &c.partition)
+	return err
+}
+
+func (c laneCalls) storeOffset(ctx context.Context, client *IggyTcpClient) error {
+	return client.StoreConsumerOffset(ctx, c.consumer, c.stream, c.topic, 7, &c.partition)
+}
+
+func (c laneCalls) deleteOffset(ctx context.Context, client *IggyTcpClient) error {
+	return client.DeleteConsumerOffset(ctx, c.consumer, c.stream, c.topic, &c.partition)
+}
+
+// consumerOffsetBody builds the reply of an offset read: the partition, its
+// current offset and the stored offset.
+func consumerOffsetBody(partition uint32) []byte {
+	body := binary.LittleEndian.AppendUint32(nil, partition)
+	body = binary.LittleEndian.AppendUint64(body, 0)
+	return binary.LittleEndian.AppendUint64(body, 0)
+}
+
+// progressLaneWaiters counts the calls that wait for a progress lane.
+func progressLaneWaiters(client *IggyTcpClient) int {
+	client.operations.mu.Lock()
+	defer client.operations.mu.Unlock()
+	waiters := 0
+	for key, lane := range client.operations.lanes {
+		if key.progress {
+			waiters += len(lane.waiters)
+		}
+	}
+	return waiters
+}
+
+// An uncertain offset store or delete blocks the progress of its partition
+// until it is resolved or abandoned, because a replay can still move the
+// offset. An uncertain send blocks only the later writes.
+func TestDriverOnlyARetainedOffsetWriteBlocksProgress(t *testing.T) {
+	calls := newLaneCalls(t)
+	for _, test := range []struct {
+		name           string
+		write          func(context.Context, *IggyTcpClient) error
+		blocksProgress bool
+	}{
+		{"store", calls.storeOffset, true},
+		{"delete", calls.deleteOffset, true},
+		{"send", calls.send, false},
+	} {
+		t.Run(test.name, func(t *testing.T) {
+			client, serverConn := newPipeClient(t)
+			attempts := make(map[uint64]int)
+			servePartitionOperations(t, serverConn, func(_ int, read request) []byte {
+				switch {
+				case read.operation() != vsr.OperationNonReplicated:
+					// 57 makes the write uncertain, and the refusal of its
+					// replay does not prove the outcome.
+					status := ierror.UnauthorizedCode
+					if attempts[read.requestID()] == 0 {
+						status = ierror.TransientNotCommittedCode
+					}
+					attempts[read.requestID()]++
+					return statusReplyFrame(read.operation(), uint32(status), nil)
+				case read.code() == uint32(command.GetOffsetCode):
+					return replyFrame(vsr.OperationNonReplicated, consumerOffsetBody(calls.partition))
+				default:
+					return replyFrame(vsr.OperationNonReplicated, emptyBatchBody(calls.partition))
+				}
+			})
+			ctx, cancel := context.WithTimeout(context.Background(), deferredTestTimeout)
+			defer cancel()
+			var uncertain ierror.OperationUncertain
+			require.ErrorAs(t, test.write(ctx, client), &uncertain)
+			requireBlocked := func(err error, call string) {
+				t.Helper()
+				var blocked ierror.LaneBlocked
+				require.ErrorAs(t, err, &blocked, call)
+				require.Equal(t, uncertain.OperationID, blocked.OperationID, call)
+			}
+
+			requireBlocked(calls.send(ctx, client), "a send")
+			requireBlocked(calls.storeOffset(ctx, client), "an offset store")
+			for _, progress := range []struct {
+				call string
+				err  error
+			}{
+				{"an offset read", calls.getOffset(ctx, client)},
+				{"a Next poll", calls.poll(ctx, client, iggcon.NextPollingStrategy(), false)},
+				{"an auto-commit poll", calls.poll(ctx, client, iggcon.OffsetPollingStrategy(0), true)},
+			} {
+				if test.blocksProgress {
+					requireBlocked(progress.err, progress.call)
+				} else {
+					require.NoError(t, progress.err, progress.call)
+				}
+			}
+			require.NoError(t, calls.poll(ctx, client, iggcon.OffsetPollingStrategy(0), false), "a plain poll takes no lane")
+			require.NoError(t, client.AbandonOperation(ctx, uncertain.OperationID))
+			require.NoError(t, calls.poll(ctx, client, iggcon.NextPollingStrategy(), false))
+		})
+	}
+}
+
 func TestDriverPureReadRetriesUncertainReply(t *testing.T) {
 	client, peer := newPipeClient(t)
 	server := serve(peer, func(index int, read request) []byte {
diff --git a/foreign/go/client/tcp/tcp_group_polling.go b/foreign/go/client/tcp/tcp_group_polling.go
index 13dccc7..5757cd7 100644
--- a/foreign/go/client/tcp/tcp_group_polling.go
+++ b/foreign/go/client/tcp/tcp_group_polling.go
@@ -204,7 +204,7 @@
 		}
 
 		polled, err := c.pollPartition(
-			ctx, streamId, topicId, consumer, strategy, count, autoCommit, &partitionId)
+			ctx, streamId, topicId, consumer, strategy, count, autoCommit, &partitionId, nil)
 		if err != nil {
 			if !isAssignmentStale(err) {
 				return nil, err
diff --git a/foreign/go/client/tcp/tcp_group_polling_test.go b/foreign/go/client/tcp/tcp_group_polling_test.go
index bae44eb..8ae8664 100644
--- a/foreign/go/client/tcp/tcp_group_polling_test.go
+++ b/foreign/go/client/tcp/tcp_group_polling_test.go
@@ -74,7 +74,7 @@
 func pollOnce(t *testing.T, client *IggyTcpClient) (*iggcon.PolledMessage, error) {
 	t.Helper()
 	streamId, topicId, consumer := groupConsumer(t)
-	return client.PollMessages(context.Background(), streamId, topicId,
+	return client.pollMessages(context.Background(), streamId, topicId,
 		consumer, iggcon.NextPollingStrategy(), 1, false, nil)
 }
 
diff --git a/foreign/go/client/tcp/tcp_link.go b/foreign/go/client/tcp/tcp_link.go
index 775e06c..238e2c9 100644
--- a/foreign/go/client/tcp/tcp_link.go
+++ b/foreign/go/client/tcp/tcp_link.go
@@ -20,6 +20,7 @@
 import (
 	"bufio"
 	"context"
+	"errors"
 	"io"
 	"net"
 	"sync"
@@ -92,13 +93,16 @@
 	l.pending[id] = pending
 	l.senders.Add(1)
 	l.mu.Unlock()
+	cancelled := false
 	defer func() {
 		l.mu.Lock()
 		if l.pending[id] == pending {
 			delete(l.pending, id)
 		}
 		l.mu.Unlock()
-		<-l.capacity
+		if !cancelled {
+			<-l.capacity
+		}
 	}()
 	queued := frame.acquire()
 	sent := false
@@ -114,10 +118,29 @@
 	if !sent {
 		return linkReply{err: ierror.ErrTransientNotAccepted}, false
 	}
+	caller := ctx
+	ctx, stopWaiting := replyContext(ctx, frame)
+	defer stopWaiting()
+	if frame.uncancellable {
+		select {
+		case reply := <-pending.reply:
+			return reply, true
+		case <-caller.Done():
+			// The server answers the cancelled request once, refused or with what it stored.
+			if errors.Is(caller.Err(), context.Canceled) {
+				l.queueCancellation(frame.data)
+			}
+		case <-ctx.Done():
+		case <-l.closed:
+		}
+	}
 	select {
 	case reply := <-pending.reply:
 		return reply, true
 	case <-ctx.Done():
+		if !frame.uncancellable {
+			cancelled = l.cancelDeferred(id, pending, frame.data)
+		}
 		return linkReply{err: ierror.ErrTransientNotCommitted}, true
 	case <-l.closed:
 		select {
@@ -129,6 +152,66 @@
 	}
 }
 
+// replyContext is the context of the wait for the reply of frame once it is
+// sent. The caller of an uncancellable frame waits for its reply until the
+// deadline of ctx, also after ctx is cancelled.
+func replyContext(ctx context.Context, frame *operationFrame) (context.Context, context.CancelFunc) {
+	if !frame.uncancellable {
+		return ctx, func() {}
+	}
+	detached := context.WithoutCancel(ctx)
+	if deadline, bounded := ctx.Deadline(); bounded {
+		return context.WithDeadline(detached, deadline)
+	}
+	return detached, func() {}
+}
+
+// cancelDeferred removes the pending entry of a deferred poll or fetch whose caller stopped waiting, and queues its cancellation behind it,
+// so that the server frees the parked request. A reply that comes later
+// finds no entry and is dropped. The cancellation keeps the capacity slot of
+// the request until it is queued, so a stalled writer holds a bounded number
+// of them. It returns false when nothing was cancelled.
+func (l *tcpLink) cancelDeferred(id uint64, pending *linkPending, frame []byte) bool {
+	cancellation, deferred := vsr.DeferredCancellation(frame)
+	if !deferred {
+		return false
+	}
+	l.mu.Lock()
+	defer l.mu.Unlock()
+	// The reply or the close of the link came first.
+	if l.pending[id] != pending {
+		return false
+	}
+	delete(l.pending, id)
+	l.senders.Add(1)
+	go func() {
+		defer func() { <-l.capacity }()
+		defer l.senders.Done()
+		select {
+		case l.writes <- newOperationFrame(cancellation, nil):
+		case <-l.closed:
+		}
+	}()
+	return true
+}
+
+// queueCancellation queues the cancellation of a deferred request whose caller
+// stopped waiting and keeps its pending entry, so its reply still arrives.
+func (l *tcpLink) queueCancellation(frame []byte) {
+	cancellation, deferred := vsr.DeferredCancellation(frame)
+	if !deferred {
+		return
+	}
+	l.senders.Add(1)
+	go func() {
+		defer l.senders.Done()
+		select {
+		case l.writes <- newOperationFrame(cancellation, nil):
+		case <-l.closed:
+		}
+	}()
+}
+
 func (l *tcpLink) close(err error) {
 	l.closeOnce.Do(func() {
 		l.mu.Lock()
diff --git a/foreign/go/client/tcp/tcp_link_test.go b/foreign/go/client/tcp/tcp_link_test.go
index 075a900..9260b43 100644
--- a/foreign/go/client/tcp/tcp_link_test.go
+++ b/foreign/go/client/tcp/tcp_link_test.go
@@ -143,6 +143,37 @@
 	require.Zero(t, registry.bytes)
 }
 
+func TestPendingRegistryReplayOfAnOffsetWriteHoldsItsProgressLane(t *testing.T) {
+	registry := newOperationRegistry(DefaultMaxPendingOperations, DefaultMaxPendingOperationBytes)
+	key := laneKey{incarnation: 1, partition: 1}
+	ticket, err := registry.enterProgress(context.Background(), key, true)
+	require.NoError(t, err)
+	require.NoError(t, ticket.reserve(context.Background(), vsr.HeaderSize))
+	data := make([]byte, vsr.HeaderSize)
+	binary.LittleEndian.PutUint64(data[frameOffsetRequest:], uint64(1)<<63)
+	operation := ticket.freeze(command.StoreOffsetCode, nil, data)
+	ticket.finish(true)
+	_, err = registry.enter(context.Background(), key.progressLane())
+	var blocked ierror.LaneBlocked
+	require.ErrorAs(t, err, &blocked, "the retained store blocks progress")
+	require.Equal(t, operation.id, blocked.OperationID)
+	replay, err := registry.resume(context.Background(), operation.id)
+	require.NoError(t, err)
+	require.NoError(t, registry.abandon(operation.id))
+	busy, cancelBusy := context.WithTimeout(context.Background(), 10*time.Millisecond)
+	defer cancelBusy()
+	_, err = registry.enter(busy, key.progressLane())
+	require.ErrorIs(t, err, ierror.ErrTransientNotAccepted, "the replay holds the progress lane")
+	replay.finish(true)
+	free, cancelFree := context.WithTimeout(context.Background(), time.Second)
+	defer cancelFree()
+	progress, err := registry.enter(free, key.progressLane())
+	require.NoError(t, err)
+	progress.finish(false)
+	require.Empty(t, registry.lanes)
+	require.Zero(t, registry.bytes)
+}
+
 func TestPendingRegistryResetKeepsPhysicalBytesUntilOldWriterFinishes(t *testing.T) {
 	registry := newOperationRegistry(1, vsr.HeaderSize)
 	ticket, err := registry.enter(context.Background(), laneKey{})
diff --git a/foreign/go/client/tcp/tcp_messaging.go b/foreign/go/client/tcp/tcp_messaging.go
index f234264..1fe2af4 100644
--- a/foreign/go/client/tcp/tcp_messaging.go
+++ b/foreign/go/client/tcp/tcp_messaging.go
@@ -186,6 +186,34 @@
 	ctx context.Context,
 	streamId iggcon.Identifier,
 	topicId iggcon.Identifier,
+	partitionId uint32,
+	consumer iggcon.Consumer,
+	strategy iggcon.PollingStrategy,
+	options iggcon.PollOptions,
+) (*iggcon.PolledMessage, error) {
+	if ctx == nil {
+		return nil, ierror.ErrNilContext
+	}
+	if err := strategy.RequirePartition(partitionId); err != nil {
+		return nil, err
+	}
+	if err := options.Validate(); err != nil {
+		return nil, err
+	}
+	if options.Commit.Kind == iggcon.CommitAuto {
+		return nil, fmt.Errorf("%w: a poll of one partition has nothing to count for an automatic commit; "+
+			"use a partition reader", ierror.ErrInvalidConfiguration)
+	}
+	return c.pollMessagesDeferred(ctx, streamId, topicId, consumer, strategy, options.Count,
+		options.Commit.Kind == iggcon.CommitOnRead, &partitionId, options.Deferred())
+}
+
+// pollMessages is the poll of the earlier API: a plain poll without a wait, and
+// a group poll without a partition that polls the assignment of the member in turn.
+func (c *IggyTcpClient) pollMessages(
+	ctx context.Context,
+	streamId iggcon.Identifier,
+	topicId iggcon.Identifier,
 	consumer iggcon.Consumer,
 	strategy iggcon.PollingStrategy,
 	count uint32,
@@ -202,7 +230,33 @@
 	if consumer.Kind == iggcon.ConsumerKindGroup && partitionId == nil {
 		return c.pollGroup(ctx, streamId, topicId, consumer, strategy, count, autoCommit)
 	}
-	return c.pollPartition(ctx, streamId, topicId, consumer, strategy, count, autoCommit, partitionId)
+	return c.pollPartition(ctx, streamId, topicId, consumer, strategy, count, autoCommit, partitionId, nil)
+}
+
+func (c *IggyTcpClient) pollMessagesDeferred(
+	ctx context.Context,
+	streamId iggcon.Identifier,
+	topicId iggcon.Identifier,
+	consumer iggcon.Consumer,
+	strategy iggcon.PollingStrategy,
+	count uint32,
+	autoCommit bool,
+	partitionId *uint32,
+	options iggcon.DeferredPollOptions,
+) (*iggcon.PolledMessage, error) {
+	if ctx == nil {
+		return nil, ierror.ErrNilContext
+	}
+	if consumer.Kind == iggcon.ConsumerKindGroup && partitionId == nil {
+		return nil, fmt.Errorf("%w: a deferred group poll needs a partition id; "+
+			"use a partition reader to read the whole assignment", ierror.ErrInvalidCommand)
+	}
+	if err := options.Validate(count); err != nil {
+		return nil, err
+	}
+	ctx, cancel := c.deferredContext(ctx, options.RequestTimeout)
+	defer cancel()
+	return c.pollPartition(ctx, streamId, topicId, consumer, strategy, count, autoCommit, partitionId, &options)
 }
 
 // pollPartition issues one poll against the partition the caller named.
@@ -215,6 +269,7 @@
 	count uint32,
 	autoCommit bool,
 	partitionId *uint32,
+	options *iggcon.DeferredPollOptions,
 ) (*iggcon.PolledMessage, error) {
 	request := &command.PollMessages{
 		StreamId:    streamId,
@@ -224,6 +279,7 @@
 		Strategy:    strategy,
 		Count:       count,
 		PartitionId: partitionId,
+		Deferred:    options,
 	}
 	buffer, err := c.do(ctx, request)
 	if err != nil {
@@ -289,7 +345,8 @@
 
 func isPartitionCommand(code command.Code) bool {
 	switch code {
-	case command.SendMessagesCode, command.PollMessagesCode, command.GetOffsetCode, command.StoreOffsetCode, command.DeleteConsumerOffsetCode:
+	case command.SendMessagesCode, command.PollMessagesCode, command.PollMessagesDeferredCode,
+		command.FetchMessagesCode, command.GetOffsetCode, command.StoreOffsetCode, command.DeleteConsumerOffsetCode:
 		return true
 	default:
 		return false
@@ -306,26 +363,25 @@
 		}
 		return binary.LittleEndian.AppendUint32([]byte(key.stream+key.topic), partition), nil
 	}
-	if len(payload) < 1 {
-		return nil, ierror.ErrInvalidCommand
+	topic, position, err := pollTarget(payload)
+	if err != nil {
+		return nil, err
 	}
-	var fields [3][]byte
-	position := 1
-	for index := range fields {
-		if len(payload)-position < fieldPrefixSize {
+	if code == command.FetchMessagesCode {
+		// A fetch goes to the primary of its first target.
+		// Its sender groups the targets by primary.
+		targets := position + fetchParametersSize
+		if len(payload)-targets < fetchTargetSize {
 			return nil, ierror.ErrInvalidCommand
 		}
-		end := position + fieldPrefixSize + int(payload[position+1])
-		if end > len(payload) {
-			return nil, ierror.ErrInvalidCommand
-		}
-		fields[index] = payload[position:end]
-		position = end
+		return routeQuery(topic, binary.LittleEndian.Uint32(payload[targets:])), nil
 	}
 	suffix := 0
 	switch code {
 	case command.PollMessagesCode:
 		suffix = pollParametersSize
+	case command.PollMessagesDeferredCode:
+		suffix = pollParametersSize + deferredParametersSize
 	case command.StoreOffsetCode:
 		suffix = storeOffsetSuffixSize
 	case command.DeleteConsumerOffsetCode:
@@ -336,12 +392,44 @@
 	}
 	partition := binary.LittleEndian.Uint32(payload[position+1:])
 	if payload[position] == 0 {
+		poll := code == command.PollMessagesCode || code == command.PollMessagesDeferredCode
 		if code == command.StoreOffsetCode || code == command.DeleteConsumerOffsetCode ||
-			(code == command.PollMessagesCode && payload[0] == byte(iggcon.ConsumerKindGroup)) {
+			(poll && payload[0] == byte(iggcon.ConsumerKindGroup)) {
 			return nil, ierror.ErrInvalidCommand
 		}
 		partition = 0
 	}
-	query := append(append(make([]byte, 0, len(fields[1])+len(fields[2])+partitionIdSize), fields[1]...), fields[2]...)
-	return binary.LittleEndian.AppendUint32(query, partition), nil
+	return routeQuery(topic, partition), nil
+}
+
+// pollTarget reads the consumer, the stream and the topic that start a poll,
+// a fetch and an offset command. It returns the encoded
+// stream and topic, which start the route query of each of their partitions,
+// and the position after them.
+func pollTarget(payload []byte) ([]byte, int, error) {
+	if len(payload) < 1 {
+		return nil, 0, ierror.ErrInvalidCommand
+	}
+	position, streamStart := 1, 0
+	for index := range 3 {
+		if len(payload)-position < fieldPrefixSize {
+			return nil, 0, ierror.ErrInvalidCommand
+		}
+		end := position + fieldPrefixSize + int(payload[position+1])
+		if end > len(payload) {
+			return nil, 0, ierror.ErrInvalidCommand
+		}
+		if index == 0 {
+			streamStart = end
+		}
+		position = end
+	}
+	return payload[streamStart:position], position, nil
+}
+
+// routeQuery is the route cache key of a partition: the encoded stream and
+// topic, then the partition id.
+func routeQuery(topic []byte, partition uint32) []byte {
+	query := append(make([]byte, 0, len(topic)+partitionIdSize), topic...)
+	return binary.LittleEndian.AppendUint32(query, partition)
 }
diff --git a/foreign/go/client/tcp/tcp_offset_management.go b/foreign/go/client/tcp/tcp_offset_management.go
index 741a685..e43b891 100644
--- a/foreign/go/client/tcp/tcp_offset_management.go
+++ b/foreign/go/client/tcp/tcp_offset_management.go
@@ -68,6 +68,15 @@
 	}, nil)
 }
 
+func (c *IggyTcpClient) DeleteConsumerPosition(ctx context.Context, consumer iggcon.Consumer, streamId iggcon.Identifier, topicId iggcon.Identifier, position iggcon.ConsumerPosition) error {
+	return c.writeOffset(ctx, &command.DeleteConsumerOffset{
+		Consumer:    consumer,
+		StreamId:    streamId,
+		TopicId:     topicId,
+		PartitionId: &position.PartitionId,
+	}, &position.Context)
+}
+
 func (c *IggyTcpClient) writeOffset(ctx context.Context, cmd command.Command, captured *iggcon.PartitionContext) error {
 	_, err := c.doCaptured(ctx, cmd, captured, nil)
 	return err
diff --git a/foreign/go/client/tcp/tcp_pending.go b/foreign/go/client/tcp/tcp_pending.go
index b1efffe..a7dff36 100644
--- a/foreign/go/client/tcp/tcp_pending.go
+++ b/foreign/go/client/tcp/tcp_pending.go
@@ -19,6 +19,7 @@
 
 import (
 	"context"
+	"errors"
 	"slices"
 	"sync"
 	"sync/atomic"
@@ -36,6 +37,15 @@
 type laneKey struct {
 	incarnation uint64
 	partition   uint32
+	// progress names the progress lane of the partition. Progress polls and
+	// offset calls take it, sends do not, so a parked poll does not hold sends.
+	progress bool
+}
+
+// progressLane is the key of the progress lane of the partition of k.
+func (k laneKey) progressLane() laneKey {
+	k.progress = true
+	return k
 }
 
 type mutationLane struct {
@@ -83,6 +93,17 @@
 	// The outcomes that replays proved, oldest first and at most maxOperations.
 	// A reset keeps them, because request IDs never repeat within a client.
 	proved []provedOutcome
+	// Fetch recovery survives a session reset and retains only target keys.
+	fetchReservations map[*fetchRecovery]struct{}
+	fetchRetained     map[uint64]*fetchRecovery
+	fetchBlocked      map[laneKey]*fetchRecovery
+	fetchBytes        int
+}
+
+type fetchRecovery struct {
+	id   uint64
+	keys []laneKey
+	size int
 }
 
 // provedOutcome is the committed result of a replayed operation: nil for a
@@ -102,6 +123,10 @@
 	data        []byte
 	refs        atomic.Int32
 	reservation *frameReservation
+	// uncancellable keeps the link from cancelling the request when its
+	// caller stops waiting: a fetch with auto-commit, because a cancel after
+	// its read drops what it stored.
+	uncancellable bool
 }
 
 func newOperationFrame(data []byte, reservation *frameReservation) *operationFrame {
@@ -136,41 +161,53 @@
 	frame *operationFrame
 	// replica is the replica the caller chose, nil when the client routes the request.
 	replica *uint8
+	// budget is the budget of a deferred poll or a fetch, nil for
+	// any other request.
+	budget *deferredBudget
 }
 
 type laneTicket struct {
-	owner       *operationRegistry
-	key         laneKey
-	lane        *mutationLane
-	fence       uint64
-	operation   *pendingOperation
-	reservation *frameReservation
-	resumed     bool
-	finished    bool
-	proved      bool
-	outcome     error
+	owner        *operationRegistry
+	key          laneKey
+	lane         *mutationLane
+	fence        uint64
+	operation    *pendingOperation
+	reservation  *frameReservation
+	resumed      bool
+	finished     bool
+	proved       bool
+	outcome      error
+	fetch        *fetchRecovery
+	fetchUnknown []laneKey
+	// held are the other lanes of the call: the progress lane of an offset
+	// store or delete, which also takes the mutation lane, or the progress
+	// lanes of the other partitions of a fetch.
+	held []*laneTicket
 }
 
 func newOperationRegistry(maxOperations, maxBytes int) *operationRegistry {
-	return &operationRegistry{fence: 1, lanes: make(map[laneKey]*mutationLane), retained: make(map[uint64]*pendingOperation), reservations: make(map[*frameReservation]struct{}), maxOperations: maxOperations, maxBytes: maxBytes, resumers: make(map[uint64][]registryWaiter)}
+	return &operationRegistry{fence: 1, lanes: make(map[laneKey]*mutationLane), retained: make(map[uint64]*pendingOperation), reservations: make(map[*frameReservation]struct{}), maxOperations: maxOperations, maxBytes: maxBytes, resumers: make(map[uint64][]registryWaiter), fetchReservations: make(map[*fetchRecovery]struct{}), fetchRetained: make(map[uint64]*fetchRecovery), fetchBlocked: make(map[laneKey]*fetchRecovery)}
 }
 
 // enter waits for the lane of key in arrival order. A reset keeps the queue,
 // so the ticket takes the fence of the session it is granted under.
 func (r *operationRegistry) enter(ctx context.Context, key laneKey) (*laneTicket, error) {
+	return r.enterLane(ctx, key, false)
+}
+
+func (r *operationRegistry) enterLane(ctx context.Context, key laneKey, inspect bool) (*laneTicket, error) {
 	r.mu.Lock()
 	defer r.mu.Unlock()
-	lane := r.lanes[key]
-	if lane == nil {
-		lane = &mutationLane{}
-		r.lanes[key] = lane
-	}
+	lane := r.laneLocked(key)
 	var waiter registryWaiter
 	for {
-		if lane.retained != nil {
-			id := lane.retained.id
+		if blocking := r.fetchBlocked[key]; blocking != nil && !inspect {
 			r.leaveLaneLocked(key, lane, waiter)
-			return nil, ierror.LaneBlocked{OperationID: id}
+			return nil, ierror.PartitionLaneBlocked{PartitionID: key.partition, OperationID: blocking.id}
+		}
+		if blocking := r.blockingLocked(key, lane); blocking != nil {
+			r.leaveLaneLocked(key, lane, waiter)
+			return nil, ierror.LaneBlocked{OperationID: blocking.id}
 		}
 		if ctx.Err() != nil {
 			r.leaveLaneLocked(key, lane, waiter)
@@ -192,6 +229,80 @@
 	}
 }
 
+// enterProgress enters the progress lane of the partition of key and, for an
+// offset store or delete, then its mutation lane. The progress lane comes
+// first, so a call that waits for a parked poll holds no mutation lane.
+func (r *operationRegistry) enterProgress(ctx context.Context, key laneKey, mutation bool) (*laneTicket, error) {
+	progress, err := r.enter(ctx, key.progressLane())
+	if err != nil || !mutation {
+		return progress, err
+	}
+	ticket, err := r.enter(ctx, key)
+	if err != nil {
+		progress.finish(false)
+		return nil, err
+	}
+	ticket.held = []*laneTicket{progress}
+	return ticket, nil
+}
+
+// enterLanes enters the lanes of keys in their order, and its ticket holds
+// them all. A fetch takes the progress lanes of its partitions this way, in
+// ascending partition order. Every call that waits for a lane while it holds
+// another takes its lanes in ascending partition order, and the progress
+// lane of a partition before its mutation lane, so no two calls wait for each
+// other. Without keys, it holds nothing and returns no ticket.
+func (r *operationRegistry) enterLanes(ctx context.Context, keys []laneKey) (*laneTicket, error) {
+	var ticket *laneTicket
+	for _, key := range keys {
+		entered, err := r.enter(ctx, key)
+		if err != nil {
+			if ticket != nil {
+				ticket.finish(false)
+			}
+			var blocked ierror.LaneBlocked
+			if errors.As(err, &blocked) {
+				return nil, ierror.PartitionLaneBlocked{PartitionID: key.partition, OperationID: blocked.OperationID}
+			}
+			return nil, err
+		}
+		if ticket == nil {
+			ticket = entered
+		} else {
+			ticket.held = append(ticket.held, entered)
+		}
+	}
+	return ticket, nil
+}
+
+func (r *operationRegistry) laneLocked(key laneKey) *mutationLane {
+	lane := r.lanes[key]
+	if lane == nil {
+		lane = &mutationLane{}
+		r.lanes[key] = lane
+	}
+	return lane
+}
+
+// blockingLocked returns the retained operation that blocks the lane of key.
+// A progress lane retains nothing itself. An offset store or delete that the
+// mutation lane of its partition retains blocks it, because a replay can
+// still move the offset.
+func (r *operationRegistry) blockingLocked(key laneKey, lane *mutationLane) *pendingOperation {
+	if !key.progress {
+		return lane.retained
+	}
+	key.progress = false
+	if mutations := r.lanes[key]; mutations != nil && mutations.retained != nil && changesProgress(mutations.retained.code) {
+		return mutations.retained
+	}
+	return nil
+}
+
+func changesProgress(code command.Code) bool {
+	return code == command.StoreOffsetCode || code == command.DeleteConsumerOffsetCode
+}
+
 // waitLocked releases r.mu until waiter wakes or ctx ends.
 func (r *operationRegistry) waitLocked(ctx context.Context, waiter registryWaiter) {
 	r.mu.Unlock()
@@ -328,9 +439,44 @@
 func (t *laneTicket) freeze(code command.Code, query []byte, frame []byte) *pendingOperation {
 	operation := &pendingOperation{id: vsr.StampedRequestID(frame), code: code, query: append([]byte(nil), query...), key: t.key, fence: t.fence, frame: newOperationFrame(frame, t.reservation)}
 	t.operation = operation
+	if t.fetch != nil {
+		t.fetch.id = operation.id
+	}
 	return operation
 }
 
+// reserveFetch bounds local recovery metadata before the request can be sent.
+// It uses the configured limits independently of replayable mutation frames,
+// whose reservations are reset when their session is replaced.
+func (t *laneTicket) reserveFetch() error {
+	const recoveryHeaderBytes = 16
+	const recoveryKeyBytes = 16
+	r := t.owner
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	if t.fetch != nil {
+		return nil
+	}
+	size := recoveryHeaderBytes + recoveryKeyBytes*(1+len(t.held))
+	if len(r.fetchReservations) >= r.maxOperations || size > r.maxBytes-r.fetchBytes {
+		return ierror.ErrTransientNotAccepted
+	}
+	keys := make([]laneKey, 0, 1+len(t.held))
+	keys = append(keys, t.key)
+	for _, held := range t.held {
+		keys = append(keys, held.key)
+	}
+	t.fetch = &fetchRecovery{keys: keys, size: size}
+	r.fetchReservations[t.fetch] = struct{}{}
+	r.fetchBytes += size
+	return nil
+}
+
+func (t *laneTicket) retainFetch(keys []laneKey) error {
+	t.fetchUnknown = keys
+	return ierror.FetchProgressUncertain{OperationID: t.operation.id}
+}
+
 // releaseOperation frees the operation of the ticket and keeps its lane, so the
 // call starts a new operation in its place.
 func (t *laneTicket) releaseOperation() {
@@ -366,6 +512,18 @@
 	}
 	t.finished = true
 	operation := t.operation
+	if recovery := t.fetch; recovery != nil {
+		if len(t.fetchUnknown) > 0 {
+			recovery.keys = t.fetchUnknown
+			r.fetchRetained[recovery.id] = recovery
+			for _, key := range recovery.keys {
+				r.fetchBlocked[key] = recovery
+			}
+		} else {
+			delete(r.fetchReservations, recovery)
+			r.fetchBytes -= recovery.size
+		}
+	}
 	if t.proved && t.resumed {
 		// Recorded with the release under one lock, so a resolve that no longer
 		// finds the operation finds its outcome, also after an abandon.
@@ -395,6 +553,14 @@
 	}
 	r.cleanLaneLocked(t.key, t.lane)
 	r.wakeLaneLocked(t.lane)
+	// Released after the retention above and under the same lock, so a
+	// progress waiter sees the store that now blocks it, and a resolver finds
+	// both lanes free.
+	for _, held := range t.held {
+		held.lane.active = false
+		r.cleanLaneLocked(held.key, held.lane)
+		r.wakeLaneLocked(held.lane)
+	}
 	// While the ticket holds the lane, only its own operation can be retained
 	// there, so only the resolvers of that operation wait for the lane.
 	if operation != nil {
@@ -428,6 +594,9 @@
 func (r *operationRegistry) resume(ctx context.Context, id uint64) (*laneTicket, error) {
 	r.mu.Lock()
 	defer r.mu.Unlock()
+	if r.fetchRetained[id] != nil {
+		return nil, ierror.FetchProgressUncertain{OperationID: id}
+	}
 	var waiter registryWaiter
 	defer func() { r.leaveResumersLocked(id, waiter) }()
 	for {
@@ -439,10 +608,22 @@
 		if ctx.Err() != nil {
 			return nil, ierror.OperationUncertain{OperationID: id}
 		}
-		if !lane.active {
+		// A replay of an offset store or delete holds the progress lane too, as
+		// its first attempt did. While the operation blocks that lane, only its
+		// replays hold it, and their end wakes this call.
+		var progress *mutationLane
+		if changesProgress(operation.code) {
+			progress = r.laneLocked(operation.key.progressLane())
+		}
+		if !lane.active && (progress == nil || !progress.active) {
 			lane.active = true
 			operation.frame.acquire()
-			return &laneTicket{owner: r, key: operation.key, lane: lane, fence: r.fence, operation: operation, reservation: operation.frame.reservation, resumed: true}, nil
+			ticket := &laneTicket{owner: r, key: operation.key, lane: lane, fence: r.fence, operation: operation, reservation: operation.frame.reservation, resumed: true}
+			if progress != nil {
+				progress.active = true
+				ticket.held = []*laneTicket{{owner: r, key: operation.key.progressLane(), lane: progress, fence: r.fence}}
+			}
+			return ticket, nil
 		}
 		if waiter == nil {
 			waiter = newRegistryWaiter()
@@ -468,6 +649,15 @@
 func (r *operationRegistry) abandon(id uint64) error {
 	r.mu.Lock()
 	defer r.mu.Unlock()
+	if recovery := r.fetchRetained[id]; recovery != nil {
+		delete(r.fetchRetained, id)
+		delete(r.fetchReservations, recovery)
+		r.fetchBytes -= recovery.size
+		for _, key := range recovery.keys {
+			delete(r.fetchBlocked, key)
+		}
+		return nil
+	}
 	operation := r.retained[id]
 	if operation == nil {
 		return ierror.OperationNotRetained{OperationID: id}
@@ -483,6 +673,21 @@
 	return nil
 }
 
+func (r *operationRegistry) unresolvedFetch() error {
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	var first uint64
+	for id := range r.fetchRetained {
+		if first == 0 || id < first {
+			first = id
+		}
+	}
+	if first != 0 {
+		return ierror.FetchProgressUncertain{OperationID: first}
+	}
+	return nil
+}
+
 // recordedOutcome returns what a replay proved for id while the record keeps it.
 func (r *operationRegistry) recordedOutcome(id uint64) error {
 	r.mu.Lock()
diff --git a/foreign/go/client/tcp/tcp_poll_routing.go b/foreign/go/client/tcp/tcp_poll_routing.go
index 84480fb..1806a39 100644
--- a/foreign/go/client/tcp/tcp_poll_routing.go
+++ b/foreign/go/client/tcp/tcp_poll_routing.go
@@ -201,11 +201,39 @@
 	p.hints[route.key] = hint
 }
 
-// relookup starts again at the metadata primary after a missing namespace,
-// a lost partition history or a change of consumer group ownership.
+// refusedAt marks the cached route of query after a fetch got err for its
+// partition: it keeps the view floor for NotPrimary, and uses relookup for an
+// error that needsRelookup. A route that is not cached is looked up at its
+// next use anyway.
+func (p *pollRouter) refusedAt(query []byte, err error) {
+	p.mu.Lock()
+	defer p.mu.Unlock()
+	route, cached := p.routes[string(query)]
+	switch {
+	case !cached:
+	case errors.Is(err, ierror.ErrNotPrimary):
+		p.mayHaveMovedLocked(route)
+	case needsRelookup(err):
+		p.relookupLocked(route)
+	}
+}
+
+func (p *pollRouter) mayHaveMovedLocked(route pollRoute) {
+	if hint, known := p.staleLocked(route); known {
+		hint.preferred, hint.hasPreferred = route.primary, true
+		p.hints[route.key] = hint
+	}
+}
+
+// relookup starts again at the metadata primary after an error that
+// needsRelookup.
 func (p *pollRouter) relookup(route pollRoute) {
 	p.mu.Lock()
 	defer p.mu.Unlock()
+	p.relookupLocked(route)
+}
+
+func (p *pollRouter) relookupLocked(route pollRoute) {
 	if hint, known := p.staleLocked(route); known {
 		hint.metadataFirst = true
 		hint.hasPreferred = false
@@ -355,6 +383,13 @@
 		errors.Is(err, ierror.ErrPartitionNotFound)
 }
 
+// needsRelookup reports whether err leaves the route of its partition behind:
+// a missing namespace, a lost partition history or a change of consumer group
+// ownership.
+func needsRelookup(err error) bool {
+	return errors.Is(err, ierror.ErrHistoryUnavailable) || errors.Is(err, ierror.ErrConsumerGroupPartitionNotOwned) || isNamespaceMiss(err)
+}
+
 // isMetadataPrimary reports whether link reaches the metadata primary. Only its
 // namespace miss is final, because a backup can lag a namespace that another
 // client just created. The belief can predate a view change, so a roster read
diff --git a/foreign/go/client/tcp/tcp_poll_routing_test.go b/foreign/go/client/tcp/tcp_poll_routing_test.go
index 68bcc60..ef0a7a3 100644
--- a/foreign/go/client/tcp/tcp_poll_routing_test.go
+++ b/foreign/go/client/tcp/tcp_poll_routing_test.go
@@ -20,6 +20,7 @@
 import (
 	"context"
 	"encoding/binary"
+	"errors"
 	"fmt"
 	"io"
 	"log/slog"
@@ -146,7 +147,7 @@
 
 func pollRoutingReply(t testing.TB, read request, replica uint8, watermark uint64) []byte {
 	t.Helper()
-	body := binary.LittleEndian.AppendUint64(nil, 0)
+	body := binary.LittleEndian.AppendUint64(nil, 17)
 	body = binary.LittleEndian.AppendUint64(body, watermark)
 	body = binary.LittleEndian.AppendUint32(body, 0)
 	body = binary.LittleEndian.AppendUint32(body, 2)
@@ -158,10 +159,19 @@
 	stream, _ := iggcon.NewIdentifier(uint32(1))
 	topic, _ := iggcon.NewIdentifier(uint32(2))
 	consumer, _ := iggcon.NewIdentifier(uint32(3))
-	return client.PollMessages(ctx, stream, topic, iggcon.NewGroupConsumer(consumer),
+	return client.pollMessages(ctx, stream, topic, iggcon.NewGroupConsumer(consumer),
 		iggcon.NextPollingStrategy(), 1, true, &partition)
 }
 
+// readPrimaryPartition polls from Next without storing the offset.
+func readPrimaryPartition(ctx context.Context, client *IggyTcpClient, partition uint32) (*iggcon.PolledMessage, error) {
+	stream, _ := iggcon.NewIdentifier(uint32(1))
+	topic, _ := iggcon.NewIdentifier(uint32(2))
+	consumer, _ := iggcon.NewIdentifier(uint32(3))
+	return client.pollMessages(ctx, stream, topic, iggcon.NewGroupConsumer(consumer),
+		iggcon.NextPollingStrategy(), 1, false, &partition)
+}
+
 func routedPollPartition(t *testing.T, read request) uint32 {
 	t.Helper()
 	if read.code() == uint32(command.GetPartitionRouteCode) {
@@ -206,7 +216,7 @@
 	parent := fixture.client.session.ClientID()
 	session := fixture.client.session.SessionID()
 	for index := range 4 {
-		polled, err := fixture.client.PollMessages(context.Background(), stream, topic, consumer,
+		polled, err := fixture.client.pollMessages(context.Background(), stream, topic, consumer,
 			iggcon.NextPollingStrategy(), 1, true, nil)
 		require.NoError(t, err)
 		require.Equal(t, uint32(index%2), polled.PartitionId)
@@ -273,9 +283,16 @@
 				return nil, false
 			}, nil)
 			_, err := pollPrimaryPartition(context.Background(), fixture.client, 0)
-			require.ErrorIs(t, err, ierror.ErrTransientNotCommitted)
+			// The poll stores its offset, so its unknown outcome is uncertain progress.
+			var unknown ierror.FetchProgressUncertain
+			require.ErrorAs(t, err, &unknown)
 			require.Equal(t, int32(1), polls.Load(), "the admitted poll is never replayed")
 			_, err = pollPrimaryPartition(context.Background(), fixture.client, 0)
+			var blocked ierror.PartitionLaneBlocked
+			require.ErrorAs(t, err, &blocked)
+			assert.Equal(t, unknown.OperationID, blocked.OperationID)
+			require.NoError(t, fixture.client.AbandonOperation(context.Background(), unknown.OperationID))
+			_, err = pollPrimaryPartition(context.Background(), fixture.client, 0)
 			require.NoError(t, err)
 			connections := 1
 			if lostReply {
@@ -318,7 +335,8 @@
 	ctx, cancel := context.WithCancel(context.Background())
 	defer cancel()
 	completed := make(chan error, 1)
-	go func() { _, err := pollPrimaryPartition(ctx, fixture.client, 0); completed <- err }()
+	// A poll that stores nothing, since a cancelled poll that stores its offset waits for its reply.
+	go func() { _, err := readPrimaryPartition(ctx, fixture.client, 0); completed <- err }()
 	select {
 	case <-entered:
 	case <-time.After(time.Second):
@@ -326,7 +344,7 @@
 	}
 	waiterCtx, waiterCancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
 	defer waiterCancel()
-	_, err := pollPrimaryPartition(waiterCtx, fixture.client, 0)
+	_, err := readPrimaryPartition(waiterCtx, fixture.client, 0)
 	require.ErrorIs(t, err, ierror.ErrTransientNotAccepted)
 	cancel()
 	select {
@@ -365,7 +383,7 @@
 	require.NoError(t, fixture.client.LogoutUser(context.Background()))
 	select {
 	case err := <-completed:
-		require.ErrorIs(t, err, ierror.ErrTransientNotCommitted)
+		require.ErrorIs(t, err, ierror.FetchProgressUncertain{}, "the server can have stored the offset of the poll")
 	case <-time.After(time.Second):
 		t.Fatal("logout left the data exchange running")
 	}
@@ -389,9 +407,9 @@
 				}
 				return nil, false
 			}, nil)
-			_, err := pollPrimaryPartition(context.Background(), fixture.client, 0)
+			_, err := readPrimaryPartition(context.Background(), fixture.client, 0)
 			require.ErrorIs(t, err, status)
-			_, err = pollPrimaryPartition(context.Background(), fixture.client, 0)
+			_, err = readPrimaryPartition(context.Background(), fixture.client, 0)
 			require.NoError(t, err)
 			assert.Equal(t, 1, fixture.primaries[0].connections())
 			assert.Equal(t, 1, requestCount(fixture.primaries[0].recorded(), command.BindSessionCode))
@@ -411,21 +429,30 @@
 		return nil, false
 	}, nil)
 	_, err := pollPrimaryPartition(context.Background(), fixture.client, 0)
-	require.ErrorIs(t, err, ierror.ErrTransientNotCommitted)
+	var unknown ierror.FetchProgressUncertain
+	require.ErrorAs(t, err, &unknown)
 	require.Equal(t, int32(1), polls.Load())
+	require.NoError(t, fixture.client.AbandonOperation(context.Background(), unknown.OperationID))
 	_, err = pollPrimaryPartition(context.Background(), fixture.client, 0)
 	require.NoError(t, err)
 	assert.Equal(t, 2, fixture.primaries[0].connections())
 }
 
 func TestPrimaryPoll_DetachedLinkPreservesTheSessionAndReplaysOnlyARefusedPoll(t *testing.T) {
+	// The fake server stamps each reply frame, so every case gets its own.
+	unauthenticated := func() []byte {
+		return statusReplyFrame(vsr.OperationNonReplicated, uint32(ierror.ErrUnauthenticated.Code()), nil)
+	}
 	for _, test := range []struct {
 		name  string
 		reply []byte
+		poll  func(context.Context, *IggyTcpClient, uint32) (*iggcon.PolledMessage, error)
 		want  error
 	}{
-		{"evicted", evictionFrame(vsr.EvictionNoSession, 0, 0), ierror.ErrTransientNotCommitted},
-		{"unauthenticated", statusReplyFrame(vsr.OperationNonReplicated, uint32(ierror.ErrUnauthenticated.Code()), nil), nil},
+		{"evicted", evictionFrame(vsr.EvictionNoSession, 0, 0), pollPrimaryPartition, ierror.FetchProgressUncertain{}},
+		// A poll that stores its offset leaves an unauthenticated reply unknown.
+		{"unauthenticated", unauthenticated(), pollPrimaryPartition, ierror.FetchProgressUncertain{}},
+		{"unauthenticated_read", unauthenticated(), readPrimaryPartition, nil},
 	} {
 		t.Run(test.name, func(t *testing.T) {
 			var polls atomic.Int32
@@ -435,8 +462,12 @@
 				}
 				return nil, false
 			}, nil)
-			_, err := pollPrimaryPartition(context.Background(), fixture.client, 0)
+			_, err := test.poll(context.Background(), fixture.client, 0)
 			require.ErrorIs(t, err, test.want)
+			var unknown ierror.FetchProgressUncertain
+			if errors.As(err, &unknown) {
+				require.NoError(t, fixture.client.AbandonOperation(context.Background(), unknown.OperationID))
+			}
 			require.Eventually(t, func() bool {
 				fixture.client.mtx.Lock()
 				defer fixture.client.mtx.Unlock()
@@ -447,7 +478,7 @@
 			} else {
 				require.Equal(t, int32(1), polls.Load())
 			}
-			_, err = pollPrimaryPartition(context.Background(), fixture.client, 0)
+			_, err = test.poll(context.Background(), fixture.client, 0)
 			require.NoError(t, err)
 			assert.Equal(t, 2, fixture.primaries[0].connections())
 			assert.Equal(t, 1, fixture.coordinator.connections())
@@ -763,13 +794,13 @@
 	stream, topic, group := groupConsumer(t)
 	consumer := iggcon.NewSingleConsumer(group.Id)
 	partition := uint32(0)
-	_, err := fixture.client.PollMessages(context.Background(), stream, topic, consumer,
+	_, err := fixture.client.pollMessages(context.Background(), stream, topic, consumer,
 		iggcon.NextPollingStrategy(), 1, true, &partition)
 	require.NoError(t, err)
-	_, err = fixture.client.PollMessages(context.Background(), stream, topic, consumer,
+	_, err = fixture.client.pollMessages(context.Background(), stream, topic, consumer,
 		iggcon.NextPollingStrategy(), 1, true, nil)
 	require.NoError(t, err)
-	_, err = fixture.client.PollMessages(context.Background(), stream, topic, consumer,
+	_, err = fixture.client.pollMessages(context.Background(), stream, topic, consumer,
 		iggcon.NextPollingStrategy(), 1, false, &partition)
 	require.NoError(t, err)
 	assert.Zero(t, requestCount(fixture.coordinator.recorded(), command.PollMessagesCode))
@@ -829,7 +860,7 @@
 		call    func(*IggyTcpClient) error
 	}{
 		{"poll", command.GetPartitionRouteCode, func(client *IggyTcpClient) error {
-			_, err := client.PollMessages(context.Background(), stream, topic, consumer,
+			_, err := client.pollMessages(context.Background(), stream, topic, consumer,
 				iggcon.NextPollingStrategy(), 1, false, &partition)
 			return err
 		}},
@@ -1071,7 +1102,7 @@
 			return replyFrame(vsr.OperationNonReplicated, emptyBatchBody(partition))
 		})
 
-		_, err := client.PollMessages(context.Background(), stream, topic, iggcon.NewSingleConsumer(group.Id),
+		_, err := client.pollMessages(context.Background(), stream, topic, iggcon.NewSingleConsumer(group.Id),
 			strategy, 1, false, &partition)
 		require.NoError(t, err)
 
@@ -1088,7 +1119,7 @@
 			return nil, false
 		}, nil)
 
-		_, err := fixture.client.PollMessages(context.Background(), stream, topic, group, strategy, 1, true, &partition)
+		_, err := fixture.client.pollMessages(context.Background(), stream, topic, group, strategy, 1, true, &partition)
 		require.NoError(t, err)
 
 		assert.Equal(t, caller, <-stamped)
@@ -1110,7 +1141,7 @@
 	require.NoError(t, fixture.client.JoinConsumerGroup(context.Background(), stream, topic, consumer.Id))
 	strategy := iggcon.NextPollingStrategy().WithContext(iggcon.PartitionContext{Incarnation: 5, OwnerGeneration: 3})
 
-	_, err := fixture.client.PollMessages(context.Background(), stream, topic, consumer, strategy, 1, true, nil)
+	_, err := fixture.client.pollMessages(context.Background(), stream, topic, consumer, strategy, 1, true, nil)
 
 	require.ErrorIs(t, err, ierror.ErrConsumerGroupPartitionNotOwned)
 	assert.Equal(t, int32(1), polls.Load())
@@ -1841,7 +1872,7 @@
 		call func(client *IggyTcpClient) error
 	}{
 		{"poll", func(client *IggyTcpClient) error {
-			_, err := client.PollMessages(ctx, stream, topic, consumer, iggcon.OffsetPollingStrategy(0), 1, false, &partition)
+			_, err := client.pollMessages(ctx, stream, topic, consumer, iggcon.OffsetPollingStrategy(0), 1, false, &partition)
 			return err
 		}},
 		{"store_offset", func(client *IggyTcpClient) error {
@@ -2481,3 +2512,29 @@
 		require.Zero(t, requestCount(replica.recorded(), command.GetPartitionRouteCode))
 	}
 }
+
+func TestDeleteConsumerPosition_StampsTheCapturedContext(t *testing.T) {
+	client, serverConn := newPipeClient(t)
+	server := servePartitionOperations(t, serverConn, func(_ int, _ request) []byte {
+		return replyFrame(vsr.OperationDeleteConsumerOffset, resultSection())
+	})
+	stream, topic, consumer := groupConsumer(t)
+	polled := iggcon.PartitionContext{
+		Incarnation:     5,
+		OwnerGeneration: 3,
+		MetadataOp:      9,
+	}
+	position := iggcon.ConsumerPosition{PartitionId: 4, Offset: 42, Context: polled}
+
+	require.NoError(t, client.DeleteConsumerPosition(context.Background(), consumer, stream, topic, position))
+
+	recorded := server.recorded()
+	require.Len(t, recorded, 2, "the route chooses the primary but never replaces the captured context")
+	var stamped iggcon.PartitionContext
+	require.NoError(t, stamped.UnmarshalBinary(recorded[1].header[frameOffsetContext:frameOffsetContext+iggcon.PartitionContextSize]))
+	assert.Equal(t, polled, stamped)
+	want, err := (&command.DeleteConsumerOffset{StreamId: stream, TopicId: topic, Consumer: consumer,
+		PartitionId: &position.PartitionId}).MarshalBinary()
+	require.NoError(t, err)
+	assert.Equal(t, want, recorded[1].payload)
+}
diff --git a/foreign/go/client/tcp/tcp_replicas_test.go b/foreign/go/client/tcp/tcp_replicas_test.go
index ad1cd1c..8f5e407 100644
--- a/foreign/go/client/tcp/tcp_replicas_test.go
+++ b/foreign/go/client/tcp/tcp_replicas_test.go
@@ -1296,7 +1296,7 @@
 		{"keyed_send", command.GetPartitionRouteCode, send(iggcon.Partitioning{Kind: iggcon.MessageKey, Value: []byte("key")})},
 		{"roster_before_send", command.GetClusterMetadataCode, send(iggcon.PartitionId(0))},
 		{"group_poll", command.SyncGroupCode, func(ctx context.Context, client *IggyTcpClient) error {
-			_, err := client.PollMessages(ctx, stream, topic, consumer, iggcon.NextPollingStrategy(), 1, false, nil)
+			_, err := client.pollMessages(ctx, stream, topic, consumer, iggcon.NextPollingStrategy(), 1, false, nil)
 			return err
 		}},
 	} {
diff --git a/foreign/go/client/tcp/tcp_topic_cache_test.go b/foreign/go/client/tcp/tcp_topic_cache_test.go
index 2c8f7fa..bf336cf 100644
--- a/foreign/go/client/tcp/tcp_topic_cache_test.go
+++ b/foreign/go/client/tcp/tcp_topic_cache_test.go
@@ -191,7 +191,7 @@
 	stream, topic, group := groupConsumer(t)
 	consumer := iggcon.NewSingleConsumer(group.Id)
 	for _, expected := range []uint32{2, 3} {
-		_, err := client.PollMessages(context.Background(), stream, topic, consumer,
+		_, err := client.pollMessages(context.Background(), stream, topic, consumer,
 			iggcon.NextPollingStrategy(), 1, false, nil)
 		require.NoError(t, err)
 		count, exists := client.topics.partitionsCount(newTopicKey(stream, topic))
diff --git a/foreign/go/contracts/checkpoint.go b/foreign/go/contracts/checkpoint.go
new file mode 100644
index 0000000..e04b6af
--- /dev/null
+++ b/foreign/go/contracts/checkpoint.go
@@ -0,0 +1,69 @@
+// 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.
+
+package iggcon
+
+// Checkpoint is the position after one delivered message: its partition, its
+// offset and the history of the partition it came from. Only a poll or a
+// reader makes one. Storing it stores the offset of that message.
+type Checkpoint struct {
+	partitionID uint32
+	offset      uint64
+	context     PartitionContext
+}
+
+// PartitionID is the partition of the message.
+func (c Checkpoint) PartitionID() uint32 { return c.partitionID }
+
+// Offset is the offset of the message.
+func (c Checkpoint) Offset() uint64 { return c.offset }
+
+// Context is the history of the partition that the message came from, which a
+// store sends so that it fails on another one.
+func (c Checkpoint) Context() PartitionContext { return c.context }
+
+// Next is the continuation after the message, bound to its partition.
+func (c Checkpoint) Next() PollingStrategy {
+	partition := c.partitionID
+	context := c.context
+	return PollingStrategy{Kind: POLLING_OFFSET, Value: c.offset + 1, Context: &context, partition: &partition}
+}
+
+// Checkpoint is the position after the last message, or false for an empty batch.
+func (p *PolledMessage) Checkpoint() (Checkpoint, bool) {
+	return p.CheckpointAt(len(p.Messages) - 1)
+}
+
+// CheckpointAt is the position after the message at index, or false for an
+// index outside the batch.
+func (p *PolledMessage) CheckpointAt(index int) (Checkpoint, bool) {
+	if index < 0 || index >= len(p.Messages) {
+		return Checkpoint{}, false
+	}
+	return Checkpoint{partitionID: p.PartitionId, offset: p.Messages[index].Header.Offset, context: p.Context}, true
+}
+
+// Continuation is where the next poll of this partition starts, after the
+// last message, or false for an empty batch, whose start the caller keeps. A
+// poll or a seek of another partition refuses it.
+func (p *PolledMessage) Continuation() (PollingStrategy, bool) {
+	checkpoint, ok := p.Checkpoint()
+	if !ok {
+		return PollingStrategy{}, false
+	}
+	return checkpoint.Next(), true
+}
diff --git a/foreign/go/contracts/client.go b/foreign/go/contracts/client.go
index e142366..7f2dfcd 100644
--- a/foreign/go/contracts/client.go
+++ b/foreign/go/contracts/client.go
@@ -27,6 +27,8 @@
 	Close() error
 
 	// ResolveOperation replays a retained mutation until its final result or ctx deadline.
+	// A retained committing poll or fetch returns FetchProgressUncertain without I/O and
+	// stays blocked until explicit AbandonOperation. Its ID is not a replay receipt.
 	// The client keeps the last outcomes that its replays proved, as many as the operation
 	// limit of tcp.WithPendingOperations, also across a session reset. So a later call for
 	// the same operation returns that outcome without a request. ierror.OperationNotRetained
@@ -141,45 +143,43 @@
 		messages []IggyMessage,
 	) (*SendMessagesResponse, error)
 
-	// PollMessages poll given amount of messages using the specified consumer and strategy from the specified stream and topic by unique IDs or names.
-	// Authentication is required, and the permission to poll the messages.
+	// PollMessages reads one partition with the limits and the commit policy
+	// of options. The server waits up to options.MaxWait for options.MinCount
+	// messages and returns at most options.Count messages and options.MaxBytes
+	// of records. A first message larger than MaxBytes comes alone in the
+	// reply. A Last poll of more than one message whose first message is
+	// larger than MaxBytes fails with ierror.ErrInvalidSizeBytes. The call
+	// ends after options.RequestTimeout or the deadline of ctx.
 	//
-	// Clustered polls use persistent partition-primary connections while the
-	// coordinator retains group membership. Only explicit refusal before
-	// admission is retried. ErrTransientNotCommitted, or cancellation after
-	// sending an auto-commit poll, can mean the offset advanced without a reply.
-	// These polls are never replayed automatically after an unknown outcome.
-	// Servers must support primary routing and consumer-session attachment.
+	// With CommitOnRead the server stores the offset of the last message it
+	// returns as part of the read. When ctx is cancelled, the SDK cancels the
+	// poll on the server and waits for its one reply: refused with
+	// ierror.ErrTransientNotAccepted when the cancel won and nothing was
+	// stored, or with what it read and stored. A reply that the connection
+	// loses, or an error reply that does not prove that nothing was stored,
+	// ends the call with ierror.FetchProgressUncertain: the server can have
+	// stored the offset. The next poll of the partition that stores its
+	// offset and every offset store or delete of it then fail with
+	// ierror.PartitionLaneBlocked, also after a reconnect, until
+	// AbandonOperation. The next Next poll starts after what was stored.
+	// CommitAuto is refused with ierror.ErrInvalidConfiguration, because one
+	// poll has nothing to count. A partition reader counts and stores it.
 	//
-	// Without a caller-supplied partition context, a poll stamps the context
-	// of a cached route. After another client deleted and recreated the
-	// partition, one such poll can fail with ErrHistoryUnavailable, or with
-	// ErrConsumerGroupPartitionNotOwned for a consumer group that names the
-	// partition. The SDK drops the failed route and does not retry the poll.
-	// The next call routes again.
-	//
-	// A group poll that names no partition is orchestrated client-side and
-	// has three outcomes:
-	//   - err == nil with PartitionId == NoAssignedPartition and an empty
-	//     batch: nothing to read right now, poll again. This covers a member
-	//     that owns no partition and a rebalance still settling.
-	//   - ErrConsumerGroupMemberNotFound: the client is not a member, for
-	//     example after an explicit LeaveConsumerGroup. JoinConsumerGroup
-	//     restores membership.
-	//   - err == nil with a real partition id: the poll completed; the batch may be empty.
+	// A continuation of another partition, from PolledMessage.Continuation,
+	// is refused with ierror.ErrInvalidCommand. The client holds at most 64
+	// deferred polls at once, and a call waits for a free slot within its
+	// deadline.
 	//
 	// Uncompressed payloads and user headers alias the reply buffer; copy
-	// those bytes to retain only the message you need. S2 decompression
-	// allocates a separate payload buffer.
+	// those bytes to retain only the message you need.
 	PollMessages(
 		ctx context.Context,
 		streamId Identifier,
 		topicId Identifier,
+		partitionId uint32,
 		consumer Consumer,
 		strategy PollingStrategy,
-		count uint32,
-		autoCommit bool,
-		partitionId *uint32,
+		options PollOptions,
 	) (*PolledMessage, error)
 
 	// StoreConsumerOffset store the consumer offset for a specific consumer or consumer group for the given stream and topic by unique IDs or names.
@@ -241,6 +241,8 @@
 		partitionId *uint32,
 	) error
 
+	DeleteConsumerPosition(ctx context.Context, consumer Consumer, streamId Identifier, topicId Identifier, position ConsumerPosition) error
+
 	// GetConsumerGroup get the info about a specific consumer group by unique ID or name for the given stream and topic by unique IDs or names.
 	// Authentication is required, and the permission to read the streams or topics.
 	GetConsumerGroup(
diff --git a/foreign/go/contracts/deferred_polling.go b/foreign/go/contracts/deferred_polling.go
new file mode 100644
index 0000000..5ca93b5
--- /dev/null
+++ b/foreign/go/contracts/deferred_polling.go
@@ -0,0 +1,158 @@
+// 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.
+
+package iggcon
+
+import (
+	"time"
+
+	ierror "github.com/apache/iggy/foreign/go/errors"
+	"github.com/apache/iggy/foreign/go/internal/batch"
+)
+
+const (
+	// MaxDeferredPollWait is the longest wait that the protocol carries. A
+	// server can admit less.
+	MaxDeferredPollWait = 600 * time.Second
+	// MaxDeferredPollTimeout leaves 30 seconds after the longest wait for the
+	// final read and the reply.
+	MaxDeferredPollTimeout = MaxDeferredPollWait + 30*time.Second
+	// MinDeferredPollReplyMargin is how much a request timeout must exceed a
+	// positive wait. The server needs it for the final read and the reply.
+	MinDeferredPollReplyMargin = time.Second
+	// MinDeferredPollMaxBytes holds the reply prefix (partition id, current
+	// offset, message count and partition context) and one batch of one
+	// empty message, the least room that a message needs.
+	MinDeferredPollMaxBytes = 16 + PartitionContextSize + batch.HeaderSize + batch.MessageHeaderSize
+	// MaxPartitionsPerRequest is the most partitions that one fetch names.
+	MaxPartitionsPerRequest = 1000
+)
+
+// DeferredPollOptions are the limits of a deferred poll and a fetch. The server holds a deferred poll until MinCount messages are ready,
+// until MaxBytes stops it from selecting more, or until MaxWait passes. Then
+// it reads what it has, possibly nothing. RequestTimeout bounds the whole
+// call with its retries, and a call that reaches it fails instead of
+// returning an empty reply.
+type DeferredPollOptions struct {
+	MaxWait  time.Duration
+	MinCount uint32
+	// MaxBytes bounds the reply body, its prefix and batch framing included.
+	MaxBytes       uint32
+	RequestTimeout time.Duration
+}
+
+// DefaultDeferredPollOptions wait up to one second for one message, read up
+// to 1 MiB and give the call 11 seconds.
+func DefaultDeferredPollOptions() DeferredPollOptions {
+	return DeferredPollOptions{
+		MaxWait:        time.Second,
+		MinCount:       1,
+		MaxBytes:       1024 * 1024,
+		RequestTimeout: 11 * time.Second,
+	}
+}
+
+// Validate refuses the options that the server refuses, before anything is
+// sent. count is the message count of the poll.
+func (o DeferredPollOptions) Validate(count uint32) error {
+	if o.MinCount == 0 || o.MinCount > count {
+		return ierror.ErrInvalidMessagesCount
+	}
+	if err := o.ValidateWait(); err != nil {
+		return err
+	}
+	if o.MaxBytes < MinDeferredPollMaxBytes {
+		return ierror.ErrInvalidSizeBytes
+	}
+	return nil
+}
+
+// ValidateFetch checks the initial limits of a fetch. Even a zero-wait fetch
+// needs one second for the server's read and reply, so the initial timeout must
+// exceed it so that local setup can consume time without extending the call.
+func (o DeferredPollOptions) ValidateFetch(count uint32) error {
+	if err := o.Validate(count); err != nil {
+		return err
+	}
+	if o.RequestTimeout.Truncate(time.Microsecond) <= MinDeferredPollReplyMargin {
+		return ierror.ErrInvalidCommand
+	}
+	return nil
+}
+
+// ValidateWait refuses a MaxWait and a RequestTimeout that the server
+// refuses. Other limits are checked by Validate and ValidateFetch.
+func (o DeferredPollOptions) ValidateWait() error {
+	// The server checks the reply margin on the microseconds that the wire carries.
+	wait, timeout := o.MaxWait.Truncate(time.Microsecond), o.RequestTimeout.Truncate(time.Microsecond)
+	if o.MaxWait < 0 || o.MaxWait > MaxDeferredPollWait ||
+		o.RequestTimeout < time.Microsecond || o.RequestTimeout > MaxDeferredPollTimeout ||
+		wait > 0 && timeout < wait+MinDeferredPollReplyMargin {
+		return ierror.ErrInvalidCommand
+	}
+	return nil
+}
+
+// Remaining is what is left of the options after elapsed, so that the
+// retries and the steps of one call share one budget. MaxWait and
+// RequestTimeout shrink by elapsed rounded up to whole microseconds, and
+// MaxWait stops at zero. The wire carries whole microseconds, so both lose
+// the same number of them, and the reply margin that ValidateWait checks
+// holds. Once the budget is spent, Remaining returns
+// ierror.ErrTransientNotAccepted, because a request that is not sent is not
+// accepted.
+func (o DeferredPollOptions) Remaining(elapsed time.Duration) (DeferredPollOptions, error) {
+	elapsed = (elapsed + time.Microsecond - time.Nanosecond).Truncate(time.Microsecond)
+	o.RequestTimeout -= elapsed
+	if o.RequestTimeout < time.Microsecond {
+		return DeferredPollOptions{}, ierror.ErrTransientNotAccepted
+	}
+	o.MaxWait = max(o.MaxWait-elapsed, 0)
+	return o, nil
+}
+
+// FetchMessagesTarget is one partition of a fetch and the position that it
+// reads from, which must be Next or Offset. A Strategy.Context fences the
+// read to that incarnation and owner of the partition. Without one, the
+// fetch takes the incarnation from the route of the partition.
+type FetchMessagesTarget struct {
+	PartitionId uint32
+	Strategy    PollingStrategy
+}
+
+// FetchMessagesSection is the outcome of one target of a fetch. Err is the
+// refusal of its partition, such as ierror.ErrNotPrimary after a move, and a
+// refused section carries no position and no records: the target keeps the
+// position that it was sent with. The records of a served section stay
+// encoded, and binaryserialization.DeserializeSectionMessages decodes them.
+// They alias the reply buffer.
+type FetchMessagesSection struct {
+	PartitionId   uint32
+	Err           error
+	CurrentOffset uint64
+	MessageCount  uint32
+	// Context is the partition history that served the section. The next
+	// fetch of the partition fences its target with it.
+	Context PartitionContext
+	// NextOffset is where the next fetch of the partition starts, also after
+	// an empty section.
+	NextOffset uint64
+	Records    []byte
+	// Compression is the message compression of the client that fetched the
+	// section, which the decode of Records undoes.
+	Compression IggyMessageCompression
+}
diff --git a/foreign/go/contracts/deferred_polling_test.go b/foreign/go/contracts/deferred_polling_test.go
new file mode 100644
index 0000000..cfec4e0
--- /dev/null
+++ b/foreign/go/contracts/deferred_polling_test.go
@@ -0,0 +1,194 @@
+// 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.
+
+package iggcon
+
+import (
+	"errors"
+	"testing"
+	"time"
+
+	ierror "github.com/apache/iggy/foreign/go/errors"
+)
+
+func TestDefaultDeferredPollOptions_MatchTheRustDefaults(t *testing.T) {
+	options := DefaultDeferredPollOptions()
+
+	want := DeferredPollOptions{MaxWait: time.Second, MinCount: 1, MaxBytes: 1 << 20, RequestTimeout: 11 * time.Second}
+	if options != want {
+		t.Fatalf("defaults = %+v, want %+v", options, want)
+	}
+	if err := options.Validate(1); err != nil {
+		t.Fatalf("the defaults must be valid for a poll of one message: %v", err)
+	}
+}
+
+// The server reads the smallest byte limit as the reply prefix, one batch
+// header and one message header, 344 bytes.
+func TestMinDeferredPollMaxBytes_MatchesTheServer(t *testing.T) {
+	if MinDeferredPollMaxBytes != 344 {
+		t.Fatalf("MinDeferredPollMaxBytes = %d, want 344", MinDeferredPollMaxBytes)
+	}
+}
+
+func TestDeferredPollOptions_ValidateRefusesWhatTheServerRefuses(t *testing.T) {
+	valid := DefaultDeferredPollOptions()
+	with := func(change func(*DeferredPollOptions)) DeferredPollOptions {
+		options := valid
+		change(&options)
+		return options
+	}
+	tests := []struct {
+		name    string
+		options DeferredPollOptions
+		count   uint32
+		want    error
+	}{
+		{"min count equal to the count", with(func(o *DeferredPollOptions) { o.MinCount = 10 }), 10, nil},
+		{"zero min count", with(func(o *DeferredPollOptions) { o.MinCount = 0 }), 10, ierror.ErrInvalidMessagesCount},
+		{"min count over the count", with(func(o *DeferredPollOptions) { o.MinCount = 11 }), 10, ierror.ErrInvalidMessagesCount},
+		{"longest wait and timeout", with(func(o *DeferredPollOptions) {
+			o.MaxWait, o.RequestTimeout = MaxDeferredPollWait, MaxDeferredPollTimeout
+		}), 1, nil},
+		{"wait over the longest", with(func(o *DeferredPollOptions) {
+			o.MaxWait, o.RequestTimeout = MaxDeferredPollWait+time.Microsecond, MaxDeferredPollTimeout
+		}), 1, ierror.ErrInvalidCommand},
+		{"negative wait", with(func(o *DeferredPollOptions) { o.MaxWait = -time.Microsecond }), 1, ierror.ErrInvalidCommand},
+		{"timeout under one microsecond", with(func(o *DeferredPollOptions) {
+			o.MaxWait, o.RequestTimeout = 0, time.Microsecond-time.Nanosecond
+		}), 1, ierror.ErrInvalidCommand},
+		{"timeout over the longest", with(func(o *DeferredPollOptions) {
+			o.RequestTimeout = MaxDeferredPollTimeout + time.Microsecond
+		}), 1, ierror.ErrInvalidCommand},
+		{"zero wait without a margin", with(func(o *DeferredPollOptions) {
+			o.MaxWait, o.RequestTimeout = 0, time.Microsecond
+		}), 1, nil},
+		{"exact reply margin", with(func(o *DeferredPollOptions) {
+			o.MaxWait, o.RequestTimeout = time.Second, 2*time.Second
+		}), 1, nil},
+		{"short reply margin", with(func(o *DeferredPollOptions) {
+			o.MaxWait, o.RequestTimeout = time.Second, 2*time.Second-time.Microsecond
+		}), 1, ierror.ErrInvalidCommand},
+		// The wire carries microseconds, so the margin holds on what is sent.
+		{"margin on the sent microseconds", with(func(o *DeferredPollOptions) {
+			o.MaxWait, o.RequestTimeout = time.Second+999*time.Nanosecond, 2*time.Second
+		}), 1, nil},
+		{"byte limit at the least", with(func(o *DeferredPollOptions) { o.MaxBytes = MinDeferredPollMaxBytes }), 1, nil},
+		{"byte limit under the least", with(func(o *DeferredPollOptions) { o.MaxBytes = MinDeferredPollMaxBytes - 1 }), 1, ierror.ErrInvalidSizeBytes},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := tt.options.Validate(tt.count)
+			if tt.want == nil && err != nil {
+				t.Fatalf("Validate refused valid options: %v", err)
+			}
+			if tt.want != nil && !errors.Is(err, tt.want) {
+				t.Fatalf("Validate = %v, want %v", err, tt.want)
+			}
+		})
+	}
+}
+
+func TestDeferredPollOptions_RemainingSharesOneBudget(t *testing.T) {
+	options := DeferredPollOptions{MaxWait: time.Second, MinCount: 1, MaxBytes: 1 << 20, RequestTimeout: 11 * time.Second}
+
+	partly, err := options.Remaining(300 * time.Millisecond)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if partly.MaxWait != 700*time.Millisecond || partly.RequestTimeout != 10700*time.Millisecond {
+		t.Fatalf("after 300 ms: wait %v, timeout %v, want 700ms and 10.7s", partly.MaxWait, partly.RequestTimeout)
+	}
+	if partly.MinCount != options.MinCount || partly.MaxBytes != options.MaxBytes {
+		t.Fatalf("Remaining must keep the count and byte limits, got %+v", partly)
+	}
+	if err := partly.ValidateWait(); err != nil {
+		t.Fatalf("Remaining must keep the reply margin: %v", err)
+	}
+
+	waited, err := options.Remaining(2 * time.Second)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if waited.MaxWait != 0 || waited.RequestTimeout != 9*time.Second {
+		t.Fatalf("after the full wait: wait %v, timeout %v, want 0 and 9s", waited.MaxWait, waited.RequestTimeout)
+	}
+
+	last, err := options.Remaining(11*time.Second - time.Microsecond)
+	if err != nil || last.RequestTimeout != time.Microsecond {
+		t.Fatalf("one microsecond before the deadline: %+v, %v", last, err)
+	}
+	if _, err := options.Remaining(11 * time.Second); !errors.Is(err, ierror.ErrTransientNotAccepted) {
+		t.Fatalf("a spent budget = %v, want %v", err, ierror.ErrTransientNotAccepted)
+	}
+}
+
+func TestDeferredPollOptions_ValidateFetchKeepsRoomForLocalSetup(t *testing.T) {
+	for _, tt := range []struct {
+		name    string
+		wait    time.Duration
+		timeout time.Duration
+		want    error
+	}{
+		{"below the reply margin", 0, time.Second - time.Microsecond, ierror.ErrInvalidCommand},
+		{"exactly the reply margin", 0, time.Second, ierror.ErrInvalidCommand},
+		{"fraction rounded down to the reply margin", 0, time.Second + 999*time.Nanosecond, ierror.ErrInvalidCommand},
+		{"one wire microsecond above the reply margin", 0, time.Second + time.Microsecond, nil},
+		{"positive wait without its reply margin", time.Second, 1500 * time.Millisecond, ierror.ErrInvalidCommand},
+		{"positive wait with its reply margin", time.Second, 2 * time.Second, nil},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+			options := DefaultDeferredPollOptions()
+			options.MaxWait, options.RequestTimeout = tt.wait, tt.timeout
+			if err := options.ValidateFetch(1); !errors.Is(err, tt.want) {
+				t.Fatalf("ValidateFetch = %v, want %v", err, tt.want)
+			}
+			if tt.wait == 0 {
+				if err := options.Validate(1); err != nil {
+					t.Fatalf("an immediate deferred poll must keep accepting this timeout: %v", err)
+				}
+				if err := options.ValidateWait(); err != nil {
+					t.Fatalf("the shared wait validation must keep accepting this timeout: %v", err)
+				}
+			}
+		})
+	}
+}
+
+// Valid limits that are not whole microseconds keep the reply margin that the
+// wire carries after any elapsed time, so a later step of the call is not
+// refused.
+func TestDeferredPollOptions_RemainingKeepsTheReplyMarginOfTheSentMicroseconds(t *testing.T) {
+	options := DeferredPollOptions{MaxWait: time.Second + 999*time.Nanosecond, MinCount: 1, MaxBytes: 1 << 20, RequestTimeout: 2 * time.Second}
+	if err := options.Validate(1); err != nil {
+		t.Fatalf("the options must be valid: %v", err)
+	}
+
+	for _, elapsed := range []time.Duration{0, time.Nanosecond, 500 * time.Nanosecond, time.Microsecond, 1500 * time.Nanosecond, time.Millisecond} {
+		remaining, err := options.Remaining(elapsed)
+		if err != nil {
+			t.Fatalf("after %v: %v", elapsed, err)
+		}
+		if err := remaining.ValidateWait(); err != nil {
+			t.Fatalf("after %v: wait %v and timeout %v lost the reply margin: %v",
+				elapsed, remaining.MaxWait, remaining.RequestTimeout, err)
+		}
+		if spent := options.RequestTimeout - remaining.RequestTimeout; spent < elapsed || spent >= elapsed+time.Microsecond {
+			t.Fatalf("after %v: the budget shrank by %v, want %v rounded up to whole microseconds", elapsed, spent, elapsed)
+		}
+	}
+}
diff --git a/foreign/go/contracts/message_polling.go b/foreign/go/contracts/message_polling.go
index 4137489..3125a8f 100644
--- a/foreign/go/contracts/message_polling.go
+++ b/foreign/go/contracts/message_polling.go
@@ -17,6 +17,8 @@
 
 package iggcon
 
+import ierror "github.com/apache/iggy/foreign/go/errors"
+
 type PollingStrategy struct {
 	Kind  MessagePolling
 	Value uint64
@@ -24,6 +26,26 @@
 	// polled under. A stale context refuses the poll instead of reading that
 	// offset in another incarnation. Nil takes the context the route reports.
 	Context *PartitionContext
+	// partition binds a continuation to the partition that it continues.
+	partition *uint32
+}
+
+// Partition is the partition of a continuation, which a poll or a seek of
+// another partition refuses.
+func (s PollingStrategy) Partition() (uint32, bool) {
+	if s.partition == nil {
+		return 0, false
+	}
+	return *s.partition, true
+}
+
+// RequirePartition refuses a continuation of another partition with
+// ierror.ErrInvalidCommand.
+func (s PollingStrategy) RequirePartition(partition uint32) error {
+	if s.partition != nil && *s.partition != partition {
+		return ierror.ErrInvalidCommand
+	}
+	return nil
 }
 
 // WithContext continues at an offset under the incarnation and owner that
diff --git a/foreign/go/contracts/poll_options.go b/foreign/go/contracts/poll_options.go
new file mode 100644
index 0000000..d85bd1e
--- /dev/null
+++ b/foreign/go/contracts/poll_options.go
@@ -0,0 +1,180 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package iggcon
+
+import (
+	"time"
+
+	ierror "github.com/apache/iggy/foreign/go/errors"
+	"github.com/apache/iggy/foreign/go/internal/batch"
+)
+
+const (
+	// DefaultPollCount is the most messages of one batch unless the options say otherwise.
+	DefaultPollCount = 1000
+	// PollRequestTimeoutMargin is how much longer than its wait a poll may take.
+	PollRequestTimeoutMargin = 10 * time.Second
+	// MaxRecordSize is the largest record of one message in a read reply, with
+	// its batch and message headers. A read returns a first record up to this
+	// size even above its MaxBytes.
+	MaxRecordSize             = batch.HeaderSize + batch.MessageHeaderSize + 64_000_000 + 100_000
+	defaultAutoCommitInterval = 5 * time.Second
+	defaultAutoCommitMessages = 10_000
+)
+
+// CommitPolicyKind says who stores the offsets of what a read returns.
+type CommitPolicyKind uint8
+
+const (
+	// CommitManual stores nothing. The application commits checkpoints.
+	CommitManual CommitPolicyKind = iota
+	// CommitOnRead makes the server store the offset of the last message of
+	// each read as part of the read, in one round trip. While the primary
+	// stays up a message is delivered at most once. A failover before the
+	// store replicates can deliver the last batch again.
+	CommitOnRead
+	// CommitAuto makes the client store the latest counted position, at
+	// least once with CommitAfterProcessing. A poll of one partition refuses
+	// it, because it has nothing to count. A reader and a consumer accept it.
+	CommitAuto
+)
+
+// CommitAfter is when a message counts under CommitAuto.
+type CommitAfter uint8
+
+const (
+	// CommitAfterProcessing counts a message once the application asks for
+	// the next one, returns from its handler with success, or closes the
+	// reader. A handler that fails leaves its message uncounted.
+	CommitAfterProcessing CommitAfter = iota
+	// CommitAfterDelivery counts a message when it is handed over.
+	CommitAfterDelivery
+	// CommitAfterMarked counts only marked checkpoints, so messages processed
+	// concurrently count once they are done.
+	CommitAfterMarked
+)
+
+// CommitEveryKind is the event on which CommitAuto also stores.
+type CommitEveryKind uint8
+
+const (
+	// CommitEveryNever stores only at the interval and on close.
+	CommitEveryNever CommitEveryKind = iota
+	// CommitEveryMessage stores on each counted message, or each counted batch of a reader.
+	CommitEveryMessage
+	// CommitEveryMessages stores once Messages messages of a partition counted since its last store.
+	CommitEveryMessages
+	// CommitEveryBatch stores on the last message of each batch.
+	CommitEveryBatch
+)
+
+// CommitEvery is the event of AutoCommit.
+type CommitEvery struct {
+	Kind     CommitEveryKind
+	Messages uint32
+}
+
+// AutoCommit says when CommitAuto stores. It needs an Interval or an Every
+// event. It also stores on close, on shutdown and before a rebalance takes a
+// partition away. A store only moves forward, and a position of an older
+// generation of its partition, after a seek or a new assignment, is dropped.
+type AutoCommit struct {
+	// Interval stores the latest counted position this often, when it moved. Zero is none.
+	Interval time.Duration
+	Every    CommitEvery
+	After    CommitAfter
+}
+
+// DefaultAutoCommit stores every 10,000 messages or 5 s, whichever comes
+// first, after CommitAfterProcessing.
+func DefaultAutoCommit() AutoCommit {
+	return AutoCommit{
+		Interval: defaultAutoCommitInterval,
+		Every:    CommitEvery{Kind: CommitEveryMessages, Messages: defaultAutoCommitMessages},
+		After:    CommitAfterProcessing,
+	}
+}
+
+// CommitPolicy is who stores the offsets of what a read returns. Auto holds
+// the settings of CommitAuto.
+type CommitPolicy struct {
+	Kind CommitPolicyKind
+	Auto AutoCommit
+}
+
+// ManualCommit stores nothing, the default.
+func ManualCommit() CommitPolicy { return CommitPolicy{Kind: CommitManual} }
+
+// OnReadCommit makes the server store the offset of each read.
+func OnReadCommit() CommitPolicy { return CommitPolicy{Kind: CommitOnRead} }
+
+// AutoCommitPolicy makes the client store counted positions as auto says.
+func AutoCommitPolicy(auto AutoCommit) CommitPolicy {
+	return CommitPolicy{Kind: CommitAuto, Auto: auto}
+}
+
+// PollOptions are the limits of one read and who stores its offsets: a poll
+// of one partition, or a fetch of partitions of one primary that a reader
+// sends, whose sections share the limits. MaxWait zero answers at once. A first message larger than MaxBytes
+// comes alone in the reply, up to MaxRecordSize.
+type PollOptions struct {
+	// Count is the most messages of one batch, at least 1.
+	Count uint32
+	// MaxWait is at most MaxDeferredPollWait.
+	MaxWait time.Duration
+	// MinCount is 1 to Count.
+	MinCount uint32
+	// MaxBytes is at least MinDeferredPollMaxBytes.
+	MaxBytes uint32
+	Commit   CommitPolicy
+}
+
+// DefaultPollOptions read up to 1000 messages and 1 MiB at once and store nothing.
+func DefaultPollOptions() PollOptions {
+	return PollOptions{Count: DefaultPollCount, MinCount: 1, MaxBytes: DefaultDeferredPollOptions().MaxBytes}
+}
+
+// WaitPollOptions are the defaults with a wait of up to maxWait.
+func WaitPollOptions(maxWait time.Duration) PollOptions {
+	options := DefaultPollOptions()
+	options.MaxWait = maxWait
+	return options
+}
+
+// RequestTimeout is MaxWait plus PollRequestTimeoutMargin.
+func (o PollOptions) RequestTimeout() time.Duration {
+	return o.MaxWait + PollRequestTimeoutMargin
+}
+
+// Deferred are the wire limits of the options.
+func (o PollOptions) Deferred() DeferredPollOptions {
+	return DeferredPollOptions{MaxWait: o.MaxWait, MinCount: o.MinCount, MaxBytes: o.MaxBytes, RequestTimeout: o.RequestTimeout()}
+}
+
+// Validate refuses what the server refuses, and an automatic commit without
+// an interval or an event, which would store only on close.
+func (o PollOptions) Validate() error {
+	if o.Commit.Kind == CommitAuto {
+		auto := o.Commit.Auto
+		if (auto.Interval <= 0 && auto.Every.Kind == CommitEveryNever) ||
+			(auto.Every.Kind == CommitEveryMessages && auto.Every.Messages == 0) {
+			return ierror.ErrInvalidConfiguration
+		}
+	}
+	return o.Deferred().Validate(o.Count)
+}
diff --git a/foreign/go/contracts/poll_options_test.go b/foreign/go/contracts/poll_options_test.go
new file mode 100644
index 0000000..fe74973
--- /dev/null
+++ b/foreign/go/contracts/poll_options_test.go
@@ -0,0 +1,91 @@
+// 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.
+
+package iggcon
+
+import (
+	"testing"
+	"time"
+
+	ierror "github.com/apache/iggy/foreign/go/errors"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestPollOptions_DefaultsAnswerAtOnceAndStoreNothing(t *testing.T) {
+	options := DefaultPollOptions()
+
+	assert.Equal(t, PollOptions{Count: 1000, MinCount: 1, MaxBytes: 1 << 20, Commit: ManualCommit()}, options)
+	assert.Equal(t, 10*time.Second, options.RequestTimeout())
+	require.NoError(t, options.Validate())
+	assert.Equal(t, AutoCommit{Interval: 5 * time.Second, Every: CommitEvery{Kind: CommitEveryMessages, Messages: 10_000},
+		After: CommitAfterProcessing}, DefaultAutoCommit())
+}
+
+func TestPollOptions_RefusesAnAutomaticCommitThatStoresOnlyOnClose(t *testing.T) {
+	options := DefaultPollOptions()
+	options.Commit = AutoCommitPolicy(AutoCommit{After: CommitAfterDelivery})
+
+	require.ErrorIs(t, options.Validate(), ierror.ErrInvalidConfiguration)
+
+	options.Commit = AutoCommitPolicy(AutoCommit{Interval: time.Second})
+	require.NoError(t, options.Validate())
+	options.Commit = AutoCommitPolicy(AutoCommit{Every: CommitEvery{Kind: CommitEveryMessages}})
+	require.ErrorIs(t, options.Validate(), ierror.ErrInvalidConfiguration)
+}
+
+func TestPollOptions_RefusesLimitsThatTheServerRefuses(t *testing.T) {
+	for name, change := range map[string]func(*PollOptions){
+		"no count":          func(o *PollOptions) { o.Count = 0 },
+		"min above count":   func(o *PollOptions) { o.MinCount = o.Count + 1 },
+		"wait too long":     func(o *PollOptions) { o.MaxWait = MaxDeferredPollWait + time.Second },
+		"too few max bytes": func(o *PollOptions) { o.MaxBytes = 1 },
+	} {
+		t.Run(name, func(t *testing.T) {
+			options := DefaultPollOptions()
+			change(&options)
+			require.Error(t, options.Validate())
+		})
+	}
+}
+
+func TestPolledMessage_BindsItsCheckpointAndContinuationToItsPartition(t *testing.T) {
+	context := PartitionContext{Incarnation: 3, OwnerGeneration: 4, MetadataOp: 5}
+	polled := &PolledMessage{PartitionId: 2, Context: context, Messages: []IggyMessage{
+		{Header: MessageHeader{Offset: 7}}, {Header: MessageHeader{Offset: 8}},
+	}}
+
+	last, ok := polled.Checkpoint()
+	require.True(t, ok)
+	assert.Equal(t, []any{uint32(2), uint64(8), context}, []any{last.PartitionID(), last.Offset(), last.Context()})
+	first, ok := polled.CheckpointAt(0)
+	require.True(t, ok)
+	assert.Equal(t, uint64(7), first.Offset())
+	_, ok = polled.CheckpointAt(2)
+	assert.False(t, ok)
+	continuation, ok := polled.Continuation()
+	require.True(t, ok)
+	assert.Equal(t, uint64(9), continuation.Value)
+	assert.Equal(t, POLLING_OFFSET, continuation.Kind)
+	require.NoError(t, continuation.RequirePartition(2))
+	require.ErrorIs(t, continuation.RequirePartition(3), ierror.ErrInvalidCommand)
+	bound, ok := continuation.WithContext(PartitionContext{}).Partition()
+	assert.True(t, ok)
+	assert.Equal(t, uint32(2), bound)
+	_, ok = (&PolledMessage{PartitionId: 2}).Continuation()
+	assert.False(t, ok)
+}
diff --git a/foreign/go/contracts/testdata/deferred_polling.json b/foreign/go/contracts/testdata/deferred_polling.json
new file mode 100644
index 0000000..452b461
--- /dev/null
+++ b/foreign/go/contracts/testdata/deferred_polling.json
@@ -0,0 +1,610 @@
+{
+  "schema_version": 1,
+  "generated_by": "core/common/examples/deferred_polling_fixtures.rs",
+  "conventions": {
+    "body_hex": "Every *_hex field is lowercase hex without separators. body_hex is the command payload without framing.",
+    "header_hex": "header_hex is the whole request header of a cancellation, which has no body, before the transport stamps its checksum and checksum_body.",
+    "oversized": "A reply may carry a first record above max_bytes, up to max_record_size, so a client reply cap is at least max_record_reply_size.",
+    "refused": "Every refused fetch fails to decode or to validate. Every refused fetch reply fails to decode.",
+    "status": "A section status of status_ok was read. Any other status is the error code of that partition alone, and its section holds no records.",
+    "strategy": "strategy.kind is the wire kind: 1 offset, 2 timestamp, 3 first, 4 last, 5 next."
+  },
+  "constants": {
+    "codes": {
+      "cancel_deferred_request": 108,
+      "fetch_messages": 109,
+      "poll_messages": 100,
+      "poll_messages_deferred": 106
+    },
+    "status_ok": 0,
+    "errors": {
+      "deferred_poll_capacity_exceeded": 90,
+      "fetch_progress_uncertain": 14010,
+      "history_unavailable": 87,
+      "invalid_size_bytes": 80,
+      "not_primary": 89,
+      "partition_lane_blocked": 14009,
+      "too_many_consumer_offsets": 3024,
+      "transient_not_accepted": 58,
+      "transient_not_committed": 57
+    },
+    "min_deferred_poll_max_bytes": 344,
+    "max_deferred_poll_wait_us": 600000000,
+    "max_deferred_poll_timeout_us": 630000000,
+    "min_deferred_poll_reply_margin_us": 1000000,
+    "fetch_reply_margin_us": 200000,
+    "max_partitions_per_request": 1000,
+    "max_record_size": 64100304,
+    "max_record_reply_size": 64156564,
+    "request_header_size": 256,
+    "fetch_section_header_size": 56
+  },
+  "deferred_polls": [
+    {
+      "case": "consumer_offset_without_commit",
+      "request": {
+        "consumer": {
+          "kind": 1,
+          "id": {
+            "kind": "numeric",
+            "value": 1
+          }
+        },
+        "stream_id": {
+          "kind": "numeric",
+          "value": 1
+        },
+        "topic_id": {
+          "kind": "named",
+          "value": "orders"
+        },
+        "partition_id": 2,
+        "strategy": {
+          "kind": 1,
+          "value": 41
+        },
+        "count": 100,
+        "auto_commit": false,
+        "wait_us": 5000000,
+        "min_count": 1,
+        "max_bytes": 1048576,
+        "request_timeout_us": 6000000
+      },
+      "body_hex": "0101040100000001040100000002066f726465727301020000000129000000000000006400000000404b4c00000000000100000000001000808d5b0000000000"
+    },
+    {
+      "case": "group_next_with_commit",
+      "request": {
+        "consumer": {
+          "kind": 2,
+          "id": {
+            "kind": "named",
+            "value": "orders-group"
+          }
+        },
+        "stream_id": {
+          "kind": "numeric",
+          "value": 1
+        },
+        "topic_id": {
+          "kind": "named",
+          "value": "orders"
+        },
+        "partition_id": 2,
+        "strategy": {
+          "kind": 5,
+          "value": 0
+        },
+        "count": 100,
+        "auto_commit": true,
+        "wait_us": 5000000,
+        "min_count": 1,
+        "max_bytes": 1048576,
+        "request_timeout_us": 6000000
+      },
+      "body_hex": "02020c6f72646572732d67726f757001040100000002066f726465727301020000000500000000000000006400000001404b4c00000000000100000000001000808d5b0000000000"
+    }
+  ],
+  "fetches": [
+    {
+      "case": "consumer_two_targets_without_commit",
+      "request": {
+        "consumer": {
+          "kind": 1,
+          "id": {
+            "kind": "numeric",
+            "value": 1
+          }
+        },
+        "stream_id": {
+          "kind": "numeric",
+          "value": 1
+        },
+        "topic_id": {
+          "kind": "numeric",
+          "value": 2
+        },
+        "wait_us": 5000000,
+        "request_timeout_us": 6000000,
+        "min_count": 1,
+        "count": 1000,
+        "max_bytes": 1048576,
+        "auto_commit": false,
+        "targets": [
+          {
+            "partition_id": 1,
+            "strategy": {
+              "kind": 1,
+              "value": 41
+            },
+            "max_bytes": 524288,
+            "incarnation": 11,
+            "owner_generation": 0
+          },
+          {
+            "partition_id": 3,
+            "strategy": {
+              "kind": 5,
+              "value": 0
+            },
+            "max_bytes": 524288,
+            "incarnation": 11,
+            "owner_generation": 0
+          }
+        ]
+      },
+      "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b000000000001000000e803000000001000000200000001000000012900000000000000000008000b00000000000000000000000000000003000000050000000000000000000008000b000000000000000000000000000000"
+    },
+    {
+      "case": "group_three_targets_with_commit",
+      "request": {
+        "consumer": {
+          "kind": 2,
+          "id": {
+            "kind": "numeric",
+            "value": 9
+          }
+        },
+        "stream_id": {
+          "kind": "numeric",
+          "value": 1
+        },
+        "topic_id": {
+          "kind": "numeric",
+          "value": 2
+        },
+        "wait_us": 5000000,
+        "request_timeout_us": 6000000,
+        "min_count": 1,
+        "count": 1000,
+        "max_bytes": 1048576,
+        "auto_commit": true,
+        "targets": [
+          {
+            "partition_id": 1,
+            "strategy": {
+              "kind": 5,
+              "value": 0
+            },
+            "max_bytes": 524288,
+            "incarnation": 11,
+            "owner_generation": 4
+          },
+          {
+            "partition_id": 2,
+            "strategy": {
+              "kind": 5,
+              "value": 0
+            },
+            "max_bytes": 524288,
+            "incarnation": 11,
+            "owner_generation": 4
+          },
+          {
+            "partition_id": 4,
+            "strategy": {
+              "kind": 1,
+              "value": 7
+            },
+            "max_bytes": 524288,
+            "incarnation": 11,
+            "owner_generation": 4
+          }
+        ]
+      },
+      "body_hex": "02010409000000010401000000010402000000404b4c0000000000808d5b000000000001000000e803000000001000010300000001000000050000000000000000000008000b00000000000000040000000000000002000000050000000000000000000008000b00000000000000040000000000000004000000010700000000000000000008000b000000000000000400000000000000"
+    }
+  ],
+  "cancellations": [
+    {
+      "case": "cancel_deferred_poll",
+      "cancels_code": 106,
+      "client": 7,
+      "request": 21,
+      "session": 3,
+      "header_hex": "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000050000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007000000000000000000000000000000000000000000000000000000000000000000000000000000150000000000000002000000000000000300000000000000000000006c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
+    },
+    {
+      "case": "cancel_fetch",
+      "cancels_code": 109,
+      "client": 7,
+      "request": 22,
+      "session": 3,
+      "header_hex": "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000050000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007000000000000000000000000000000000000000000000000000000000000000000000000000000160000000000000002000000000000000300000000000000000000006c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
+    }
+  ],
+  "fetch_replies": [
+    {
+      "case": "ok_section_and_empty_start_section",
+      "sections": [
+        {
+          "partition_id": 1,
+          "status": 0,
+          "current_offset": 44,
+          "messages_count": 3,
+          "context": {
+            "incarnation": 11,
+            "owner_generation": 4,
+            "metadata_op": 42
+          },
+          "next_offset": 44,
+          "records_len": 416,
+          "messages": [
+            {
+              "offset": 41,
+              "id": "258",
+              "timestamp": 1700000000000500,
+              "origin_timestamp": 1700000000000000,
+              "payload": "first"
+            },
+            {
+              "offset": 42,
+              "id": "259",
+              "timestamp": 1700000000000500,
+              "origin_timestamp": 1700000000000000,
+              "payload": "second"
+            },
+            {
+              "offset": 43,
+              "id": "260",
+              "timestamp": 1700000000000500,
+              "origin_timestamp": 1700000000000000,
+              "payload": "third"
+            }
+          ]
+        },
+        {
+          "partition_id": 3,
+          "status": 0,
+          "current_offset": 9,
+          "messages_count": 0,
+          "context": {
+            "incarnation": 11,
+            "owner_generation": 4,
+            "metadata_op": 42
+          },
+          "next_offset": 10,
+          "records_len": 0,
+          "messages": []
+        }
+      ],
+      "body_hex": "0200000001000000000000002c00000000000000030000000b0000000000000004000000000000002a000000000000002c00000000000000a001000003000000000000000900000000000000000000000b0000000000000004000000000000002a000000000000000a000000000000000000000001000000000000002900000000000000f4411e18240a060000401e18240a0600a00100000000000088fc3268f2c0e02103000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000bb5b1cd43b71761a02010000000000000000000000000000000000000000000000000000050000000000000000000000666972737453c25c376f98c265030100000000000000000000000000000100000000000000000000000600000000000000000000007365636f6e64a761c18f632fb022040100000000000000000000000000000200000000000000000000000500000000000000000000007468697264"
+    },
+    {
+      "case": "ok_section_beside_error_sections",
+      "sections": [
+        {
+          "partition_id": 1,
+          "status": 89,
+          "current_offset": 0,
+          "messages_count": 0,
+          "context": {
+            "incarnation": 0,
+            "owner_generation": 0,
+            "metadata_op": 0
+          },
+          "next_offset": 0,
+          "records_len": 0,
+          "messages": []
+        },
+        {
+          "partition_id": 2,
+          "status": 0,
+          "current_offset": 7,
+          "messages_count": 1,
+          "context": {
+            "incarnation": 11,
+            "owner_generation": 4,
+            "metadata_op": 42
+          },
+          "next_offset": 7,
+          "records_len": 308,
+          "messages": [
+            {
+              "offset": 6,
+              "id": "340282366920938463463374607431768211455",
+              "timestamp": 1700000000000500,
+              "origin_timestamp": 1700000000000000,
+              "payload": "only"
+            }
+          ]
+        },
+        {
+          "partition_id": 4,
+          "status": 58,
+          "current_offset": 0,
+          "messages_count": 0,
+          "context": {
+            "incarnation": 0,
+            "owner_generation": 0,
+            "metadata_op": 0
+          },
+          "next_offset": 0,
+          "records_len": 0,
+          "messages": []
+        }
+      ],
+      "body_hex": "03000000010000005900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000700000000000000010000000b0000000000000004000000000000002a00000000000000070000000000000034010000040000003a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000600000000000000f4411e18240a060000401e18240a060034010000000000003e89bfefc17b805301000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000fbb4aeb35a85bdaeffffffffffffffffffffffffffffffff0000000000000000000000000400000000000000000000006f6e6c79"
+    },
+    {
+      "case": "no_sections",
+      "sections": [],
+      "body_hex": "00000000"
+    }
+  ],
+  "refused": {
+    "fetches": [
+      {
+        "case": "no_targets",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b000000000001000000e8030000000010000000000000"
+      },
+      {
+        "case": "duplicate_partition",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b000000000001000000e803000000001000000200000001000000010000000000000000000008000b00000000000000000000000000000001000000050000000000000000000008000b000000000000000000000000000000"
+      },
+      {
+        "case": "first_start_point",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b000000000001000000e803000000001000000100000001000000030000000000000000000008000b000000000000000000000000000000"
+      },
+      {
+        "case": "min_count_above_count",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b0000000000e9030000e803000000001000000100000001000000010000000000000000000008000b000000000000000000000000000000"
+      },
+      {
+        "case": "target_bytes_above_request_bytes",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b000000000001000000e803000000001000000100000001000000010000000000000000010010000b000000000000000000000000000000"
+      },
+      {
+        "case": "timeout_without_reply_margin",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000404b4c000000000001000000e803000000001000000100000001000000010000000000000000000008000b000000000000000000000000000000"
+      }
+    ],
+    "fetch_replies": [
+      {
+        "case": "error_section_with_records",
+        "body_hex": "01000000010000005900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000"
+      },
+      {
+        "case": "truncated_table",
+        "body_hex": "02000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
+      }
+    ]
+  },
+  "commit_outcomes": {
+    "rule": "A committing deferred poll (106) or fetch (109) that fails with a certain status stored nothing. Every other status, a status not listed here, a lost reply and a transport failure after the send leave the outcome unknown.",
+    "request": [
+      {
+        "status": 5,
+        "error": "feature_unavailable",
+        "certain": true
+      },
+      {
+        "status": 6,
+        "error": "invalid_identifier",
+        "certain": true
+      },
+      {
+        "status": 30,
+        "error": "stale_client",
+        "certain": false
+      },
+      {
+        "status": 40,
+        "error": "unauthenticated",
+        "certain": false
+      },
+      {
+        "status": 41,
+        "error": "unauthorized",
+        "certain": false
+      },
+      {
+        "status": 57,
+        "error": "transient_not_committed",
+        "certain": false
+      },
+      {
+        "status": 58,
+        "error": "transient_not_accepted",
+        "certain": true
+      },
+      {
+        "status": 72,
+        "error": "cannot_decrypt_data",
+        "certain": false
+      },
+      {
+        "status": 80,
+        "error": "invalid_size_bytes",
+        "certain": true
+      },
+      {
+        "status": 87,
+        "error": "history_unavailable",
+        "certain": true
+      },
+      {
+        "status": 89,
+        "error": "not_primary",
+        "certain": true
+      },
+      {
+        "status": 90,
+        "error": "deferred_poll_capacity_exceeded",
+        "certain": true
+      },
+      {
+        "status": 1009,
+        "error": "stream_id_not_found",
+        "certain": true
+      },
+      {
+        "status": 1010,
+        "error": "stream_name_not_found",
+        "certain": true
+      },
+      {
+        "status": 2010,
+        "error": "topic_id_not_found",
+        "certain": true
+      },
+      {
+        "status": 2011,
+        "error": "topic_name_not_found",
+        "certain": true
+      },
+      {
+        "status": 3007,
+        "error": "partition_not_found",
+        "certain": true
+      },
+      {
+        "status": 3024,
+        "error": "too_many_consumer_offsets",
+        "certain": true
+      },
+      {
+        "status": 4100,
+        "error": "invalid_offset",
+        "certain": true
+      },
+      {
+        "status": 5009,
+        "error": "consumer_group_partition_not_owned",
+        "certain": true
+      },
+      {
+        "status": 11001,
+        "error": "shard_communication_error",
+        "certain": false
+      }
+    ],
+    "section": [
+      {
+        "status": 5,
+        "error": "feature_unavailable",
+        "certain": false
+      },
+      {
+        "status": 6,
+        "error": "invalid_identifier",
+        "certain": false
+      },
+      {
+        "status": 30,
+        "error": "stale_client",
+        "certain": false
+      },
+      {
+        "status": 40,
+        "error": "unauthenticated",
+        "certain": false
+      },
+      {
+        "status": 41,
+        "error": "unauthorized",
+        "certain": false
+      },
+      {
+        "status": 57,
+        "error": "transient_not_committed",
+        "certain": false
+      },
+      {
+        "status": 58,
+        "error": "transient_not_accepted",
+        "certain": true
+      },
+      {
+        "status": 72,
+        "error": "cannot_decrypt_data",
+        "certain": false
+      },
+      {
+        "status": 80,
+        "error": "invalid_size_bytes",
+        "certain": true
+      },
+      {
+        "status": 87,
+        "error": "history_unavailable",
+        "certain": true
+      },
+      {
+        "status": 89,
+        "error": "not_primary",
+        "certain": true
+      },
+      {
+        "status": 90,
+        "error": "deferred_poll_capacity_exceeded",
+        "certain": false
+      },
+      {
+        "status": 1009,
+        "error": "stream_id_not_found",
+        "certain": false
+      },
+      {
+        "status": 1010,
+        "error": "stream_name_not_found",
+        "certain": false
+      },
+      {
+        "status": 2010,
+        "error": "topic_id_not_found",
+        "certain": false
+      },
+      {
+        "status": 2011,
+        "error": "topic_name_not_found",
+        "certain": false
+      },
+      {
+        "status": 3007,
+        "error": "partition_not_found",
+        "certain": true
+      },
+      {
+        "status": 3024,
+        "error": "too_many_consumer_offsets",
+        "certain": true
+      },
+      {
+        "status": 4100,
+        "error": "invalid_offset",
+        "certain": false
+      },
+      {
+        "status": 5009,
+        "error": "consumer_group_partition_not_owned",
+        "certain": true
+      },
+      {
+        "status": 11001,
+        "error": "shard_communication_error",
+        "certain": false
+      }
+    ]
+  }
+}
diff --git a/foreign/go/contracts/testdata/resource_options.json b/foreign/go/contracts/testdata/resource_options.json
index c5cc2ac..202888f 100644
--- a/foreign/go/contracts/testdata/resource_options.json
+++ b/foreign/go/contracts/testdata/resource_options.json
@@ -16,18 +16,18 @@
   },
   "constants": {
     "protocol_version": {
-      "packed": 12288,
-      "text": "0.12.0",
+      "packed": 12289,
+      "text": "0.12.1",
       "major": 0,
       "minor": 12,
-      "patch": 0
+      "patch": 1
     },
     "protocol_version_min": {
-      "packed": 12288,
-      "text": "0.12.0",
+      "packed": 12289,
+      "text": "0.12.1",
       "major": 0,
       "minor": 12,
-      "patch": 0
+      "patch": 1
     },
     "command_codes": {
       "create_stream": {
diff --git a/foreign/go/contracts/version.go b/foreign/go/contracts/version.go
index c22f3ea..2345522 100644
--- a/foreign/go/contracts/version.go
+++ b/foreign/go/contracts/version.go
@@ -17,4 +17,4 @@
 
 package iggcon
 
-const Version = "0.9.1-edge.5"
+const Version = "0.9.1-edge.6"
diff --git a/foreign/go/errors/errors.go b/foreign/go/errors/errors.go
index f6b541e..b653ba8 100644
--- a/foreign/go/errors/errors.go
+++ b/foreign/go/errors/errors.go
@@ -22,3 +22,9 @@
 import "errors"
 
 var ErrNilContext = errors.New("nil context")
+
+// Unwrap preserves generic lane-blocked recovery while a fetch also names
+// the partition whose retained operation must be resolved or abandoned.
+func (e PartitionLaneBlocked) Unwrap() error {
+	return LaneBlocked{OperationID: e.OperationID}
+}
diff --git a/foreign/go/errors/errors.yaml b/foreign/go/errors/errors.yaml
index 5ef461a..b486590 100644
--- a/foreign/go/errors/errors.yaml
+++ b/foreign/go/errors/errors.yaml
@@ -351,6 +351,10 @@
   code: 89
   format: "the replica is not the primary of the target group"
   fields: []
+- name: DeferredPollCapacityExceeded
+  code: 90
+  format: "deferred poll capacity exceeded; retry after backoff on the same replica"
+  fields: []
 - name: ClientNotFound
   code: 100
   format: "client with id: %d was not found."
@@ -1386,3 +1390,17 @@
   fields:
     - name: OperationID
       type: uint64
+- name: PartitionLaneBlocked
+  code: 14009
+  format: "partition %d lane blocked by operation %d"
+  fields:
+    - name: PartitionID
+      type: uint32
+    - name: OperationID
+      type: uint64
+- name: FetchProgressUncertain
+  code: 14010
+  format: "read %d has an unknown outcome: the server can have stored its offset"
+  fields:
+    - name: OperationID
+      type: uint64
diff --git a/foreign/go/errors/errors_gen.go b/foreign/go/errors/errors_gen.go
index c865689..a9ac16b 100644
--- a/foreign/go/errors/errors_gen.go
+++ b/foreign/go/errors/errors_gen.go
@@ -764,6 +764,17 @@
 	return ok
 }
 
+type DeferredPollCapacityExceeded struct{}
+
+func (e DeferredPollCapacityExceeded) Error() string {
+	return "deferred poll capacity exceeded; retry after backoff on the same replica"
+}
+func (e DeferredPollCapacityExceeded) Code() Code { return 90 }
+func (e DeferredPollCapacityExceeded) Is(target error) bool {
+	_, ok := target.(DeferredPollCapacityExceeded)
+	return ok
+}
+
 type ClientNotFound struct {
 	ID uint32
 }
@@ -2871,6 +2882,33 @@
 	return ok
 }
 
+type PartitionLaneBlocked struct {
+	PartitionID uint32
+	OperationID uint64
+}
+
+func (e PartitionLaneBlocked) Error() string {
+	return fmt.Sprintf("partition %d lane blocked by operation %d", e.PartitionID, e.OperationID)
+}
+func (e PartitionLaneBlocked) Code() Code { return 14009 }
+func (e PartitionLaneBlocked) Is(target error) bool {
+	_, ok := target.(PartitionLaneBlocked)
+	return ok
+}
+
+type FetchProgressUncertain struct {
+	OperationID uint64
+}
+
+func (e FetchProgressUncertain) Error() string {
+	return fmt.Sprintf("read %d has an unknown outcome: the server can have stored its offset", e.OperationID)
+}
+func (e FetchProgressUncertain) Code() Code { return 14010 }
+func (e FetchProgressUncertain) Is(target error) bool {
+	_, ok := target.(FetchProgressUncertain)
+	return ok
+}
+
 var (
 	ErrError                                      = Error{}
 	ErrInvalidConfiguration                       = InvalidConfiguration{}
@@ -2944,6 +2982,7 @@
 	ErrHistoryUnavailable                         = HistoryUnavailable{}
 	ErrLifecycleBusy                              = LifecycleBusy{}
 	ErrNotPrimary                                 = NotPrimary{}
+	ErrDeferredPollCapacityExceeded               = DeferredPollCapacityExceeded{}
 	ErrClientNotFound                             = ClientNotFound{}
 	ErrInvalidClientId                            = InvalidClientId{}
 	ErrConnectionClosed                           = ConnectionClosed{}
@@ -3130,6 +3169,8 @@
 	ErrOperationUncertain                         = OperationUncertain{}
 	ErrLaneBlocked                                = LaneBlocked{}
 	ErrOperationNotRetained                       = OperationNotRetained{}
+	ErrPartitionLaneBlocked                       = PartitionLaneBlocked{}
+	ErrFetchProgressUncertain                     = FetchProgressUncertain{}
 )
 
 type Code uint32
@@ -3207,6 +3248,7 @@
 	HistoryUnavailableCode                         Code = 87
 	LifecycleBusyCode                              Code = 88
 	NotPrimaryCode                                 Code = 89
+	DeferredPollCapacityExceededCode               Code = 90
 	ClientNotFoundCode                             Code = 100
 	InvalidClientIdCode                            Code = 101
 	ConnectionClosedCode                           Code = 206
@@ -3393,6 +3435,8 @@
 	OperationUncertainCode                         Code = 14006
 	LaneBlockedCode                                Code = 14007
 	OperationNotRetainedCode                       Code = 14008
+	PartitionLaneBlockedCode                       Code = 14009
+	FetchProgressUncertainCode                     Code = 14010
 )
 
 func (c Code) String() string {
@@ -3541,6 +3585,8 @@
 		return "LifecycleBusy"
 	case NotPrimaryCode:
 		return "NotPrimary"
+	case DeferredPollCapacityExceededCode:
+		return "DeferredPollCapacityExceeded"
 	case ClientNotFoundCode:
 		return "ClientNotFound"
 	case InvalidClientIdCode:
@@ -3913,6 +3959,10 @@
 		return "LaneBlocked"
 	case OperationNotRetainedCode:
 		return "OperationNotRetained"
+	case PartitionLaneBlockedCode:
+		return "PartitionLaneBlocked"
+	case FetchProgressUncertainCode:
+		return "FetchProgressUncertain"
 	default:
 		return "Unknown error code"
 	}
@@ -4064,6 +4114,8 @@
 		return ErrLifecycleBusy
 	case NotPrimaryCode:
 		return ErrNotPrimary
+	case DeferredPollCapacityExceededCode:
+		return ErrDeferredPollCapacityExceeded
 	case ClientNotFoundCode:
 		return ErrClientNotFound
 	case InvalidClientIdCode:
@@ -4436,6 +4488,10 @@
 		return ErrLaneBlocked
 	case OperationNotRetainedCode:
 		return ErrOperationNotRetained
+	case PartitionLaneBlockedCode:
+		return ErrPartitionLaneBlocked
+	case FetchProgressUncertainCode:
+		return ErrFetchProgressUncertain
 	default:
 		return ErrError
 	}
diff --git a/foreign/go/internal/command/code.go b/foreign/go/internal/command/code.go
index f2bb2fe..e4d2ca0 100644
--- a/foreign/go/internal/command/code.go
+++ b/foreign/go/internal/command/code.go
@@ -50,8 +50,8 @@
 	// 35, 103, 104, 123, 124, 204 and 304 are retired and must never be reused.
 	GetPartitionRouteCode     Code = 105
 	PollMessagesDeferredCode  Code = 106
-	WaitForMessagesCode       Code = 107
 	CancelDeferredRequestCode Code = 108
+	FetchMessagesCode         Code = 109
 	GetOffsetCode             Code = 120
 	StoreOffsetCode           Code = 121
 	DeleteConsumerOffsetCode  Code = 122
diff --git a/foreign/go/internal/command/deferred_polling_fixture_test.go b/foreign/go/internal/command/deferred_polling_fixture_test.go
new file mode 100644
index 0000000..6538212
--- /dev/null
+++ b/foreign/go/internal/command/deferred_polling_fixture_test.go
@@ -0,0 +1,186 @@
+// 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.
+
+package command
+
+import (
+	"encoding/hex"
+	"encoding/json"
+	"os"
+	"testing"
+	"time"
+
+	iggcon "github.com/apache/iggy/foreign/go/contracts"
+	"github.com/stretchr/testify/require"
+)
+
+// The deferred polling vectors that the Rust encoders generate, shared with
+// every SDK, so the wire here is the server's contract and not this SDK's
+// encoder run twice.
+type fixtureIdentifier struct {
+	Kind  string          `json:"kind"`
+	Value json.RawMessage `json:"value"`
+}
+
+type fixtureStrategy struct {
+	Kind  uint8  `json:"kind"`
+	Value uint64 `json:"value"`
+}
+
+type fixtureRequest struct {
+	Consumer struct {
+		Kind uint8             `json:"kind"`
+		Id   fixtureIdentifier `json:"id"`
+	} `json:"consumer"`
+	StreamId         fixtureIdentifier `json:"stream_id"`
+	TopicId          fixtureIdentifier `json:"topic_id"`
+	PartitionId      uint32            `json:"partition_id"`
+	Strategy         fixtureStrategy   `json:"strategy"`
+	Count            uint32            `json:"count"`
+	AutoCommit       bool              `json:"auto_commit"`
+	WaitUs           uint64            `json:"wait_us"`
+	MinCount         uint32            `json:"min_count"`
+	MaxBytes         uint32            `json:"max_bytes"`
+	RequestTimeoutUs uint64            `json:"request_timeout_us"`
+	Targets          []struct {
+		PartitionId     uint32          `json:"partition_id"`
+		Strategy        fixtureStrategy `json:"strategy"`
+		MaxBytes        uint32          `json:"max_bytes"`
+		Incarnation     uint64          `json:"incarnation"`
+		OwnerGeneration uint64          `json:"owner_generation"`
+	} `json:"targets"`
+}
+
+type fixtureVector struct {
+	Case    string         `json:"case"`
+	Request fixtureRequest `json:"request"`
+	BodyHex string         `json:"body_hex"`
+}
+
+func loadRequestVectors(t *testing.T) (polls, fetches []fixtureVector) {
+	t.Helper()
+	data, err := os.ReadFile("../../contracts/testdata/deferred_polling.json")
+	require.NoError(t, err)
+	var fixture struct {
+		DeferredPolls []fixtureVector `json:"deferred_polls"`
+		Fetches       []fixtureVector `json:"fetches"`
+	}
+	require.NoError(t, json.Unmarshal(data, &fixture))
+	require.NotEmpty(t, fixture.DeferredPolls)
+	require.NotEmpty(t, fixture.Fetches)
+	return fixture.DeferredPolls, fixture.Fetches
+}
+
+func (i fixtureIdentifier) identifier(t *testing.T) iggcon.Identifier {
+	t.Helper()
+	var id iggcon.Identifier
+	var err error
+	if i.Kind == "numeric" {
+		var value uint32
+		require.NoError(t, json.Unmarshal(i.Value, &value))
+		id, err = iggcon.NewIdentifier(value)
+	} else {
+		var value string
+		require.NoError(t, json.Unmarshal(i.Value, &value))
+		id, err = iggcon.NewIdentifier(value)
+	}
+	require.NoError(t, err)
+	return id
+}
+
+func (r fixtureRequest) consumer(t *testing.T) iggcon.Consumer {
+	id := r.Consumer.Id.identifier(t)
+	if r.Consumer.Kind == 2 {
+		return iggcon.NewGroupConsumer(id)
+	}
+	return iggcon.NewSingleConsumer(id)
+}
+
+func (r fixtureRequest) options() iggcon.DeferredPollOptions {
+	return iggcon.DeferredPollOptions{
+		MaxWait:        time.Duration(r.WaitUs) * time.Microsecond,
+		MinCount:       r.MinCount,
+		MaxBytes:       r.MaxBytes,
+		RequestTimeout: time.Duration(r.RequestTimeoutUs) * time.Microsecond,
+	}
+}
+
+func (s fixtureStrategy) strategy() iggcon.PollingStrategy {
+	return iggcon.NewPollingStrategy(iggcon.MessagePolling(s.Kind), s.Value)
+}
+
+func TestFixture_EncodesEachDeferredPollOfTheSharedVectors(t *testing.T) {
+	polls, _ := loadRequestVectors(t)
+	for _, vector := range polls {
+		t.Run(vector.Case, func(t *testing.T) {
+			request := vector.Request
+			partition := request.PartitionId
+			options := request.options()
+			poll := PollMessages{
+				StreamId:    request.StreamId.identifier(t),
+				TopicId:     request.TopicId.identifier(t),
+				Consumer:    request.consumer(t),
+				PartitionId: &partition,
+				Strategy:    request.Strategy.strategy(),
+				Count:       request.Count,
+				AutoCommit:  request.AutoCommit,
+				Deferred:    &options,
+			}
+
+			body, err := poll.MarshalBinary()
+
+			require.NoError(t, err)
+			require.Equal(t, PollMessagesDeferredCode, poll.Code())
+			require.Equal(t, vector.BodyHex, hex.EncodeToString(body))
+		})
+	}
+}
+
+func TestFixture_EncodesEachFetchOfTheSharedVectors(t *testing.T) {
+	_, fetches := loadRequestVectors(t)
+	for _, vector := range fetches {
+		t.Run(vector.Case, func(t *testing.T) {
+			request := vector.Request
+			targets := make([]FetchTarget, 0, len(request.Targets))
+			for _, target := range request.Targets {
+				fence := iggcon.PartitionContext{Incarnation: target.Incarnation, OwnerGeneration: target.OwnerGeneration}
+				targets = append(targets, FetchTarget{
+					FetchMessagesTarget: iggcon.FetchMessagesTarget{
+						PartitionId: target.PartitionId,
+						Strategy:    target.Strategy.strategy().WithContext(fence),
+					},
+					MaxBytes: target.MaxBytes,
+				})
+			}
+			fetch := FetchMessages{
+				StreamId:   request.StreamId.identifier(t),
+				TopicId:    request.TopicId.identifier(t),
+				Consumer:   request.consumer(t),
+				Targets:    targets,
+				Count:      request.Count,
+				AutoCommit: request.AutoCommit,
+				Options:    request.options(),
+			}
+
+			body, err := fetch.MarshalBinary()
+
+			require.NoError(t, err)
+			require.Equal(t, FetchMessagesCode, fetch.Code())
+			require.Equal(t, vector.BodyHex, hex.EncodeToString(body))
+		})
+	}
+}
diff --git a/foreign/go/internal/command/message.go b/foreign/go/internal/command/message.go
index 4b995d8..698bd2e 100644
--- a/foreign/go/internal/command/message.go
+++ b/foreign/go/internal/command/message.go
@@ -23,6 +23,7 @@
 	"fmt"
 	"math"
 	"math/rand/v2"
+	"time"
 
 	"github.com/apache/iggy/foreign/go/contracts"
 	"github.com/apache/iggy/foreign/go/internal/batch"
@@ -176,22 +177,21 @@
 	Strategy    iggcon.PollingStrategy `json:"pollingStrategy"`
 	Count       uint32                 `json:"count"`
 	AutoCommit  bool                   `json:"autoCommit"`
+	// Deferred makes the poll a deferred poll, which the server holds until
+	// its limits are met.
+	Deferred *iggcon.DeferredPollOptions `json:"deferred"`
 }
 
 func (m *PollMessages) Code() Code {
+	if m.Deferred != nil {
+		return PollMessagesDeferredCode
+	}
 	return PollMessagesCode
 }
 
 func (m *PollMessages) AppendBinary(b []byte) ([]byte, error) {
-	b = append(b, byte(m.Consumer.Kind))
-	var err error
-	if b, err = m.Consumer.Id.AppendBinary(b); err != nil {
-		return nil, err
-	}
-	if b, err = m.StreamId.AppendBinary(b); err != nil {
-		return nil, err
-	}
-	if b, err = m.TopicId.AppendBinary(b); err != nil {
+	b, err := appendPollTarget(b, m.Consumer, m.StreamId, m.TopicId)
+	if err != nil {
 		return nil, err
 	}
 	if m.PartitionId != nil {
@@ -208,9 +208,98 @@
 	} else {
 		b = append(b, 0)
 	}
+	if m.Deferred != nil {
+		b = binary.LittleEndian.AppendUint64(b, Micros(m.Deferred.MaxWait))
+		b = binary.LittleEndian.AppendUint32(b, m.Deferred.MinCount)
+		b = binary.LittleEndian.AppendUint32(b, m.Deferred.MaxBytes)
+		b = binary.LittleEndian.AppendUint64(b, Micros(m.Deferred.RequestTimeout))
+	}
 	return b, nil
 }
 
 func (m *PollMessages) MarshalBinary() ([]byte, error) {
 	return m.AppendBinary(nil)
 }
+
+// FetchMessages reads the targets of one primary in one deferred request.
+// Each target carries its own cap and partition fence, so the request header
+// carries no partition context.
+type FetchMessages struct {
+	StreamId   iggcon.Identifier
+	TopicId    iggcon.Identifier
+	Consumer   iggcon.Consumer
+	Targets    []FetchTarget
+	Count      uint32
+	AutoCommit bool
+	Options    iggcon.DeferredPollOptions
+}
+
+// FetchTarget is a fetch target with its cap, the most record bytes that its
+// section holds. Its fence is Strategy.Context, zero without one.
+type FetchTarget struct {
+	iggcon.FetchMessagesTarget
+	MaxBytes uint32
+}
+
+func (m *FetchMessages) Code() Code {
+	return FetchMessagesCode
+}
+
+func (m *FetchMessages) AppendBinary(b []byte) ([]byte, error) {
+	b, err := appendPollTarget(b, m.Consumer, m.StreamId, m.TopicId)
+	if err != nil {
+		return nil, err
+	}
+	b = binary.LittleEndian.AppendUint64(b, Micros(m.Options.MaxWait))
+	b = binary.LittleEndian.AppendUint64(b, Micros(m.Options.RequestTimeout))
+	b = binary.LittleEndian.AppendUint32(b, m.Options.MinCount)
+	b = binary.LittleEndian.AppendUint32(b, m.Count)
+	b = binary.LittleEndian.AppendUint32(b, m.Options.MaxBytes)
+	if m.AutoCommit {
+		b = append(b, 1)
+	} else {
+		b = append(b, 0)
+	}
+	b = binary.LittleEndian.AppendUint32(b, uint32(len(m.Targets)))
+	for _, target := range m.Targets {
+		var fence iggcon.PartitionContext
+		if target.Strategy.Context != nil {
+			fence = *target.Strategy.Context
+		}
+		b = binary.LittleEndian.AppendUint32(b, target.PartitionId)
+		b = append(b, byte(target.Strategy.Kind))
+		b = binary.LittleEndian.AppendUint64(b, target.Strategy.Value)
+		b = binary.LittleEndian.AppendUint32(b, target.MaxBytes)
+		b = binary.LittleEndian.AppendUint64(b, fence.Incarnation)
+		b = binary.LittleEndian.AppendUint64(b, fence.OwnerGeneration)
+	}
+	return b, nil
+}
+
+func (m *FetchMessages) MarshalBinary() ([]byte, error) {
+	return m.AppendBinary(nil)
+}
+
+// appendPollTarget writes the consumer, the stream and the topic that start a
+// poll and a fetch.
+func appendPollTarget(b []byte, consumer iggcon.Consumer, streamId, topicId iggcon.Identifier) ([]byte, error) {
+	b = append(b, byte(consumer.Kind))
+	var err error
+	if b, err = consumer.Id.AppendBinary(b); err != nil {
+		return nil, err
+	}
+	if b, err = streamId.AppendBinary(b); err != nil {
+		return nil, err
+	}
+	if b, err = topicId.AppendBinary(b); err != nil {
+		return nil, err
+	}
+	return b, nil
+}
+
+// Micros is a duration of deferred poll options as the wire carries it. The
+// options are validated before they are encoded, so the duration is never
+// negative.
+func Micros(duration time.Duration) uint64 {
+	return uint64(duration / time.Microsecond)
+}
diff --git a/foreign/go/internal/command/message_test.go b/foreign/go/internal/command/message_test.go
index 5554932..caa5fda 100644
--- a/foreign/go/internal/command/message_test.go
+++ b/foreign/go/internal/command/message_test.go
@@ -93,6 +93,56 @@
 	return true
 }
 
+// Golden vectors generated by the Rust encoder for consumer group 7, stream 1,
+// topic "orders", partition 3, the Next strategy, count 100, auto commit and
+// the default deferred poll options. The fetch vectors are in the shared
+// fixture, see deferred_polling_fixture_test.go.
+const (
+	goldenPollBody         = "0201040700000001040100000002066f726465727301030000000500000000000000006400000001"
+	goldenDeferredPollBody = "0201040700000001040100000002066f72646572730103000000050000000000000000640000000140420f00000000000100000000001000c0d8a70000000000"
+)
+
+func TestSerialize_PollsMatchTheGoldenVectors(t *testing.T) {
+	groupId, _ := iggcon.NewIdentifier(uint32(7))
+	streamId, _ := iggcon.NewIdentifier(uint32(1))
+	topicId, _ := iggcon.NewIdentifier("orders")
+	partitionId := uint32(3)
+	options := iggcon.DefaultDeferredPollOptions()
+	poll := PollMessages{
+		StreamId:    streamId,
+		TopicId:     topicId,
+		Consumer:    iggcon.NewGroupConsumer(groupId),
+		PartitionId: &partitionId,
+		Strategy:    iggcon.NextPollingStrategy(),
+		Count:       100,
+		AutoCommit:  true,
+	}
+	deferred := poll
+	deferred.Deferred = &options
+	for _, tc := range []struct {
+		name    string
+		request Command
+		code    Code
+		golden  string
+	}{
+		{name: "poll", request: &poll, code: PollMessagesCode, golden: goldenPollBody},
+		{name: "deferred poll", request: &deferred, code: PollMessagesDeferredCode, golden: goldenDeferredPollBody},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			serialized, err := tc.request.MarshalBinary()
+			if err != nil {
+				t.Fatal(err)
+			}
+			if code := tc.request.Code(); code != tc.code {
+				t.Errorf("the request carries code %d, want %d", code, tc.code)
+			}
+			if got := hex.EncodeToString(serialized); got != tc.golden {
+				t.Errorf("the body diverges from the golden vector.\nExpected:\t%s\nGot:\t\t%s", tc.golden, got)
+			}
+		})
+	}
+}
+
 // Golden vectors generated by the Rust encoder for stream 1, topic 2,
 // balanced partitioning, and two messages: {id 7, origin timestamp 1000,
 // payload "first-payload"} and {id 8, origin timestamp 1050, payload
diff --git a/foreign/go/internal/vsr/deferred_polling_fixture_test.go b/foreign/go/internal/vsr/deferred_polling_fixture_test.go
new file mode 100644
index 0000000..9486eeb
--- /dev/null
+++ b/foreign/go/internal/vsr/deferred_polling_fixture_test.go
@@ -0,0 +1,64 @@
+// 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.
+
+package vsr
+
+import (
+	"encoding/hex"
+	"encoding/json"
+	"os"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+// The cancellations of the shared deferred polling vectors: a header without a
+// body that names the client, session and request of the cancelled request.
+func TestFixture_BuildsEachCancellationOfTheSharedVectors(t *testing.T) {
+	data, err := os.ReadFile("../../contracts/testdata/deferred_polling.json")
+	require.NoError(t, err)
+	var fixture struct {
+		Cancellations []struct {
+			Case        string `json:"case"`
+			CancelsCode uint32 `json:"cancels_code"`
+			Client      uint64 `json:"client"`
+			Request     uint64 `json:"request"`
+			Session     uint64 `json:"session"`
+			HeaderHex   string `json:"header_hex"`
+		} `json:"cancellations"`
+	}
+	require.NoError(t, json.Unmarshal(data, &fixture))
+	require.NotEmpty(t, fixture.Cancellations)
+	for _, vector := range fixture.Cancellations {
+		t.Run(vector.Case, func(t *testing.T) {
+			request := make([]byte, HeaderSize)
+			EncodeRequestHeader((*[HeaderSize]byte)(request), RequestFields{
+				Size:              HeaderSize,
+				Client:            ClientID{Lo: vector.Client},
+				Request:           vector.Request,
+				Operation:         OperationNonReplicated,
+				Session:           vector.Session,
+				NonReplicatedCode: vector.CancelsCode,
+			})
+
+			cancellation, deferred := DeferredCancellation(request)
+
+			require.True(t, deferred)
+			require.Equal(t, vector.HeaderHex, hex.EncodeToString(cancellation))
+		})
+	}
+}
diff --git a/foreign/go/internal/vsr/envelope.go b/foreign/go/internal/vsr/envelope.go
index 8ace3a8..adfb26f 100644
--- a/foreign/go/internal/vsr/envelope.go
+++ b/foreign/go/internal/vsr/envelope.go
@@ -21,6 +21,7 @@
 	"crypto/rand"
 	"encoding/binary"
 	ierror "github.com/apache/iggy/foreign/go/errors"
+	"github.com/apache/iggy/foreign/go/internal/command"
 )
 
 // EncodeRequest returns the complete request frame for a command code and its
@@ -98,3 +99,28 @@
 	}
 	return nil
 }
+
+// DeferredCancellation returns the frame that cancels the deferred poll or fetch in frame: a header without a body that names the
+// same client, session and request. It must follow that request on the same
+// connection. It returns false for any other request.
+func DeferredCancellation(frame []byte) ([]byte, bool) {
+	code := binary.LittleEndian.Uint32(frame[requestOffsetReserved:])
+	if Operation(frame[requestOffsetOperation]) != OperationNonReplicated ||
+		code != uint32(command.PollMessagesDeferredCode) &&
+			code != uint32(command.FetchMessagesCode) {
+		return nil, false
+	}
+	cancellation := make([]byte, HeaderSize)
+	EncodeRequestHeader((*[HeaderSize]byte)(cancellation), RequestFields{
+		Size: HeaderSize,
+		Client: ClientID{
+			Lo: binary.LittleEndian.Uint64(frame[requestOffsetClient:]),
+			Hi: binary.LittleEndian.Uint64(frame[requestOffsetClient+8:]),
+		},
+		Request:           StampedRequestID(frame),
+		Operation:         OperationNonReplicated,
+		Session:           binary.LittleEndian.Uint64(frame[requestOffsetSession:]),
+		NonReplicatedCode: uint32(command.CancelDeferredRequestCode),
+	})
+	return cancellation, true
+}
diff --git a/foreign/go/internal/vsr/envelope_test.go b/foreign/go/internal/vsr/envelope_test.go
index 30f6279..0162512 100644
--- a/foreign/go/internal/vsr/envelope_test.go
+++ b/foreign/go/internal/vsr/envelope_test.go
@@ -252,3 +252,40 @@
 	assert.Zero(t, binary.LittleEndian.Uint32(frame[requestOffsetReserved:]))
 	assert.Zero(t, binary.LittleEndian.Uint64(frame[requestOffsetTimestamp:]))
 }
+
+func TestDeferredCancellation_NamesTheParkedRequestWithoutABody(t *testing.T) {
+	session := boundSession(t)
+	for _, code := range []command.Code{command.PollMessagesDeferredCode, command.FetchMessagesCode} {
+		frame, err := EncodeRequest(session, uint32(code), []byte{1, 2, 3})
+		require.NoError(t, err)
+		// The partition context of the request must not reach its cancellation.
+		binary.LittleEndian.PutUint64(frame[requestOffsetContext:], 17)
+
+		cancellation, ok := DeferredCancellation(frame)
+
+		require.True(t, ok, "code %d", code)
+		require.Len(t, cancellation, HeaderSize, "a cancellation has no body")
+		header := frameHeader(t, cancellation)
+		assert.Equal(t, uint32(HeaderSize), binary.LittleEndian.Uint32(header[requestOffsetSize:]))
+		assert.Equal(t, byte(FrameRequest), header[requestOffsetCommand])
+		assert.Equal(t, frame[requestOffsetClient:requestOffsetClient+16], cancellation[requestOffsetClient:requestOffsetClient+16])
+		assert.Equal(t, StampedRequestID(frame), StampedRequestID(cancellation))
+		assert.Equal(t, frame[requestOffsetSession:requestOffsetSession+8], cancellation[requestOffsetSession:requestOffsetSession+8])
+		assert.Equal(t, byte(OperationNonReplicated), header[requestOffsetOperation])
+		assert.Equal(t, uint32(command.CancelDeferredRequestCode), binary.LittleEndian.Uint32(header[requestOffsetReserved:]))
+		assert.Equal(t, make([]byte, 16), cancellation[requestOffsetChecksum:requestOffsetChecksum+16])
+		assert.Equal(t, make([]byte, HeaderSize-requestOffsetContext), cancellation[requestOffsetContext:])
+	}
+}
+
+func TestDeferredCancellation_RefusesAnyOtherRequest(t *testing.T) {
+	session := boundSession(t)
+	for _, code := range []command.Code{command.PollMessagesCode, command.CancelDeferredRequestCode, command.CreateStreamCode} {
+		frame, err := EncodeRequest(session, uint32(code), nil)
+		require.NoError(t, err)
+
+		_, ok := DeferredCancellation(frame)
+
+		assert.False(t, ok, "code %d", code)
+	}
+}
diff --git a/foreign/go/internal/vsr/register.go b/foreign/go/internal/vsr/register.go
index 6f995c3..bdc6dd5 100644
--- a/foreign/go/internal/vsr/register.go
+++ b/foreign/go/internal/vsr/register.go
@@ -25,10 +25,10 @@
 )
 
 // ProtocolVersion is the packed semver of the wire contract this codec
-// implements, 0.12.0 per core/binary_protocol/src/version.rs. Update it with
+// implements, 0.12.1 per core/binary_protocol/src/version.rs. Update it with
 // the Rust constant during stable release preparation. Edge builds sharing
 // this number are not guaranteed to have compatible layouts.
-var ProtocolVersion = packProtocolVersion(0, 12, 0)
+var ProtocolVersion = packProtocolVersion(0, 12, 1)
 
 // packProtocolVersion packs a semver into the ten-bits-per-field layout the
 // register handshake carries.
diff --git a/foreign/go/internal/vsr/register_test.go b/foreign/go/internal/vsr/register_test.go
index dd93a36..d94234d 100644
--- a/foreign/go/internal/vsr/register_test.go
+++ b/foreign/go/internal/vsr/register_test.go
@@ -36,10 +36,10 @@
 }
 
 func TestProtocolVersion_PacksTheWireContractSemver(t *testing.T) {
-	assert.Equal(t, uint32(12288), ProtocolVersion)
+	assert.Equal(t, uint32(12289), ProtocolVersion)
 	assert.Equal(t, uint32(0), ProtocolVersion>>20)
 	assert.Equal(t, uint32(12), (ProtocolVersion>>10)&0x3FF)
-	assert.Equal(t, uint32(0), ProtocolVersion&0x3FF)
+	assert.Equal(t, uint32(1), ProtocolVersion&0x3FF)
 }
 
 func TestSerializeLoginRegister_EncodesTheDeclaredLayout(t *testing.T) {
diff --git a/foreign/go/tests/e2e_deferred_test.go b/foreign/go/tests/e2e_deferred_test.go
new file mode 100644
index 0000000..a8fc0c2
--- /dev/null
+++ b/foreign/go/tests/e2e_deferred_test.go
@@ -0,0 +1,364 @@
+// 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.
+
+package tests_test
+
+import (
+	"context"
+	"errors"
+	"testing"
+	"time"
+
+	"github.com/apache/iggy/foreign/go/client/reader"
+	iggcon "github.com/apache/iggy/foreign/go/contracts"
+	ierror "github.com/apache/iggy/foreign/go/errors"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+const (
+	// longWait is far longer than any reply the cases expect, so a call that
+	// waits for it fails the prompt bound.
+	longWait = 10 * time.Second
+	// promptReply bounds a call that a message ends long before longWait.
+	promptReply = 2 * time.Second
+	// serverParkedLimit is how many deferred requests the server parks for one
+	// session on one shard.
+	serverParkedLimit = 64
+)
+
+// countOptions read up to count messages at once, and store the offset of the last one with onRead.
+func countOptions(count uint32, onRead bool) iggcon.PollOptions {
+	options := iggcon.DefaultPollOptions()
+	options.Count = count
+	if onRead {
+		options.Commit = iggcon.OnReadCommit()
+	}
+	return options
+}
+
+func longWaitOptions(count uint32) iggcon.PollOptions {
+	options := iggcon.WaitPollOptions(longWait)
+	options.Count = count
+	return options
+}
+
+// A member that owns three partitions reads the one that holds messages
+// without waiting on the idle ones: the reader reads each readiness group with
+// one request, wherever the primaries of the partitions are.
+func TestE2E_ReaderReadsThePartitionThatHoldsMessages(t *testing.T) {
+	connected := connect(t)
+	ctx := context.Background()
+	streamId, topicId := scratchTopic(t, connected, 3)
+	group := newGroup(t, connected, streamId, topicId)
+	partitionReader, err := reader.NewPartitionReader(ctx, connected, streamId, topicId, iggcon.NewGroupConsumer(group),
+		reader.WithPollOptions(longWaitOptions(10)))
+	require.NoError(t, err)
+	t.Cleanup(func() { _ = partitionReader.Close(context.Background()) })
+	require.Eventually(t, func() bool { return len(partitionReader.Partitions()) == 3 }, promptReply, 10*time.Millisecond)
+
+	for round := range 3 {
+		busy := uint32(2 - round)
+		_, err := connected.SendMessages(ctx, streamId, topicId, iggcon.PartitionId(busy), testMessages(t, 1))
+		require.NoError(t, err)
+		started := time.Now()
+		readCtx, cancel := context.WithTimeout(ctx, longWait)
+		polled, err := partitionReader.Next(readCtx)
+		cancel()
+
+		require.NoError(t, err)
+		assert.Less(t, time.Since(started), promptReply, "round %d waited on an idle partition", round)
+		assert.Equal(t, busy, polled.PartitionId, "round %d", round)
+		require.Len(t, polled.Messages, 1, "round %d", round)
+	}
+}
+
+// A partition with a backlog holds messages at every read, and a late message
+// of another partition is still read: the server allots each read target its
+// share, and the reader reads the backlog one message at a time.
+func TestE2E_ReaderFindsAPartitionBehindABacklog(t *testing.T) {
+	connected := connect(t)
+	ctx := context.Background()
+	streamId, topicId := scratchTopic(t, connected, 3)
+	const backlogSize = 50
+	_, err := connected.SendMessages(ctx, streamId, topicId, iggcon.PartitionId(0), testMessages(t, backlogSize))
+	require.NoError(t, err)
+	partitionReader, err := reader.NewPartitionReader(ctx, connected, streamId, topicId, singleConsumer(t),
+		reader.WithPartitions(0, 2), reader.WithStart(iggcon.FirstPollingStrategy()),
+		reader.WithPollOptions(longWaitOptions(1)), reader.WithPrefetchMessages(1))
+	require.NoError(t, err)
+	t.Cleanup(func() { _ = partitionReader.Close(context.Background()) })
+	first, err := partitionReader.Next(ctx)
+	require.NoError(t, err)
+	require.Equal(t, uint32(0), first.PartitionId)
+	_, err = connected.SendMessages(ctx, streamId, topicId, iggcon.PartitionId(2), testMessages(t, 1))
+	require.NoError(t, err)
+
+	late := false
+	for range backlogSize {
+		readCtx, cancel := context.WithTimeout(ctx, longWait)
+		polled, err := partitionReader.Next(readCtx)
+		cancel()
+		require.NoError(t, err)
+		if polled.PartitionId == 2 {
+			late = true
+			break
+		}
+	}
+
+	assert.True(t, late, "the late message waits behind the backlog")
+}
+
+// A group consumer that commits after processing resumes after the last
+// message its handler processed, and a message whose handler failed is read again.
+func TestE2E_ConsumerResumesAfterWhatItsHandlerProcessed(t *testing.T) {
+	connected := connect(t)
+	ctx := context.Background()
+	streamId, topicId := scratchTopic(t, connected, 3)
+	for partition := range uint32(3) {
+		_, err := connected.SendMessages(ctx, streamId, topicId, iggcon.PartitionId(partition), testMessages(t, 2))
+		require.NoError(t, err)
+	}
+	group := iggcon.NewGroupConsumer(newGroup(t, connected, streamId, topicId))
+	auto := iggcon.AutoCommit{Every: iggcon.CommitEvery{Kind: iggcon.CommitEveryMessage}, After: iggcon.CommitAfterProcessing}
+	options := iggcon.WaitPollOptions(time.Second)
+	options.Commit = iggcon.AutoCommitPolicy(auto)
+	first, err := reader.NewIggyConsumer(ctx, connected, streamId, topicId, group, reader.WithPollOptions(options))
+	require.NoError(t, err)
+	failure := errors.New("stop after four")
+	var handled []uint64
+	err = first.Consume(ctx, func(_ context.Context, message reader.ReceivedMessage) error {
+		if len(handled) == 3 {
+			return failure
+		}
+		handled = append(handled, uint64(message.PartitionID)<<32|message.Checkpoint.Offset())
+		return nil
+	})
+	require.ErrorIs(t, err, failure)
+	require.NoError(t, first.Shutdown(ctx))
+
+	second, err := reader.NewIggyConsumer(ctx, connected, streamId, topicId, group, reader.WithPollOptions(options))
+	require.NoError(t, err)
+	t.Cleanup(func() { _ = second.Shutdown(context.Background()) })
+	rest := make(map[uint64]bool)
+	for len(rest) < 3 {
+		readCtx, cancel := context.WithTimeout(ctx, longWait)
+		message, err := second.Next(readCtx)
+		cancel()
+		require.NoError(t, err)
+		rest[uint64(message.PartitionID)<<32|message.Checkpoint.Offset()] = true
+	}
+	for _, key := range handled {
+		assert.False(t, rest[key], "a processed message is not read again")
+	}
+}
+
+// A poll that stores its offset on read stores the offset of its last message.
+func TestE2E_PollWithOnReadStoresItsLastOffset(t *testing.T) {
+	connected := connect(t)
+	ctx := context.Background()
+	streamId, topicId := scratchTopic(t, connected, 1)
+	_, err := connected.SendMessages(ctx, streamId, topicId, iggcon.PartitionId(0), testMessages(t, 3))
+	require.NoError(t, err)
+	consumer := singleConsumer(t)
+	options := iggcon.DefaultPollOptions()
+	options.Count = 2
+	options.Commit = iggcon.OnReadCommit()
+
+	polled, err := connected.PollMessages(ctx, streamId, topicId, 0, consumer, iggcon.NextPollingStrategy(), options)
+
+	require.NoError(t, err)
+	require.Len(t, polled.Messages, 2)
+	partition := uint32(0)
+	offset, err := connected.GetConsumerOffset(ctx, consumer, streamId, topicId, &partition)
+	require.NoError(t, err)
+	assert.Equal(t, uint64(1), offset.StoredOffset)
+	continuation, ok := polled.Continuation()
+	require.True(t, ok)
+	_, err = connected.PollMessages(ctx, streamId, topicId, 1, consumer, continuation, iggcon.DefaultPollOptions())
+	require.ErrorIs(t, err, ierror.ErrInvalidCommand)
+}
+
+func TestE2E_DeferredPollReturnsWhenAMessageArrives(t *testing.T) {
+	connected := connect(t)
+	ctx := context.Background()
+	streamId, topicId := scratchTopic(t, connected, 1)
+	consumer := singleConsumer(t)
+	partition := uint32(0)
+	const sendDelay = 300 * time.Millisecond
+	go func() {
+		time.Sleep(sendDelay)
+		_, err := connected.SendMessages(context.Background(), streamId, topicId, iggcon.PartitionId(partition), testMessages(t, 1))
+		assert.NoError(t, err)
+	}()
+	started := time.Now()
+
+	// The parked Next poll holds the progress lane of the partition, and the
+	// send of the same client does not wait for that lane.
+	polled, err := connected.PollMessages(ctx, streamId, topicId, partition, consumer,
+		iggcon.NextPollingStrategy(), longWaitOptions(10))
+
+	require.NoError(t, err)
+	elapsed := time.Since(started)
+	assert.GreaterOrEqual(t, elapsed, sendDelay, "the poll waits for the message")
+	assert.Less(t, elapsed, promptReply, "the send of the same client wakes its parked poll")
+	require.Len(t, polled.Messages, 1)
+
+	quiet := iggcon.WaitPollOptions(sendDelay)
+	quiet.Count = 10
+	started = time.Now()
+	polled, err = connected.PollMessages(ctx, streamId, topicId, partition, consumer,
+		iggcon.OffsetPollingStrategy(1), quiet)
+	require.NoError(t, err)
+	assert.GreaterOrEqual(t, time.Since(started), quiet.MaxWait, "an idle poll waits for MaxWait")
+	assert.Empty(t, polled.Messages)
+}
+
+// The server parks a bounded number of deferred requests for a session. A
+// cancellation that did not reach it would leave those slots taken until
+// each wait passed, and the last poll would be refused until its deadline.
+func TestE2E_CancelledDeferredPollsFreeTheirServerSlots(t *testing.T) {
+	connected := connect(t)
+	ctx := context.Background()
+	streamId, topicId := scratchTopic(t, connected, 1)
+	consumer := singleConsumer(t)
+	partition := uint32(0)
+	parkedPoll := iggcon.WaitPollOptions(30 * time.Second)
+	parkedPoll.Count = 1
+	const parkFor = 50 * time.Millisecond
+	// The route lookup happens before the loop, so each poll reaches the
+	// server well before it is cancelled.
+	_, err := connected.PollMessages(ctx, streamId, topicId, partition, consumer, iggcon.OffsetPollingStrategy(0), iggcon.DefaultPollOptions())
+	require.NoError(t, err)
+
+	for range serverParkedLimit + 8 {
+		// A deadline would shorten the wait to fit it, so the poll is
+		// cancelled instead.
+		cancelled, cancel := context.WithCancel(ctx)
+		stop := time.AfterFunc(parkFor, cancel)
+		_, err := connected.PollMessages(cancelled, streamId, topicId, partition, consumer,
+			iggcon.OffsetPollingStrategy(0), parkedPoll)
+		stop.Stop()
+		cancel()
+		require.ErrorIs(t, err, ierror.ErrTransientNotCommitted, "the poll ends with its context")
+	}
+	_, err = connected.SendMessages(ctx, streamId, topicId, iggcon.PartitionId(partition), testMessages(t, 1))
+	require.NoError(t, err)
+	started := time.Now()
+
+	polled, err := connected.PollMessages(ctx, streamId, topicId, partition, consumer,
+		iggcon.OffsetPollingStrategy(0), longWaitOptions(1))
+
+	require.NoError(t, err)
+	assert.Less(t, time.Since(started), promptReply)
+	require.Len(t, polled.Messages, 1)
+}
+
+// A disconnect while a committing poll waits on the server leaves its outcome
+// unknown, so the partition takes no committing poll and no offset store until
+// the caller abandons that poll, also after a reconnect and a new sign-in.
+func TestE2E_LostCommittingPollBlocksItsPartitionUntilAbandoned(t *testing.T) {
+	connected := connect(t)
+	ctx := context.Background()
+	streamId, topicId := scratchTopic(t, connected, 1)
+	consumer := singleConsumer(t)
+	partition := uint32(0)
+	// The route lookup happens first, so the committing poll reaches the server before the disconnect.
+	_, err := connected.PollMessages(ctx, streamId, topicId, partition, consumer, iggcon.OffsetPollingStrategy(0), iggcon.DefaultPollOptions())
+	require.NoError(t, err)
+	committing := longWaitOptions(1)
+	committing.Commit = iggcon.OnReadCommit()
+	lost := make(chan error, 1)
+	go func() {
+		_, err := connected.PollMessages(ctx, streamId, topicId, partition, consumer, iggcon.NextPollingStrategy(), committing)
+		lost <- err
+	}()
+	time.Sleep(200 * time.Millisecond)
+	disconnecting, ok := connected.(interface{ Unwrap() iggcon.Client }).Unwrap().(interface{ Disconnect() error })
+	require.True(t, ok)
+
+	require.NoError(t, disconnecting.Disconnect())
+
+	var unknown ierror.FetchProgressUncertain
+	select {
+	case err := <-lost:
+		require.ErrorAs(t, err, &unknown)
+	case <-time.After(promptReply):
+		t.Fatal("the committing poll outlived the disconnect")
+	}
+	require.NoError(t, connected.Connect(ctx))
+	_, err = connected.LoginUser(ctx, rootUsername, rootPassword)
+	require.NoError(t, err)
+	_, err = connected.PollMessages(ctx, streamId, topicId, partition, consumer, iggcon.NextPollingStrategy(), countOptions(1, true))
+	var blocked ierror.PartitionLaneBlocked
+	require.ErrorAs(t, err, &blocked)
+	assert.Equal(t, unknown.OperationID, blocked.OperationID)
+	require.NoError(t, connected.AbandonOperation(ctx, unknown.OperationID))
+	_, err = connected.SendMessages(ctx, streamId, topicId, iggcon.PartitionId(partition), testMessages(t, 1))
+	require.NoError(t, err)
+	polled, err := connected.PollMessages(ctx, streamId, topicId, partition, consumer, iggcon.NextPollingStrategy(), countOptions(1, true))
+	require.NoError(t, err)
+	assert.Len(t, polled.Messages, 1)
+}
+
+// Close cancels a committing read that waits on the server and takes its one
+// reply, so it ends in about a round trip instead of after the wait.
+func TestE2E_OnReadCloseCancelsItsParkedRead(t *testing.T) {
+	connected := connect(t)
+	ctx := context.Background()
+	streamId, topicId := scratchTopic(t, connected, 1)
+	_, err := connected.SendMessages(ctx, streamId, topicId, iggcon.PartitionId(0), testMessages(t, 1))
+	require.NoError(t, err)
+	options := longWaitOptions(1)
+	options.Commit = iggcon.OnReadCommit()
+	partitionReader, err := reader.NewPartitionReader(ctx, connected, streamId, topicId, singleConsumer(t),
+		reader.WithPartitions(0), reader.WithPollOptions(options))
+	require.NoError(t, err)
+	readCtx, cancel := context.WithTimeout(ctx, promptReply)
+	_, err = partitionReader.Next(readCtx)
+	cancel()
+	require.NoError(t, err)
+	parked := make(chan error, 1)
+	go func() {
+		_, err := partitionReader.Next(context.Background())
+		parked <- err
+	}()
+	time.Sleep(300 * time.Millisecond)
+	started := time.Now()
+
+	require.NoError(t, partitionReader.Close(ctx))
+
+	assert.Less(t, time.Since(started), promptReply, "the close waited out the parked read")
+	require.ErrorIs(t, <-parked, ierror.ErrClientShutdown)
+}
+
+// newGroup creates a consumer group on the topic, which a reader or a consumer then joins.
+func newGroup(t *testing.T, connected iggcon.Client, streamId, topicId iggcon.Identifier) iggcon.Identifier {
+	t.Helper()
+	group, err := connected.CreateConsumerGroup(context.Background(), streamId, topicId, "deferred-readers")
+	require.NoError(t, err)
+	id, err := iggcon.NewIdentifier(group.Id)
+	require.NoError(t, err)
+	return id
+}
+
+func singleConsumer(t *testing.T) iggcon.Consumer {
+	t.Helper()
+	id, err := iggcon.NewIdentifier(uint32(1))
+	require.NoError(t, err)
+	return iggcon.NewSingleConsumer(id)
+}
diff --git a/foreign/go/tests/e2e_resource_options_test.go b/foreign/go/tests/e2e_resource_options_test.go
index 10f87a0..3922a2b 100644
--- a/foreign/go/tests/e2e_resource_options_test.go
+++ b/foreign/go/tests/e2e_resource_options_test.go
@@ -120,8 +120,8 @@
 		assert.Equal(t, created.EncryptionKekFingerprints(), renamed.EncryptionKekFingerprints())
 
 		partitionId := uint32(0)
-		polled, err := connected.PollMessages(ctx, streamId, topicId,
-			iggcon.DefaultConsumer(), iggcon.OffsetPollingStrategy(0), 1, false, &partitionId)
+		polled, err := connected.PollMessages(ctx, streamId, topicId, partitionId,
+			iggcon.DefaultConsumer(), iggcon.OffsetPollingStrategy(0), countOptions(1, false))
 		require.NoError(t, err)
 		require.Len(t, polled.Messages, 1)
 		assert.Equal(t, payload, polled.Messages[0].Payload)
diff --git a/foreign/go/tests/e2e_test.go b/foreign/go/tests/e2e_test.go
index b68244b..91991b6 100644
--- a/foreign/go/tests/e2e_test.go
+++ b/foreign/go/tests/e2e_test.go
@@ -63,8 +63,8 @@
 	}
 
 	partitionId := uint32(0)
-	polled, err := connected.PollMessages(ctx, streamId, topicId,
-		iggcon.DefaultConsumer(), iggcon.OffsetPollingStrategy(0), 12, false, &partitionId)
+	polled, err := connected.PollMessages(ctx, streamId, topicId, partitionId,
+		iggcon.DefaultConsumer(), iggcon.OffsetPollingStrategy(0), countOptions(12, false))
 	require.NoError(t, err)
 	assert.Len(t, polled.Messages, 12)
 	assert.Equal(t, uint32(0), polled.PartitionId)
@@ -167,14 +167,13 @@
 		require.NoError(t, err)
 	}
 
-	// A group poll without an explicit partition round-robins the partitions
-	// the member owns, so polling once per partition covers every one of them.
+	// The member polls each partition that it owns, and the server stores what it reads.
 	polledPartitions := make(map[uint32]bool)
-	for range len(assignment.Partitions) {
-		polled, err := connected.PollMessages(ctx, streamId, topicId,
-			iggcon.NewGroupConsumer(groupId), iggcon.NextPollingStrategy(), 2, true, nil)
+	for _, partitionId := range assignment.Partitions {
+		polled, err := connected.PollMessages(ctx, streamId, topicId, partitionId,
+			iggcon.NewGroupConsumer(groupId), iggcon.NextPollingStrategy(), countOptions(2, true))
 		require.NoError(t, err)
-		require.NotEqual(t, iggcon.NoAssignedPartition, polled.PartitionId)
+		require.Len(t, polled.Messages, 2)
 		polledPartitions[polled.PartitionId] = true
 	}
 	assert.Len(t, polledPartitions, len(assignment.Partitions),
@@ -221,8 +220,9 @@
 	require.NoError(t, err)
 	require.Equal(t, uint32(1), beforeClient.ConsumerGroupsCount)
 	counts := make(map[uint32]int)
-	for range int(details.PartitionsCount) * messagesPerPartition {
-		polled, err := connected.PollMessages(ctx, stream, topic, consumer, iggcon.NextPollingStrategy(), 1, true, nil)
+	for index := range int(details.PartitionsCount) * messagesPerPartition {
+		partition := uint32(index) % details.PartitionsCount
+		polled, err := connected.PollMessages(ctx, stream, topic, partition, consumer, iggcon.NextPollingStrategy(), countOptions(1, true))
 		require.NoError(t, err)
 		require.Len(t, polled.Messages, 1)
 		counts[polled.PartitionId]++
@@ -234,10 +234,10 @@
 			return err == nil && offset != nil && offset.StoredOffset == uint64(messagesPerPartition-1)
 		}, 5*time.Second, 50*time.Millisecond,
 			"partition %d auto-commit must replicate to the coordinator's backup", partition)
-		polled, err := connected.PollMessages(ctx, stream, topic, consumer, iggcon.NextPollingStrategy(), 1, true, &partition)
+		polled, err := connected.PollMessages(ctx, stream, topic, partition, consumer, iggcon.NextPollingStrategy(), countOptions(1, true))
 		require.NoError(t, err)
 		assert.Empty(t, polled.Messages, "the group auto-commit advanced on the primary")
-		polled, err = connected.PollMessages(ctx, stream, topic, iggcon.DefaultConsumer(), iggcon.OffsetPollingStrategy(0), 1, true, &partition)
+		polled, err = connected.PollMessages(ctx, stream, topic, partition, iggcon.DefaultConsumer(), iggcon.OffsetPollingStrategy(0), countOptions(1, true))
 		require.NoError(t, err)
 		require.Len(t, polled.Messages, 1)
 	}
@@ -449,8 +449,8 @@
 	require.NotEmpty(t, response.Confirmations)
 
 	partitionId := uint32(0)
-	polled, err := connected.PollMessages(ctx, streamId, topicId,
-		iggcon.DefaultConsumer(), iggcon.OffsetPollingStrategy(0), 3, false, &partitionId)
+	polled, err := connected.PollMessages(ctx, streamId, topicId, partitionId,
+		iggcon.DefaultConsumer(), iggcon.OffsetPollingStrategy(0), countOptions(3, false))
 	require.NoError(t, err)
 	assert.Len(t, polled.Messages, 3)
 }
diff --git a/foreign/java/README.md b/foreign/java/README.md
index f628d39..7f52cd0 100644
--- a/foreign/java/README.md
+++ b/foreign/java/README.md
@@ -38,7 +38,7 @@
 
 ### Snapshot Versions
 
-Development builds of the next release carry the version `0.9.5-SNAPSHOT`. Get them from the ASF snapshot repository:
+Development builds of the next release carry the version `0.9.6-SNAPSHOT`. Get them from the ASF snapshot repository:
 
 **Gradle:**
 
@@ -51,7 +51,7 @@
 }
 
 dependencies {
-    implementation 'org.apache.iggy:iggy:0.9.5-SNAPSHOT'
+    implementation 'org.apache.iggy:iggy:0.9.6-SNAPSHOT'
 }
 ```
 
@@ -72,7 +72,7 @@
     <dependency>
         <groupId>org.apache.iggy</groupId>
         <artifactId>iggy</artifactId>
-        <version>0.9.5-SNAPSHOT</version>
+        <version>0.9.6-SNAPSHOT</version>
     </dependency>
 </dependencies>
 ```
@@ -125,7 +125,7 @@
 
 `sendBinaryRequestToReplica(code, payload, replicaId)` sends a raw command to one node of a cluster, named by the replica id that cluster metadata returns.
 
-Each node answers non-replicated commands such as `GetStats` from its own state, so this reads the state of that node. Replicated commands, polls, readiness waits, deferred-request cancels, consumer-offset reads and session-control codes fail with `INVALID_COMMAND` before anything is sent.
+Each node answers non-replicated commands such as `GetStats` from its own state, so this reads the state of that node. Replicated commands, polls, fetches, deferred-request cancels, consumer-offset reads and session-control codes fail with `INVALID_COMMAND` before anything is sent.
 
 An id that cluster metadata does not name fails with `INVALID_IDENTIFIER`. An unreachable node is retried until the request timeout, and a `NOT_PRIMARY` reply is returned rather than sent to another node. The HTTP client throws `IggyOperationNotSupportedException`.
 
@@ -416,72 +416,117 @@
 
 ## Polling
 
-`pollMessages` returns whatever the server already holds. An empty topic answers
-at once, so the caller has to poll again.
+The SDK reads messages at three levels: a poll of one partition, a partition reader that reads many partitions in batches, and a consumer that hands over one message at a time. All three take `PollOptions`, whose `CommitPolicy` decides who stores the consumer offsets.
 
-`pollMessagesDeferred` lets the server hold the request instead. It waits up to
-`maxWait` for `minCount` messages, caps the encoded response at `maxBytes`, and
-bounds the whole call by `requestTimeout`.
+### Poll one partition
+
+`pollMessages` with a partition and `PollOptions` lets the server hold the request until messages arrive. It waits up to `maxWait` for `minCount` messages and returns at most `count` messages and `maxBytes` of records. The request ends after `maxWait` plus 10 s.
 
 ```java
-import org.apache.iggy.message.DeferredPollOptions;
+import org.apache.iggy.message.CommitPolicy;
+import org.apache.iggy.message.PollOptions;
 
-// Defaults: 1 s wait, 1 message, 1 MiB, 11 s budget.
-var options = DeferredPollOptions.defaults()
-    .withMaxWait(Duration.ofSeconds(5))
-    .withMinCount(10);
+// Defaults: 1000 messages, no wait, 1 message, 1 MiB, nothing stored.
+var options = PollOptions.waitFor(Duration.ofSeconds(5))
+    .withCount(100)
+    .withCommit(CommitPolicy.onRead());
 
-var polled = client.messages().pollMessagesDeferred(
-    streamId, topicId, Optional.of(0L), Consumer.of(1L),
-    PollingStrategy.next(), 100L, false, options);
+PolledMessages polled = client.messages()
+    .pollMessages(streamId, topicId, 0L, Consumer.of(1L), PollingStrategy.next(), options)
+    .get();
 ```
 
-If the readiness wait expires, the server still answers, with a partial batch or
-an empty one. If the request timeout expires, the call fails.
+`CommitPolicy.manual()` (the default) stores nothing, and the application stores a `Checkpoint` with `storeConsumerPosition`. `CommitPolicy.onRead()` makes the server store the offset of the last message it returns as part of the read, in one round trip. While the primary stays up a message is delivered at most once. A failover before the store replicates can deliver the last batch again.
 
-The TCP client multiplexes deferred polls with other requests on its shared
-connections and routes them to the partition primary. Up to 64 deferred polls
-can be in flight per client; further polls wait for a slot in arrival order,
-within their original request timeout. A poll that expires before getting a
-slot fails with the retryable `TRANSIENT_NOT_ACCEPTED`. Queued and active
-requests share the client's 4096 pending request limit.
-Each TCP attempt uses the time remaining before the original call deadline,
-including time spent waiting for a slot, routing, or retrying. A server capacity
-refusal (`DEFERRED_POLL_CAPACITY_EXCEEDED`) backs off without discarding the
-cached partition route.
-The HTTP client accepts a plain consumer only,
-because the server refuses a consumer group over HTTP: without a persistent
-connection the client cannot follow the group's partition assignment.
-Deferred HTTP polls use a separate pool of up to 64 connections. A timeout or
-transport failure before the request can be sent reports `IggyServerException`
-with `TRANSIENT_NOT_ACCEPTED` (58). Once sending starts, those failures report
-`TRANSIENT_NOT_COMMITTED` (57), because the server may already have advanced the
-offset. The original failure is available as the cause. The client retries only
-definite non-admission, including `DEFERRED_POLL_CAPACITY_EXCEEDED` (90), within
-the original request timeout.
+A poll of one partition refuses `CommitPolicy.auto(...)` with `INVALID_CONFIGURATION` (2), because it has nothing to count. A reader and a consumer accept it.
 
-A parked `Next` or auto-commit poll does not block sends to its partition.
-Polls that advance progress share a separate partition lane with offset reads,
-stores, and deletes. Stores and deletes also take the mutation lane, after their
-progress turn. A retained uncertain store or delete blocks later progress calls
-until it is resolved or abandoned; an active replay keeps its lanes until it
-finishes. Consumers on the same partition share this ordering, including when
-they name a consumer group by different aliases.
+A first message larger than `maxBytes` comes alone in the reply, up to the largest message size. A `Last` poll of more than one message whose first message is larger than `maxBytes` fails with `INVALID_SIZE_BYTES` (80). Every reply limit of the client holds the largest message, and `maxVsrFrameSize` refuses a frame that cannot.
 
-Cancelling a TCP deferred poll sends a per-request cancellation on the same
-authenticated connection. This stops a poll still waiting for readiness;
-cancellation can race an offset update that the server has already accepted.
-With auto-commit, a lost or timed-out reply can leave the stored offset advanced
-without delivering its messages to the caller. Use manual offset commits after
-processing when those messages must remain available for another attempt.
+`PolledMessages.checkpoint()` and `checkpointAt(index)` name the position after a message, with the history of its partition. `continuation()` is the strategy after the last message, bound to its partition, and a poll or a seek of another partition refuses it with `INVALID_COMMAND` (3).
+
+A cancel of a poll with `OnRead` stops it on the server and waits for its reply: the server answers `TRANSIENT_NOT_ACCEPTED` (58) when the cancel won and nothing was stored, or with what it read and stored. Until then the poll blocks its partition: an offset store or another progress poll of the partition fails at once with `PARTITION_LANE_BLOCKED` (14009) naming the poll, and the reply lifts the block. Without a reply by the deadline of the poll, its outcome is unknown, as below.
+
+If the reply of such a poll is lost, or the server refuses it with a status that does not prove that nothing was stored, the server can have stored its offset. The call fails with `FETCH_PROGRESS_UNCERTAIN` (14010), whose `getOperationId()` names the poll.
+
+Every later poll with `OnRead` and every offset store or delete of that partition on this client then fails with `PARTITION_LANE_BLOCKED` (14009) naming the same operation, also after a reconnect and a new sign-in, until `abandonOperation(id)` releases it. The next `Next` poll then starts after what the lost poll stored. A close waits for the reply of each pending poll with `OnRead`, and the blocking client does not stop such a poll on an interrupt.
+
+`disconnect()` drops the connections without waiting, so a poll with `OnRead` that still waits fails with 14010, and `connect()` with a sign-in continues the client.
+
+### Partition reader
+
+A `PartitionReader` reads the partitions of a topic in batches. It groups the partitions by their primary and reads each group with one request, puts the partitions that a read served last at the end of the next one so a busy partition does not starve the others, regroups after a primary moves, keeps a prefetch, and stays usable after an error.
+
+The prefetch slots that the groups leave free spread the partitions whose last read ended before their head over reads of their own, each with the whole `count` and `maxBytes`, while the quiet partitions of a group share the one read that may wait. Under `OnRead` every read stores, so a group holds one read that may wait at a time, and a waiting `next()` fills the free slots with the backlogged partitions, one committing read per partition.
+
+A standalone consumer names its partitions. A consumer group reader joins its group, follows its assignment, and leaves the group on close.
+
+```java
+PartitionReader reader = client.reader(streamId, topicId, Consumer.of(1L))
+    .partitions(List.of(0L, 1L, 2L))
+    .start(PollingStrategy.first())
+    .options(PollOptions.waitFor(Duration.ofSeconds(5)))
+    .build()
+    .get();
+PolledMessages batch = reader.next().get();
+reader.commit(batch.checkpoint().orElseThrow()).get();
+reader.closeAsync().get();
+```
+
+`next()` returns the next batch of one partition, never empty, in offset order within one generation of the partition: from its first read, from each `seek`, and from each new assignment. A cancelled `next()` leaves its batch in the reader. The default wait of a reader is 5 s. The futures of a reader complete on its own thread, so a callback must not block.
+
+- `Manual`: `commit(checkpoint)` stores the offset of a delivered checkpoint, forward only within a generation.
+- `OnRead`: the server stores each read, and the reader reads only for a waiting `next()`. After a read whose reply was lost, the reader abandons it and reads that partition again after its retry interval. That read starts after what the lost read stored, so the reader reads those messages again without a store and delivers them first.
+
+  Only a lost read explains a read that starts past the expected offset: without one, the application or another session stored that offset, and the reader delivers from there. A partition that leaves the assignment still hands over the reads that the server stored for it. `closeAsync()` moves the offset of each partition with undelivered batches, or with a lost or undecoded read, back to its last delivered message.
+
+  `seek` to an offset above 0 stores the offset before it, which moves the server position, and the other strategies store nothing. A seek that waits for a read in flight applies after that read ends, also when `closeAsync()` starts first: its store then runs before the rewind, and a refused store fails the seek, or the close. `commit` and `mark` are refused.
+- `Auto(AutoCommit)`: the reader counts each batch as `CommitAfter` says (`PROCESSING` at the next `next()` or the close, `DELIVERY` when `next()` returns it, `MARKED` on `mark(checkpoint)`), and stores the latest counted position at its `interval`, on its `every` event, before a rebalance takes a partition away, and on close. `AutoCommit.defaults()` stores every 10,000 messages or 5 s after processing.
+
+A `ReadException` names its partition. `HISTORY_UNAVAILABLE` (87) stops a partition until `seek` when its position came from an earlier read of a history that is gone.
+
+A message that does not decode arrives as a `ReadException` whose `skip()` is the continuation after it, after a batch with the messages before it, and the partition stops until a `seek`.
+
+The exception carries the bytes the server served for it: `record()` is the message frame as received (its header, payload and user headers) or, when the framing of the section is malformed, the remaining bytes of the section from where decoding stopped.
+
+`offset()` is the offset of the message when its frame decoded and a later check failed, empty for malformed framing.
+
+A refusal that a retry does not change is retried with a doubling wait and stops its partition after six in a row. A partition that another member owns (`CONSUMER_GROUP_PARTITION_NOT_OWNED`, 5009) drops its position and resolves its start again after the next assignment, and six such refusals in a row are reported once without a stop.
+
+The blocking client builds a blocking reader with `org.apache.iggy.client.blocking.reader.PartitionReader.build(client.reader(...))`, whose `next(Duration)` returns empty at its timeout and keeps the batch for the next call.
+
+### Consumer
+
+An `IggyConsumer` hands over one message at a time from a reader. Under `Auto` with `PROCESSING`, a message counts when the next one is asked for, or after its handler succeeds in `consume`. A handler that fails leaves its message uncounted and ends `consume` with its error.
+
+```java
+IggyConsumer consumer = IggyConsumer.build(client.reader(streamId, topicId, Consumer.group(groupId))
+        .options(PollOptions.waitFor(Duration.ofSeconds(5)).withCommit(CommitPolicy.auto())))
+    .get();
+consumer.consume(message -> process(message.message())).get();
+consumer.shutdown().get();
+```
+
+`shutdown()` stops a running loop after its current handler, counts the last message under `PROCESSING`, stores, moves offsets back under `OnRead`, and leaves the group.
+
+### Transport notes
+
+The TCP client multiplexes deferred polls with other requests on its shared connections and routes them to the partition primary. Up to 64 deferred requests can be in flight per client, and further ones wait for a slot within their request timeout.
+
+A server capacity refusal (`DEFERRED_POLL_CAPACITY_EXCEEDED`) backs off, and if it lasts until the deadline, the call fails with `DEFERRED_POLL_CAPACITY_EXCEEDED` (90). A deferred poll of a consumer group must name its partition, a reader reads the whole assignment.
+
+The HTTP client sends a poll that stores its offset as a `POST` to `.../messages/deferred`, which no proxy or client library resends, and a poll that stores nothing as a `GET`. If the answer of a storing poll is lost after the request went out, or the server refuses it with a status that does not prove that nothing was stored, the call fails with `FETCH_PROGRESS_UNCERTAIN` (14010), whose `getOperationId()` names the poll on this client.
+
+Every later poll that stores its offset or starts at it (`Next`), and every offset store, of that partition on the client then fails with `PARTITION_LANE_BLOCKED` (14009) naming the same operation, until `abandonOperation(id)` releases it. The block covers the stream and topic as the call spells them, and any spelling of a call that carries the context of the same partition incarnation, for every consumer.
+
+A call that spells the stream or topic differently and carries no context does not see it. Such polls of one partition take turns, so a second one waits for the first and then meets its block. A timeout or transport failure before the request can be sent reports `TRANSIENT_NOT_ACCEPTED` (58).
+
+The HTTP client accepts a plain consumer only, because the server refuses a consumer group over HTTP. An interrupt of the calling thread ends a poll that stores nothing, also while it waits for the reply headers.
 
 ## Exception Handling
 
 The SDK's custom exception types inherit from `IggyException`. When you join a failed future, the cause can come back inside a `CompletionException`. The HTTP client's `close()` method declares `IOException`. Handle these two boundaries as well as the specific SDK errors.
 
-The HTTP client treats 503 `relay_refused` through Apache HttpClient's ordinary
-429/503 retry strategy. That behavior does not provide retained-operation
-recovery over HTTP.
+The HTTP client treats 503 `relay_refused` through Apache HttpClient's ordinary 429/503 retry strategy. That behavior does not provide retained-operation recovery over HTTP. A `pollMessages` with auto-commit is the `POST` above, which the client never sends again after the request may have reached the server, and whose unknown outcome fails with `FETCH_PROGRESS_UNCERTAIN` (14010).
 
 On a multi-node cluster, an HTTP call often reaches a node that is not the
 partition primary. For message sends, polls and consumer offset calls, that
@@ -543,11 +588,12 @@
 A mutation already sent may remain retained. The byte limit counts admitted frozen frames and
 writer references; API input snapshots and queued serialization buffers are outside this limit.
 
-Auto-commit and `Next` polls use a temporary partition ordering barrier. Their success
-acknowledges local admission, not replicated offset durability. Code 57, a lost or malformed
-reply, or an eviction ends the call with code 57. Progress is unknown, and the call
-releases the barrier. These polls are never retained or replayed after uncertainty. Explicit non-admission
-(58 or 89) may retry within the same deadline.
+Polls with `OnRead` and `Next` polls use a temporary partition ordering barrier. Their success
+acknowledges local admission, not replicated offset durability. For a `Next` poll that stores nothing, code 57, a lost or
+malformed reply, or an eviction ends the call with code 57. For a poll with `OnRead` they end it with
+`FETCH_PROGRESS_UNCERTAIN` (14010), because the server can have stored its offset. Either way the call releases the
+barrier, and these polls are never retained or replayed after uncertainty. Explicit non-admission (58 or 89) may retry
+within the same deadline.
 
 ## Examples
 
diff --git a/foreign/java/gradle.properties b/foreign/java/gradle.properties
index 3e28a08..4bd57b1 100644
--- a/foreign/java/gradle.properties
+++ b/foreign/java/gradle.properties
@@ -15,5 +15,5 @@
 # specific language governing permissions and limitations
 # under the License.
 
-version=0.9.5-SNAPSHOT
+version=0.9.6-SNAPSHOT
 group=org.apache.iggy
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/ConsumerOffsetsClient.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/ConsumerOffsetsClient.java
index cc270f5..935c351 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/ConsumerOffsetsClient.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/ConsumerOffsetsClient.java
@@ -23,6 +23,7 @@
 import org.apache.iggy.consumeroffset.ConsumerOffsetInfo;
 import org.apache.iggy.identifier.StreamId;
 import org.apache.iggy.identifier.TopicId;
+import org.apache.iggy.message.Checkpoint;
 import org.apache.iggy.partition.PartitionContext;
 
 import java.math.BigInteger;
@@ -106,6 +107,16 @@
      * @param consumerId The consumer identifier (numeric ID)
      * @return A CompletableFuture containing the consumer offset info if it exists
      */
+    /**
+     * Stores the offset of the message that {@code checkpoint} follows, under the history of its partition, so a store
+     * for a partition that was deleted and recreated since is refused.
+     */
+    default CompletableFuture<Void> storeConsumerPosition(
+            StreamId streamId, TopicId topicId, Consumer consumer, Checkpoint checkpoint) {
+        return storeConsumerOffset(
+                streamId, topicId, checkpoint.partitionId(), consumer, checkpoint.offset(), checkpoint.context());
+    }
+
     default CompletableFuture<Optional<ConsumerOffsetInfo>> getConsumerOffset(
             Long streamId, Long topicId, Optional<Long> partitionId, Long consumerId) {
         return getConsumerOffset(StreamId.of(streamId), TopicId.of(topicId), partitionId, Consumer.of(consumerId));
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/MessagesClient.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/MessagesClient.java
index 184621d..11ffb5a 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/MessagesClient.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/MessagesClient.java
@@ -23,10 +23,13 @@
 import org.apache.iggy.exception.IggyOperationNotSupportedException;
 import org.apache.iggy.identifier.StreamId;
 import org.apache.iggy.identifier.TopicId;
+import org.apache.iggy.message.CommitPolicy;
 import org.apache.iggy.message.DeferredPollOptions;
 import org.apache.iggy.message.Message;
 import org.apache.iggy.message.Partitioning;
+import org.apache.iggy.message.PollOptions;
 import org.apache.iggy.message.PolledMessages;
+import org.apache.iggy.message.PollingKind;
 import org.apache.iggy.message.PollingStrategy;
 import org.apache.iggy.message.SendMessagesResponse;
 
@@ -74,6 +77,50 @@
 public interface MessagesClient {
 
     /**
+     * Polls one partition with the limits and the commit policy of {@code options}.
+     *
+     * <p>The server waits up to {@link PollOptions#maxWait()} for {@link PollOptions#minCount()} messages and returns
+     * at most {@link PollOptions#count()} messages and {@link PollOptions#maxBytes()} of records. A first message
+     * larger than {@code maxBytes} comes alone in the reply. A {@link PollingKind#Last} poll of more than one message
+     * whose first message is larger than {@code maxBytes} fails with
+     * {@link org.apache.iggy.exception.IggyErrorCode#INVALID_SIZE_BYTES}. The request ends after
+     * {@link PollOptions#requestTimeout()}.
+     *
+     * <p>With {@link CommitPolicy#onRead()} the server stores the offset of the last message it returns as part of the
+     * read. A cancel of such a poll stops it on the server and waits for its reply, which shows whether it stored.
+     * Until then the poll blocks its partition, so an offset store or another progress poll of it fails at once with
+     * {@link org.apache.iggy.exception.IggyErrorCode#PARTITION_LANE_BLOCKED}. A
+     * reply that the connection loses, or a refusal that does not prove that nothing was stored, fails with
+     * {@link org.apache.iggy.exception.IggyErrorCode#FETCH_PROGRESS_UNCERTAIN} naming the operation: the server can
+     * have stored the offset. Every later committing poll and offset store or delete of the partition on this client
+     * then fails with {@link org.apache.iggy.exception.IggyErrorCode#PARTITION_LANE_BLOCKED} naming that operation,
+     * also after a reconnect, until {@code abandonOperation} releases it. {@link CommitPolicy.Auto} is refused with
+     * {@link org.apache.iggy.exception.IggyErrorCode#INVALID_CONFIGURATION}: a one-shot poll has nothing to count. A
+     * {@link org.apache.iggy.client.async.reader.PartitionReader} counts and stores it.
+     *
+     * <p>Continue a partition with {@link PolledMessages#continuation()}, which a poll of another partition refuses
+     * with {@link org.apache.iggy.exception.IggyErrorCode#INVALID_COMMAND}.
+     *
+     * @param streamId    the stream identifier
+     * @param topicId     the topic identifier
+     * @param partitionId the partition, 0 to 4294967295
+     * @param consumer    the consumer or consumer group whose offset {@code Next} reads and a commit stores
+     * @param strategy    where the poll starts
+     * @param options     the limits and the commit policy
+     * @return a {@link CompletableFuture} that completes with the messages of the partition
+     */
+    default CompletableFuture<PolledMessages> pollMessages(
+            StreamId streamId,
+            TopicId topicId,
+            long partitionId,
+            Consumer consumer,
+            PollingStrategy strategy,
+            PollOptions options) {
+        return CompletableFuture.failedFuture(new IggyOperationNotSupportedException(
+                "pollMessages", getClass().getSimpleName()));
+    }
+
+    /**
      * Polls messages from a topic partition asynchronously.
      *
      * <p>Messages are retrieved according to the specified {@link PollingStrategy}, which
@@ -169,11 +216,13 @@
      * operator wait limit below the requested wait and a request this client encoded wrongly.
      * Never retry it and never infer the cause from the code alone.
      *
+     * <p>A group poll must name its partition. Without one, the TCP client fails with
+     * {@link org.apache.iggy.exception.IggyErrorCode#INVALID_COMMAND} before it sends anything.
+     * A {@link org.apache.iggy.client.async.reader.PartitionReader} reads the whole assignment of a group.
+     *
      * @param streamId    the stream identifier (numeric or string-based)
      * @param topicId     the topic identifier (numeric or string-based)
-     * @param partitionId optional partition ID to poll from; when empty and polling with a
-     *                    group consumer, the client selects the partition round-robin from
-     *                    the member's synced group assignment
+     * @param partitionId the partition ID to poll from, which only a plain consumer can leave empty
      * @param consumer    the consumer identity, either individual or group
      * @param strategy    the polling strategy controlling where to start reading
      * @param count       the maximum number of messages to return
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/reader/IggyConsumer.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/reader/IggyConsumer.java
new file mode 100644
index 0000000..5dfa242
--- /dev/null
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/reader/IggyConsumer.java
@@ -0,0 +1,287 @@
+/*
+ * 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.
+ */
+
+package org.apache.iggy.client.async.reader;
+
+import org.apache.iggy.exception.IggyClientException;
+import org.apache.iggy.message.AutoCommit;
+import org.apache.iggy.message.Checkpoint;
+import org.apache.iggy.message.CommitAfter;
+import org.apache.iggy.message.PolledMessages;
+import org.apache.iggy.message.PollingStrategy;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.function.Function;
+
+/**
+ * Reads one message at a time from a {@link PartitionReader}: a standalone consumer over its partitions, or a
+ * consumer group member over its assignment. The commit policy of the reader options applies per message:
+ *
+ * <ul>
+ *   <li>{@link #next()}: under {@link CommitAfter#DELIVERY} a message counts when it is returned, under
+ *       {@link CommitAfter#PROCESSING} when the next one is asked for or at {@link #shutdown()}.
+ *   <li>{@link #consume}: under {@link CommitAfter#DELIVERY} a message counts before its handler runs, under
+ *       {@link CommitAfter#PROCESSING} once its handler succeeds. A handler that fails leaves its message uncounted and
+ *       ends the loop with its error.
+ *   <li>Under {@link CommitAfter#MARKED} only {@link #mark} counts. Under {@code OnRead} the server stores each read,
+ *       and {@link #shutdown()} moves the offset back to the last message handed over.
+ * </ul>
+ *
+ * <p>One caller at a time calls {@link #next()} or runs {@link #consume}.
+ */
+public final class IggyConsumer {
+    private final PartitionReader reader;
+    private final CommitAfter after;
+    private PolledMessages batch;
+    private int index;
+    // The partitions of the seeks that the reader has not applied yet, each with the number of its latest seek.
+    private final Map<Long, Long> seeking = new HashMap<>();
+    private long seeks;
+    private ReceivedMessage uncounted;
+    private CompletableFuture<ReceivedMessage> waiting;
+    private CompletableFuture<PolledMessages> reading;
+    private boolean waitingProcesses;
+    private volatile boolean stopping;
+    private CompletableFuture<Void> loop = CompletableFuture.completedFuture(null);
+
+    private IggyConsumer(PartitionReader reader) {
+        this.reader = reader;
+        this.after = reader.autoCommit().map(AutoCommit::after).orElse(CommitAfter.MARKED);
+    }
+
+    /** Builds the reader of {@code builder} and a consumer on it, a group member once it joined its group. */
+    public static CompletableFuture<IggyConsumer> build(PartitionReaderBuilder builder) {
+        return builder.build().thenApply(IggyConsumer::new);
+    }
+
+    /**
+     * The next message. Fails with a {@link ReadException} for an error of a read, after which the consumer goes on.
+     */
+    public synchronized CompletableFuture<ReceivedMessage> next() {
+        countUncounted();
+        return take(true);
+    }
+
+    /**
+     * Runs {@code handler} on each message, one after another, until {@link #shutdown()} or the first failure of a
+     * handler or a read.
+     *
+     * @return a future that ends with the shutdown, or fails with the first error
+     */
+    public synchronized CompletableFuture<Void> consume(Function<ReceivedMessage, CompletableFuture<Void>> handler) {
+        countUncounted();
+        CompletableFuture<Void> done = new CompletableFuture<>();
+        loop = done;
+        step(handler, done);
+        return done;
+    }
+
+    /** See {@link PartitionReader#commit}. */
+    public CompletableFuture<Void> commit(Checkpoint checkpoint) {
+        return reader.commit(checkpoint);
+    }
+
+    /** See {@link PartitionReader#mark}. */
+    public CompletableFuture<Void> mark(Checkpoint checkpoint) {
+        return reader.mark(checkpoint);
+    }
+
+    /** See {@link PartitionReader#seek}. The messages of the partition that the consumer still holds are dropped. */
+    public synchronized CompletableFuture<Void> seek(long partitionId, PollingStrategy start) {
+        if (batch != null && batch.partitionId() == partitionId) {
+            batch = null;
+        }
+        if (uncounted != null && uncounted.partitionId() == partitionId) {
+            uncounted = null;
+        }
+        long seek = ++seeks;
+        seeking.put(partitionId, seek);
+        try {
+            return reader.seek(partitionId, start, () -> applied(partitionId, seek));
+        } catch (RuntimeException refused) {
+            applied(partitionId, seek);
+            throw refused;
+        }
+    }
+
+    /** The partitions of the reader. */
+    public List<Long> partitions() {
+        return reader.partitions();
+    }
+
+    /**
+     * Stops a running {@link #consume} after its current handler, counts the last message under
+     * {@link CommitAfter#PROCESSING}, and closes the reader, which stores, moves offsets back and leaves its group as
+     * its commit policy says.
+     */
+    public CompletableFuture<Void> shutdown() {
+        stopping = true;
+        CompletableFuture<Void> running;
+        synchronized (this) {
+            if (waiting != null && !waiting.isDone()) {
+                waiting.completeExceptionally(new IggyClientException("The consumer is shut down"));
+            }
+            running = loop;
+        }
+        return running.handle((ignored, error) -> null).thenCompose(ignored -> {
+            synchronized (this) {
+                countUncounted();
+            }
+            return reader.closeAsync();
+        });
+    }
+
+    private void step(Function<ReceivedMessage, CompletableFuture<Void>> handler, CompletableFuture<Void> done) {
+        // Held messages and quick handlers complete at once, so the loop goes on here instead of nesting callbacks.
+        while (!stopping) {
+            CompletableFuture<Void> handled = handleNext(handler);
+            if (!handled.isDone()) {
+                handled.whenComplete((ignored, error) -> {
+                    if (stopping) {
+                        done.complete(null);
+                    } else if (error != null) {
+                        done.completeExceptionally(unwrap(error));
+                    } else {
+                        step(handler, done);
+                    }
+                });
+                return;
+            }
+            if (handled.isCompletedExceptionally()) {
+                handled.whenComplete((ignored, error) -> done.completeExceptionally(unwrap(error)));
+                return;
+            }
+        }
+        done.complete(null);
+    }
+
+    private CompletableFuture<Void> handleNext(Function<ReceivedMessage, CompletableFuture<Void>> handler) {
+        CompletableFuture<ReceivedMessage> taken;
+        synchronized (this) {
+            taken = take(false);
+        }
+        return taken.thenCompose(message -> {
+            CompletableFuture<Void> handled;
+            try {
+                handled = handler.apply(message);
+            } catch (RuntimeException failure) {
+                handled = CompletableFuture.failedFuture(failure);
+            }
+            return handled.thenCompose(ignored -> after == CommitAfter.PROCESSING
+                    ? reader.countDelivered(message.checkpoint(), 1, message.endsBatch())
+                    : CompletableFuture.completedFuture(null));
+        });
+    }
+
+    private static Throwable unwrap(Throwable error) {
+        return error instanceof CompletionException && error.getCause() != null ? error.getCause() : error;
+    }
+
+    /** Hands the next held message over, or takes the next batch from the reader first. */
+    private CompletableFuture<ReceivedMessage> take(boolean processing) {
+        CompletableFuture<ReceivedMessage> result = new CompletableFuture<>() {
+            @Override
+            public boolean cancel(boolean mayInterruptIfRunning) {
+                synchronized (IggyConsumer.this) {
+                    boolean cancelled = super.cancel(mayInterruptIfRunning);
+                    if (cancelled && waiting == this && reading != null) {
+                        reading.cancel(false);
+                    }
+                    return cancelled;
+                }
+            }
+        };
+        waiting = result;
+        waitingProcesses = processing;
+        resumeTake();
+        return result;
+    }
+
+    private void resumeTake() {
+        if (waiting == null || waiting.isDone()) {
+            return;
+        }
+        if (stopping) {
+            waiting.completeExceptionally(new IggyClientException("The consumer is shut down"));
+            return;
+        }
+        if (batch != null && index < batch.messages().size()) {
+            CompletableFuture<ReceivedMessage> result = waiting;
+            waiting = null;
+            ReceivedMessage message = handOver();
+            if (waitingProcesses && after == CommitAfter.PROCESSING) {
+                uncounted = message;
+            }
+            result.complete(message);
+            return;
+        }
+        if (reading != null) {
+            return;
+        }
+        reading = reader.nextBatch();
+        reading.whenComplete(this::receiveBatch);
+    }
+
+    /** The reader handed a batch of a sought partition over before it applied the seek, so the batch is stale. */
+    private synchronized void receiveBatch(PolledMessages next, Throwable error) {
+        reading = null;
+        if (error != null) {
+            if (waiting != null) {
+                waiting.completeExceptionally(unwrap(error));
+            }
+            return;
+        }
+        if (!seeking.containsKey(next.partitionId())) {
+            batch = next;
+            index = 0;
+        }
+        resumeTake();
+    }
+
+    private synchronized void applied(long partitionId, long seek) {
+        seeking.remove(partitionId, seek);
+    }
+
+    private ReceivedMessage handOver() {
+        int at = index++;
+        Checkpoint checkpoint = batch.checkpointAt(at).orElseThrow();
+        ReceivedMessage message = new ReceivedMessage(
+                batch.partitionId(),
+                batch.messages().get(at),
+                checkpoint,
+                at == batch.messages().size() - 1);
+        reader.deliverMessage(checkpoint);
+        if (after == CommitAfter.DELIVERY) {
+            reader.countDelivered(checkpoint, 1, message.endsBatch());
+        }
+        return message;
+    }
+
+    private void countUncounted() {
+        if (uncounted != null) {
+            ReceivedMessage message = uncounted;
+            uncounted = null;
+            reader.countDelivered(message.checkpoint(), 1, message.endsBatch());
+        }
+    }
+}
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/reader/PartitionReader.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/reader/PartitionReader.java
new file mode 100644
index 0000000..30ba5d0
--- /dev/null
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/reader/PartitionReader.java
@@ -0,0 +1,1662 @@
+/*
+ * 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.
+ */
+
+package org.apache.iggy.client.async.reader;
+
+import io.netty.buffer.Unpooled;
+import org.apache.iggy.exception.IggyClientException;
+import org.apache.iggy.exception.IggyErrorCode;
+import org.apache.iggy.exception.IggyOperationException;
+import org.apache.iggy.exception.IggyServerException;
+import org.apache.iggy.exception.IggyValidationException;
+import org.apache.iggy.message.AutoCommit;
+import org.apache.iggy.message.Checkpoint;
+import org.apache.iggy.message.CommitAfter;
+import org.apache.iggy.message.CommitEvery;
+import org.apache.iggy.message.DecodeFailure;
+import org.apache.iggy.message.FetchedMessages;
+import org.apache.iggy.message.Message;
+import org.apache.iggy.message.PollOptions;
+import org.apache.iggy.message.PolledMessages;
+import org.apache.iggy.message.PollingKind;
+import org.apache.iggy.message.PollingStrategy;
+import org.apache.iggy.partition.PartitionContext;
+import org.apache.iggy.serde.BytesDeserializer;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.math.BigInteger;
+import java.time.Duration;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.Deque;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.OptionalLong;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.function.Supplier;
+
+/**
+ * Reads the partitions of a topic in batches, with routing, prefetch and the position of each partition inside.
+ *
+ * <ul>
+ *   <li>A standalone consumer reads the partitions of its builder. A consumer group reads the assignment of its
+ *       member: the reader joins the group, follows its assignment, and leaves it in {@link #closeAsync()}.
+ *   <li>{@link #next()} returns the next batch of one partition, in offset order within one generation of the
+ *       partition: from its first read, from each {@link #seek}, and from each new assignment to this member.
+ *   <li>The commit policy of the options decides who stores offsets. With {@code Manual}, {@link #commit} stores the
+ *       offset of a checkpoint. With {@code OnRead}, the server stores the offset of each read, the reader reads only
+ *       when the application waits for a batch, and {@link #closeAsync()} moves the offset back to the last delivered
+ *       message. A partition that leaves the assignment still hands over the reads that the server stored for it.
+ *       After a read whose reply was lost, the reader reads again what the lost read stored and delivers it first.
+ *       With {@code Auto}, the reader counts each batch as its {@link CommitAfter} says, or each {@link #mark},
+ *       and stores the latest counted position at the interval or the event of the policy, before a rebalance takes a
+ *       partition away, and in {@link #closeAsync()}.
+ *   <li>The reader stays usable after a {@link ReadException} and retries by itself. {@code HistoryUnavailable} stops
+ *       its partition until a {@link #seek} when the position came from an earlier read of a history that is gone. A
+ *       refusal that a quick retry does not change, such as {@code Unauthorized}, is retried after a wait that doubles
+ *       each time, and after six in a row it stops its partition until a {@link #seek} with one error. A partition
+ *       that another member owns resolves its start again, and six such refusals in a row are reported once.
+ * </ul>
+ *
+ * <p>The futures of a reader complete on its own thread, so a callback must not block.
+ */
+public final class PartitionReader implements AutoCloseable {
+    private static final Logger log = LoggerFactory.getLogger(PartitionReader.class);
+    private static final AtomicLong READERS = new AtomicLong();
+    private static final int LASTING_REFUSALS_BEFORE_STOP = 6;
+    private static final int MAX_BACKOFF_SHIFT = 5;
+    private static final long WHOLE_READER = -1;
+
+    private final ReaderTransport transport;
+    private final List<Long> standalonePartitions;
+    private final PollingStrategy start;
+    private final PollOptions options;
+    private final int slots;
+    private final long retryIntervalNanos;
+    private final boolean onRead;
+    private final Optional<AutoCommit> autoCommit;
+    private final ScheduledThreadPoolExecutor loop;
+
+    // Everything below is read and written on the loop only, except the volatile fields.
+    private final Map<Long, Partition> partitions = new TreeMap<>();
+    private final Map<Long, Long> seekGenerations = new HashMap<>();
+    private volatile List<Long> partitionIds = List.of();
+    private final Deque<Entry> queue = new ArrayDeque<>();
+    private final Deque<Waiter> waiters = new ArrayDeque<>();
+    private final Set<CompletableFuture<List<FetchedMessages>>> reads = new HashSet<>();
+    // The committing requests of the reads in flight, which the close cancels with them.
+    private final Set<CompletableFuture<List<FetchedMessages>>> sends = new HashSet<>();
+    private final List<Retired> retiring = new ArrayList<>();
+    private List<List<Long>> groups = List.of();
+    private boolean regroup = true;
+    private boolean grouping;
+    private long regroupAt;
+    private int groupCursor;
+    private long serves;
+    private boolean refreshing;
+    private long refreshAt;
+    private long lastGeneration;
+    private Uncounted uncounted;
+    private CompletableFuture<Void> flushing;
+    private boolean flushAgain;
+    private ScheduledFuture<?> wake;
+    private ScheduledFuture<?> interval;
+    private volatile CompletableFuture<Void> closed;
+
+    PartitionReader(
+            ReaderTransport transport,
+            List<Long> standalonePartitions,
+            PollingStrategy start,
+            PollOptions options,
+            int slots,
+            Duration retryInterval) {
+        this.transport = transport;
+        this.standalonePartitions = standalonePartitions;
+        this.start = start;
+        this.options = options;
+        this.slots = slots;
+        this.retryIntervalNanos = retryInterval.toNanos();
+        this.onRead = options.commitsOnRead();
+        this.autoCommit = options.autoCommit();
+        this.loop = new ScheduledThreadPoolExecutor(1, runnable -> {
+            Thread thread = new Thread(runnable, "iggy-partition-reader-" + READERS.incrementAndGet());
+            thread.setDaemon(true);
+            return thread;
+        });
+        this.loop.setRemoveOnCancelPolicy(true);
+    }
+
+    /** Starts the builder of a reader that sends its requests through {@code transport}. */
+    public static PartitionReaderBuilder builder(ReaderTransport transport) {
+        return new PartitionReaderBuilder(transport);
+    }
+
+    /**
+     * The next batch of one partition. Never empty, and it has no time limit. Cancel safe: a cancelled call leaves its
+     * batch in the reader. Fails with a {@link ReadException} for an error of a read.
+     */
+    public CompletableFuture<PolledMessages> next() {
+        CompletableFuture<PolledMessages> result = new CompletableFuture<>();
+        run(() -> {
+            countUncounted();
+            if (closed != null) {
+                result.completeExceptionally(closedError());
+                return;
+            }
+            waiters.addLast(new Waiter(result, true));
+            handOver();
+            pump();
+        });
+        return result;
+    }
+
+    /**
+     * The next batch, without recording it as delivered: an {@link IggyConsumer} hands its messages over one by one
+     * and records each with {@link #deliverMessage}.
+     */
+    CompletableFuture<PolledMessages> nextBatch() {
+        CompletableFuture<PolledMessages> result = new CompletableFuture<>();
+        run(() -> {
+            if (closed != null) {
+                result.completeExceptionally(closedError());
+                return;
+            }
+            waiters.addLast(new Waiter(result, false));
+            handOver();
+            pump();
+        });
+        return result;
+    }
+
+    /** Records one message that an {@link IggyConsumer} handed over. */
+    CompletableFuture<Void> deliverMessage(Checkpoint checkpoint) {
+        return call(() -> {
+            Partition partition = partitions.get(checkpoint.partitionId());
+            if (partition != null) {
+                if (partition.firstDelivered == null) {
+                    partition.firstDelivered = checkpoint.offset();
+                }
+                partition.delivered = checkpoint;
+            }
+            return null;
+        });
+    }
+
+    /**
+     * Counts a checkpoint that this reader delivered, under {@code CommitPolicy.Auto}. It stores nothing now: the
+     * policy stores the latest counted position of the partition. With {@link CommitAfter#MARKED} only marks count,
+     * so batches processed concurrently count once they are done.
+     *
+     * @return a future that fails with {@link IggyErrorCode#INVALID_COMMAND} without {@code Auto}, and for a
+     *         checkpoint that this reader did not deliver in the current generation of its partition
+     */
+    public CompletableFuture<Void> mark(Checkpoint checkpoint) {
+        return call(() -> {
+            if (autoCommit.isEmpty()) {
+                throw invalid("Only an automatic commit counts marks");
+            }
+            Partition partition = delivering(checkpoint);
+            count(partition, checkpoint, 1, false);
+            return null;
+        });
+    }
+
+    /**
+     * Stores the offset of a checkpoint that this reader delivered. A checkpoint of an older generation of its
+     * partition, or at or below the last commit of its generation, stores nothing.
+     *
+     * @return a future that fails with {@link IggyErrorCode#INVALID_COMMAND} under {@code OnRead}, and for a
+     *         checkpoint that this reader did not deliver, or with the error of the store
+     */
+    public CompletableFuture<Void> commit(Checkpoint checkpoint) {
+        return this.<CompletableFuture<Void>>call(() -> {
+                    if (onRead) {
+                        throw invalid("The server stores the offsets of a reader with OnRead");
+                    }
+                    Partition partition = partitions.get(checkpoint.partitionId());
+                    if (partition == null) {
+                        throw invalid("This reader does not read partition " + checkpoint.partitionId());
+                    }
+                    if (!partition.delivered(checkpoint)
+                            || (partition.committed != null
+                                    && checkpoint.offset().compareTo(partition.committed) <= 0)) {
+                        return CompletableFuture.completedFuture(null);
+                    }
+                    long generation = partition.generation;
+                    return transport
+                            .store(checkpoint.partitionId(), checkpoint.offset(), checkpoint.context())
+                            .thenRun(() -> run(() -> recordCommit(checkpoint, generation)));
+                })
+                .thenCompose(store -> store);
+    }
+
+    /**
+     * Sets the next read position of {@code partitionId} and starts a new generation of it. It drops the queued
+     * batches of the partition and ignores its late replies, and it resumes a stopped partition. Under {@code OnRead},
+     * a seek to an offset stores the offset before it, so the next read of the server starts there, after the read in
+     * flight ends.
+     *
+     * @return a future that fails with {@link IggyErrorCode#INVALID_COMMAND} for a partition that the reader does
+     *         not read and for a continuation of another partition
+     */
+    public CompletableFuture<Void> seek(long partitionId, PollingStrategy start) {
+        return seek(partitionId, start, () -> {});
+    }
+
+    /** {@link #seek}, which runs {@code applied} on the reader thread once the seek drops the queued batches. */
+    CompletableFuture<Void> seek(long partitionId, PollingStrategy start, Runnable applied) {
+        start.requirePartition(partitionId);
+        return this.<CompletableFuture<Void>>call(() -> {
+                    try {
+                        Partition partition = partitions.get(partitionId);
+                        if (partition == null) {
+                            throw invalid("This reader does not read partition " + partitionId);
+                        }
+                        seekGenerations.put(partitionId, ++lastGeneration);
+                        queue.removeIf(entry -> entry.partition == partitionId);
+                        PollingStrategy position = start.kind() == PollingKind.First
+                                ? new PollingStrategy(PollingKind.Offset, BigInteger.ZERO, start.context())
+                                : start;
+                        if (onRead && partition.inFlight) {
+                            partition.pendingSeek = position;
+                            partition.sought = new CompletableFuture<>();
+                            return partition.sought;
+                        }
+                        return applySeek(partition, position);
+                    } finally {
+                        applied.run();
+                    }
+                })
+                .thenCompose(sought -> sought);
+    }
+
+    /**
+     * The partitions that the reader reads: those of a standalone consumer, or the current assignment of a group
+     * member.
+     */
+    public List<Long> partitions() {
+        return partitionIds;
+    }
+
+    /**
+     * Stops the reader. It cancels the reads in flight. With {@code Manual} it stores nothing. With {@code Auto} it
+     * counts the last batch under {@link CommitAfter#PROCESSING} and stores the latest counted position of each
+     * partition. With {@code OnRead} it moves the offset of each partition with undelivered batches back to its last
+     * delivered message. A group reader then leaves its group.
+     *
+     * @return a future that fails with the first failed store
+     */
+    public CompletableFuture<Void> closeAsync() {
+        CompletableFuture<Void> result = new CompletableFuture<>();
+        try {
+            loop.execute(() -> shutDown(result));
+        } catch (RejectedExecutionException alreadyClosed) {
+            CompletableFuture<Void> first = closed;
+            if (first == null) {
+                result.complete(null);
+            } else {
+                first.whenComplete((ignored, error) -> complete(result, error));
+            }
+        }
+        return result;
+    }
+
+    /** {@link #closeAsync()} and waits for it. */
+    @Override
+    public void close() {
+        try {
+            closeAsync().join();
+        } catch (CompletionException error) {
+            throw error.getCause() instanceof RuntimeException runtime ? runtime : error;
+        }
+    }
+
+    CompletableFuture<PartitionReader> start() {
+        CompletableFuture<PartitionReader> started = new CompletableFuture<>();
+        autoCommit
+                .flatMap(AutoCommit::interval)
+                .ifPresent(period -> interval = loop.scheduleWithFixedDelay(
+                        this::flush, period.toNanos(), period.toNanos(), TimeUnit.NANOSECONDS));
+        run(() -> {
+            if (!transport.group()) {
+                standalonePartitions.forEach(this::addPartition);
+                publishPartitions();
+                started.complete(this);
+                pump();
+                return;
+            }
+            transport
+                    .join()
+                    .whenCompleteAsync(
+                            (ignored, error) -> {
+                                if (error != null) {
+                                    loop.shutdownNow();
+                                    started.completeExceptionally(unwrap(error));
+                                    return;
+                                }
+                                started.complete(this);
+                                pump();
+                            },
+                            loop);
+        });
+        return started;
+    }
+
+    /** Counts {@code messages} up to {@code checkpoint} of a delivered batch, for an {@code IggyConsumer}. */
+    CompletableFuture<Void> countDelivered(Checkpoint checkpoint, long messages, boolean endsBatch) {
+        return call(() -> {
+            Partition partition = partitions.get(checkpoint.partitionId());
+            if (partition != null && partition.delivered(checkpoint)) {
+                count(partition, checkpoint, messages, endsBatch);
+            }
+            return null;
+        });
+    }
+
+    boolean commitsOnRead() {
+        return onRead;
+    }
+
+    Optional<AutoCommit> autoCommit() {
+        return autoCommit;
+    }
+
+    private void shutDown(CompletableFuture<Void> result) {
+        if (closed != null) {
+            closed.whenComplete((ignored, error) -> complete(result, error));
+            return;
+        }
+        closed = result;
+        cancel(wake);
+        cancel(interval);
+        IggyClientException closing = closedError();
+        waiters.forEach(waiter -> waiter.future.completeExceptionally(closing));
+        waiters.clear();
+        Set<Long> reading = new HashSet<>();
+        partitions.values().stream().filter(partition -> partition.inFlight).forEach(partition -> {
+            reading.add(partition.id);
+        });
+        List.copyOf(reads).forEach(read -> read.cancel(false));
+        List.copyOf(sends).forEach(send -> send.cancel(false));
+        countUncounted();
+        // The server can store until it answers the cancel of a committing read, so the stores wait for that reply. A
+        // read whose outcome stays unknown would block its partition with no reader left to recover it.
+        CompletableFuture<Void> stores = onRead
+                ? transport
+                        .cancelledReadsSettled()
+                        .thenComposeAsync(ignored -> transport.abandonLost(), loop)
+                        .thenComposeAsync(ignored -> seekThenRewind(reading), loop)
+                : flushThen();
+        stores.handle((ignored, error) -> error)
+                .thenCompose(storeError ->
+                        leaveGroup().thenApply(leaveError -> storeError == null ? leaveError : storeError))
+                .whenComplete((error, ignored) -> {
+                    loop.shutdown();
+                    complete(result, error);
+                });
+    }
+
+    /** Leaves the group of a member. A membership that the server already ended has nothing to leave. */
+    private CompletableFuture<Throwable> leaveGroup() {
+        if (!transport.group()) {
+            return CompletableFuture.completedFuture(null);
+        }
+        return transport.leave().handle((ignored, error) -> {
+            Throwable cause = error == null ? null : unwrap(error);
+            return cause instanceof IggyServerException server
+                            && server.getRawErrorCode() == IggyErrorCode.CONSUMER_GROUP_MEMBER_NOT_FOUND.getCode()
+                    ? null
+                    : cause;
+        });
+    }
+
+    /** With {@code Auto}, the final store of each partition. */
+    private CompletableFuture<Void> flushThen() {
+        if (autoCommit.isEmpty()) {
+            return CompletableFuture.completedFuture(null);
+        }
+        CompletableFuture<Void> pending = flushing == null ? CompletableFuture.completedFuture(null) : flushing;
+        return pending.handle((ignored, error) -> null).thenComposeAsync(ignored -> storeCounted(), loop);
+    }
+
+    /** Applies the seeks that waited for a read in flight, store included, then moves the other partitions back. */
+    private CompletableFuture<Void> seekThenRewind(Set<Long> reading) {
+        List<CompletableFuture<Void>> seeks = new ArrayList<>();
+        for (Partition partition : partitions.values()) {
+            if (partition.pendingSeek != null) {
+                reading.remove(partition.id);
+                seeks.add(applyPendingSeek(partition));
+            }
+        }
+        CompletableFuture<Void> rewound = rewind(reading);
+        return CompletableFuture.allOf(seeks.toArray(CompletableFuture[]::new))
+                .handle((ignored, seekError) -> seekError)
+                .thenCompose(seekError ->
+                        rewound.handle((ignored, rewindError) -> seekError != null ? seekError : rewindError))
+                .thenCompose(error -> error == null
+                        ? CompletableFuture.completedFuture(null)
+                        : CompletableFuture.failedFuture(unwrap(error)));
+    }
+
+    /** Moves the offset of each partition with undelivered messages back to its last delivered message. */
+    @SuppressWarnings({"checkstyle:CyclomaticComplexity", "checkstyle:NPathComplexity"})
+    private CompletableFuture<Void> rewind(Set<Long> reading) {
+        Map<Long, BigInteger> firstQueued = new HashMap<>();
+        for (Entry entry : queue) {
+            if (entry.batch != null && !entry.batch.messages().isEmpty()) {
+                firstQueued.putIfAbsent(
+                        entry.partition, entry.batch.messages().get(0).header().offset());
+            }
+        }
+        queue.clear();
+        CompletableFuture<Void> chain = CompletableFuture.completedFuture(null);
+        List<Throwable> failures = new ArrayList<>();
+        for (Partition partition : partitions.values()) {
+            boolean undelivered = firstQueued.containsKey(partition.id)
+                    || reading.contains(partition.id)
+                    || (partition.received != null
+                            && (partition.delivered == null
+                                    || partition.received.compareTo(partition.delivered.offset()) > 0))
+                    || (partition.lost != null
+                            && (partition.delivered == null
+                                    || partition.lost.from().compareTo(partition.delivered.offset()) > 0));
+            if (!undelivered) {
+                continue;
+            }
+            BigInteger offset;
+            PartitionContext context;
+            BigInteger before = partition.baseline != null
+                    ? partition.baseline
+                    : partition.lost != null ? partition.lost.from() : null;
+            if (partition.delivered != null) {
+                offset = partition.delivered.offset();
+                context = partition.delivered.context();
+            } else if (before != null) {
+                context = partition.baseline != null ? partition.context : partition.lost.context();
+                if (before.signum() == 0) {
+                    chain = chain.thenCompose(
+                            ignored -> transport.delete(partition.id, context).exceptionally(error -> {
+                                failures.add(unwrap(error));
+                                return null;
+                            }));
+                    continue;
+                }
+                offset = before.subtract(BigInteger.ONE);
+            } else if (start.kind() == PollingKind.Next
+                    && firstQueued.containsKey(partition.id)
+                    && firstQueued.get(partition.id).signum() > 0) {
+                offset = firstQueued.get(partition.id).subtract(BigInteger.ONE);
+                context = partition.context;
+            } else {
+                log.warn("Cannot move the offset of partition {} back, no message of it was delivered", partition.id);
+                continue;
+            }
+            chain = chain.thenCompose(
+                    ignored -> transport.store(partition.id, offset, context).exceptionally(error -> {
+                        failures.add(unwrap(error));
+                        return null;
+                    }));
+        }
+        return chain.thenCompose(ignored -> failures.isEmpty()
+                ? CompletableFuture.completedFuture(null)
+                : CompletableFuture.failedFuture(failures.get(0)));
+    }
+
+    private CompletableFuture<Void> applySeek(Partition partition, PollingStrategy position) {
+        partition.generation = ++lastGeneration;
+        partition.stopped = null;
+        partition.retryAt = 0;
+        partition.lastingRefusals = 0;
+        partition.received = null;
+        partition.baseline = null;
+        partition.delivered = null;
+        partition.firstDelivered = null;
+        partition.committed = null;
+        partition.counted = null;
+        partition.sinceStore = 0;
+        partition.gapFrom = null;
+        partition.held = null;
+        partition.lost = null;
+        partition.backlogged = false;
+        partition.fromRead = position.context().isPresent();
+        CompletableFuture<Void> stored = CompletableFuture.completedFuture(null);
+        if (onRead && position.kind() == PollingKind.Offset && position.value().signum() > 0) {
+            // The server stores forward only on a read, so a store moves the offset that the next read starts after.
+            position.context().ifPresent(context -> partition.context = context);
+            partition.baseline = position.value();
+            partition.position = PollingStrategy.next();
+            stored = transport.store(partition.id, position.value().subtract(BigInteger.ONE), partition.context);
+        } else {
+            partition.position = position;
+        }
+        pump();
+        return stored;
+    }
+
+    private void addPartition(long id) {
+        Partition partition = new Partition(id);
+        partition.generation = ++lastGeneration;
+        partition.position = start;
+        partition.fromRead = start.context().isPresent();
+        partitions.put(id, partition);
+    }
+
+    private void publishPartitions() {
+        partitionIds = List.copyOf(partitions.keySet());
+    }
+
+    /** Sends what the budget and the demand allow. */
+    @SuppressWarnings({"checkstyle:CyclomaticComplexity", "checkstyle:NPathComplexity"})
+    private void pump() {
+        if (closed != null) {
+            return;
+        }
+        long now = System.nanoTime();
+        if (transport.group() && !refreshing && due(now, refreshAt)) {
+            refreshAssignment();
+        }
+        if (regroup && !grouping && !partitions.isEmpty() && due(now, regroupAt)) {
+            regroup();
+        }
+        if (!grouping) {
+            for (Partition partition : partitions.values()) {
+                if (partition.gapFrom != null
+                        && !partition.inFlight
+                        && partition.stopped == null
+                        && due(now, partition.retryAt)) {
+                    readGap(partition);
+                }
+            }
+        }
+        if (!grouping && !groups.isEmpty() && demanded()) {
+            readGroups(slots - queue.size() - reads.size(), now);
+        }
+        scheduleWake(now);
+    }
+
+    /**
+     * Every group with ready partitions gets a read, those without a read that may wait in flight first. The slots left
+     * spread the backlogged partitions of each group over reads of their own, each with the whole limits. Such a read
+     * returns at once, so each slot keeps a full read in flight, and the quiet partitions share the one read that may
+     * wait. Under {@code OnRead} that read stores, so a group holds one at a time, and a waiting call fills the slots.
+     */
+    @SuppressWarnings({"checkstyle:CyclomaticComplexity", "checkstyle:NPathComplexity"})
+    private void readGroups(int budget, long now) {
+        int first = groupCursor;
+        List<GroupPlan> plans = new ArrayList<>();
+        for (boolean waiting : new boolean[] {false, true}) {
+            for (int turn = 0; turn < groups.size(); turn++) {
+                int index = (first + turn) % groups.size();
+                List<Long> group = groups.get(index);
+                boolean parked = group.stream()
+                        .map(partitions::get)
+                        .anyMatch(partition -> partition != null && partition.inFlight && partition.parked);
+                List<Partition> ready = parked == waiting ? ready(group, now, parked) : List.of();
+                if (!ready.isEmpty() && plans.size() < budget) {
+                    plans.add(new GroupPlan(index, ready));
+                }
+            }
+        }
+        int extra = budget - plans.size();
+        boolean dealt = true;
+        while (extra > 0 && dealt) {
+            dealt = false;
+            for (GroupPlan plan : plans) {
+                if (extra > 0 && plan.extra < plan.room()) {
+                    plan.extra++;
+                    extra--;
+                    dealt = true;
+                }
+            }
+        }
+        for (GroupPlan plan : plans) {
+            int chunks = plan.extra + (plan.quiet.isEmpty() ? 1 : 0);
+            List<Partition> waiting = new ArrayList<>(plan.quiet);
+            List<List<Partition>> spread = new ArrayList<>();
+            for (int chunk = 0; chunk < chunks; chunk++) {
+                spread.add(new ArrayList<>());
+            }
+            for (int at = 0; at < plan.backlogged.size(); at++) {
+                (chunks == 0 ? waiting : spread.get(at % chunks)).add(plan.backlogged.get(at));
+            }
+            if (!waiting.isEmpty()) {
+                read(targetsOf(waiting), options.count(), onRead, true);
+            }
+            spread.forEach(targets -> read(targetsOf(targets), options.count(), onRead, false));
+            groupCursor = plan.index + 1;
+        }
+    }
+
+    /** Under {@code OnRead} the reader reads only for a waiting {@link #next()}, so it stores only what it hands over. */
+    private boolean demanded() {
+        waiters.removeIf(waiter -> waiter.future.isDone());
+        return !onRead || (!waiters.isEmpty() && queue.isEmpty());
+    }
+
+    /**
+     * The partitions served last go last, since a read that reaches its count serves its first targets. Under
+     * {@code OnRead} a quiet partition waits for the read of the group that may wait, since a second one would store.
+     */
+    private List<Partition> ready(List<Long> group, long now, boolean parked) {
+        List<Partition> ready = new ArrayList<>();
+        for (Long id : group) {
+            Partition partition = partitions.get(id);
+            if (partition != null
+                    && !partition.inFlight
+                    && partition.stopped == null
+                    && partition.gapFrom == null
+                    && due(now, partition.retryAt)
+                    && (partition.backlogged || !onRead || !parked)) {
+                ready.add(partition);
+            }
+        }
+        ready.sort(Comparator.comparingLong(partition -> partition.servedAt));
+        return ready;
+    }
+
+    private static Map<Long, PollingStrategy> targetsOf(List<Partition> ready) {
+        Map<Long, PollingStrategy> targets = new LinkedHashMap<>();
+        ready.forEach(partition -> targets.put(partition.id, partition.position));
+        return targets;
+    }
+
+    private void read(Map<Long, PollingStrategy> targets, long count, boolean commit, boolean parked) {
+        Map<Long, Long> generations = new HashMap<>();
+        // A partition that leaves and comes back gets a new state, which another read may hold.
+        List<Partition> held = new ArrayList<>();
+        targets.keySet().forEach(id -> {
+            Partition partition = partitions.get(id);
+            partition.inFlight = true;
+            partition.parked = parked;
+            generations.put(id, partition.generation);
+            held.add(partition);
+        });
+        CompletableFuture<List<FetchedMessages>> read;
+        try {
+            read = commit
+                    ? prepareRead(targets, generations, count)
+                    : transport.read(
+                            targets,
+                            count,
+                            false,
+                            options.withMinCount(Math.min(count, options.minCount()))
+                                    .deferredOptions());
+        } catch (RuntimeException refused) {
+            read = CompletableFuture.failedFuture(refused);
+        }
+        reads.add(read);
+        CompletableFuture<List<FetchedMessages>> sent = read;
+        read.whenCompleteAsync(
+                (sections, error) -> {
+                    reads.remove(sent);
+                    if (closed != null) {
+                        return;
+                    }
+                    held.forEach(partition -> partition.inFlight = false);
+                    if (error == null) {
+                        accept(targets, generations, commit, sections);
+                    } else {
+                        refuse(targets.keySet(), error);
+                    }
+                    applyPendingSeeks(held);
+                    handOver();
+                    pump();
+                },
+                loop);
+    }
+
+    private CompletableFuture<List<FetchedMessages>> prepareRead(
+            Map<Long, PollingStrategy> targets, Map<Long, Long> generations, long count) {
+        List<CompletableFuture<Void>> probes = new ArrayList<>();
+        targets.forEach((id, strategy) -> {
+            Partition partition = partitions.get(id);
+            if (strategy.kind() != PollingKind.Next || partition.received != null || partition.baseline != null) {
+                return;
+            }
+            probes.add(transport
+                    .read(
+                            Map.of(id, strategy),
+                            1,
+                            false,
+                            options.withMinCount(1).deferredOptions())
+                    .thenAcceptAsync(sections -> acceptProbe(id, generations.get(id), sections), loop));
+        });
+        return CompletableFuture.allOf(probes.toArray(CompletableFuture[]::new))
+                .thenComposeAsync(ignored -> commitPrepared(targets, generations, count), loop);
+    }
+
+    private void acceptProbe(long id, long generation, List<FetchedMessages> sections) {
+        Partition partition = partitions.get(id);
+        if (partition == null || partition.generation != generation) {
+            return;
+        }
+        partition.retryAt = System.nanoTime() + retryIntervalNanos;
+        for (FetchedMessages section : sections) {
+            if (section.partitionId() != id) {
+                continue;
+            }
+            if (section.status() != FetchedMessages.STATUS_OK) {
+                refuseSection(partition, section.status(), System.nanoTime());
+            } else {
+                captureBaseline(partition, section, System.nanoTime());
+            }
+        }
+    }
+
+    private CompletableFuture<List<FetchedMessages>> commitPrepared(
+            Map<Long, PollingStrategy> targets, Map<Long, Long> generations, long count) {
+        if (closed != null || !demanded()) {
+            return CompletableFuture.completedFuture(List.of());
+        }
+        Map<Long, PollingStrategy> prepared = new LinkedHashMap<>();
+        targets.forEach((id, strategy) -> {
+            Partition partition = partitions.get(id);
+            if (readyToCommit(partition, generations.get(id), strategy)) {
+                prepared.put(id, partition.position);
+            }
+        });
+        if (prepared.isEmpty()) {
+            return CompletableFuture.completedFuture(List.of());
+        }
+        CompletableFuture<List<FetchedMessages>> sent =
+                transport.read(prepared, count, true, options.deferredOptions());
+        sends.add(sent);
+        sent.whenCompleteAsync((sections, error) -> sends.remove(sent), loop);
+        return sent;
+    }
+
+    private boolean readyToCommit(Partition partition, long generation, PollingStrategy strategy) {
+        return partition != null
+                && partition.generation == generation
+                && partition.pendingSeek == null
+                && partition.stopped == null
+                && (strategy.kind() != PollingKind.Next || partition.received != null || partition.baseline != null);
+    }
+
+    private void applyPendingSeeks(List<Partition> held) {
+        for (Partition partition : held) {
+            if (partitions.get(partition.id) == partition && partition.pendingSeek != null) {
+                applyPendingSeek(partition);
+            }
+        }
+    }
+
+    private CompletableFuture<Void> applyPendingSeek(Partition partition) {
+        PollingStrategy position = partition.pendingSeek;
+        CompletableFuture<Void> sought = partition.sought;
+        partition.pendingSeek = null;
+        partition.sought = null;
+        queue.removeIf(entry -> entry.partition == partition.id);
+        CompletableFuture<Void> applied = applySeek(partition, position);
+        applied.whenComplete((ignored, error) -> complete(sought, error));
+        return applied;
+    }
+
+    @SuppressWarnings({"checkstyle:CyclomaticComplexity", "checkstyle:NPathComplexity"})
+    private void accept(
+            Map<Long, PollingStrategy> targets,
+            Map<Long, Long> generations,
+            boolean commit,
+            List<FetchedMessages> sections) {
+        Set<Long> answered = new HashSet<>();
+        long now = System.nanoTime();
+        for (FetchedMessages section : sections) {
+            Partition partition = partitions.get(section.partitionId());
+            answered.add(section.partitionId());
+            Long generation = generations.get(section.partitionId());
+            Optional<IggyOperationException> unknown = section.recoveryError();
+            // The lost read blocks its partition until abandoned, also after a seek or a revoke.
+            unknown.ifPresent(lost -> transport.abandon(lost.getOperationId()));
+            if (generation == null || !keeps(section.partitionId(), generation)) {
+                continue;
+            }
+            if (commit && (partition == null || partition.generation != generation)) {
+                // The server stored this read, so a partition that left the assignment still hands it over.
+                receiveStored(section, generations.get(section.partitionId()));
+                continue;
+            }
+            if (partition == null
+                    || partition.generation != generations.getOrDefault(section.partitionId(), -1L)
+                    || partition.pendingSeek != null) {
+                continue;
+            }
+            if (unknown.isPresent()) {
+                // A later read of the server shows what the lost read stored, and the gap check reads it again.
+                lose(partition, now);
+                continue;
+            }
+            if (section.status() != FetchedMessages.STATUS_OK) {
+                refuseSection(partition, section.status(), now);
+                continue;
+            }
+            partition.lastingRefusals = 0;
+            partition.context = section.context();
+            receive(partition, section, commit, targets.get(section.partitionId()), now);
+        }
+        for (Long id : targets.keySet()) {
+            Partition partition = partitions.get(id);
+            if (!answered.contains(id) && partition != null && options.maxWait().isZero()) {
+                partition.retryAt = now + retryIntervalNanos;
+            }
+        }
+    }
+
+    private void captureBaseline(Partition partition, FetchedMessages section, long now) {
+        List<Message> messages = new ArrayList<>();
+        Optional<DecodeFailure> malformed =
+                BytesDeserializer.readMessagesUntilMalformed(Unpooled.wrappedBuffer(section.records()), messages);
+        if (malformed.isPresent()) {
+            BigInteger bad = undecoded(section, malformed.get(), messages);
+            partition.stopped = new ReadException(
+                    OptionalLong.of(partition.id),
+                    malformed.get(),
+                    Optional.of(skipPast(partition.id, bad, section.context())));
+            queue.addLast(new Entry(partition.id, partition.generation, null, partition.stopped));
+        } else if (messages.isEmpty()) {
+            partition.retryAt = now + retryIntervalNanos;
+        } else {
+            partition.baseline = messages.get(0).header().offset();
+            partition.context = section.context();
+            partition.retryAt = 0;
+            partition.position = new PollingStrategy(PollingKind.Next, BigInteger.ZERO, Optional.of(section.context()));
+        }
+    }
+
+    @SuppressWarnings({"checkstyle:CyclomaticComplexity", "checkstyle:NPathComplexity"})
+    private void receive(Partition partition, FetchedMessages section, boolean commit, PollingStrategy from, long now) {
+        List<Message> messages = new ArrayList<>();
+        Optional<DecodeFailure> malformed =
+                BytesDeserializer.readMessagesUntilMalformed(Unpooled.wrappedBuffer(section.records()), messages);
+        // A read that ends before the head returns at once. An empty partition reports the head 0.
+        partition.backlogged = section.nextOffset().compareTo(section.currentOffset()) <= 0
+                && (section.count() > 0 || section.currentOffset().signum() > 0);
+        BigInteger expected = expected(partition);
+        // Only a lost read explains a later start. Without one, the application or another session stored it.
+        boolean catchesUp = commit && partition.gapFrom == null && partition.lost != null && expected != null;
+        if (messages.isEmpty() && malformed.isEmpty()) {
+            if (catchesUp && section.nextOffset().compareTo(expected) > 0) {
+                partition.gapFrom = expected;
+                partition.gapEnd = section.nextOffset();
+                partition.held =
+                        new PolledMessages(partition.id, section.currentOffset(), 0L, List.of(), section.context());
+                partition.received = section.nextOffset().subtract(BigInteger.ONE);
+                readGap(partition);
+                return;
+            }
+            if (!commit && partition.gapFrom != null) {
+                skipGap(partition, section.nextOffset(), now);
+                return;
+            }
+            if (options.maxWait().isZero()) {
+                partition.retryAt = now + retryIntervalNanos;
+            }
+            return;
+        }
+        if (!messages.isEmpty()) {
+            partition.servedAt = ++serves;
+            PolledMessages batch = new PolledMessages(
+                    partition.id, section.currentOffset(), (long) messages.size(), messages, section.context());
+            BigInteger first = messages.get(0).header().offset();
+            BigInteger last = messages.get(messages.size() - 1).header().offset();
+            if (catchesUp && first.compareTo(expected) > 0) {
+                // A read whose reply was lost stored up to here, so the messages in between are read again first.
+                partition.gapFrom = expected;
+                partition.gapEnd = first;
+                partition.held = batch;
+                partition.received = last;
+                readGap(partition);
+            } else if (partition.gapFrom != null && !commit) {
+                acceptGap(partition, batch);
+            } else {
+                if (commit) {
+                    // This store fences out the lost read, so a later start no longer comes from it.
+                    partition.lost = null;
+                }
+                partition.received = last;
+                queue.addLast(new Entry(partition.id, partition.generation, batch, null));
+            }
+            partition.position =
+                    onRead ? PollingStrategy.next() : batch.continuation().orElse(from);
+            partition.fromRead = !onRead;
+        }
+        malformed.ifPresent(failure -> {
+            BigInteger bad = undecoded(section, failure, messages);
+            if (commit) {
+                // The server stored the whole read, so the close moves the offset back before the bad message.
+                remember(partition, bad, section.context());
+            }
+            partition.stopped = new ReadException(
+                    OptionalLong.of(partition.id),
+                    failure,
+                    Optional.of(skipPast(partition.id, bad, section.context())));
+            queue.addLast(new Entry(partition.id, partition.generation, null, partition.stopped));
+        });
+    }
+
+    /**
+     * A committing read whose outcome is unknown can have stored from the expected offset on. The close moves the
+     * offset back over it, a later read catches up a forward gap, and the partition waits the retry interval.
+     */
+    private void lose(Partition partition, long now) {
+        BigInteger expected = expected(partition);
+        if (expected == null && partition.position.kind() == PollingKind.Offset) {
+            // The first read from an offset start can have stored from there.
+            remember(
+                    partition,
+                    partition.position.value(),
+                    partition.position.context().orElse(partition.context));
+        } else {
+            remember(partition, expected, partition.context);
+        }
+        partition.retryAt = now + retryIntervalNanos;
+    }
+
+    /** Queues the messages of a stored read of a partition that left the assignment. */
+    private void receiveStored(FetchedMessages section, long generation) {
+        if (section.status() != FetchedMessages.STATUS_OK
+                || section.recoveryError().isPresent()) {
+            return;
+        }
+        List<Message> messages = new ArrayList<>();
+        BytesDeserializer.readMessagesUntilMalformed(Unpooled.wrappedBuffer(section.records()), messages);
+        if (!messages.isEmpty()) {
+            PolledMessages batch = new PolledMessages(
+                    section.partitionId(),
+                    section.currentOffset(),
+                    (long) messages.size(),
+                    messages,
+                    section.context());
+            queue.addLast(new Entry(section.partitionId(), generation, batch, null));
+        }
+    }
+
+    /** Reads the messages between the last received one and the batch that a lost read moved past, without a store. */
+    private void readGap(Partition partition) {
+        BigInteger missing = partition.gapEnd.subtract(partition.gapFrom);
+        long count = Math.min(
+                options.count(),
+                missing.min(BigInteger.valueOf(Integer.MAX_VALUE)).longValue());
+        Map<Long, PollingStrategy> targets = new LinkedHashMap<>();
+        targets.put(
+                partition.id,
+                new PollingStrategy(PollingKind.Offset, partition.gapFrom, Optional.of(partition.context)));
+        read(targets, count, false, false);
+    }
+
+    private void acceptGap(Partition partition, PolledMessages read) {
+        BigInteger heldFirst = partition.gapEnd;
+        List<Message> missing = read.messages().stream()
+                .filter(message -> message.header().offset().compareTo(heldFirst) < 0)
+                .toList();
+        if (!missing.isEmpty()) {
+            queue.addLast(new Entry(
+                    partition.id,
+                    partition.generation,
+                    new PolledMessages(
+                            partition.id, read.currentOffset(), (long) missing.size(), missing, read.context()),
+                    null));
+            partition.gapFrom =
+                    missing.get(missing.size() - 1).header().offset().add(BigInteger.ONE);
+        }
+        if (missing.isEmpty() || partition.gapFrom.compareTo(heldFirst) >= 0) {
+            closeGap(partition);
+        } else {
+            readGap(partition);
+        }
+    }
+
+    /** An empty gap read: the messages before its next offset are gone. */
+    private void skipGap(Partition partition, BigInteger next, long now) {
+        if (next.compareTo(partition.gapFrom) > 0) {
+            partition.gapFrom = next;
+        }
+        if (partition.gapFrom.compareTo(partition.gapEnd) >= 0) {
+            closeGap(partition);
+        } else if (options.maxWait().isZero()) {
+            partition.retryAt = now + retryIntervalNanos;
+        }
+    }
+
+    private void closeGap(Partition partition) {
+        if (!partition.held.messages().isEmpty()) {
+            queue.addLast(new Entry(partition.id, partition.generation, partition.held, null));
+        }
+        partition.gapFrom = null;
+        partition.held = null;
+    }
+
+    private void refuseSection(Partition partition, long status, long now) {
+        int code = (int) status;
+        handleRefusal(partition, code, IggyServerException.fromTcpResponse(code, new byte[0]), now);
+    }
+
+    /** Another member owns the partition now, so the messages that the lost read stored are gone for this one. */
+    private void giveUpGap(Partition partition) {
+        ReadException lost = new ReadException(
+                OptionalLong.of(partition.id),
+                new IggyServerException(
+                        IggyErrorCode.FETCH_PROGRESS_UNCERTAIN,
+                        IggyErrorCode.FETCH_PROGRESS_UNCERTAIN.getCode(),
+                        "Offsets " + partition.gapFrom + " to "
+                                + partition.gapEnd.subtract(BigInteger.ONE)
+                                + " were stored by a read whose reply was lost",
+                        Optional.empty(),
+                        Optional.empty()),
+                Optional.empty());
+        queue.addLast(new Entry(partition.id, partition.generation, null, lost));
+        if (!partition.held.messages().isEmpty()) {
+            queue.addLast(new Entry(partition.id, partition.generation, partition.held, null));
+        }
+        partition.gapFrom = null;
+        partition.held = null;
+    }
+
+    @SuppressWarnings({"checkstyle:CyclomaticComplexity", "checkstyle:NPathComplexity"})
+    private void refuse(Set<Long> targets, Throwable error) {
+        Throwable cause = unwrap(error);
+        long now = System.nanoTime();
+        if (cause instanceof CancellationException) {
+            return;
+        }
+        if (cause instanceof IggyOperationException operation
+                && operation.getErrorCode() == IggyErrorCode.FETCH_PROGRESS_UNCERTAIN) {
+            // The next read of the server starts after what the lost read stored, and the gap check reads it again.
+            transport.abandon(operation.getOperationId());
+            for (Long id : targets) {
+                Partition partition = partitions.get(id);
+                if (partition != null) {
+                    lose(partition, now);
+                }
+            }
+            return;
+        }
+        if (cause instanceof IggyServerException server) {
+            int code = server.getRawErrorCode();
+            if (code == IggyErrorCode.NOT_PRIMARY.getCode()) {
+                regroup = true;
+                return;
+            }
+            for (Long id : targets) {
+                Partition partition = partitions.get(id);
+                if (partition != null) {
+                    handleRefusal(partition, code, server, now);
+                }
+            }
+            return;
+        }
+        targets.forEach(id -> {
+            Partition partition = partitions.get(id);
+            if (partition != null) {
+                partition.retryAt = now + retryIntervalNanos;
+            }
+        });
+        log.warn("A read of partitions {} failed, the reader retries it: {}", targets, cause.getMessage());
+        if (queue.isEmpty() || queue.peekLast().partition != WHOLE_READER) {
+            queue.addLast(
+                    new Entry(WHOLE_READER, 0, null, new ReadException(OptionalLong.empty(), cause, Optional.empty())));
+        }
+    }
+
+    @SuppressWarnings({"checkstyle:CyclomaticComplexity", "checkstyle:NPathComplexity"})
+    private void handleRefusal(Partition partition, int code, IggyServerException error, long now) {
+        if (code == IggyErrorCode.NOT_PRIMARY.getCode()) {
+            regroup = true;
+            return;
+        }
+        if (code == IggyErrorCode.CONSUMER_GROUP_PARTITION_NOT_OWNED.getCode()) {
+            if (partition.gapFrom != null) {
+                giveUpGap(partition);
+            }
+            // A rejoin gives the partition a new owner generation, which only a new start resolves.
+            partition.position = start;
+            partition.fromRead = start.context().isPresent();
+            partition.context = PartitionContext.EMPTY;
+            partition.received = null;
+            partition.baseline = null;
+            partition.lost = null;
+            partition.backlogged = false;
+            refreshAt = now;
+            partition.retryAt = now + retryIntervalNanos;
+            if (++partition.lastingRefusals >= LASTING_REFUSALS_BEFORE_STOP) {
+                partition.lastingRefusals = 0;
+                queue.addLast(new Entry(
+                        partition.id,
+                        partition.generation,
+                        null,
+                        new ReadException(OptionalLong.of(partition.id), error, Optional.empty())));
+            }
+            return;
+        }
+        if (code == IggyErrorCode.TRANSIENT_NOT_ACCEPTED.getCode()
+                || code == IggyErrorCode.TRANSIENT_NOT_COMMITTED.getCode()
+                || code == IggyErrorCode.DEFERRED_POLL_CAPACITY_EXCEEDED.getCode()) {
+            partition.retryAt = now + retryIntervalNanos;
+            return;
+        }
+        if (code == IggyErrorCode.HISTORY_UNAVAILABLE.getCode() && !partition.fromRead) {
+            log.warn("Partition {} has a new history, the reader reads it again", partition.id);
+            regroup = true;
+            partition.retryAt = now + retryIntervalNanos;
+            return;
+        }
+        boolean stops = code == IggyErrorCode.HISTORY_UNAVAILABLE.getCode()
+                || code == IggyErrorCode.INVALID_SIZE_BYTES.getCode()
+                || ++partition.lastingRefusals >= LASTING_REFUSALS_BEFORE_STOP;
+        ReadException failure = new ReadException(OptionalLong.of(partition.id), error, Optional.empty());
+        if (stops) {
+            partition.stopped = failure;
+            queue.addLast(new Entry(partition.id, partition.generation, null, failure));
+            return;
+        }
+        int shift = Math.min(partition.lastingRefusals - 1, MAX_BACKOFF_SHIFT);
+        partition.retryAt = now + (retryIntervalNanos << shift);
+        log.warn("Partition {} refused a read, the reader retries it: {}", partition.id, error.getMessage());
+    }
+
+    private void regroup() {
+        grouping = true;
+        regroup = false;
+        transport
+                .groups(List.copyOf(partitions.keySet()))
+                .whenCompleteAsync(
+                        (found, error) -> {
+                            grouping = false;
+                            if (closed != null) {
+                                return;
+                            }
+                            if (error != null) {
+                                regroup = true;
+                                regroupAt = System.nanoTime() + retryIntervalNanos;
+                                log.warn(
+                                        "Failed to group the partitions of a reader, the reader tries again: {}",
+                                        error.getMessage());
+                                partitions.values().forEach(partition -> partition.retryAt = regroupAt);
+                            } else {
+                                groups = found;
+                            }
+                            pump();
+                        },
+                        loop);
+    }
+
+    private void refreshAssignment() {
+        refreshing = true;
+        transport
+                .assignment()
+                .thenCompose(assignment -> assignment.isPresent()
+                        ? CompletableFuture.completedFuture(assignment)
+                        : transport.join().thenCompose(ignored -> transport.assignment()))
+                .whenCompleteAsync(
+                        (assignment, error) -> {
+                            refreshing = false;
+                            refreshAt = System.nanoTime() + retryIntervalNanos;
+                            if (closed != null) {
+                                return;
+                            }
+                            if (error != null) {
+                                log.warn(
+                                        "Failed to read the group assignment of a reader, it tries again: {}",
+                                        error.getMessage());
+                            } else {
+                                assignment.ifPresent(found -> assign(found.partitions()));
+                            }
+                            handOver();
+                            pump();
+                        },
+                        loop);
+    }
+
+    @SuppressWarnings({"checkstyle:CyclomaticComplexity", "checkstyle:NPathComplexity"})
+    private void assign(List<Long> assigned) {
+        Set<Long> next = new HashSet<>(assigned);
+        boolean changed = false;
+        for (Long id : List.copyOf(partitions.keySet())) {
+            if (!next.contains(id)) {
+                Partition revoked = partitions.remove(id);
+                failSeek(revoked, invalid("Partition " + id + " left the assignment of this reader"));
+                if (!onRead) {
+                    queue.removeIf(entry -> entry.partition == id);
+                } else if (revoked.gapFrom != null) {
+                    giveUpGap(revoked);
+                }
+                if (revoked.counted != null && revoked.counted != revoked.stored) {
+                    // The partition goes to another member, so its counted position is stored first.
+                    retiring.add(new Retired(revoked.id, revoked.counted));
+                }
+                changed = true;
+            }
+        }
+        for (Long id : assigned) {
+            if (!partitions.containsKey(id)) {
+                addPartition(id);
+                changed = true;
+            }
+        }
+        if (changed) {
+            regroup = true;
+            publishPartitions();
+            if (!retiring.isEmpty()) {
+                flush();
+            }
+        }
+    }
+
+    /** Hands the queued batches over to the waiting calls, in order. */
+    @SuppressWarnings({"checkstyle:CyclomaticComplexity", "checkstyle:NPathComplexity"})
+    private void handOver() {
+        while (!waiters.isEmpty() && !queue.isEmpty()) {
+            Waiter waiting = waiters.pollFirst();
+            CompletableFuture<PolledMessages> waiter = waiting.future;
+            if (waiter.isDone()) {
+                continue;
+            }
+            Entry entry = queue.pollFirst();
+            if (!current(entry)) {
+                waiters.addFirst(waiting);
+                continue;
+            }
+            if (entry.error != null) {
+                if (!waiter.completeExceptionally(entry.error)) {
+                    queue.addFirst(entry);
+                }
+                continue;
+            }
+            if (!waiter.complete(entry.batch)) {
+                queue.addFirst(entry);
+                continue;
+            }
+            Partition partition = partitions.get(entry.partition);
+            if (waiting.delivers && partition != null) {
+                delivered(partition, entry.batch);
+            }
+        }
+        if (onRead && !waiters.isEmpty() && queue.isEmpty()) {
+            pump();
+        }
+    }
+
+    /**
+     * Whether an entry still counts. A seek or a new assignment of its partition drops it. A partition that left the
+     * assignment keeps the reads that the server stored under {@code OnRead}.
+     */
+    private boolean current(Entry entry) {
+        if (entry.partition == WHOLE_READER) {
+            return true;
+        }
+        return keeps(entry.partition, entry.generation);
+    }
+
+    private boolean keeps(long partitionId, long generation) {
+        Partition partition = partitions.get(partitionId);
+        return (partition != null && partition.generation == generation)
+                || (onRead && generation >= seekGenerations.getOrDefault(partitionId, 0L));
+    }
+
+    private void delivered(Partition partition, PolledMessages batch) {
+        Checkpoint last = batch.checkpoint().orElseThrow();
+        if (partition.firstDelivered == null) {
+            partition.firstDelivered = batch.messages().get(0).header().offset();
+        }
+        partition.delivered = last;
+        CommitAfter after = autoCommit.map(AutoCommit::after).orElse(CommitAfter.MARKED);
+        if (after == CommitAfter.DELIVERY) {
+            count(partition, last, batch.messages().size(), true);
+        } else if (after == CommitAfter.PROCESSING) {
+            uncounted = new Uncounted(
+                    partition.id, partition.generation, last, batch.messages().size());
+        }
+    }
+
+    private void countUncounted() {
+        if (uncounted == null) {
+            return;
+        }
+        Uncounted batch = uncounted;
+        uncounted = null;
+        Partition partition = partitions.get(batch.partition);
+        if (partition != null && partition.generation == batch.generation) {
+            count(partition, batch.checkpoint, batch.messages, true);
+        }
+    }
+
+    private void count(Partition partition, Checkpoint checkpoint, long messages, boolean endsBatch) {
+        if (autoCommit.isEmpty()) {
+            return;
+        }
+        if (partition.counted == null || checkpoint.offset().compareTo(partition.counted.offset()) > 0) {
+            partition.counted = checkpoint;
+        }
+        partition.sinceStore += messages;
+        boolean due = autoCommit
+                .get()
+                .every()
+                .map(every -> every instanceof CommitEvery.Message
+                        || (every instanceof CommitEvery.Messages many && partition.sinceStore >= many.count())
+                        || (every instanceof CommitEvery.Batch && endsBatch))
+                .orElse(false);
+        if (due) {
+            flush();
+        }
+    }
+
+    /** Stores the counted positions once the store in flight ends. */
+    private void flush() {
+        if (flushing != null) {
+            flushAgain = true;
+            return;
+        }
+        flushing = storeCounted();
+        flushing.whenCompleteAsync(
+                (ignored, error) -> {
+                    flushing = null;
+                    if (error != null) {
+                        log.error(
+                                "Failed to store the counted offsets of a reader: {}",
+                                unwrap(error).getMessage());
+                    }
+                    if (flushAgain && closed == null) {
+                        flushAgain = false;
+                        flush();
+                    }
+                },
+                loop);
+    }
+
+    /** Stores the retired positions, then each counted position ahead of its last store, one at a time. */
+    private CompletableFuture<Void> storeCounted() {
+        List<Retired> stores = new ArrayList<>(retiring);
+        retiring.clear();
+        for (Partition partition : partitions.values()) {
+            if (partition.counted != null && partition.counted != partition.stored) {
+                stores.add(new Retired(partition.id, partition.counted));
+            }
+        }
+        CompletableFuture<Void> chain = CompletableFuture.completedFuture(null);
+        List<Throwable> failures = new ArrayList<>();
+        for (Retired store : stores) {
+            Checkpoint checkpoint = store.checkpoint;
+            long sinceStore = Optional.ofNullable(partitions.get(store.partition))
+                    .map(partition -> partition.sinceStore)
+                    .orElse(0L);
+            chain = chain.thenCompose(ignored -> transport
+                    .store(store.partition, checkpoint.offset(), checkpoint.context())
+                    .handleAsync(
+                            (stored, error) -> {
+                                if (error != null) {
+                                    failures.add(unwrap(error));
+                                    return null;
+                                }
+                                Partition partition = partitions.get(store.partition);
+                                if (partition != null && partition.delivered(checkpoint)) {
+                                    partition.stored = checkpoint;
+                                    partition.sinceStore = Math.max(0, partition.sinceStore - sinceStore);
+                                }
+                                return null;
+                            },
+                            loop));
+        }
+        return chain.thenCompose(ignored -> failures.isEmpty()
+                ? CompletableFuture.completedFuture(null)
+                : CompletableFuture.failedFuture(failures.get(0)));
+    }
+
+    private void recordCommit(Checkpoint checkpoint, long generation) {
+        Partition partition = partitions.get(checkpoint.partitionId());
+        if (partition != null
+                && partition.generation == generation
+                && (partition.committed == null || checkpoint.offset().compareTo(partition.committed) > 0)) {
+            partition.committed = checkpoint.offset();
+        }
+    }
+
+    private Partition delivering(Checkpoint checkpoint) {
+        Partition partition = partitions.get(checkpoint.partitionId());
+        if (partition == null || !partition.delivered(checkpoint)) {
+            throw invalid("This reader did not deliver " + checkpoint + " in the current generation of its partition");
+        }
+        return partition;
+    }
+
+    @SuppressWarnings({"checkstyle:CyclomaticComplexity", "checkstyle:NPathComplexity"})
+    private void scheduleWake(long now) {
+        long earliest = Long.MAX_VALUE;
+        for (Partition partition : partitions.values()) {
+            if (!partition.inFlight && partition.stopped == null && !due(now, partition.retryAt)) {
+                earliest = Math.min(earliest, partition.retryAt - now);
+            }
+        }
+        if (transport.group() && !refreshing) {
+            earliest = Math.min(earliest, due(now, refreshAt) ? 0 : refreshAt - now);
+        }
+        if (regroup && !grouping) {
+            earliest = Math.min(earliest, due(now, regroupAt) ? retryIntervalNanos : regroupAt - now);
+        }
+        if (earliest == Long.MAX_VALUE) {
+            return;
+        }
+        // A wake that is due runs this pump now, so only a later one still covers the next turn.
+        long pending = wake == null || wake.isDone() ? 0 : wake.getDelay(TimeUnit.NANOSECONDS);
+        if (pending > 0 && pending <= earliest) {
+            return;
+        }
+        cancel(wake);
+        wake = loop.schedule(this::pump, earliest, TimeUnit.NANOSECONDS);
+    }
+
+    private void run(Runnable task) {
+        try {
+            loop.execute(task);
+        } catch (RejectedExecutionException closedReader) {
+            log.debug("The reader is closed: {}", closedReader.getMessage());
+        }
+    }
+
+    private <T> CompletableFuture<T> call(Supplier<T> task) {
+        CompletableFuture<T> result = new CompletableFuture<>();
+        try {
+            loop.execute(() -> {
+                try {
+                    if (closed != null) {
+                        throw closedError();
+                    }
+                    result.complete(task.get());
+                } catch (RuntimeException error) {
+                    result.completeExceptionally(error);
+                }
+            });
+        } catch (RejectedExecutionException closedReader) {
+            result.completeExceptionally(closedError());
+        }
+        return result;
+    }
+
+    /** The first message that does not decode. Malformed framing isolates none, so it follows the healthy prefix. */
+    private static BigInteger undecoded(FetchedMessages section, DecodeFailure failure, List<Message> messages) {
+        return failure.offset()
+                .orElseGet(() -> messages.isEmpty()
+                        ? section.nextOffset().subtract(BigInteger.valueOf(section.count()))
+                        : messages.get(messages.size() - 1).header().offset().add(BigInteger.ONE));
+    }
+
+    private static PollingStrategy skipPast(long partitionId, BigInteger bad, PartitionContext context) {
+        return new PollingStrategy(
+                PollingKind.Offset, bad.add(BigInteger.ONE), Optional.of(context), Optional.of(partitionId));
+    }
+
+    /** The offset that the next read of the partition starts at, or null before its first read. */
+    private static BigInteger expected(Partition partition) {
+        return partition.received == null ? partition.baseline : partition.received.add(BigInteger.ONE);
+    }
+
+    /** Keeps the lowest start of what the server stored without handing it over. */
+    private static void remember(Partition partition, BigInteger from, PartitionContext context) {
+        if (from != null && (partition.lost == null || from.compareTo(partition.lost.from()) < 0)) {
+            partition.lost = new Lost(from, context);
+        }
+    }
+
+    /** Offsets compare within one incarnation and owner. The server raises the metadata op of a history meanwhile. */
+    private static boolean sameIncarnationAndOwner(PartitionContext left, PartitionContext right) {
+        return left.incarnation().equals(right.incarnation())
+                && left.ownerGeneration().equals(right.ownerGeneration());
+    }
+
+    /** Whether the time {@code at} passed, where zero is no time. */
+    private static boolean due(long now, long at) {
+        return at == 0 || now - at >= 0;
+    }
+
+    private static void failSeek(Partition partition, RuntimeException error) {
+        if (partition.sought != null) {
+            CompletableFuture<Void> sought = partition.sought;
+            partition.pendingSeek = null;
+            partition.sought = null;
+            sought.completeExceptionally(error);
+        }
+    }
+
+    private static void cancel(ScheduledFuture<?> timer) {
+        if (timer != null) {
+            timer.cancel(false);
+        }
+    }
+
+    private static <T> void complete(CompletableFuture<T> future, Throwable error) {
+        if (error == null) {
+            future.complete(null);
+        } else {
+            future.completeExceptionally(unwrap(error));
+        }
+    }
+
+    private static Throwable unwrap(Throwable error) {
+        Throwable cause = error;
+        while (cause instanceof CompletionException && cause.getCause() != null) {
+            cause = cause.getCause();
+        }
+        return cause;
+    }
+
+    private static IggyClientException closedError() {
+        return new IggyClientException("The partition reader is closed");
+    }
+
+    private static IggyValidationException invalid(String reason) {
+        return new IggyValidationException(
+                IggyErrorCode.INVALID_COMMAND,
+                IggyErrorCode.INVALID_COMMAND.getCode(),
+                reason,
+                Optional.empty(),
+                Optional.empty());
+    }
+
+    private static final class Partition {
+        private final long id;
+        private long generation;
+        private PollingStrategy position;
+        // The position came from a read, so it carries the history of the partition.
+        private boolean fromRead;
+        private boolean inFlight;
+        // The read in flight holds quiet partitions, so it may wait.
+        private boolean parked;
+        private long retryAt;
+        // The read turn that last returned messages of the partition.
+        private long servedAt;
+        // The last read ended before the head of the partition.
+        private boolean backlogged;
+        private int lastingRefusals;
+        private ReadException stopped;
+        private PartitionContext context = PartitionContext.EMPTY;
+        // The last offset that a read of the current generation returned.
+        private BigInteger received;
+        private BigInteger baseline;
+        private BigInteger firstDelivered;
+        private Checkpoint delivered;
+        private BigInteger committed;
+        // Under OnRead, the start of the messages that a lost read stored, and the batch read after them.
+        private BigInteger gapFrom;
+        private BigInteger gapEnd;
+        private PolledMessages held;
+        // Under OnRead, the start of what the server stored without handing it over, see lose().
+        private Lost lost;
+        private PollingStrategy pendingSeek;
+        private CompletableFuture<Void> sought;
+        private Checkpoint counted;
+        private Checkpoint stored;
+        private long sinceStore;
+
+        private Partition(long id) {
+            this.id = id;
+        }
+
+        /** Whether this reader delivered {@code checkpoint} in the current generation of the partition. */
+        private boolean delivered(Checkpoint checkpoint) {
+            return delivered != null
+                    && firstDelivered != null
+                    && sameIncarnationAndOwner(checkpoint.context(), delivered.context())
+                    && checkpoint.offset().compareTo(firstDelivered) >= 0
+                    && checkpoint.offset().compareTo(delivered.offset()) <= 0;
+        }
+    }
+
+    private record Entry(long partition, long generation, PolledMessages batch, ReadException error) {}
+
+    /** A waiting call, which records the batch that it takes as delivered unless an {@link IggyConsumer} does. */
+    private record Waiter(CompletableFuture<PolledMessages> future, boolean delivers) {}
+
+    private record Uncounted(long partition, long generation, Checkpoint checkpoint, long messages) {}
+
+    private record Retired(long partition, Checkpoint checkpoint) {}
+
+    private record Lost(BigInteger from, PartitionContext context) {}
+
+    /** The reads of one group in one turn: its quiet partitions share one, its backlogged ones take the extra slots. */
+    private static final class GroupPlan {
+        private final int index;
+        private final List<Partition> quiet = new ArrayList<>();
+        // The least recently served first.
+        private final List<Partition> backlogged = new ArrayList<>();
+        private int extra;
+
+        private GroupPlan(int index, List<Partition> ready) {
+            this.index = index;
+            ready.forEach(partition -> (partition.backlogged ? backlogged : quiet).add(partition));
+        }
+
+        /** The extra slots that the group can use, so that every read holds a backlogged partition. */
+        private int room() {
+            return backlogged.size() - (quiet.isEmpty() ? 1 : 0);
+        }
+    }
+}
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/reader/PartitionReaderBuilder.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/reader/PartitionReaderBuilder.java
new file mode 100644
index 0000000..9565aa0
--- /dev/null
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/reader/PartitionReaderBuilder.java
@@ -0,0 +1,143 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iggy.client.async.reader;
+
+import org.apache.iggy.exception.IggyErrorCode;
+import org.apache.iggy.exception.IggyInvalidArgumentException;
+import org.apache.iggy.exception.IggyValidationException;
+import org.apache.iggy.message.PollOptions;
+import org.apache.iggy.message.PollingKind;
+import org.apache.iggy.message.PollingStrategy;
+
+import java.math.BigInteger;
+import java.time.Duration;
+import java.util.Collection;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+
+/** Builds a {@link PartitionReader}. Get one from the {@code reader} method of a client. */
+public final class PartitionReaderBuilder {
+    /** The wait of a read of a reader, unless its options say otherwise. */
+    public static final Duration DEFAULT_MAX_WAIT = Duration.ofSeconds(5);
+
+    public static final long DEFAULT_PREFETCH_MESSAGES = 16_000;
+    public static final long DEFAULT_PREFETCH_BYTES = 16L * 1024 * 1024;
+    public static final Duration DEFAULT_RETRY_INTERVAL = Duration.ofSeconds(1);
+
+    private final ReaderTransport transport;
+    private List<Long> partitions;
+    private PollingStrategy start = PollingStrategy.next();
+    private PollOptions options = PollOptions.waitFor(DEFAULT_MAX_WAIT);
+    private long prefetchMessages;
+    private long prefetchBytes = DEFAULT_PREFETCH_BYTES;
+    private Duration retryInterval = DEFAULT_RETRY_INTERVAL;
+
+    PartitionReaderBuilder(ReaderTransport transport) {
+        this.transport = Objects.requireNonNull(transport, "transport");
+    }
+
+    /**
+     * The partitions of a standalone consumer, which requires them. A consumer group reads the assignment of its
+     * member, so {@link #build()} refuses them for a group.
+     */
+    public PartitionReaderBuilder partitions(Collection<Long> partitions) {
+        this.partitions = List.copyOf(partitions);
+        return this;
+    }
+
+    /**
+     * The start of a partition without a position. Default {@link PollingStrategy#next()}. {@link #build()} refuses a
+     * continuation, because it belongs to one partition.
+     */
+    public PartitionReaderBuilder start(PollingStrategy start) {
+        this.start = Objects.requireNonNull(start, "start");
+        return this;
+    }
+
+    /**
+     * The options of each read and the commit policy of the reader. Default {@code PollOptions.waitFor(5 s)}, which
+     * stores nothing.
+     */
+    public PartitionReaderBuilder options(PollOptions options) {
+        this.options = Objects.requireNonNull(options, "options");
+        return this;
+    }
+
+    /** The messages of the reads in flight and the queued batches. Default the larger of 16,000 and the count. */
+    public PartitionReaderBuilder prefetchMessages(long messages) {
+        this.prefetchMessages = messages;
+        return this;
+    }
+
+    /** The bytes of the reads in flight and the queued batches. Default 16 MiB. */
+    public PartitionReaderBuilder prefetchBytes(long bytes) {
+        this.prefetchBytes = bytes;
+        return this;
+    }
+
+    /** The pause before a refused or failed read is sent again. Default 1 s. */
+    public PartitionReaderBuilder retryInterval(Duration retryInterval) {
+        this.retryInterval = Objects.requireNonNull(retryInterval, "retryInterval");
+        return this;
+    }
+
+    /**
+     * Starts the reader. A consumer group reader joins its group first.
+     *
+     * @return the reader, or the error of the join
+     * @throws IggyValidationException with {@link IggyErrorCode#INVALID_COMMAND} for partitions of a group, no
+     *                                 partitions of a standalone consumer, or a continuation as the start
+     */
+    public CompletableFuture<PartitionReader> build() {
+        options.validate();
+        if (start.partitionId().isPresent()) {
+            throw invalid("A reader starts every partition, so its start cannot be the continuation of one");
+        }
+        if (transport.group() == (partitions != null)) {
+            throw invalid(
+                    transport.group()
+                            ? "A consumer group reads the assignment of its member, not chosen partitions"
+                            : "A standalone consumer needs its partitions");
+        }
+        if (prefetchBytes < options.maxBytes() || retryInterval.isNegative() || retryInterval.isZero()) {
+            throw new IggyInvalidArgumentException(
+                    "A reader needs room for one read in its prefetch and a positive retry interval");
+        }
+        long messages = prefetchMessages > 0 ? prefetchMessages : Math.max(DEFAULT_PREFETCH_MESSAGES, options.count());
+        int slots = (int) Math.max(1, Math.min(messages / options.count(), prefetchBytes / options.maxBytes()));
+        PollingStrategy first = start.kind() == PollingKind.First
+                ? new PollingStrategy(PollingKind.Offset, BigInteger.ZERO, start.context())
+                : start;
+        var reader = new PartitionReader(
+                transport, partitions == null ? List.of() : partitions, first, options, slots, retryInterval);
+        return reader.start();
+    }
+
+    private static IggyValidationException invalid(String reason) {
+        return new IggyValidationException(
+                IggyErrorCode.INVALID_COMMAND,
+                IggyErrorCode.INVALID_COMMAND.getCode(),
+                reason,
+                Optional.empty(),
+                Optional.empty());
+    }
+}
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/reader/ReadException.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/reader/ReadException.java
new file mode 100644
index 0000000..4d37766
--- /dev/null
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/reader/ReadException.java
@@ -0,0 +1,105 @@
+/*
+ * 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.
+ */
+
+package org.apache.iggy.client.async.reader;
+
+import org.apache.iggy.exception.IggyException;
+import org.apache.iggy.exception.IggyServerException;
+import org.apache.iggy.message.DecodeFailure;
+import org.apache.iggy.message.PollingStrategy;
+
+import java.math.BigInteger;
+import java.util.Optional;
+import java.util.OptionalLong;
+
+/**
+ * An error of a read. The reader stays usable after it and retries by itself. The cause is the error of the server
+ * or the client.
+ */
+public final class ReadException extends IggyException {
+    private static final byte[] NO_RECORD = new byte[0];
+
+    private final OptionalLong partitionId;
+    private final transient Optional<PollingStrategy> skip;
+    private final transient Optional<BigInteger> offset;
+    private final byte[] record;
+
+    ReadException(OptionalLong partitionId, Throwable cause, Optional<PollingStrategy> skip) {
+        this(partitionId, cause, skip, Optional.empty(), NO_RECORD);
+    }
+
+    ReadException(OptionalLong partitionId, DecodeFailure failure, Optional<PollingStrategy> skip) {
+        this(partitionId, failure.error(), skip, failure.offset(), failure.record());
+    }
+
+    private ReadException(
+            OptionalLong partitionId,
+            Throwable cause,
+            Optional<PollingStrategy> skip,
+            Optional<BigInteger> offset,
+            byte[] record) {
+        super(
+                partitionId.isPresent()
+                        ? "Partition " + partitionId.getAsLong() + ": " + cause.getMessage()
+                        : String.valueOf(cause.getMessage()),
+                cause);
+        this.partitionId = partitionId;
+        this.skip = skip;
+        this.offset = offset;
+        this.record = record;
+    }
+
+    /** The partition of the error, or empty for an error of the whole reader. */
+    public OptionalLong partitionId() {
+        return partitionId;
+    }
+
+    /**
+     * For a message that does not decode, the continuation after it. The reader reads the message again until a
+     * {@link PartitionReader#seek} to this continuation skips it. The messages before it arrive in a batch before this
+     * error.
+     */
+    public Optional<PollingStrategy> skip() {
+        return skip;
+    }
+
+    /**
+     * For a message that does not decode, its offset, known when its frame decoded and a later check failed. Empty
+     * when the framing of the reply is malformed, so no message can be isolated, and for every other error.
+     */
+    public Optional<BigInteger> offset() {
+        return offset;
+    }
+
+    /**
+     * For a message that does not decode, the message as the reply carried it: the frame header, the payload and the
+     * user headers. For malformed framing, the remaining bytes of the section from where decoding stopped. Empty for
+     * every other error.
+     */
+    public byte[] record() {
+        return record;
+    }
+
+    /** The code of the server error behind this one, or empty for an error of the client. */
+    public OptionalLong errorCode() {
+        return getCause() instanceof IggyServerException server
+                ? OptionalLong.of(server.getRawErrorCode())
+                : OptionalLong.empty();
+    }
+}
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/reader/ReaderTransport.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/reader/ReaderTransport.java
new file mode 100644
index 0000000..33129f6
--- /dev/null
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/reader/ReaderTransport.java
@@ -0,0 +1,83 @@
+/*
+ * 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.
+ */
+
+package org.apache.iggy.client.async.reader;
+
+import org.apache.iggy.consumergroup.ConsumerGroupAssignment;
+import org.apache.iggy.consumeroffset.ConsumerOffsetInfo;
+import org.apache.iggy.message.DeferredPollOptions;
+import org.apache.iggy.message.FetchedMessages;
+import org.apache.iggy.message.PollingStrategy;
+import org.apache.iggy.partition.PartitionContext;
+
+import java.math.BigInteger;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * The requests that a {@link PartitionReader} sends for one stream, topic and consumer. The clients of the SDK
+ * implement it, an application does not call it: {@code AsyncIggyTcpClient#reader} reads the partitions of one
+ * primary with one request.
+ */
+public interface ReaderTransport {
+
+    /** Whether the consumer is a consumer group, whose member reads its assignment. */
+    boolean group();
+
+    /**
+     * Groups {@code partitions} so that each group can be read with one {@link #read} request, in the order of
+     * {@code partitions}.
+     */
+    CompletableFuture<List<List<Long>>> groups(List<Long> partitions);
+
+    /**
+     * Reads the partitions of one group, each from its strategy, an {@link org.apache.iggy.message.PollingKind#Offset}
+     * or a {@link org.apache.iggy.message.PollingKind#Next}. A partition without a section was not read.
+     */
+    CompletableFuture<List<FetchedMessages>> read(
+            Map<Long, PollingStrategy> targets, long count, boolean commit, DeferredPollOptions options);
+
+    /** Stores {@code offset} of {@code partition} under the history of {@code context}. */
+    CompletableFuture<Void> store(long partition, BigInteger offset, PartitionContext context);
+
+    CompletableFuture<Void> delete(long partition, PartitionContext context);
+
+    /** The offset that the server holds for {@code partition}. */
+    CompletableFuture<Optional<ConsumerOffsetInfo>> storedOffset(long partition);
+
+    /** Joins the consumer group. */
+    CompletableFuture<Void> join();
+
+    /** The current assignment of this member, or empty when it is not a member. */
+    CompletableFuture<Optional<ConsumerGroupAssignment>> assignment();
+
+    /** Leaves the consumer group. */
+    CompletableFuture<Void> leave();
+
+    /** Lets the client forget a read whose outcome the reader repairs by itself. */
+    CompletableFuture<Void> abandon(long operationId);
+
+    /** Completes once no committing read of the client that was cancelled after its send still waits for its reply. */
+    CompletableFuture<Void> cancelledReadsSettled();
+
+    /** Abandons the committing reads of this transport whose outcome stayed unknown, so their partitions take stores. */
+    CompletableFuture<Void> abandonLost();
+}
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/reader/ReceivedMessage.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/reader/ReceivedMessage.java
new file mode 100644
index 0000000..fb5ec65
--- /dev/null
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/reader/ReceivedMessage.java
@@ -0,0 +1,33 @@
+/*
+ * 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.
+ */
+
+package org.apache.iggy.client.async.reader;
+
+import org.apache.iggy.message.Checkpoint;
+import org.apache.iggy.message.Message;
+
+/**
+ * One message that an {@link IggyConsumer} handed over.
+ *
+ * @param partitionId the partition of the message
+ * @param message     the message
+ * @param checkpoint  the position after the message, for a commit or a mark
+ * @param endsBatch   whether it is the last message of the batch that the reader read
+ */
+public record ReceivedMessage(long partitionId, Message message, Checkpoint checkpoint, boolean endsBatch) {}
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClient.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClient.java
index 66ffbdb..cabb93e 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClient.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClient.java
@@ -34,11 +34,14 @@
 import org.apache.iggy.client.async.SystemClient;
 import org.apache.iggy.client.async.TopicsClient;
 import org.apache.iggy.client.async.UsersClient;
+import org.apache.iggy.client.async.reader.PartitionReader;
+import org.apache.iggy.client.async.reader.PartitionReaderBuilder;
 import org.apache.iggy.client.async.tcp.AsyncTcpConnection.TcpConnectionPoolConfig;
 import org.apache.iggy.client.async.tcp.vsr.VsrFrameDecoder;
 import org.apache.iggy.client.async.tcp.vsr.VsrOperation;
 import org.apache.iggy.cluster.ClusterMetadata;
 import org.apache.iggy.config.RetryPolicy;
+import org.apache.iggy.consumergroup.Consumer;
 import org.apache.iggy.exception.IggyConnectionException;
 import org.apache.iggy.exception.IggyErrorCode;
 import org.apache.iggy.exception.IggyInvalidArgumentException;
@@ -46,6 +49,8 @@
 import org.apache.iggy.exception.IggyNotConnectedException;
 import org.apache.iggy.exception.IggyServerException;
 import org.apache.iggy.exception.IggyTimeoutException;
+import org.apache.iggy.identifier.StreamId;
+import org.apache.iggy.identifier.TopicId;
 import org.apache.iggy.serde.BytesDeserializer;
 import org.apache.iggy.serde.CommandCode;
 import org.apache.iggy.user.IdentityInfo;
@@ -415,9 +420,7 @@
      *
      * <p>Session-control codes complete the returned future with an invalid-command error.
      * So does CancelDeferredRequest, because a cancel names a pending request by its
-     * id and the server answers no cancel. WaitForMessages completes it with a
-     * feature-unavailable error, because this client does not route readiness waits.
-     * None of them is sent.
+     * id and the server answers no cancel. Neither is sent.
      *
      * <p>SendMessages, PollMessages, deferred polls, StoreConsumerOffset and
      * DeleteConsumerOffset carry the partition context the server fences them by,
@@ -439,6 +442,18 @@
      * typed deferred poll, the request timeout in a deferred poll's payload sets
      * its deadline.
      *
+     * <p>FetchMessages goes as it is to the primary of its first target, as the fetch of a
+     * {@link #reader} goes, with the fences and byte limits of its
+     * targets. A refusal with NotPrimary (89) and a NotPrimary section make the next
+     * grouping of a reader look up those routes again. The request timeout
+     * in its payload sets its deadline. A payload that the server would refuse to decode
+     * completes the future with an {@link org.apache.iggy.exception.IggyInvalidArgumentException}
+     * and sends nothing.
+     * A committing fetch with uncertain progress retains local partition blocks. Its raw
+     * section statuses and bytes are unchanged: inspect those statuses, then obtain the
+     * recovery ID from a blocked progress call on an affected target. Whole unknown
+     * outcomes return {@code FETCH_PROGRESS_UNCERTAIN} directly. Recovery never replays 109.
+     *
      * <p>The future completes on the client event loop, as the callbacks section of this class says.
      *
      * @param code the command code
@@ -467,9 +482,9 @@
      * as GetStats from its own state, so this reads the state of the chosen node.
      *
      * <p>The request carries the metadata watermark of the session, so the replica answers after it applied the
-     * metadata that this client saw. Replicated commands, PollMessages, deferred polls, WaitForMessages,
-     * CancelDeferredRequest, GetConsumerOffset and session-control codes complete the returned future with an
-     * invalid-command error before anything is sent. An id that cluster metadata does not
+     * metadata that this client saw. Replicated commands, PollMessages, deferred polls, FetchMessages,
+     * CancelDeferredRequest, GetConsumerOffset and session-control codes complete the returned future
+     * with an invalid-command error before anything is sent. An id that cluster metadata does not
      * name after one read of it completes the future with an invalid-identifier error. A replica that cannot be
      * reached, or that answers with code 58, is asked again until the request timeout, which ends the call with code
      * 58. Any other refusal, NotPrimary (89) included, completes the future unchanged and is never sent to another
@@ -486,11 +501,8 @@
     public CompletableFuture<byte[]> sendBinaryRequestToReplica(int code, byte[] payload, int replicaId) {
         Objects.requireNonNull(payload, "payload cannot be null");
         if (isSessionControlCode(code)
-                || code == CommandCode.Messages.POLL.getValue()
-                || code == CommandCode.Messages.POLL_DEFERRED.getValue()
-                || code == CommandCode.Messages.WAIT_FOR_MESSAGES.getValue()
+                || AsyncTcpConnection.isPartitionCommand(code)
                 || code == CommandCode.Messages.CANCEL_DEFERRED_REQUEST.getValue()
-                || code == CommandCode.ConsumerOffset.GET.getValue()
                 || VsrOperation.operationForCode(code) != VsrOperation.NON_REPLICATED) {
             return CompletableFuture.failedFuture(
                     IggyServerException.fromTcpResponse(INVALID_COMMAND_ERROR_CODE, new byte[0]));
@@ -506,6 +518,8 @@
      * Replays a retained mutation with its original identity, context and bytes. While another resolve of it
      * replays, this one waits for that replay within {@code timeout}. A wait that runs out fails with
      * {@code OPERATION_UNCERTAIN}.
+     * A retained fetch instead fails with {@code FETCH_PROGRESS_UNCERTAIN} without I/O. Its
+     * recovery id is local, survives reconnects, and has no replayable server receipt.
      *
      * <p>The client keeps the proved outcomes of its last {@code count} replays of
      * {@code pendingOperations(count, bytes)}, also across a session reset. A later resolve of the operation
@@ -520,6 +534,10 @@
         if (timeout.isZero() || timeout.isNegative()) {
             throw new IggyInvalidArgumentException("Resolution timeout must be positive");
         }
+        var fetchRecovery = session.fetchRecovery(operationId);
+        if (fetchRecovery != null) {
+            return CompletableFuture.failedFuture(fetchRecovery);
+        }
         long timeoutNanos;
         try {
             timeoutNanos = timeout.toNanos();
@@ -550,13 +568,12 @@
      * operation was uncommitted. A replay of the operation that is already running keeps its lane until the replay
      * ends, so a later mutation of that lane waits for it. That replay still keeps the outcome it proves for
      * {@link #resolveOperation}.
+     * A fetch abandon accepts its unknown progress and releases all of its local target blocks.
+     * It does not cancel or fence the original server request.
      */
     public CompletableFuture<Void> abandonOperation(long operationId) {
-        if (connection.get() == null) {
-            throw new IggyNotConnectedException();
-        }
         try {
-            connection.get().sessionOwner().abandon(operationId);
+            session.abandon(operationId);
             return CompletableFuture.completedFuture(null);
         } catch (RuntimeException error) {
             return CompletableFuture.failedFuture(error);
@@ -597,6 +614,22 @@
     }
 
     /**
+     * Starts a reader of the partitions of {@code topicId}. A standalone consumer names its partitions on the builder,
+     * a consumer group reads the assignment of its member. The reader groups the partitions by their primary and reads
+     * each group with one request.
+     *
+     * @param streamId the stream identifier
+     * @param topicId  the topic identifier
+     * @param consumer the consumer or consumer group whose offsets the reader reads and stores
+     * @return the builder of the reader
+     * @throws IggyNotConnectedException if the client is not connected
+     */
+    public PartitionReaderBuilder reader(StreamId streamId, TopicId topicId, Consumer consumer) {
+        messages();
+        return PartitionReader.builder(new TcpReaderTransport(this, streamId, topicId, consumer));
+    }
+
+    /**
      * Returns the async consumer groups client for group membership management.
      *
      * @return the {@link ConsumerGroupsClient} instance
@@ -688,6 +721,18 @@
     }
 
     /**
+     * Drops the connections without a sign-out, as a lost network does, and keeps the event loop group. Every pending
+     * request fails. A poll or fetch with {@code autoCommit} fails with {@code FETCH_PROGRESS_UNCERTAIN}, because the
+     * server can still store its offsets, and the progress of its partitions stays blocked on this client until
+     * {@link #abandonOperation(long)}. {@link #connect()} and a sign-in continue the client.
+     *
+     * @return a {@link CompletableFuture} that completes when the connections are closed
+     */
+    public CompletableFuture<Void> disconnect() {
+        return dropConnections();
+    }
+
+    /**
      * Closes the TCP connection and releases the Netty resources that this client owns.
      *
      * <p>If the client created its own event loop group, it shuts that group down gracefully.
@@ -696,9 +741,58 @@
      * that group down. After you call this method, you cannot reuse the client. If you need
      * a client again, create a new instance.
      *
+     * <p>A close first waits for the reply of every pending poll or fetch with {@code autoCommit}, at
+     * most until its request timeout, because a cancel can lose the messages that the server
+     * committed for it. It cancels every other pending request.
+     * A fetch whose outcome stays unknown does not fail the close. The close logs it, and its recovery
+     * stays until {@link #abandonOperation(long)} releases it, so the close of a blocking client in
+     * try-with-resources does not throw.
+     *
      * @return a {@link CompletableFuture} that completes when all resources are released
      */
     public CompletableFuture<Void> close() {
+        CompletableFuture<Void> committingFetches = pollRouter.committingReadsEnded();
+        if (committingFetches.isDone()) {
+            return closeNow();
+        }
+        // A fetch ends under the monitors of the router, so the close goes on in a task of the client loop.
+        return pollRouter.onClientLoop(committingFetches).thenCompose(ignored -> closeNow());
+    }
+
+    private CompletableFuture<Void> closeNow() {
+        return dropConnections()
+                .thenCompose(ignored -> {
+                    IoEventLoopGroup owned;
+                    synchronized (this) {
+                        owned = sharedEventLoopGroup.isEmpty() ? eventLoopGroup : null;
+                        if (owned != null) {
+                            eventLoopGroup = null;
+                        }
+                    }
+                    if (owned == null) {
+                        return CompletableFuture.completedFuture(null);
+                    }
+                    CompletableFuture<Void> stopped = new CompletableFuture<>();
+                    owned.shutdownGracefully().addListener(done -> {
+                        if (done.isSuccess()) {
+                            stopped.complete(null);
+                        } else {
+                            stopped.completeExceptionally(done.cause());
+                        }
+                    });
+                    return stopped;
+                })
+                .thenRun(() -> {
+                    var unresolved = session.unresolvedFetch();
+                    if (unresolved != null) {
+                        log.warn(
+                                "Closed with fetch {} of unknown progress, which stays retained until it is abandoned",
+                                Long.toUnsignedString(unresolved.getOperationId()));
+                    }
+                });
+    }
+
+    private CompletableFuture<Void> dropConnections() {
         // Closing is caller intent, like a logout: connect() clears `closed`
         // again, and a session the caller ended must not come back with the
         // credentials the earlier sign-in used.
@@ -711,29 +805,7 @@
         session.endLineage();
         CompletableFuture<Void> dataClosed = pollRouter.clearSession();
         session.reset();
-        CompletableFuture<Void> linksClosed =
-                currentConnection == null ? dataClosed : dataClosed.thenCompose(ignored -> currentConnection.close());
-        return linksClosed.thenCompose(ignored -> {
-            IoEventLoopGroup owned;
-            synchronized (this) {
-                owned = sharedEventLoopGroup.isEmpty() ? eventLoopGroup : null;
-                if (owned != null) {
-                    eventLoopGroup = null;
-                }
-            }
-            if (owned == null) {
-                return CompletableFuture.completedFuture(null);
-            }
-            CompletableFuture<Void> stopped = new CompletableFuture<>();
-            owned.shutdownGracefully().addListener(done -> {
-                if (done.isSuccess()) {
-                    stopped.complete(null);
-                } else {
-                    stopped.completeExceptionally(done.cause());
-                }
-            });
-            return stopped;
-        });
+        return currentConnection == null ? dataClosed : dataClosed.thenCompose(ignored -> currentConnection.close());
     }
 
     /**
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientBuilder.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientBuilder.java
index 4aa2377..9b6dfd1 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientBuilder.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientBuilder.java
@@ -31,6 +31,8 @@
 import org.apache.iggy.config.TcpConnectionString;
 import org.apache.iggy.exception.IggyInvalidArgumentException;
 import org.apache.iggy.exception.IggyMissingCredentialsException;
+import org.apache.iggy.message.DeferredPollOptions;
+import org.apache.iggy.serde.BytesDeserializer;
 
 import java.io.File;
 import java.time.Duration;
@@ -79,6 +81,12 @@
  * @see AsyncIggyTcpClient#builder()
  */
 public final class AsyncIggyTcpClientBuilder {
+    /** The smallest frame that holds a fetch reply of the most partitions with one record of the largest size. */
+    static final long MIN_MAX_VSR_FRAME_SIZE = VsrHeaders.HEADER_SIZE
+            + BytesDeserializer.FETCH_SECTIONS_COUNT_BYTES
+            + (long) FetchMessages.MAX_PARTITIONS_PER_REQUEST * BytesDeserializer.FETCH_SECTION_BYTES
+            + DeferredPollOptions.MAX_RECORD_SIZE;
+
     private String host = "localhost";
     private Integer port = 8090;
     private String username;
@@ -423,9 +431,9 @@
     }
 
     private void validateMaxVsrFrameSize() {
-        if (maxVsrFrameSize < VsrHeaders.HEADER_SIZE || maxVsrFrameSize > Integer.MAX_VALUE) {
-            throw new IggyInvalidArgumentException("MaxVsrFrameSize must be between " + VsrHeaders.HEADER_SIZE + " and "
-                    + Integer.MAX_VALUE + " bytes");
+        if (maxVsrFrameSize < MIN_MAX_VSR_FRAME_SIZE || maxVsrFrameSize > Integer.MAX_VALUE) {
+            throw new IggyInvalidArgumentException("MaxVsrFrameSize must be between " + MIN_MAX_VSR_FRAME_SIZE + " and "
+                    + Integer.MAX_VALUE + " bytes, so a reply holds the largest message");
         }
     }
 
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/AsyncTcpConnection.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/AsyncTcpConnection.java
index 04594a4..9729ece 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/AsyncTcpConnection.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/AsyncTcpConnection.java
@@ -143,6 +143,7 @@
     private final IntConsumer sessionResetListener;
     private final BiConsumer<AsyncTcpConnection, Throwable> connectionFailureListener;
     private final long requestTimeoutNanos;
+    private final long maxReplyBody;
     private final ConnectionHeartbeat heartbeat;
     // Shared with the connections that bind this session, see bindSharedSession.
     private volatile SessionLogin login = new SessionLogin();
@@ -197,6 +198,7 @@
         this.sessionResetListener = sessionResetListener;
         this.connectionFailureListener = connectionFailureListener;
         this.requestTimeoutNanos = toTimeoutNanos(requestTimeout.orElse(DEFAULT_REQUEST_TIMEOUT));
+        this.maxReplyBody = maxVsrFrameSize - VsrHeaders.HEADER_SIZE;
         SslContext sslContext = null;
         if (enableTls) {
             try {
@@ -1077,10 +1079,11 @@
         return isPollRoutingCode(commandCode) || (sharedSession && isPartitionCommand(commandCode));
     }
 
-    private static boolean isPartitionCommand(int code) {
+    static boolean isPartitionCommand(int code) {
         return code == CommandCode.Messages.SEND.getValue()
                 || code == CommandCode.Messages.POLL.getValue()
                 || code == CommandCode.Messages.POLL_DEFERRED.getValue()
+                || code == CommandCode.Messages.FETCH_MESSAGES.getValue()
                 || code == CommandCode.ConsumerOffset.GET.getValue()
                 || isOffsetWriteCode(code);
     }
@@ -1113,14 +1116,14 @@
             TransientFailoverState state) {
         int operation = VsrOperation.operationForCode(commandCode);
         boolean mutation = operation != VsrOperation.NON_REPLICATED && !mutatesSessionState(commandCode);
-        if (state.ticket == null && (mutation || state.lane != null)) {
-            ConsensusSession.LaneKey lane = state.lane == null ? ConsensusSession.LaneKey.METADATA : state.lane;
+        if (state.ticket == null && (mutation || !state.lanes.isEmpty())) {
             CompletableFuture<ConsensusSession.Ticket> admission = consensusSession.acquire(
-                    lane,
+                    state.lanes.isEmpty() ? List.of(ConsensusSession.LaneKey.METADATA) : state.lanes,
                     mutation,
                     (long) VsrHeaders.HEADER_SIZE + payload.readableBytes(),
                     deadline,
-                    channel.eventLoop());
+                    channel.eventLoop(),
+                    commandCode == CommandCode.ConsumerOffset.GET.getValue());
             callerFuture.whenComplete((response, error) -> {
                 if (callerFuture.isCancelled()) {
                     admission.cancel(false);
@@ -1153,6 +1156,7 @@
             long requestDeadlineNanos,
             TransientFailoverState failoverState) {
         try {
+            CommittingRead.admit(failoverState.ticket, commandCode, payload);
             if (failoverState.ticket != null
                     && failoverState.ticket.prepareWrite(channel.isActive(), failoverState.lineage)) {
                 failoverState.renewRequestId();
@@ -1239,7 +1243,7 @@
         }
         ByteBuf frame;
         try {
-            frame = frameForAttempt(exchange, retainedFrame);
+            frame = exchange.frameForAttempt(retainedFrame, maxReplyBody);
         } catch (RuntimeException error) {
             releaseFrame(exchange, retainedFrame);
             responseFuture.completeExceptionally(preserveUncertainOutcome(error, uncertain));
@@ -1250,6 +1254,11 @@
         try {
             pending = handler.registerRequest(
                     channel, frame, attempt, exchange.requestDeadlineNanos(), exchange.commandCode());
+            if (exchange.commandCode() == CommandCode.Messages.POLL_DEFERRED.getValue()) {
+                pending.limitResponseBody(FetchMessages.pollReplyLimit(frame));
+            } else if (exchange.commandCode() == CommandCode.Messages.FETCH_MESSAGES.getValue()) {
+                FetchMessages.configureResponse(pending, frame);
+            }
         } catch (RuntimeException error) {
             releaseFrame(exchange, frame);
             responseFuture.completeExceptionally(error);
@@ -1258,13 +1267,12 @@
         if (exchange.failoverState().keepsLineage) {
             pending.keepLineage();
         }
-        attempt.whenComplete((response, error) ->
-                completeAttempt(exchange, frame, uncertain, pending, lifecycleRetryIntervalMs, response, error));
-        exchange.callerFuture().whenComplete((response, error) -> {
-            if (exchange.callerFuture().isCancelled()) {
-                attempt.cancel(false);
-            }
-        });
+        attempt.whenComplete(CommittingRead.settling(
+                exchange.failoverState().ticket,
+                (response, error) -> completeAttempt(
+                        exchange, frame, uncertain, pending, lifecycleRetryIntervalMs, response, error)));
+        exchange.callerFuture()
+                .whenComplete((response, error) -> CommittingRead.onCallerCancel(exchange, pending, attempt, channel));
         if (attempt.isDone()) {
             return;
         }
@@ -1291,26 +1299,6 @@
         pending.sent(channel);
     }
 
-    private static ByteBuf frameForAttempt(VsrExchange exchange, ByteBuf retainedFrame) {
-        if (exchange.commandCode() != CommandCode.Messages.POLL_DEFERRED.getValue()) {
-            return retainedFrame;
-        }
-        int parameters = retainedFrame.writerIndex() - PollRouter.DEFERRED_PARAMETERS_BYTES;
-        var options = PollRouter.deferredOptions(retainedFrame);
-        var remaining = options.remaining(
-                options.requestTimeout().minusNanos(exchange.requestDeadlineNanos() - System.nanoTime()));
-        if (remaining.isEmpty()) {
-            throw notAccepted();
-        }
-        // A progress ticket and pending writers share the frozen frame. Only this attempt's copy changes.
-        ByteBuf frame = retainedFrame.copy();
-        frame.setLongLE(
-                parameters - retainedFrame.readerIndex(), remaining.get().maxWaitMicros());
-        frame.setLongLE(frame.writerIndex() - Long.BYTES, remaining.get().requestTimeoutMicros());
-        retainedFrame.release();
-        return frame;
-    }
-
     private static void releaseFrame(VsrExchange exchange, ByteBuf frame) {
         frame.release();
         if (exchange.failoverState().ticket != null) {
@@ -1329,6 +1317,15 @@
             Throwable error) {
         Channel channel = exchange.channel();
         IggyServerException serverError = findServerError(error);
+        RuntimeException fetchFailure =
+                FetchMessages.completeAttempt(exchange, response, error, pending.isFailedByLink());
+        if (fetchFailure != null) {
+            releaseFrame(exchange, frame);
+            releaseIfPresent(response);
+            closeIfDetached(exchange, pending, serverError);
+            exchange.responseFuture().completeExceptionally(fetchFailure);
+            return;
+        }
         // A cancel can end an attempt after its reply was marked committed, and then nothing holds the outcome.
         // The operation stays uncertain, as in Rust, so a resolve can still prove it.
         boolean committed = pending.isCommitted() && (error == null || serverError != null);
@@ -1353,7 +1350,9 @@
                         || PollRouter.isNamespaceMiss(error))) {
             exchange.failoverState().ticket.settleSent(nextUncertain);
         }
-        if (shouldRetryTransient(
+        boolean abandonedRead = CommittingRead.abandoned(exchange, error);
+        if (!abandonedRead
+                && shouldRetryTransient(
                         exchange.commandCode(),
                         error,
                         exchange.deadlineNanos(),
@@ -1379,6 +1378,7 @@
             }
         }
         if (!wasUncertain
+                && !abandonedRead
                 && !exchange.failoverState().routed
                 && scheduleLifecycleRetry(exchange, frame, serverError, nextUncertain, lifecycleRetryIntervalMs)) {
             return;
@@ -1607,6 +1607,7 @@
                 || (routed
                         && (commandCode == CommandCode.Messages.POLL.getValue()
                                 || commandCode == CommandCode.Messages.POLL_DEFERRED.getValue()
+                                || commandCode == CommandCode.Messages.FETCH_MESSAGES.getValue()
                                 || commandCode == CommandCode.ConsumerOffset.GET.getValue()))) {
             return false;
         }
@@ -1807,7 +1808,8 @@
         // A 58 or 89 refusal or a link lost before the frame went out sent the call to another attempt, see Call#end.
         private boolean refused;
         private long pauseCeilingNanos = RETRY_BACKOFF_BASE.toNanos();
-        private ConsensusSession.LaneKey lane;
+        // In the order that the session admits them, see ConsensusSession#acquire.
+        private List<ConsensusSession.LaneKey> lanes = List.of();
         private ConsensusSession.Ticket ticket;
         private RuntimeException retainedError;
         private byte[] routingBody;
@@ -1881,7 +1883,11 @@
                     return retainedError;
                 }
                 // Retaining wakes resolvers inline, so publish the guard before invoking callbacks without this lock.
-                retainedError = new IggyOperationException(IggyErrorCode.OPERATION_UNCERTAIN, retained.operationId());
+                retainedError = new IggyOperationException(
+                        retained.commitsOnRead()
+                                ? IggyErrorCode.FETCH_PROGRESS_UNCERTAIN
+                                : IggyErrorCode.OPERATION_UNCERTAIN,
+                        retained.operationId());
             }
             RuntimeException error = retained.retain();
             synchronized (this) {
@@ -1898,8 +1904,12 @@
             return committed;
         }
 
-        void setLane(ConsensusSession.LaneKey lane) {
-            this.lane = lane;
+        void setLanes(List<ConsensusSession.LaneKey> lanes) {
+            this.lanes = lanes;
+        }
+
+        List<ConsensusSession.LaneKey> lanes() {
+            return lanes;
         }
 
         void setRoutingBody(byte[] routingBody) {
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/CommittingRead.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/CommittingRead.java
new file mode 100644
index 0000000..025f778
--- /dev/null
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/CommittingRead.java
@@ -0,0 +1,118 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iggy.client.async.tcp;
+
+import io.netty.buffer.ByteBuf;
+import io.netty.channel.Channel;
+import org.apache.iggy.client.async.tcp.vsr.ConsensusSession;
+import org.apache.iggy.client.async.tcp.vsr.VsrResponseHandler;
+import org.apache.iggy.serde.CommandCode;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.concurrent.CompletableFuture;
+import java.util.function.BiConsumer;
+
+/**
+ * A poll or fetch that stores the offsets that it reads. The server answers a cancel of one exactly once, refused when
+ * the cancel won and nothing was stored, or with what it read and stored. So a cancelled committing read keeps its
+ * request and its partitions until that reply shows whether it stored.
+ */
+final class CommittingRead {
+    private static final Logger log = LoggerFactory.getLogger(CommittingRead.class);
+
+    private CommittingRead() {}
+
+    /** Whether a poll or fetch body asks the server to store the offsets that it reads. */
+    static boolean commits(int commandCode, ByteBuf body) {
+        if (commandCode == CommandCode.Messages.FETCH_MESSAGES.getValue()) {
+            return FetchMessages.commits(body);
+        }
+        if (commandCode == CommandCode.Messages.POLL.getValue()) {
+            return body.isReadable() && body.getUnsignedByte(body.writerIndex() - 1) != 0;
+        }
+        if (commandCode == CommandCode.Messages.POLL_DEFERRED.getValue()) {
+            return body.readableBytes() > PollRouter.DEFERRED_PARAMETERS_BYTES
+                    && body.getUnsignedByte(body.writerIndex() - PollRouter.DEFERRED_PARAMETERS_BYTES - 1) != 0;
+        }
+        return false;
+    }
+
+    /** Reserves the recovery of a committing read when it is admitted, which blocks its lanes if its outcome is lost. */
+    static void admit(ConsensusSession.Ticket ticket, int commandCode, ByteBuf payload) {
+        if (ticket != null && commits(commandCode, payload)) {
+            ticket.reserveFetch();
+        }
+    }
+
+    /**
+     * Runs {@code completion} for an attempt and then marks the attempt settled on its ticket, so the partitions of a
+     * cancelled committing read are released only after its reply.
+     */
+    static BiConsumer<ByteBuf, Throwable> settling(
+            ConsensusSession.Ticket ticket, BiConsumer<ByteBuf, Throwable> completion) {
+        CompletableFuture<Void> settled = new CompletableFuture<>();
+        if (ticket != null) {
+            ticket.attemptStarted(settled);
+        }
+        return (response, error) -> {
+            try {
+                completion.accept(response, error);
+            } finally {
+                settled.complete(null);
+            }
+        };
+    }
+
+    /** A caller cancel stops a committing read on the server and waits for its reply, and cancels any other one. */
+    static void onCallerCancel(
+            VsrExchange exchange,
+            VsrResponseHandler.PendingRequest pending,
+            CompletableFuture<ByteBuf> attempt,
+            Channel channel) {
+        if (!exchange.callerFuture().isCancelled()) {
+            return;
+        }
+        if (committing(exchange)) {
+            // The server can store until it answers the cancel, so the partitions stay blocked until that reply.
+            exchange.failoverState().ticket().blockUntilSettled();
+            pending.cancelOnServer(channel);
+        } else {
+            attempt.cancel(false);
+        }
+    }
+
+    /** Whether the caller gave up a committing read, which then gets no retry. */
+    static boolean abandoned(VsrExchange exchange, Throwable error) {
+        boolean abandoned = committing(exchange) && exchange.callerFuture().isCancelled();
+        if (abandoned && error == null) {
+            log.warn(
+                    "A read with command code {} stored its offsets before its cancel arrived, so its messages are"
+                            + " dropped",
+                    exchange.commandCode());
+        }
+        return abandoned;
+    }
+
+    private static boolean committing(VsrExchange exchange) {
+        ConsensusSession.Ticket ticket = exchange.failoverState().ticket();
+        return ticket != null && ticket.commitsOnRead();
+    }
+}
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/ConsumerOffsetsTcpClient.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/ConsumerOffsetsTcpClient.java
index f8e32a3..f69a257 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/ConsumerOffsetsTcpClient.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/ConsumerOffsetsTcpClient.java
@@ -115,6 +115,17 @@
                 }));
     }
 
+    CompletableFuture<Void> deleteConsumerOffset(
+            StreamId streamId, TopicId topicId, Long partitionId, Consumer consumer, PartitionContext context) {
+        var payload = offsetTarget(consumer, streamId, topicId, Optional.of(partitionId));
+        payload.writeByte(ACK_QUORUM);
+        return pollRouter.onClientLoop(AsyncTcpConnection.mapResponse(
+                write(CommandCode.ConsumerOffset.DELETE.getValue(), payload, Optional.of(context)), response -> {
+                    response.release();
+                    return null;
+                }));
+    }
+
     private CompletableFuture<Void> store(
             StreamId streamId,
             TopicId topicId,
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/FetchMessages.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/FetchMessages.java
new file mode 100644
index 0000000..7c69495
--- /dev/null
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/FetchMessages.java
@@ -0,0 +1,448 @@
+/*
+ * 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.
+ */
+
+package org.apache.iggy.client.async.tcp;
+
+import io.netty.buffer.ByteBuf;
+import io.netty.buffer.Unpooled;
+import org.apache.iggy.client.async.tcp.vsr.ConsensusSession;
+import org.apache.iggy.client.async.tcp.vsr.VsrHeaders;
+import org.apache.iggy.client.async.tcp.vsr.VsrResponseHandler;
+import org.apache.iggy.exception.IggyErrorCode;
+import org.apache.iggy.exception.IggyInvalidArgumentException;
+import org.apache.iggy.exception.IggyOperationException;
+import org.apache.iggy.exception.IggyServerException;
+import org.apache.iggy.message.CommitErrorScope;
+import org.apache.iggy.message.DeferredPollOptions;
+import org.apache.iggy.message.FetchedMessages;
+import org.apache.iggy.message.PollingKind;
+import org.apache.iggy.message.PollingStrategy;
+import org.apache.iggy.partition.PartitionContext;
+import org.apache.iggy.serde.BytesDeserializer;
+import org.apache.iggy.serde.BytesSerializer;
+import org.apache.iggy.serde.CommandCode;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.OptionalLong;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * One fetch (code 109) over the partitions of one readiness group, sent to the primary of its first partition. Each
+ * target carries its own fence, so the request header carries none. A target whose strategy has no context takes the
+ * context of its current route, as a deferred poll does. NOT_PRIMARY of the request does not say which partition moved,
+ * so it marks the routes of all targets moved, and a NOT_PRIMARY section marks the route of its partition moved. The
+ * next readiness grouping then looks them up again.
+ */
+final class FetchMessages {
+    static final int MAX_PARTITIONS_PER_REQUEST = 1000;
+    static final long MAX_PARTITION_ID = 0xFFFF_FFFFL;
+    // The wait, the request timeout, min_count, count, max_bytes, auto_commit and the target count, which follow the
+    // consumer, stream and topic.
+    static final int LIMITS_BYTES = 2 * Long.BYTES + 4 * Integer.BYTES + Byte.BYTES;
+    // A partition id, the kind and value of a polling strategy, max_bytes, then the incarnation and owner generation of
+    // the fence of the partition.
+    static final int TARGET_BYTES = 2 * Integer.BYTES + Byte.BYTES + 3 * Long.BYTES;
+    // From the wait, which opens the limits.
+    static final int MIN_COUNT_OFFSET = 2 * Long.BYTES;
+    static final int COUNT_OFFSET = MIN_COUNT_OFFSET + Integer.BYTES;
+    static final int MAX_BYTES_OFFSET = COUNT_OFFSET + Integer.BYTES;
+    static final int AUTO_COMMIT_OFFSET = MAX_BYTES_OFFSET + Integer.BYTES;
+    static final int TARGETS_COUNT_OFFSET = AUTO_COMMIT_OFFSET + Byte.BYTES;
+    // From the start of a target.
+    static final int STRATEGY_KIND_OFFSET = Integer.BYTES;
+    static final int TARGET_MAX_BYTES_OFFSET = STRATEGY_KIND_OFFSET + Byte.BYTES + Long.BYTES;
+    static final int INCARNATION_OFFSET = TARGET_MAX_BYTES_OFFSET + Integer.BYTES;
+
+    private FetchMessages() {}
+
+    /**
+     * Fetches {@code targets}, in their order, and returns the sections of the reply in its order. Cancelling the
+     * returned future cancels the route lookups or the fetch.
+     *
+     * @param consumer the encoded consumer
+     * @param topic    the encoded stream and topic identifiers
+     */
+    @SuppressWarnings("checkstyle:ParameterNumber")
+    static CompletableFuture<List<FetchedMessages>> start(
+            PollRouter router,
+            byte[] consumer,
+            byte[] topic,
+            Map<Long, PollingStrategy> targets,
+            long count,
+            boolean autoCommit,
+            DeferredPollOptions options,
+            long deadline,
+            AsyncTcpConnection.TransientFailoverState state) {
+        List<Long> partitions = List.copyOf(targets.keySet());
+        ByteBuf payload = payload(consumer, topic, targets, count, autoCommit, options);
+        CompletableFuture<ByteBuf> reply = send(router, payload, topic, partitions, deadline, state);
+        return AsyncTcpConnection.mapResponse(reply, body -> {
+            try {
+                List<FetchedMessages> sections = attachRecovery(BytesDeserializer.readFetchedMessages(body), state);
+                markMoved(router, topic, sections);
+                return sections;
+            } finally {
+                body.release();
+            }
+        });
+    }
+
+    /**
+     * Settles an attempt of a poll or fetch that stores the offsets that it reads. A refusal that proves nothing stored
+     * frees its partitions, any other failure retains them blocked until an abandon, and a fetch reply retains the
+     * targets of its uncertain sections.
+     */
+    static RuntimeException completeAttempt(
+            VsrExchange exchange, ByteBuf response, Throwable error, boolean failedByLink) {
+        AsyncTcpConnection.TransientFailoverState state = exchange.failoverState();
+        if (state.ticket() == null || !state.ticket().commitsOnRead()) {
+            return null;
+        }
+        IggyServerException refusal = AsyncTcpConnection.findServerError(error);
+        if (error != null) {
+            if (!failedByLink
+                    && refusal != null
+                    && CommitErrorScope.REQUEST.provesNothingStored(refusal.getRawErrorCode())) {
+                state.ticket().settleSent(false);
+                return null;
+            }
+            return state.retainTicket();
+        }
+        if (exchange.commandCode() != CommandCode.Messages.FETCH_MESSAGES.getValue()) {
+            state.ticket().settleFetch(List.of());
+            return null;
+        }
+        try {
+            classifyReply(state, response);
+            return null;
+        } catch (RuntimeException malformed) {
+            return state.retainTicket();
+        }
+    }
+
+    static void classifyReply(AsyncTcpConnection.TransientFailoverState state, ByteBuf body) {
+        List<FetchedMessages> sections = BytesDeserializer.readFetchedMessages(body.duplicate());
+        var requested = new HashSet<Long>();
+        state.lanes().forEach(lane -> requested.add(lane.partition()));
+        List<Long> uncertain = new ArrayList<>();
+        for (FetchedMessages section : sections) {
+            if (!requested.remove(section.partitionId())) {
+                throw new IggyInvalidArgumentException("Fetch reply contains an unexpected or duplicate partition");
+            }
+            if (section.status() != FetchedMessages.STATUS_OK
+                    && !CommitErrorScope.SECTION.provesNothingStored(section.status())) {
+                uncertain.add(section.partitionId());
+            }
+        }
+        state.ticket().settleFetch(uncertain);
+    }
+
+    private static List<FetchedMessages> attachRecovery(
+            List<FetchedMessages> sections, AsyncTcpConnection.TransientFailoverState state) {
+        if (state.ticket() == null || !state.ticket().commitsOnRead()) {
+            return sections;
+        }
+        return sections.stream()
+                .map(section -> section.status() != FetchedMessages.STATUS_OK
+                                && !CommitErrorScope.SECTION.provesNothingStored(section.status())
+                        ? new FetchedMessages(
+                                section.partitionId(),
+                                section.status(),
+                                section.currentOffset(),
+                                section.count(),
+                                section.context(),
+                                section.nextOffset(),
+                                section.records(),
+                                OptionalLong.of(state.ticket().operationId()))
+                        : section)
+                .toList();
+    }
+
+    /**
+     * Refuses what the server refuses in a fetch request that the typed call encodes, before any lookup. The partition
+     * ids of {@code targets} are checked by the caller.
+     */
+    static void validate(Map<Long, PollingStrategy> targets, long count, DeferredPollOptions options) {
+        if (targets.isEmpty() || targets.size() > FetchMessages.MAX_PARTITIONS_PER_REQUEST) {
+            throw new IggyInvalidArgumentException("A fetch needs 1 to " + FetchMessages.MAX_PARTITIONS_PER_REQUEST
+                    + " partitions, not " + targets.size());
+        }
+        for (PollingStrategy strategy : targets.values()) {
+            if (strategy.kind() != PollingKind.Offset && strategy.kind() != PollingKind.Next) {
+                throw new IggyInvalidArgumentException(
+                        "A fetch starts at an offset or at the next offset, not at " + strategy.kind());
+            }
+        }
+        options.validate(count);
+        if (!canStart(options)) {
+            throw new IggyInvalidArgumentException("Fetch requestTimeout " + options.requestTimeout()
+                    + " must exceed the reply margin " + DeferredPollOptions.REPLY_MARGIN + " and cover maxWait "
+                    + options.maxWait() + " plus that margin");
+        }
+    }
+
+    /** A fresh fetch needs time for routing before the mandatory server reply margin. */
+    static boolean canStart(DeferredPollOptions options) {
+        return options.requestTimeout().compareTo(DeferredPollOptions.REPLY_MARGIN) > 0 && keepsReplyMargin(options);
+    }
+
+    /**
+     * Whether the request timeout leaves the reply margin after the wait. Unlike a deferred poll, a fetch needs the
+     * margin also for a zero wait, because the server ends its reads before the request timeout.
+     */
+    static boolean keepsReplyMargin(DeferredPollOptions options) {
+        return options.requestTimeout().compareTo(options.maxWait().plus(DeferredPollOptions.REPLY_MARGIN)) >= 0;
+    }
+
+    /**
+     * Sends one fetch over {@code partitions} to the primary of the first one. A NOT_PRIMARY failure marks the routes
+     * of all of them moved before the returned future fails.
+     *
+     * @param topic the encoded stream and topic identifiers
+     */
+    static CompletableFuture<ByteBuf> send(
+            PollRouter router,
+            ByteBuf payload,
+            byte[] topic,
+            List<Long> partitions,
+            long deadline,
+            AsyncTcpConnection.TransientFailoverState state) {
+        int first = PollRouter.waitParameters(payload, payload.readerIndex()) + LIMITS_BYTES;
+        List<Long> unfenced = unfencedPartitions(payload, first);
+        if (unfenced.isEmpty()) {
+            return sendResolved(router, payload, topic, partitions, deadline, state);
+        }
+        CompletableFuture<ByteBuf> result = new CompletableFuture<>();
+        CompletableFuture<Map<Long, PartitionContext>> routed = router.routeContexts(topic, unfenced, deadline, state);
+        AtomicReference<CompletableFuture<?>> step = new AtomicReference<>(routed);
+        result.whenComplete((body, error) -> {
+            if (result.isCancelled()) {
+                step.get().cancel(false);
+            }
+        });
+        routed.whenComplete((contexts, error) -> {
+            if (error != null || result.isDone()) {
+                payload.release();
+                if (error != null) {
+                    result.completeExceptionally(PollRouter.unwrap(error));
+                }
+                return;
+            }
+            applyContexts(payload, first, contexts);
+            CompletableFuture<ByteBuf> reply = sendResolved(router, payload, topic, partitions, deadline, state);
+            step.set(reply);
+            if (result.isDone()) {
+                reply.cancel(false);
+            }
+            reply.whenComplete((body, failed) -> {
+                if (failed != null) {
+                    result.completeExceptionally(PollRouter.unwrap(failed));
+                } else if (!result.complete(body)) {
+                    body.release();
+                }
+            });
+        });
+        return result;
+    }
+
+    private static List<Long> unfencedPartitions(ByteBuf payload, int first) {
+        List<Long> unfenced = new ArrayList<>();
+        for (int target = first; target < payload.writerIndex(); target += TARGET_BYTES) {
+            if (payload.getLongLE(target + INCARNATION_OFFSET) == 0) {
+                unfenced.add(payload.getUnsignedIntLE(target));
+            }
+        }
+        return unfenced;
+    }
+
+    private static void applyContexts(ByteBuf payload, int first, Map<Long, PartitionContext> contexts) {
+        for (int target = first; target < payload.writerIndex(); target += TARGET_BYTES) {
+            if (payload.getLongLE(target + INCARNATION_OFFSET) == 0) {
+                PartitionContext context = contexts.get(payload.getUnsignedIntLE(target));
+                payload.setLongLE(
+                        target + INCARNATION_OFFSET, context.incarnation().longValue());
+                if (payload.getLongLE(target + INCARNATION_OFFSET + Long.BYTES) == 0) {
+                    payload.setLongLE(
+                            target + INCARNATION_OFFSET + Long.BYTES,
+                            context.ownerGeneration().longValue());
+                }
+            }
+        }
+    }
+
+    private static CompletableFuture<ByteBuf> sendResolved(
+            PollRouter router,
+            ByteBuf payload,
+            byte[] topic,
+            List<Long> partitions,
+            long deadline,
+            AsyncTcpConnection.TransientFailoverState state) {
+        CompletableFuture<ByteBuf> reply = router.fetchRequest(payload, topic, partitions.get(0), deadline, state);
+        CompletableFuture<ByteBuf> result = new CompletableFuture<>();
+        result.whenComplete((body, error) -> {
+            if (result.isCancelled()) {
+                reply.cancel(false);
+            }
+        });
+        reply.whenComplete((body, error) -> {
+            if (error == null) {
+                if (!result.complete(body)) {
+                    body.release();
+                }
+                return;
+            }
+            var refusal = AsyncTcpConnection.findServerError(error);
+            if (refusal != null && refusal.getRawErrorCode() == AsyncTcpConnection.NOT_PRIMARY) {
+                router.mayHaveMoved(topic, partitions);
+            }
+            if (refusal instanceof IggyOperationException blocked
+                    && blocked.getErrorCode() == IggyErrorCode.LANE_BLOCKED
+                    && blocked.getPartitionId().isPresent()) {
+                result.completeExceptionally(new IggyOperationException(
+                        IggyErrorCode.PARTITION_LANE_BLOCKED,
+                        blocked.getOperationId(),
+                        blocked.getPartitionId().getAsLong()));
+                return;
+            }
+            result.completeExceptionally(error);
+        });
+        return result;
+    }
+
+    /** Marks the routes of the partitions with a NOT_PRIMARY section moved. */
+    static void markMoved(PollRouter router, byte[] topic, List<FetchedMessages> sections) {
+        List<Long> moved = sections.stream()
+                .filter(section -> section.status() == AsyncTcpConnection.NOT_PRIMARY)
+                .map(FetchedMessages::partitionId)
+                .toList();
+        if (!moved.isEmpty()) {
+            router.mayHaveMoved(topic, moved);
+        }
+    }
+
+    /**
+     * The progress lanes of a fetch whose body is {@code payload}, or none. A fetch that commits or that starts a
+     * partition at its next offset takes the progress lane of every target, so it keeps its place among the stores and
+     * polls of those partitions. Any other fetch only reads, and takes no lane.
+     */
+    static List<ConsensusSession.LaneKey> progressLanes(ByteBuf payload) {
+        int limits = PollRouter.waitParameters(payload, payload.readerIndex());
+        long targetsCount = payload.getUnsignedIntLE(limits + TARGETS_COUNT_OFFSET);
+        int first = limits + LIMITS_BYTES;
+        boolean progress = commits(payload);
+        List<ConsensusSession.LaneKey> lanes = new ArrayList<>((int) targetsCount);
+        for (int target = first; target < first + targetsCount * TARGET_BYTES; target += TARGET_BYTES) {
+            progress |= payload.getUnsignedByte(target + STRATEGY_KIND_OFFSET) == PollingKind.Next.asCode();
+            lanes.add(new ConsensusSession.LaneKey(
+                    payload.getLongLE(target + INCARNATION_OFFSET), payload.getUnsignedIntLE(target), true));
+        }
+        return progress ? List.copyOf(lanes) : List.of();
+    }
+
+    /** Whether the fetch whose body is {@code payload} commits the consumer offset of each partition that it reads. */
+    static boolean commits(ByteBuf payload) {
+        return payload.getUnsignedByte(PollRouter.waitParameters(payload, payload.readerIndex()) + AUTO_COMMIT_OFFSET)
+                != 0;
+    }
+
+    /**
+     * Lowers the byte limits of the fetch whose limits start at {@code limits} in {@code frame} until its whole reply
+     * fits in {@code maxReplyBody}, the largest reply body that the link reads. The link drops a larger reply, after
+     * the server may have committed what it holds.
+     *
+     * @throws IggyInvalidArgumentException if the section table leaves less than one record of the largest size
+     */
+    static void fitReply(ByteBuf frame, int limits, long maxReplyBody) {
+        long targetsCount = frame.getUnsignedIntLE(limits + TARGETS_COUNT_OFFSET);
+        long records = maxReplyBody
+                - BytesDeserializer.FETCH_SECTIONS_COUNT_BYTES
+                - targetsCount * BytesDeserializer.FETCH_SECTION_BYTES;
+        if (records < DeferredPollOptions.MAX_RECORD_SIZE) {
+            throw new IggyInvalidArgumentException("A fetch of " + targetsCount
+                    + " partitions can get a first message that needs a reply larger than " + maxReplyBody
+                    + " bytes, the most that the link reads");
+        }
+        long maxBytes = Math.min(frame.getUnsignedIntLE(limits + MAX_BYTES_OFFSET), records);
+        frame.setIntLE(limits + MAX_BYTES_OFFSET, (int) maxBytes);
+        int first = limits + LIMITS_BYTES;
+        for (int target = first; target < first + targetsCount * TARGET_BYTES; target += TARGET_BYTES) {
+            if (frame.getUnsignedIntLE(target + TARGET_MAX_BYTES_OFFSET) > maxBytes) {
+                frame.setIntLE(target + TARGET_MAX_BYTES_OFFSET, (int) maxBytes);
+            }
+        }
+    }
+
+    /**
+     * The largest reply body that the fetch in {@code frame} can get: the section count, one table entry per target and
+     * the byte limit of the request, or one record of the largest size, which the server returns alone above it.
+     */
+    static long replyLimit(ByteBuf frame) {
+        int limits = PollRouter.waitParameters(frame, frame.readerIndex() + VsrHeaders.HEADER_SIZE);
+        return BytesDeserializer.FETCH_SECTIONS_COUNT_BYTES
+                + frame.getUnsignedIntLE(limits + TARGETS_COUNT_OFFSET) * BytesDeserializer.FETCH_SECTION_BYTES
+                + Math.max(frame.getUnsignedIntLE(limits + MAX_BYTES_OFFSET), DeferredPollOptions.MAX_RECORD_SIZE);
+    }
+
+    /** The largest reply body of the deferred poll in {@code frame}: its byte limit, or one record of the largest size. */
+    static long pollReplyLimit(ByteBuf frame) {
+        return Math.max(PollRouter.deferredMaxBytes(frame), DeferredPollOptions.MAX_RECORD_POLL_REPLY_SIZE);
+    }
+
+    static void configureResponse(VsrResponseHandler.PendingRequest pending, ByteBuf frame) {
+        pending.limitResponseBody(replyLimit(frame));
+    }
+
+    static ByteBuf payload(
+            byte[] consumer,
+            byte[] topic,
+            Map<Long, PollingStrategy> targets,
+            long count,
+            boolean autoCommit,
+            DeferredPollOptions options) {
+        ByteBuf payload =
+                Unpooled.buffer(consumer.length + topic.length + LIMITS_BYTES + targets.size() * TARGET_BYTES);
+        payload.writeBytes(consumer)
+                .writeBytes(topic)
+                .writeLongLE(options.maxWaitMicros())
+                .writeLongLE(options.requestTimeoutMicros())
+                .writeIntLE((int) options.minCount())
+                .writeIntLE((int) count)
+                .writeIntLE((int) options.maxBytes())
+                .writeByte(autoCommit ? 1 : 0)
+                .writeIntLE(targets.size());
+        targets.forEach((partition, strategy) -> {
+            PartitionContext fence = strategy.context().orElse(PartitionContext.EMPTY);
+            ByteBuf encodedStrategy = BytesSerializer.toBytes(strategy);
+            try {
+                payload.writeIntLE((int) (long) partition)
+                        .writeBytes(encodedStrategy)
+                        .writeIntLE((int) options.maxBytes())
+                        .writeLongLE(fence.incarnation().longValue())
+                        .writeLongLE(fence.ownerGeneration().longValue());
+            } finally {
+                encodedStrategy.release();
+            }
+        });
+        return payload;
+    }
+}
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/MessagesTcpClient.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/MessagesTcpClient.java
index dc63aab..67593a8 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/MessagesTcpClient.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/MessagesTcpClient.java
@@ -24,15 +24,19 @@
 import org.apache.iggy.client.async.MessagesClient;
 import org.apache.iggy.consumergroup.Consumer;
 import org.apache.iggy.exception.IggyErrorCode;
+import org.apache.iggy.exception.IggyInvalidArgumentException;
 import org.apache.iggy.exception.IggyResourceNotFoundException;
 import org.apache.iggy.exception.IggyServerException;
+import org.apache.iggy.exception.IggyValidationException;
 import org.apache.iggy.hash.XxHash32;
 import org.apache.iggy.identifier.StreamId;
 import org.apache.iggy.identifier.TopicId;
 import org.apache.iggy.message.DeferredPollOptions;
+import org.apache.iggy.message.FetchedMessages;
 import org.apache.iggy.message.Message;
 import org.apache.iggy.message.Partitioning;
 import org.apache.iggy.message.PartitioningKind;
+import org.apache.iggy.message.PollOptions;
 import org.apache.iggy.message.PolledMessages;
 import org.apache.iggy.message.PollingStrategy;
 import org.apache.iggy.message.SendMessagesResponse;
@@ -43,7 +47,10 @@
 
 import java.math.BigInteger;
 import java.time.Duration;
+import java.util.Collection;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.Optional;
 import java.util.concurrent.CancellationException;
 import java.util.concurrent.CompletableFuture;
@@ -80,6 +87,18 @@
      */
     private static final Duration ROUTING_CACHE_REFRESH = Duration.ofSeconds(5);
 
+    // The requests that name partitions in a partition id error.
+    private static final String READINESS_GROUPS = "readiness group";
+    private static final String FETCH = "fetch";
+    private static final String POLL = "poll";
+
+    private static final String ONE_SHOT_AUTO_COMMIT = "A poll of one partition has nothing to count for an automatic"
+            + " commit. Use a partition reader, or poll with OnRead or Manual and store checkpoints.";
+
+    // A 106 names one partition, so the server answers INVALID_COMMAND for a group poll without one.
+    private static final String DEFERRED_GROUP_POLL_WITHOUT_PARTITION =
+            "A deferred group poll needs a partition id." + " Use a partition reader to read the whole assignment.";
+
     private final ClientRoutingState routingState;
     private final ConsumerGroupsTcpClient consumerGroupsClient;
     private final PollRouter pollRouter;
@@ -117,6 +136,36 @@
     }
 
     @Override
+    public CompletableFuture<PolledMessages> pollMessages(
+            StreamId streamId,
+            TopicId topicId,
+            long partitionId,
+            Consumer consumer,
+            PollingStrategy strategy,
+            PollOptions options) {
+        requireWirePartitionIds(List.of(partitionId), POLL);
+        strategy.requirePartition(partitionId);
+        options.validate();
+        if (options.autoCommit().isPresent()) {
+            throw new IggyValidationException(
+                    IggyErrorCode.INVALID_CONFIGURATION,
+                    IggyErrorCode.INVALID_CONFIGURATION.getCode(),
+                    ONE_SHOT_AUTO_COMMIT,
+                    Optional.empty(),
+                    Optional.empty());
+        }
+        return pollMessagesImpl(
+                streamId,
+                topicId,
+                Optional.of(partitionId),
+                consumer,
+                strategy,
+                options.count(),
+                options.commitsOnRead(),
+                options.deferredOptions());
+    }
+
+    @Override
     public CompletableFuture<PolledMessages> pollMessagesDeferred(
             StreamId streamId,
             TopicId topicId,
@@ -126,6 +175,14 @@
             Long count,
             boolean autoCommit,
             DeferredPollOptions options) {
+        if (consumer.kind() == Consumer.Kind.ConsumerGroup && partitionId.isEmpty()) {
+            throw new IggyValidationException(
+                    IggyErrorCode.INVALID_COMMAND,
+                    IggyErrorCode.INVALID_COMMAND.getCode(),
+                    DEFERRED_GROUP_POLL_WITHOUT_PARTITION,
+                    Optional.empty(),
+                    Optional.empty());
+        }
         options.validate(count);
         return pollMessagesImpl(streamId, topicId, partitionId, consumer, strategy, count, autoCommit, options);
     }
@@ -156,7 +213,6 @@
                     strategy,
                     count,
                     autoCommit,
-                    options,
                     GROUP_POLL_MAX_ATTEMPTS,
                     cancellation,
                     deadline,
@@ -184,36 +240,7 @@
             DeferredPollOptions options,
             long deadline,
             AsyncTcpConnection.TransientFailoverState state) {
-        DeferredPollOptions attempt = null;
-        if (options != null) {
-            // The deadline was set when the call began, so a later attempt carries only what is left of it.
-            Optional<DeferredPollOptions> remaining =
-                    options.remaining(options.requestTimeout().minusNanos(deadline - System.nanoTime()));
-            if (remaining.isEmpty()) {
-                return CompletableFuture.failedFuture(
-                        new IggyServerException(IggyErrorCode.TRANSIENT_NOT_ACCEPTED.getCode()));
-            }
-            attempt = remaining.get();
-        }
-
-        var payload = Unpooled.buffer();
-
-        writeAndRelease(payload, toBytes(consumer));
-        writeAndRelease(payload, toBytes(streamId));
-        writeAndRelease(payload, toBytes(topicId));
-        writeAndRelease(payload, toBytes(partitionId));
-        writeAndRelease(payload, toBytes(strategy));
-
-        payload.writeIntLE(count.intValue());
-
-        payload.writeByte(autoCommit ? 1 : 0);
-        if (attempt != null) {
-            payload.writeLongLE(attempt.maxWaitMicros());
-            payload.writeIntLE((int) attempt.minCount());
-            payload.writeIntLE((int) attempt.maxBytes());
-            payload.writeLongLE(attempt.requestTimeoutMicros());
-        }
-
+        var payload = pollBody(streamId, topicId, partitionId, consumer, strategy, count, autoCommit, options);
         CompletableFuture<ByteBuf> sent = pollRouter.partitionRequest(
                 options == null
                         ? org.apache.iggy.serde.CommandCode.Messages.POLL.getValue()
@@ -232,10 +259,104 @@
         return result;
     }
 
+    /** The body of a poll, and of a deferred poll when {@code options} is set. */
+    @SuppressWarnings("checkstyle:ParameterNumber")
+    static ByteBuf pollBody(
+            StreamId streamId,
+            TopicId topicId,
+            Optional<Long> partitionId,
+            Consumer consumer,
+            PollingStrategy strategy,
+            long count,
+            boolean autoCommit,
+            DeferredPollOptions options) {
+        var payload = Unpooled.buffer();
+        writeAndRelease(payload, toBytes(consumer));
+        writeAndRelease(payload, toBytes(streamId));
+        writeAndRelease(payload, toBytes(topicId));
+        writeAndRelease(payload, toBytes(partitionId));
+        writeAndRelease(payload, toBytes(strategy));
+        payload.writeIntLE((int) count);
+        payload.writeByte(autoCommit ? 1 : 0);
+        // Each write sends what the deadline of the call leaves of these budgets.
+        if (options != null) {
+            PollRouter.writeDeferredOptions(payload, options);
+        }
+        return payload;
+    }
+
     CompletableFuture<ByteBuf> sendPoll(ByteBuf payload, Optional<PartitionContext> context) {
         return pollRouter.poll(payload, context);
     }
 
+    /**
+     * Groups {@code partitions} by their primary, with at most 1000 partitions in a group, in the order of
+     * {@code partitions}. A partition without a known route costs a route lookup.
+     */
+    CompletableFuture<List<List<Long>>> readinessGroups(StreamId streamId, TopicId topicId, List<Long> partitions) {
+        requireWirePartitionIds(partitions, READINESS_GROUPS);
+        ByteBuf topic = toBytes(streamId);
+        writeAndRelease(topic, toBytes(topicId));
+        return pollRouter.onClientLoop(pollRouter.readinessGroups(
+                PollRouter.takeBytes(topic),
+                List.copyOf(partitions),
+                pollRouter.operationDeadline(),
+                pollRouter.startCall()));
+    }
+
+    /** Completes once no committing read of this client that was cancelled after its send still waits for its reply. */
+    CompletableFuture<Void> cancelledReadsSettled() {
+        return pollRouter.sessionOwner().cancelledReadsSettled();
+    }
+
+    /**
+     * Fetches the partitions of one primary in one request, sent to the primary of the first target, as a reader
+     * reads one readiness group. A committing fetch whose reply is lost fails with
+     * {@link IggyErrorCode#FETCH_PROGRESS_UNCERTAIN} and blocks its partitions until the reader abandons it.
+     */
+    CompletableFuture<List<FetchedMessages>> fetchMessages(
+            StreamId streamId,
+            TopicId topicId,
+            Map<Long, PollingStrategy> targets,
+            Consumer consumer,
+            Long count,
+            boolean autoCommit,
+            DeferredPollOptions options) {
+        return fetchMessages(streamId, topicId, targets, consumer, count, autoCommit, options, pollRouter.startCall());
+    }
+
+    /** {@link #fetchMessages}, under a call state whose ticket the caller keeps, to see a retained outcome later. */
+    @SuppressWarnings("checkstyle:ParameterNumber")
+    CompletableFuture<List<FetchedMessages>> fetchMessages(
+            StreamId streamId,
+            TopicId topicId,
+            Map<Long, PollingStrategy> targets,
+            Consumer consumer,
+            Long count,
+            boolean autoCommit,
+            DeferredPollOptions options,
+            AsyncTcpConnection.TransientFailoverState state) {
+        requireWirePartitionIds(targets.keySet(), FETCH);
+        FetchMessages.validate(targets, count, options);
+        long deadline = System.nanoTime() + options.requestTimeout().toNanos();
+        ByteBuf topic = toBytes(streamId);
+        writeAndRelease(topic, toBytes(topicId));
+        return pollRouter.onClientLoop(FetchMessages.start(
+                pollRouter,
+                PollRouter.takeBytes(toBytes(consumer)),
+                PollRouter.takeBytes(topic),
+                new LinkedHashMap<>(targets),
+                count,
+                autoCommit,
+                options,
+                deadline,
+                state));
+    }
+
+    AsyncTcpConnection.TransientFailoverState startCall() {
+        return pollRouter.startCall();
+    }
+
     @Override
     public CompletableFuture<SendMessagesResponse> sendMessages(
             StreamId streamId, TopicId topicId, Partitioning partitioning, List<Message> messages) {
@@ -355,6 +476,16 @@
         }
     }
 
+    // A wider id would wrap around on the wire and name another partition.
+    private static void requireWirePartitionIds(Collection<Long> partitions, String request) {
+        for (Long partition : partitions) {
+            if (partition == null || partition < 0 || partition > FetchMessages.MAX_PARTITION_ID) {
+                throw new IggyInvalidArgumentException("Partition id " + partition + " of a " + request
+                        + " is not between 0 and " + FetchMessages.MAX_PARTITION_ID);
+            }
+        }
+    }
+
     /**
      * One group-poll attempt: sync the assignment when missing or stale, pick
      * the next assigned partition round-robin, poll it explicitly, and on a
@@ -371,7 +502,6 @@
             PollingStrategy strategy,
             Long count,
             boolean autoCommit,
-            DeferredPollOptions options,
             int attemptsLeft,
             CallCancellation cancellation,
             long deadline,
@@ -412,7 +542,7 @@
                             strategy,
                             count,
                             autoCommit,
-                            options,
+                            null,
                             deadline,
                             state);
                     cancellation.track(polledPartition);
@@ -428,7 +558,6 @@
                                             strategy,
                                             count,
                                             autoCommit,
-                                            options,
                                             attemptsLeft - 1,
                                             cancellation,
                                             deadline,
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/PollRouter.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/PollRouter.java
index 5f764a3..a0b9d71 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/PollRouter.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/PollRouter.java
@@ -66,6 +66,7 @@
 import java.util.TreeMap;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.CompletionException;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.RejectedExecutionException;
 import java.util.concurrent.ThreadLocalRandom;
 import java.util.concurrent.TimeUnit;
@@ -80,11 +81,18 @@
  */
 final class PollRouter {
     static final int POLL_PARAMETERS_BYTES = 14;
+    // A deferred poll ends with [wait_us u64][min_count u32][max_bytes u32][request_timeout_us u64].
     static final int DEFERRED_PARAMETERS_BYTES = 2 * Long.BYTES + 2 * Integer.BYTES;
 
     static final Duration POLL_TIMEOUT = Duration.ofSeconds(30);
     static final int MAX_CONCURRENT_DIALS = 4;
-    private static final int MAX_DEFERRED_POLLS = 64;
+    // Deferred polls and fetches together, as MAX_DEFERRED_REQUESTS in core/sdk/src/driver.rs.
+    private static final int MAX_DEFERRED_REQUESTS = 64;
+    private static final int MAX_ROUTE_LOOKUPS_IN_FLIGHT = 16;
+
+    private static final int DEFERRED_MIN_COUNT_OFFSET = Long.BYTES;
+    private static final int DEFERRED_MAX_BYTES_OFFSET = Long.BYTES + Integer.BYTES;
+    private static final int DEFERRED_TIMEOUT_OFFSET = Long.BYTES + 2 * Integer.BYTES;
 
     private static final int MAX_ROUTES = 4096;
     private static final int MAX_CONNECTIONS = 256;
@@ -321,8 +329,45 @@
 
     CompletableFuture<ByteBuf> sendMessages(
             ByteBuf payload, ByteBuf query, long deadline, AsyncTcpConnection.TransientFailoverState state) {
-        byte[] target = takeBytes(query);
-        Poll poll = new Poll(target, CommandCode.Messages.SEND.getValue(), payload, deadline, Optional.empty());
+        return targetedRequest(
+                CommandCode.Messages.SEND.getValue(), payload, takeBytes(query), Optional.empty(), deadline, state);
+    }
+
+    /**
+     * Sends one fetch to the primary of its first partition, {@code partition}, using its route.
+     * Each target of a fetch carries its own fence, so the request header carries none.
+     *
+     * @param topic the encoded stream and topic identifiers
+     */
+    CompletableFuture<ByteBuf> fetchRequest(
+            ByteBuf payload,
+            byte[] topic,
+            long partition,
+            long deadline,
+            AsyncTcpConnection.TransientFailoverState state) {
+        return targetedRequest(
+                CommandCode.Messages.FETCH_MESSAGES.getValue(),
+                payload,
+                target(topic, partition),
+                Optional.of(PartitionContext.EMPTY),
+                deadline,
+                state);
+    }
+
+    private CompletableFuture<ByteBuf> targetedRequest(
+            int command,
+            ByteBuf payload,
+            byte[] target,
+            Optional<PartitionContext> context,
+            long deadline,
+            AsyncTcpConnection.TransientFailoverState state) {
+        Poll poll;
+        try {
+            poll = new Poll(target, command, payload, deadline, context);
+        } catch (RuntimeException error) {
+            payload.release();
+            return CompletableFuture.failedFuture(error);
+        }
         poll.state = state;
         return send(poll);
     }
@@ -360,6 +405,136 @@
                 return CompletableFuture.completedFuture(count);
             }
         }
+        return lookup(target, deadline, state, Route::partitionsCount);
+    }
+
+    /**
+     * The partitions grouped by their primary and split to fit one fetch each, each group in the
+     * order of {@code partitions}. A partition without a current route costs a route lookup, and at most {@value
+     * #MAX_ROUTE_LOOKUPS_IN_FLIGHT} of them run at once. Once the returned future ends, also by a cancel, no lookup of
+     * it runs on. Mirrors readiness_groups in core/sdk/src/driver.rs.
+     *
+     * @param topic the encoded stream and topic identifiers
+     */
+    CompletableFuture<List<List<Long>>> readinessGroups(
+            byte[] topic, List<Long> partitions, long deadline, AsyncTcpConnection.TransientFailoverState state) {
+        return withRoutes(topic, partitions, deadline, state, known -> groupByPrimary(partitions, known));
+    }
+
+    /**
+     * The context of the current route of each of {@code partitions}, which a fetch stamps on a target whose strategy
+     * has none. Routes are looked up as {@link #readinessGroups} looks them up.
+     *
+     * @param topic the encoded stream and topic identifiers
+     */
+    CompletableFuture<Map<Long, PartitionContext>> routeContexts(
+            byte[] topic, List<Long> partitions, long deadline, AsyncTcpConnection.TransientFailoverState state) {
+        return withRoutes(topic, partitions, deadline, state, known -> {
+            Map<Long, PartitionContext> contexts = new HashMap<>();
+            known.forEach((partition, route) -> contexts.put(partition, route.context));
+            return contexts;
+        });
+    }
+
+    private <T> CompletableFuture<T> withRoutes(
+            byte[] topic,
+            List<Long> partitions,
+            long deadline,
+            AsyncTcpConnection.TransientFailoverState state,
+            Function<Map<Long, Route>, T> answer) {
+        Map<Long, Route> known = new ConcurrentHashMap<>();
+        List<Long> unknown = new ArrayList<>();
+        synchronized (this) {
+            for (long partition : partitions) {
+                Route route = routes.get(TargetKey.of(target(topic, partition)));
+                if (route != null && routeIsCurrent(route)) {
+                    known.put(partition, route);
+                } else {
+                    unknown.add(partition);
+                }
+            }
+        }
+        CompletableFuture<T> groups = new CompletableFuture<>();
+        Set<CompletableFuture<Route>> lookups = ConcurrentHashMap.newKeySet();
+        groups.whenComplete((ignored, error) -> lookups.forEach(lookup -> lookup.cancel(false)));
+        CompletableFuture<Void> looked = CompletableFuture.completedFuture(null);
+        for (int start = 0; start < unknown.size(); start += MAX_ROUTE_LOOKUPS_IN_FLIGHT) {
+            List<Long> batch = unknown.subList(start, Math.min(unknown.size(), start + MAX_ROUTE_LOOKUPS_IN_FLIGHT));
+            looked = looked.thenCompose(ignored -> groups.isDone()
+                    ? CompletableFuture.completedFuture(null)
+                    : CompletableFuture.allOf(batch.stream()
+                            .map(partition -> {
+                                CompletableFuture<Route> lookup = lookup(
+                                        target(topic, partition), deadline, state.nextHistory(), Function.identity());
+                                lookups.add(lookup);
+                                // If the groups ended after the check above, their cancel missed this lookup.
+                                if (groups.isDone()) {
+                                    lookup.cancel(false);
+                                }
+                                return lookup.thenAccept(route -> known.put(partition, route));
+                            })
+                            .toArray(CompletableFuture[]::new)));
+        }
+        looked.whenComplete((ignored, error) -> {
+            if (error != null) {
+                groups.completeExceptionally(unwrap(error));
+                return;
+            }
+            if (groups.isDone()) {
+                return;
+            }
+            try {
+                groups.complete(answer.apply(known));
+            } catch (RuntimeException failure) {
+                groups.completeExceptionally(failure);
+            }
+        });
+        return groups;
+    }
+
+    /**
+     * Marks the routes of {@code partitions} stale after a fetch over them got NOT_PRIMARY, which does not
+     * name the partition that moved. Each next lookup asks the last known primary first, and the view of each route
+     * stays its floor, so a partition that did not move keeps its route. Mirrors may_have_moved in
+     * core/sdk/src/driver/route_cache.rs.
+     *
+     * @param topic the encoded stream and topic identifiers
+     */
+    synchronized void mayHaveMoved(byte[] topic, List<Long> partitions) {
+        for (long partition : partitions) {
+            TargetKey key = TargetKey.of(target(topic, partition));
+            Route previous = routes.get(key);
+            if (previous != null) {
+                routes.put(key, previous.markedStale(previous.view, previous.endpoint));
+            }
+        }
+    }
+
+    private static List<List<Long>> groupByPrimary(List<Long> partitions, Map<Long, Route> routes) {
+        Map<ConnectionInfo, List<Long>> byPrimary = new LinkedHashMap<>();
+        for (long partition : partitions) {
+            byPrimary
+                    .computeIfAbsent(routes.get(partition).endpoint, primary -> new ArrayList<>())
+                    .add(partition);
+        }
+        List<List<Long>> groups = new ArrayList<>();
+        for (List<Long> group : byPrimary.values()) {
+            for (int start = 0; start < group.size(); start += FetchMessages.MAX_PARTITIONS_PER_REQUEST) {
+                int end = Math.min(group.size(), start + FetchMessages.MAX_PARTITIONS_PER_REQUEST);
+                groups.add(List.copyOf(group.subList(start, end)));
+            }
+        }
+        return List.copyOf(groups);
+    }
+
+    /** The routing target of {@code partition}, which follows the encoded stream and topic identifiers. */
+    private static byte[] target(byte[] topic, long partition) {
+        return takeBytes(
+                Unpooled.buffer(topic.length + Integer.BYTES).writeBytes(topic).writeIntLE((int) partition));
+    }
+
+    private <T> CompletableFuture<T> lookup(
+            byte[] target, long deadline, AsyncTcpConnection.TransientFailoverState state, Function<Route, T> answer) {
         Poll lookup = new Poll(
                 target,
                 CommandCode.Messages.GET_PARTITION_ROUTE.getValue(),
@@ -369,7 +544,7 @@
         lookup.state = state;
         return AsyncTcpConnection.mapResponse(send(lookup), response -> {
             response.release();
-            return lookup.partitionsCount;
+            return answer.apply(lookup.route);
         });
     }
 
@@ -565,7 +740,7 @@
         body.skipBytes(length);
     }
 
-    private static byte[] takeBytes(ByteBuf payload) {
+    static byte[] takeBytes(ByteBuf payload) {
         try {
             return ByteBufUtil.getBytes(payload);
         } finally {
@@ -573,14 +748,59 @@
         }
     }
 
+    /** Appends the limits that end a deferred poll request. */
+    static void writeDeferredOptions(ByteBuf request, DeferredPollOptions options) {
+        request.writeLongLE(options.maxWaitMicros())
+                .writeIntLE((int) options.minCount())
+                .writeIntLE((int) options.maxBytes())
+                .writeLongLE(options.requestTimeoutMicros());
+    }
+
     /** The limits that end a deferred poll request or frame, checked by the {@link DeferredPollOptions} constructor. */
     static DeferredPollOptions deferredOptions(ByteBuf request) {
-        int parameters = request.writerIndex() - DEFERRED_PARAMETERS_BYTES;
+        int parameters = deferredWait(request);
         return new DeferredPollOptions(
                 Duration.of(request.getLongLE(parameters), ChronoUnit.MICROS),
-                request.getUnsignedIntLE(parameters + Long.BYTES),
-                request.getUnsignedIntLE(parameters + Long.BYTES + Integer.BYTES),
-                Duration.of(request.getLongLE(parameters + Long.BYTES + 2 * Integer.BYTES), ChronoUnit.MICROS));
+                request.getUnsignedIntLE(parameters + DEFERRED_MIN_COUNT_OFFSET),
+                deferredMaxBytes(request),
+                Duration.of(request.getLongLE(deferredTimeout(request)), ChronoUnit.MICROS));
+    }
+
+    /** The index in a deferred poll request or frame of its wait, the first of the limits that end it. */
+    static int deferredWait(ByteBuf request) {
+        return request.writerIndex() - DEFERRED_PARAMETERS_BYTES;
+    }
+
+    /** The index in a deferred poll request or frame of its request timeout. */
+    static int deferredTimeout(ByteBuf request) {
+        return deferredWait(request) + DEFERRED_TIMEOUT_OFFSET;
+    }
+
+    /** The byte limit of a deferred poll request or frame, which also bounds its reply body. */
+    static long deferredMaxBytes(ByteBuf request) {
+        return request.getUnsignedIntLE(deferredWait(request) + DEFERRED_MAX_BYTES_OFFSET);
+    }
+
+    /** The wait and request timeout of a deferred request, checked by the {@link DeferredPollOptions} constructor. */
+    static DeferredPollOptions deferredBudget(long waitMicros, long requestTimeoutMicros) {
+        return new DeferredPollOptions(
+                Duration.of(waitMicros, ChronoUnit.MICROS),
+                DeferredPollOptions.DEFAULT_MIN_COUNT,
+                DeferredPollOptions.DEFAULT_MAX_BYTES,
+                Duration.of(requestTimeoutMicros, ChronoUnit.MICROS));
+    }
+
+    /**
+     * The index in {@code request} of the wait of a fetch whose body starts at {@code body}. The consumer,
+     * stream and topic lead the body, and the request timeout follows the wait.
+     */
+    static int waitParameters(ByteBuf request, int body) {
+        ByteBuf fields = request.duplicate().readerIndex(body);
+        fields.skipBytes(Byte.BYTES);
+        skipIdentifier(fields);
+        skipIdentifier(fields);
+        skipIdentifier(fields);
+        return fields.readerIndex();
     }
 
     ConsensusSession sessionOwner() {
@@ -649,8 +869,9 @@
                 if (poll.result.isDone()) {
                     return poll.result;
                 }
-                if (poll.dataCommand == CommandCode.Messages.POLL_DEFERRED.getValue()) {
-                    if (activeDeferredPolls >= MAX_DEFERRED_POLLS) {
+                if (poll.dataCommand == CommandCode.Messages.POLL_DEFERRED.getValue()
+                        || poll.dataCommand == CommandCode.Messages.FETCH_MESSAGES.getValue()) {
+                    if (activeDeferredPolls >= MAX_DEFERRED_REQUESTS) {
                         deferredWaiting.add(poll);
                         return poll.result;
                     }
@@ -777,6 +998,20 @@
         }
     }
 
+    /** Completes once every committing poll and fetch that is pending now has ended, with any outcome. */
+    CompletableFuture<Void> committingReadsEnded() {
+        List<CompletableFuture<ByteBuf>> committing;
+        synchronized (this) {
+            committing = pending.stream()
+                    .filter(poll -> poll.commits)
+                    .map(poll -> poll.result)
+                    .toList();
+        }
+        return CompletableFuture.allOf(committing.stream()
+                .map(result -> result.handle((response, error) -> null))
+                .toArray(CompletableFuture[]::new));
+    }
+
     CompletableFuture<Void> clearSession() {
         return clearSession(null);
     }
@@ -817,7 +1052,11 @@
                 synchronized (this) {
                     Route known = routes.get(poll.key);
                     if (known != null) {
-                        coordinator.get().sessionOwner().checkRetained(poll.laneFor(known.context), poll.isMutation());
+                        var session = coordinator.get().sessionOwner();
+                        boolean inspect = !poll.advancesProgress() && !poll.isMutation();
+                        for (ConsensusSession.LaneKey lane : poll.lanesFor(known.context)) {
+                            session.checkRetained(lane, poll.isMutation(), inspect);
+                        }
                     }
                 }
             } catch (RuntimeException error) {
@@ -845,11 +1084,17 @@
     @SuppressWarnings({"checkstyle:CyclomaticComplexity", "checkstyle:NPathComplexity"})
     private void failedAttempt(Poll poll, Throwable error) {
         if (poll.retryingRefusal && unwrap(error) instanceof IggyTimeoutException) {
-            poll.fail(notAccepted());
+            poll.fail(poll.deadlineRefusal());
             return;
         }
         IggyServerException refusal = AsyncTcpConnection.findServerError(error);
         boolean notPrimary = refusal != null && refusal.getRawErrorCode() == AsyncTcpConnection.NOT_PRIMARY;
+        if (notPrimary && poll.dataCommand == CommandCode.Messages.FETCH_MESSAGES.getValue()) {
+            // A fetch names several partitions and its refusal does not name the one that moved, so the
+            // caller looks up every route of the request again, see mayHaveMoved.
+            poll.fail(error);
+            return;
+        }
         boolean lostLink = connectionFailed(error);
         invalidateRoute(poll, error, notPrimary);
         if (!poll.advancesProgress()
@@ -881,7 +1126,7 @@
                         || (refusal != null
                                 && refusal.getRawErrorCode()
                                         == IggyErrorCode.DEFERRED_POLL_CAPACITY_EXCEEDED.getCode()))) {
-            retry(poll, notPrimary);
+            retry(poll, notPrimary, refusal);
         } else {
             poll.fail(error);
         }
@@ -900,19 +1145,10 @@
             if (previous != null) {
                 routes.put(
                         poll.key,
-                        new Route(
-                                previous.endpoint,
-                                previous.attachment,
-                                previous.parent,
-                                previous.generation,
-                                previous.bindSecret,
-                                previous.context,
-                                previous.partitionsCount,
-                                previous.local,
+                        previous.markedStale(
                                 notPrimary && poll.dataSent
-                                        ? Math.max(previous.view, Math.min(0xffff_ffffL, poll.routeView + 1))
+                                        ? Math.max(previous.view, Math.min(0xffff_ffffL, poll.route.view + 1))
                                         : previous.view,
-                                true,
                                 poll.backupMiss
                                         ? metadataPrimary
                                         : notPrimary && poll.dataSent
@@ -948,9 +1184,9 @@
         coordinator.get().eventLoop().schedule(() -> attempt(poll), delay, TimeUnit.MILLISECONDS);
     }
 
-    private void retry(Poll poll, boolean notPrimary) {
+    private void retry(Poll poll, boolean notPrimary, IggyServerException refusal) {
         long delay = notPrimary ? poll.notPrimarySeen ? poll.notPrimaryRetryMillis : 0 : RETRY_INTERVAL_MILLIS;
-        if (!poll.retryRefusal(delay)) {
+        if (!poll.retryRefusal(delay, refusal)) {
             return;
         }
         if (notPrimary && poll.notPrimarySeen) {
@@ -1218,8 +1454,7 @@
         if (poll.result.isDone()) {
             return CompletableFuture.failedFuture(notAccepted());
         }
-        poll.partitionsCount = route.partitionsCount;
-        poll.routeView = route.view;
+        poll.route = route;
         poll.backupMiss = false;
         if (poll.dataCommand == CommandCode.Messages.GET_PARTITION_ROUTE.getValue()) {
             return CompletableFuture.completedFuture(Unpooled.EMPTY_BUFFER);
@@ -1229,7 +1464,7 @@
         poll.state.setContext(poll.context.get());
         poll.state.setRoutingBody(poll.routingBody);
         if (poll.requiresLane) {
-            poll.state.setLane(poll.laneFor(poll.context.get()));
+            poll.state.setLanes(poll.lanesFor(route.context));
         }
         if (route.local) {
             poll.dataSent = true;
@@ -1256,7 +1491,7 @@
         return slot;
     }
 
-    private static Throwable unwrap(Throwable error) {
+    static Throwable unwrap(Throwable error) {
         return error instanceof CompletionException && error.getCause() != null ? unwrap(error.getCause()) : error;
     }
 
@@ -1471,8 +1706,14 @@
         private final boolean hasCallerContext;
         // Read once, since the deadline timer can run after the payload was released.
         private final boolean requiresLane;
+        private final List<ConsensusSession.LaneKey> fetchLanes;
+        // A poll or fetch that commits on the server. Its cancel waits for the reply, which tells whether it stored.
+        private final boolean commits;
         private boolean historyRefreshed;
         private boolean retryingRefusal;
+        // The last capacity refusal, which the call reports when its deadline ends the retries. A budget too short
+        // for another attempt also ends them with 58, which would hide it.
+        private IggyServerException capacityRefusal;
         private boolean notPrimarySeen;
         private long notPrimaryRetryMillis = RETRY_INTERVAL_MILLIS;
         private boolean terminalRoutingRefusal;
@@ -1482,8 +1723,7 @@
         private boolean backupMiss;
         private long lifecycleRetryMillis = RETRY_INTERVAL_MILLIS;
         private IggyServerException lifecycleRefusal;
-        private long partitionsCount;
-        private long routeView;
+        private Route route;
         private final Set<ConnectionInfo> lookupVisited = new HashSet<>();
         private AsyncTcpConnection.TransientFailoverState state = new AsyncTcpConnection.TransientFailoverState();
 
@@ -1502,8 +1742,12 @@
             this.hasCallerContext = callerContext.isPresent();
             int pollEnd = payload.writerIndex()
                     - (dataCommand == CommandCode.Messages.POLL_DEFERRED.getValue() ? DEFERRED_PARAMETERS_BYTES : 0);
+            boolean fetch = dataCommand == CommandCode.Messages.FETCH_MESSAGES.getValue();
+            this.fetchLanes = fetch ? FetchMessages.progressLanes(payload) : List.of();
+            this.commits = CommittingRead.commits(dataCommand, payload);
             this.requiresLane = isMutation()
                     || dataCommand == CommandCode.ConsumerOffset.GET.getValue()
+                    || !fetchLanes.isEmpty()
                     || ((dataCommand == CommandCode.Messages.POLL.getValue()
                                     || dataCommand == CommandCode.Messages.POLL_DEFERRED.getValue())
                             && (payload.getUnsignedByte(pollEnd - 1) != 0
@@ -1519,12 +1763,19 @@
             return requiresLane && !isMutation() && dataCommand != CommandCode.ConsumerOffset.GET.getValue();
         }
 
+        private List<ConsensusSession.LaneKey> lanesFor(PartitionContext fallback) {
+            return dataCommand == CommandCode.Messages.FETCH_MESSAGES.getValue()
+                    ? fetchLanes
+                    : List.of(laneFor(fallback));
+        }
+
         private ConsensusSession.LaneKey laneFor(PartitionContext fallback) {
             PartitionContext captured = context.get();
+            long incarnation = captured == null ? 0 : captured.incarnation().longValue();
             ByteBuf query = Unpooled.wrappedBuffer(routingBody);
             try {
                 return new ConsensusSession.LaneKey(
-                        (captured == null ? fallback : captured).incarnation().longValue(),
+                        incarnation == 0 ? fallback.incarnation().longValue() : incarnation,
                         query.getUnsignedIntLE(query.writerIndex() - Integer.BYTES),
                         dataCommand != CommandCode.Messages.SEND.getValue());
             } finally {
@@ -1572,10 +1823,14 @@
             lifecycleRefusal = null;
         }
 
-        private synchronized boolean retryRefusal(long delayMillis) {
+        private synchronized boolean retryRefusal(long delayMillis, IggyServerException refusal) {
             retryingRefusal = true;
+            if (refusal != null
+                    && refusal.getRawErrorCode() == IggyErrorCode.DEFERRED_POLL_CAPACITY_EXCEEDED.getCode()) {
+                capacityRefusal = refusal;
+            }
             if (deadline - System.nanoTime() <= TimeUnit.MILLISECONDS.toNanos(delayMillis)) {
-                fail(notAccepted());
+                fail(deadlineRefusal());
                 return false;
             }
             return !result.isDone();
@@ -1589,16 +1844,24 @@
             if (payloadLease != null) {
                 payloadLease.finishWrite();
             }
-            if (state.ticket() != null) {
-                if (error != null
-                        && !state.committed()
-                        && (state.wasUncertain()
-                                || unwrap(error) instanceof IggyOperationException operationError
-                                        && operationError.getErrorCode() == IggyErrorCode.OPERATION_UNCERTAIN)) {
-                    state.retainTicket();
-                } else {
-                    state.ticket().complete();
-                }
+            CompletableFuture<Void> settled =
+                    state.ticket() == null ? null : state.ticket().attemptSettled();
+            if (commits && result.isCancelled() && settled != null && !settled.isDone()) {
+                settled.whenComplete((ignored, attemptError) -> settleTicket(error));
+            } else if (state.ticket() != null) {
+                settleTicket(error);
+            }
+        }
+
+        private void settleTicket(Throwable error) {
+            if (error != null
+                    && !state.committed()
+                    && (state.wasUncertain()
+                            || unwrap(error) instanceof IggyOperationException operationError
+                                    && operationError.getErrorCode() == IggyErrorCode.OPERATION_UNCERTAIN)) {
+                state.retainTicket();
+            } else {
+                state.ticket().complete();
             }
         }
 
@@ -1609,14 +1872,10 @@
                             : unwrap(error));
         }
 
-        /**
-         * Ends a poll that a session change or its deadline caught between attempts. A poll that a sign-in or a
-         * sign-out of the caller ended ends with 40, unless its mutation can have taken effect.
-         */
+        /** Ends a poll between attempts, preserving uncertainty once its frame may have been sent. */
         @SuppressWarnings("checkstyle:CyclomaticComplexity")
         private synchronized void endPending() {
-            // A link writes a request that needs a lane only after the lane admits it, which gives the request a
-            // ticket.
+            // Admission supplies the ticket before a lane-bound request can be written.
             boolean mayBeWritten = dataSent && (!requiresLane || state.ticket() != null);
             fail(
                     state.ticket() != null && dataSent && !retryingRefusal && lifecycleRefusal == null
@@ -1625,7 +1884,13 @@
                                     ? state.lineageEnded(null)
                                     : lifecycleRefusal != null
                                             ? lifecycleRefusal
-                                            : retryingRefusal || !mayBeWritten ? notAccepted() : uncommitted());
+                                            : retryingRefusal
+                                                    ? deadlineRefusal()
+                                                    : !mayBeWritten ? notAccepted() : uncommitted());
+        }
+
+        private synchronized IggyServerException deadlineRefusal() {
+            return capacityRefusal != null ? capacityRefusal : notAccepted();
         }
     }
 
@@ -1689,7 +1954,26 @@
             boolean local,
             long view,
             boolean stale,
-            ConnectionInfo hint) {}
+            ConnectionInfo hint) {
+        /**
+         * This route, to be looked up again before its next use. The lookup asks {@code lookupHint} first, and an answer
+         * of the same incarnation with a view below {@code floorView} does not replace the route.
+         */
+        Route markedStale(long floorView, ConnectionInfo lookupHint) {
+            return new Route(
+                    endpoint,
+                    attachment,
+                    parent,
+                    generation,
+                    bindSecret,
+                    context,
+                    partitionsCount,
+                    local,
+                    floorView,
+                    true,
+                    lookupHint);
+        }
+    }
 
     private record Attachment(long clientLow, long clientHigh, long session, long watermark) {
         private Attachment {
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/RawPartitionRequests.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/RawPartitionRequests.java
index 734d3db..071b7e8 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/RawPartitionRequests.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/RawPartitionRequests.java
@@ -27,26 +27,35 @@
 import org.apache.iggy.exception.IggyServerException;
 import org.apache.iggy.message.DeferredPollOptions;
 import org.apache.iggy.message.PartitioningKind;
+import org.apache.iggy.message.PollingKind;
+import org.apache.iggy.serde.BytesDeserializer;
 import org.apache.iggy.serde.CommandCode;
 
 import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.time.temporal.ChronoUnit;
+import java.util.Arrays;
+import java.util.LinkedHashSet;
+import java.util.List;
 import java.util.Optional;
+import java.util.Set;
 import java.util.concurrent.CompletableFuture;
 import java.util.function.Supplier;
 
 /**
  * Stamps raw partition commands with the partition context the server fences
  * them by, as Rust's raw API does: a send to an explicit partition shares the
- * typed sends' context cache, and polls, deferred polls and offset writes take
- * the routed path of a typed poll or offset write. Readiness waits, which Rust
- * routes to the primary of each partition they name, and deferred-request
- * cancels are refused. Other commands go out as they are.
+ * typed sends' context cache, and polls, deferred polls and
+ * offset writes take the routed path of the typed call. A fetch keeps the
+ * fences of its targets and goes to the primary of its first target, as a
+ * typed fetch goes. Deferred-request cancels are refused. Other commands go
+ * out as they are.
  */
 final class RawPartitionRequests {
     private static final int SEND_MESSAGES = CommandCode.Messages.SEND.getValue();
     private static final int POLL_MESSAGES = CommandCode.Messages.POLL.getValue();
     private static final int POLL_MESSAGES_DEFERRED = CommandCode.Messages.POLL_DEFERRED.getValue();
-    private static final int WAIT_FOR_MESSAGES = CommandCode.Messages.WAIT_FOR_MESSAGES.getValue();
+    private static final int FETCH_MESSAGES = CommandCode.Messages.FETCH_MESSAGES.getValue();
     private static final int CANCEL_DEFERRED_REQUEST = CommandCode.Messages.CANCEL_DEFERRED_REQUEST.getValue();
     private static final int STORE_CONSUMER_OFFSET = CommandCode.ConsumerOffset.STORE.getValue();
     private static final int DELETE_CONSUMER_OFFSET = CommandCode.ConsumerOffset.DELETE.getValue();
@@ -63,8 +72,6 @@
     private static final int DELETE_OFFSET_SUFFIX_BYTES = 1;
     private static final String UNPARTITIONED_SEND = "A raw SendMessages request needs an explicit partition id."
             + " Send balanced or messages-key batches with MessagesClient.sendMessages.";
-    private static final String UNROUTED_WAIT = "A raw WaitForMessages request needs the primary of each partition"
-            + " it names, and this client does not route readiness waits.";
 
     private final Supplier<AsyncTcpConnection> connection;
     private final PollRouter pollRouter;
@@ -89,6 +96,7 @@
      * deferred poll with limits that the server's decoder refuses fails the
      * same way.
      */
+    @SuppressWarnings({"checkstyle:CyclomaticComplexity", "checkstyle:NPathComplexity"})
     CompletableFuture<ByteBuf> send(int code, byte[] payload) {
         try {
             if (code == SEND_MESSAGES) {
@@ -100,9 +108,8 @@
             if (code == POLL_MESSAGES_DEFERRED) {
                 return pollMessagesDeferred(payload);
             }
-            if (code == WAIT_FOR_MESSAGES) {
-                throw IggyServerException.fromTcpResponse(
-                        IggyErrorCode.FEATURE_UNAVAILABLE.getCode(), UNROUTED_WAIT.getBytes(StandardCharsets.UTF_8));
+            if (code == FETCH_MESSAGES) {
+                return fetchMessages(payload);
             }
             if (code == CANCEL_DEFERRED_REQUEST) {
                 // A cancel names a pending request by its id, and the server answers no cancel,
@@ -174,6 +181,51 @@
                 System.nanoTime() + options.requestTimeout().toNanos());
     }
 
+    /** Refuses a fetch that the server would refuse to decode, see FetchMessagesRequest in core/binary_protocol. */
+    private CompletableFuture<ByteBuf> fetchMessages(byte[] payload) {
+        int topic = consumerEnd(payload, FETCH_MESSAGES);
+        int limits = identifierEnd(payload, identifierEnd(payload, topic, FETCH_MESSAGES), FETCH_MESSAGES);
+        int targets = limits + FetchMessages.LIMITS_BYTES;
+        if (targets > payload.length) {
+            throw malformed(FETCH_MESSAGES, "fetch limits", limits);
+        }
+        ByteBuf fields = Unpooled.wrappedBuffer(payload);
+        long count = fields.getUnsignedIntLE(targets - Integer.BYTES);
+        if (count == 0
+                || count > FetchMessages.MAX_PARTITIONS_PER_REQUEST
+                || payload.length - targets != count * FetchMessages.TARGET_BYTES) {
+            throw malformed(FETCH_MESSAGES, "targets", targets - Integer.BYTES);
+        }
+        DeferredPollOptions options = new DeferredPollOptions(
+                Duration.of(fields.getLongLE(limits), ChronoUnit.MICROS),
+                fields.getUnsignedIntLE(limits + FetchMessages.MIN_COUNT_OFFSET),
+                fields.getUnsignedIntLE(limits + FetchMessages.MAX_BYTES_OFFSET),
+                Duration.of(fields.getLongLE(limits + Long.BYTES), ChronoUnit.MICROS));
+        options.validate(fields.getUnsignedIntLE(limits + FetchMessages.COUNT_OFFSET));
+        if (!FetchMessages.canStart(options)) {
+            throw malformed(FETCH_MESSAGES, "request timeout", limits + Long.BYTES);
+        }
+        List<Long> partitions = fetchPartitions(fields, targets, options.maxBytes());
+        byte[] topicBytes = Arrays.copyOfRange(payload, topic, limits);
+        CompletableFuture<ByteBuf> reply = FetchMessages.send(
+                pollRouter,
+                Unpooled.copiedBuffer(payload),
+                topicBytes,
+                partitions,
+                System.nanoTime() + options.requestTimeout().toNanos(),
+                pollRouter.startCall());
+        return AsyncTcpConnection.mapResponse(reply, body -> {
+            try {
+                FetchMessages.markMoved(
+                        pollRouter, topicBytes, BytesDeserializer.readFetchedMessages(body.duplicate()));
+            } catch (RuntimeException malformedReply) {
+                body.release();
+                throw malformedReply;
+            }
+            return body;
+        });
+    }
+
     private CompletableFuture<ByteBuf> writeOffset(int code, byte[] payload) {
         int target = targetEnd(payload, code);
         int suffix = code == STORE_CONSUMER_OFFSET ? STORE_OFFSET_SUFFIX_BYTES : DELETE_OFFSET_SUFFIX_BYTES;
@@ -183,19 +235,41 @@
         return offsets.write(code, Unpooled.copiedBuffer(payload), Optional.empty());
     }
 
+    /** The partitions of the fetch targets that start at {@code targets} and end {@code fields}, in their order. */
+    private static List<Long> fetchPartitions(ByteBuf fields, int targets, long maxBytes) {
+        Set<Long> partitions = new LinkedHashSet<>();
+        for (int target = targets; target < fields.writerIndex(); target += FetchMessages.TARGET_BYTES) {
+            int kind = fields.getUnsignedByte(target + FetchMessages.STRATEGY_KIND_OFFSET);
+            long targetMaxBytes = fields.getUnsignedIntLE(target + FetchMessages.TARGET_MAX_BYTES_OFFSET);
+            if ((kind != PollingKind.Offset.asCode() && kind != PollingKind.Next.asCode())
+                    || targetMaxBytes < DeferredPollOptions.MIN_MAX_BYTES
+                    || targetMaxBytes > maxBytes) {
+                throw malformed(FETCH_MESSAGES, "target", target);
+            }
+            if (!partitions.add(fields.getUnsignedIntLE(target))) {
+                throw malformed(FETCH_MESSAGES, "unique partition id", target);
+            }
+        }
+        return List.copyOf(partitions);
+    }
+
     /** Where the consumer, stream, topic and optional partition that lead a poll or offset write end. */
     private static int targetEnd(byte[] payload, int code) {
-        int consumerKind = unsignedByteAt(payload, 0, code, "consumer");
-        if (consumerKind != Consumer.Kind.Consumer.asCode() && consumerKind != Consumer.Kind.ConsumerGroup.asCode()) {
-            throw malformed(code, "consumer", 0);
-        }
-        int partition = identifierEnd(payload, identifierEnd(payload, identifierEnd(payload, 1, code), code), code);
+        int partition = identifierEnd(payload, identifierEnd(payload, consumerEnd(payload, code), code), code);
         if (partition + OPTIONAL_PARTITION_BYTES > payload.length) {
             throw malformed(code, "partition id", partition);
         }
         return partition + OPTIONAL_PARTITION_BYTES;
     }
 
+    private static int consumerEnd(byte[] payload, int code) {
+        int consumerKind = unsignedByteAt(payload, 0, code, "consumer");
+        if (consumerKind != Consumer.Kind.Consumer.asCode() && consumerKind != Consumer.Kind.ConsumerGroup.asCode()) {
+            throw malformed(code, "consumer", 0);
+        }
+        return identifierEnd(payload, 1, code);
+    }
+
     private static int identifierEnd(byte[] payload, int start, int code) {
         int kind = unsignedByteAt(payload, start, code, "identifier");
         int length = unsignedByteAt(payload, start + 1, code, "identifier");
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/TcpReaderTransport.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/TcpReaderTransport.java
new file mode 100644
index 0000000..55d29da
--- /dev/null
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/TcpReaderTransport.java
@@ -0,0 +1,144 @@
+/*
+ * 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.
+ */
+
+package org.apache.iggy.client.async.tcp;
+
+import org.apache.iggy.client.async.reader.ReaderTransport;
+import org.apache.iggy.consumergroup.Consumer;
+import org.apache.iggy.consumergroup.ConsumerGroupAssignment;
+import org.apache.iggy.consumeroffset.ConsumerOffsetInfo;
+import org.apache.iggy.identifier.StreamId;
+import org.apache.iggy.identifier.TopicId;
+import org.apache.iggy.message.DeferredPollOptions;
+import org.apache.iggy.message.FetchedMessages;
+import org.apache.iggy.message.PollingStrategy;
+import org.apache.iggy.partition.PartitionContext;
+
+import java.math.BigInteger;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+
+/** The reads of a reader over TCP: one fetch per primary, sent to the primary of its partitions. */
+final class TcpReaderTransport implements ReaderTransport {
+    private final AsyncIggyTcpClient client;
+    private final StreamId stream;
+    private final TopicId topic;
+    private final Consumer consumer;
+    // The committing reads whose outcome is not known yet, cancelled ones included.
+    private final Set<AsyncTcpConnection.TransientFailoverState> committing = ConcurrentHashMap.newKeySet();
+
+    TcpReaderTransport(AsyncIggyTcpClient client, StreamId stream, TopicId topic, Consumer consumer) {
+        this.client = client;
+        this.stream = stream;
+        this.topic = topic;
+        this.consumer = consumer;
+    }
+
+    @Override
+    public boolean group() {
+        return consumer.kind() == Consumer.Kind.ConsumerGroup;
+    }
+
+    @Override
+    public CompletableFuture<List<List<Long>>> groups(List<Long> partitions) {
+        return messages().readinessGroups(stream, topic, partitions);
+    }
+
+    @Override
+    public CompletableFuture<List<FetchedMessages>> read(
+            Map<Long, PollingStrategy> targets, long count, boolean commit, DeferredPollOptions options) {
+        // A fetch needs the reply margin also without a wait.
+        DeferredPollOptions fetch =
+                options.requestTimeout().compareTo(options.maxWait().plus(DeferredPollOptions.REPLY_MARGIN)) < 0
+                        ? options.withRequestTimeout(options.maxWait().plus(DeferredPollOptions.REPLY_MARGIN))
+                        : options;
+        if (!commit) {
+            return messages().fetchMessages(stream, topic, targets, consumer, count, commit, fetch);
+        }
+        AsyncTcpConnection.TransientFailoverState state = messages().startCall();
+        committing.add(state);
+        CompletableFuture<List<FetchedMessages>> read =
+                messages().fetchMessages(stream, topic, targets, consumer, count, commit, fetch, state);
+        read.whenComplete((sections, error) -> {
+            if (!read.isCancelled()) {
+                committing.remove(state);
+            }
+        });
+        return read;
+    }
+
+    @Override
+    public CompletableFuture<Void> store(long partition, BigInteger offset, PartitionContext context) {
+        return client.consumerOffsets().storeConsumerOffset(stream, topic, partition, consumer, offset, context);
+    }
+
+    @Override
+    public CompletableFuture<Void> delete(long partition, PartitionContext context) {
+        return ((ConsumerOffsetsTcpClient) client.consumerOffsets())
+                .deleteConsumerOffset(stream, topic, partition, consumer, context);
+    }
+
+    @Override
+    public CompletableFuture<Optional<ConsumerOffsetInfo>> storedOffset(long partition) {
+        return client.consumerOffsets().getConsumerOffset(stream, topic, Optional.of(partition), consumer);
+    }
+
+    @Override
+    public CompletableFuture<Void> join() {
+        return client.consumerGroups().joinConsumerGroup(stream, topic, consumer.id());
+    }
+
+    @Override
+    public CompletableFuture<Optional<ConsumerGroupAssignment>> assignment() {
+        return client.consumerGroups().syncConsumerGroup(stream, topic, consumer.id());
+    }
+
+    @Override
+    public CompletableFuture<Void> leave() {
+        return client.consumerGroups().leaveConsumerGroup(stream, topic, consumer.id());
+    }
+
+    @Override
+    public CompletableFuture<Void> abandon(long operationId) {
+        return client.abandonOperation(operationId);
+    }
+
+    @Override
+    public CompletableFuture<Void> cancelledReadsSettled() {
+        return messages().cancelledReadsSettled();
+    }
+
+    @Override
+    public CompletableFuture<Void> abandonLost() {
+        List<CompletableFuture<Void>> abandons = committing.stream()
+                .filter(state -> state.ticket() != null && state.ticket().fetchRetained())
+                .map(state -> client.abandonOperation(state.ticket().operationId()))
+                .toList();
+        committing.clear();
+        return CompletableFuture.allOf(abandons.toArray(CompletableFuture[]::new));
+    }
+
+    private MessagesTcpClient messages() {
+        return (MessagesTcpClient) client.messages();
+    }
+}
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/VsrExchange.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/VsrExchange.java
index 21d8777..83ce31c 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/VsrExchange.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/VsrExchange.java
@@ -21,7 +21,9 @@
 
 import io.netty.buffer.ByteBuf;
 import io.netty.channel.Channel;
+import org.apache.iggy.client.async.tcp.vsr.VsrHeaders;
 import org.apache.iggy.client.async.tcp.vsr.VsrResponseHandler;
+import org.apache.iggy.serde.CommandCode;
 
 import java.util.concurrent.CompletableFuture;
 
@@ -35,4 +37,47 @@
         AsyncTcpConnection.TransientFailoverState failoverState,
         long requestDeadlineNanos,
         long deadlineNanos,
-        long notAcceptedDeadlineNanos) {}
+        long notAcceptedDeadlineNanos) {
+
+    /**
+     * The frame of the next attempt, which takes the place of {@code retainedFrame}. A deferred request sends the wait
+     * and the request timeout that its deadline leaves, and a fetch lowers its byte limits until its reply fits in
+     * {@code maxReplyBody}. If this throws, the caller still owns {@code retainedFrame}.
+     */
+    ByteBuf frameForAttempt(ByteBuf retainedFrame, long maxReplyBody) {
+        int wait;
+        int timeout;
+        if (commandCode == CommandCode.Messages.POLL_DEFERRED.getValue()) {
+            wait = PollRouter.deferredWait(retainedFrame);
+            timeout = PollRouter.deferredTimeout(retainedFrame);
+        } else if (commandCode == CommandCode.Messages.FETCH_MESSAGES.getValue()) {
+            wait = PollRouter.waitParameters(retainedFrame, retainedFrame.readerIndex() + VsrHeaders.HEADER_SIZE);
+            timeout = wait + Long.BYTES;
+        } else {
+            return retainedFrame;
+        }
+        var options = PollRouter.deferredBudget(retainedFrame.getLongLE(wait), retainedFrame.getLongLE(timeout));
+        var remaining =
+                options.remaining(options.requestTimeout().minusNanos(requestDeadlineNanos - System.nanoTime()));
+        if (remaining.isEmpty()
+                || (commandCode == CommandCode.Messages.FETCH_MESSAGES.getValue()
+                        && !FetchMessages.keepsReplyMargin(remaining.get()))) {
+            throw AsyncTcpConnection.notAccepted();
+        }
+        // A progress ticket and pending writers share the frozen frame. Only this attempt's copy changes.
+        ByteBuf frame = retainedFrame.copy();
+        try {
+            frame.setLongLE(wait - retainedFrame.readerIndex(), remaining.get().maxWaitMicros());
+            frame.setLongLE(
+                    timeout - retainedFrame.readerIndex(), remaining.get().requestTimeoutMicros());
+            if (commandCode == CommandCode.Messages.FETCH_MESSAGES.getValue()) {
+                FetchMessages.fitReply(frame, wait - retainedFrame.readerIndex(), maxReplyBody);
+            }
+        } catch (RuntimeException error) {
+            frame.release();
+            throw error;
+        }
+        retainedFrame.release();
+        return frame;
+    }
+}
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/vsr/ConsensusSession.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/vsr/ConsensusSession.java
index 8f6f54a..3aea631 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/vsr/ConsensusSession.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/vsr/ConsensusSession.java
@@ -32,6 +32,8 @@
 import java.math.BigInteger;
 import java.security.SecureRandom;
 import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Comparator;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -55,9 +57,15 @@
     public static final int DEFAULT_MAX_PENDING_OPERATIONS = 256;
     public static final long DEFAULT_MAX_PENDING_OPERATION_BYTES = 64L * 1024 * 1024;
     static final int BIND_SECRET_BYTES = 32;
+    private static final long FETCH_RECOVERY_BYTES = 2L * Long.BYTES;
+    private static final long FETCH_TARGET_BYTES = 2L * Long.BYTES;
     // No lineage is negative, see signingIn().
     private static final long NO_SIGN_IN = -1;
     private static final SecureRandom RANDOM = new SecureRandom();
+    // One order for the lanes of every request, with the progress lane of a partition before its mutation lane.
+    private static final Comparator<LaneKey> ADMISSION_ORDER = Comparator.comparingLong(LaneKey::partition)
+            .thenComparingLong(LaneKey::incarnation)
+            .thenComparing(LaneKey::progress, Comparator.reverseOrder());
     private final byte[] bindSecret = new byte[BIND_SECRET_BYTES];
 
     private long clientIdLow;
@@ -82,6 +90,10 @@
     private final Map<LaneKey, Ticket> lanes = new HashMap<>();
     private final Map<LaneKey, ArrayDeque<CompletableFuture<Ticket>>> laneAdmissions = new HashMap<>();
     private final Map<Long, Ticket> retained = new HashMap<>();
+    private final Map<Long, FetchRecovery> retainedFetches = new HashMap<>();
+    private final Map<LaneKey, FetchRecovery> blockedFetchLanes = new HashMap<>();
+    private int fetchReservations;
+    private long fetchBytes;
     // The outcomes of the last maxPendingOperations proved replays, oldest first. A reset keeps them, because
     // request ids never repeat within a client.
     private final ArrayDeque<ProvedOutcome> proved = new ArrayDeque<>();
@@ -473,7 +485,7 @@
     }
 
     public synchronized void configurePendingOperations(int count, long bytes) {
-        if (count <= 0 || bytes <= 0 || pendingOperations != 0 || pendingBytes != 0) {
+        if (count <= 0 || bytes <= 0 || pendingOperations != 0 || pendingBytes != 0 || fetchReservations != 0) {
             throw new IggyInvalidArgumentException(
                     "Pending operation limits must be positive and configured before use");
         }
@@ -481,40 +493,68 @@
         maxPendingBytes = bytes;
     }
 
-    /** An offset mutation takes its progress turn before joining the partition mutation queue. */
+    /**
+     * Admits a request to {@code lanes}. Every request takes its lanes in ascending partition order, so two requests
+     * never each wait for a lane that the other one reserved. A request joins the queue of a lane only once it heads
+     * the queue of every lane before it, and it takes all its lanes once it heads every queue and no ticket holds any
+     * of them. An offset mutation takes its progress turn before joining the partition mutation queue.
+     */
     public CompletableFuture<Ticket> acquire(
-            LaneKey lane, boolean mutation, long frameBytes, long deadline, EventExecutor executor) {
+            List<LaneKey> lanes, boolean mutation, long frameBytes, long deadline, EventExecutor executor) {
+        return acquire(lanes, mutation, frameBytes, deadline, executor, false);
+    }
+
+    public CompletableFuture<Ticket> acquire(
+            List<LaneKey> lanes,
+            boolean mutation,
+            long frameBytes,
+            long deadline,
+            EventExecutor executor,
+            boolean inspect) {
         ConsensusSession owner = owner();
         if (owner != this) {
-            return owner.acquire(lane, mutation, frameBytes, deadline, executor);
+            return owner.acquire(lanes, mutation, frameBytes, deadline, executor, inspect);
         }
         CompletableFuture<Ticket> result = new CompletableFuture<>();
-        LaneKey mutationLane = lane.progress() && mutation ? new LaneKey(lane.incarnation(), lane.partition()) : null;
+        List<LaneKey> order = admissionOrder(lanes, mutation);
         var timer = executor.schedule(
                 () -> result.completeExceptionally(
                         new IggyServerException(IggyErrorCode.TRANSIENT_NOT_ACCEPTED.getCode())),
                 Math.max(0, deadline - System.nanoTime()),
                 TimeUnit.NANOSECONDS);
         synchronized (this) {
-            laneAdmissions.computeIfAbsent(lane, ignored -> new ArrayDeque<>()).addLast(result);
+            laneAdmissions
+                    .computeIfAbsent(order.get(0), ignored -> new ArrayDeque<>())
+                    .addLast(result);
         }
         result.whenComplete((ticket, error) -> {
             timer.cancel(false);
-            boolean wakeNext;
+            boolean wakeNext = false;
             synchronized (this) {
-                wakeNext = removeAdmission(lane, result);
-                if (mutationLane != null) {
-                    wakeNext |= removeAdmission(mutationLane, result);
+                for (LaneKey lane : order) {
+                    wakeNext |= removeAdmission(lane, result);
                 }
             }
             if (wakeNext) {
                 changed();
             }
         });
-        acquireWhenAvailable(lane, mutationLane, mutation, frameBytes, deadline, result, false);
+        acquireWhenAvailable(order, mutation, frameBytes, deadline, result, 1, inspect);
         return result;
     }
 
+    private static List<LaneKey> admissionOrder(List<LaneKey> lanes, boolean mutation) {
+        List<LaneKey> order = new ArrayList<>(lanes.size() + 1);
+        for (LaneKey lane : lanes) {
+            order.add(lane);
+            if (mutation && lane.progress()) {
+                order.add(new LaneKey(lane.incarnation(), lane.partition()));
+            }
+        }
+        order.sort(ADMISSION_ORDER);
+        return List.copyOf(order);
+    }
+
     private boolean removeAdmission(LaneKey lane, CompletableFuture<Ticket> result) {
         var waiting = laneAdmissions.get(lane);
         if (waiting == null) {
@@ -528,55 +568,71 @@
         return wakeNext;
     }
 
+    /**
+     * Admits {@code result} once it heads the queue of each lane of {@code order} and no ticket holds any of them.
+     * The request is in the queues of the first {@code queued} lanes.
+     */
     @SuppressWarnings({"checkstyle:CyclomaticComplexity", "checkstyle:NPathComplexity"})
     private void acquireWhenAvailable(
-            LaneKey lane,
-            LaneKey mutationLane,
+            List<LaneKey> order,
             boolean mutation,
             long frameBytes,
             long deadline,
             CompletableFuture<Ticket> result,
-            boolean mutationQueued) {
+            int queued,
+            boolean inspect) {
         CompletableFuture<Void> changed = null;
         Ticket admitted = null;
         RuntimeException failure = null;
+        int joined = queued;
         synchronized (this) {
             if (result.isDone()) {
                 return;
             }
-            Ticket blocking = lanes.get(lane);
-            Ticket mutationBlocking = mutationLane == null ? null : lanes.get(mutationLane);
-            if (blocking != null && blocking.retained) {
-                failure = new IggyOperationException(IggyErrorCode.LANE_BLOCKED, blocking.operationId);
-            } else if (mutationBlocking != null && mutationBlocking.retained) {
-                failure = new IggyOperationException(IggyErrorCode.LANE_BLOCKED, mutationBlocking.operationId);
+            IggyOperationException retainedFailure = null;
+            for (LaneKey lane : order) {
+                FetchRecovery fetch = blockedFetchLanes.get(lane);
+                if (fetch != null && !inspect) {
+                    retainedFailure = new IggyOperationException(
+                            IggyErrorCode.PARTITION_LANE_BLOCKED, fetch.operationId, lane.partition());
+                    break;
+                }
+                Ticket blocking = lanes.get(lane);
+                if (blocking != null && blocking.retained) {
+                    retainedFailure = blockedLane(lane, blocking);
+                    break;
+                }
+            }
+            if (retainedFailure != null) {
+                failure = retainedFailure;
             } else if (mutation && frameBytes > maxPendingBytes) {
                 failure = new IggyInvalidArgumentException("Request exceeds the pending operation byte limit");
             } else if (deadline - System.nanoTime() <= 0) {
                 failure = new IggyServerException(IggyErrorCode.TRANSIENT_NOT_ACCEPTED.getCode());
-            } else if (laneAdmissions.get(lane).getFirst() != result || blocking != null) {
-                changed = admissionChanged;
             } else {
                 // A store enters the mutation queue only after its progress turn,
                 // so a parked poll cannot hold back producers through that store.
-                if (mutationLane != null && !mutationQueued) {
-                    laneAdmissions
-                            .computeIfAbsent(mutationLane, ignored -> new ArrayDeque<>())
-                            .addLast(result);
-                    mutationQueued = true;
+                int headed = 0;
+                while (headed < joined
+                        && laneAdmissions.get(order.get(headed)).getFirst() == result
+                        && !lanes.containsKey(order.get(headed))) {
+                    headed++;
+                    if (headed == joined && joined < order.size()) {
+                        laneAdmissions
+                                .computeIfAbsent(order.get(joined), ignored -> new ArrayDeque<>())
+                                .addLast(result);
+                        joined++;
+                    }
                 }
-                if (mutationBlocking != null
-                        || (mutationLane != null
-                                && laneAdmissions.get(mutationLane).getFirst() != result)
+                if (headed < order.size()
                         || (mutation
                                 && (pendingOperations >= maxPendingOperations
                                         || frameBytes > maxPendingBytes - pendingBytes))) {
                     changed = admissionChanged;
                 } else {
-                    admitted = new Ticket(this, lane, mutationLane, mutation, frameBytes, generation);
-                    lanes.put(lane, admitted);
-                    if (mutationLane != null) {
-                        lanes.put(mutationLane, admitted);
+                    admitted = new Ticket(this, order, mutation, frameBytes, generation);
+                    for (LaneKey lane : order) {
+                        lanes.put(lane, admitted);
                     }
                     if (mutation) {
                         pendingOperations++;
@@ -592,9 +648,8 @@
                 admitted.complete();
             }
         } else {
-            boolean queued = mutationQueued;
-            changed.thenRun(
-                    () -> acquireWhenAvailable(lane, mutationLane, mutation, frameBytes, deadline, result, queued));
+            int next = joined;
+            changed.thenRun(() -> acquireWhenAvailable(order, mutation, frameBytes, deadline, result, next, inspect));
         }
     }
 
@@ -603,21 +658,56 @@
     }
 
     public void checkRetained(LaneKey lane, boolean mutation) {
+        checkRetained(lane, mutation, false);
+    }
+
+    public void checkRetained(LaneKey lane, boolean mutation, boolean inspect) {
         ConsensusSession owner = owner();
         synchronized (owner) {
+            FetchRecovery fetch = owner.blockedFetchLanes.get(lane);
+            if (fetch != null && !inspect) {
+                throw new IggyOperationException(
+                        IggyErrorCode.PARTITION_LANE_BLOCKED, fetch.operationId, lane.partition());
+            }
             Ticket ticket = owner.lanes.get(lane);
             if (ticket != null && ticket.retained) {
-                throw new IggyOperationException(IggyErrorCode.LANE_BLOCKED, ticket.operationId);
+                throw blockedLane(lane, ticket);
             }
             if (mutation && lane.progress()) {
                 Ticket blocking = owner.lanes.get(new LaneKey(lane.incarnation(), lane.partition()));
                 if (blocking != null && blocking.retained) {
-                    throw new IggyOperationException(IggyErrorCode.LANE_BLOCKED, blocking.operationId);
+                    throw blockedLane(lane, blocking);
                 }
             }
         }
     }
 
+    /** Completes once no committing read that its caller cancelled after the send still waits for its reply. */
+    public CompletableFuture<Void> cancelledReadsSettled() {
+        CompletableFuture<Void> result = new CompletableFuture<>();
+        awaitCancelledReads(result);
+        return result;
+    }
+
+    private void awaitCancelledReads(CompletableFuture<Void> result) {
+        ConsensusSession owner = owner();
+        CompletableFuture<Void> changed;
+        synchronized (owner) {
+            if (owner.lanes.values().stream().noneMatch(ticket -> ticket.blockedOnCancel)) {
+                result.complete(null);
+                return;
+            }
+            changed = owner.admissionChanged;
+        }
+        changed.whenComplete((ignored, error) -> awaitCancelledReads(result));
+    }
+
+    private static IggyOperationException blockedLane(LaneKey lane, Ticket ticket) {
+        return lane.equals(LaneKey.METADATA)
+                ? new IggyOperationException(IggyErrorCode.LANE_BLOCKED, ticket.operationId)
+                : new IggyOperationException(IggyErrorCode.LANE_BLOCKED, ticket.operationId, lane.partition());
+    }
+
     /**
      * Claims the retained operation for a replay. While another replay of it runs, the claim waits for that
      * replay to end. A wait still running at {@code deadline} fails with {@code OPERATION_UNCERTAIN}, as in
@@ -630,6 +720,12 @@
             return owner.resume(operationId, deadline, executor);
         }
         CompletableFuture<Ticket> result = new CompletableFuture<>();
+        synchronized (this) {
+            if (retainedFetches.containsKey(operationId)) {
+                return CompletableFuture.failedFuture(
+                        new IggyOperationException(IggyErrorCode.FETCH_PROGRESS_UNCERTAIN, operationId));
+            }
+        }
         resumeWhenIdle(operationId, null, result);
         if (result.isDone()) {
             return result;
@@ -706,6 +802,14 @@
         boolean idle;
         ConsensusSession owner = owner();
         synchronized (owner) {
+            FetchRecovery fetch = owner.retainedFetches.remove(operationId);
+            if (fetch != null) {
+                fetch.abandoned = true;
+                owner.fetchReservations--;
+                owner.fetchBytes -= fetch.bytes;
+                fetch.lanes.forEach(lane -> owner.blockedFetchLanes.remove(lane, fetch));
+                return;
+            }
             ticket = owner.retained.remove(operationId);
             if (ticket == null) {
                 throw new IggyOperationException(IggyErrorCode.OPERATION_NOT_RETAINED, operationId);
@@ -793,6 +897,26 @@
         } while (clientIdLow == 0 && clientIdHigh == 0);
     }
 
+    /** The first unresolved fetch, if any, for reporting after connection cleanup. */
+    public IggyOperationException unresolvedFetch() {
+        ConsensusSession owner = owner();
+        synchronized (owner) {
+            return owner.retainedFetches.keySet().stream()
+                    .min(Long::compareUnsigned)
+                    .map(id -> new IggyOperationException(IggyErrorCode.FETCH_PROGRESS_UNCERTAIN, id))
+                    .orElse(null);
+        }
+    }
+
+    public IggyOperationException fetchRecovery(long operationId) {
+        ConsensusSession owner = owner();
+        synchronized (owner) {
+            return owner.retainedFetches.containsKey(operationId)
+                    ? new IggyOperationException(IggyErrorCode.FETCH_PROGRESS_UNCERTAIN, operationId)
+                    : null;
+        }
+    }
+
     public record LaneKey(long incarnation, long partition, boolean progress) {
         public static final LaneKey METADATA = new LaneKey(0, 0);
 
@@ -804,11 +928,23 @@
     /** A copy of the reply body of a proved commit, or no body and the code of a proved committed rejection. */
     private record ProvedOutcome(long operationId, byte[] body, int rejection) {}
 
+    private static final class FetchRecovery {
+        private final long bytes;
+        private List<LaneKey> lanes;
+        private long operationId;
+        private boolean retained;
+        private boolean abandoned;
+
+        private FetchRecovery(List<LaneKey> lanes, long bytes) {
+            this.lanes = lanes;
+            this.bytes = bytes;
+        }
+    }
+
     /** One lane admission owns one immutable frame; writer aliases keep its bytes charged. */
     public static final class Ticket {
         private final ConsensusSession owner;
-        private final LaneKey lane;
-        private final LaneKey mutationLane;
+        private final List<LaneKey> lanes;
         private final boolean mutation;
         private final long bytes;
         private long generation;
@@ -825,17 +961,15 @@
         private boolean sent;
         private boolean bytesReleased;
         private boolean restarting;
+        private FetchRecovery fetch;
+        private boolean fetchSettled;
+        // The caller cancelled the committing read after it went out, so its lanes are blocked until its reply.
+        private boolean blockedOnCancel;
+        private CompletableFuture<Void> attemptSettled;
 
-        private Ticket(
-                ConsensusSession owner,
-                LaneKey lane,
-                LaneKey mutationLane,
-                boolean mutation,
-                long bytes,
-                long generation) {
+        private Ticket(ConsensusSession owner, List<LaneKey> lanes, boolean mutation, long bytes, long generation) {
             this.owner = owner;
-            this.lane = lane;
-            this.mutationLane = mutationLane;
+            this.lanes = lanes;
             this.mutation = mutation;
             this.bytes = bytes;
             this.generation = generation;
@@ -847,6 +981,9 @@
                     throw new IggyOperationException(IggyErrorCode.OPERATION_NOT_RETAINED, operationId);
                 }
                 operationId = VsrHeaders.readRequestId(request);
+                if (fetch != null) {
+                    fetch.operationId = operationId;
+                }
                 commandCode = command;
                 routingBody = target;
                 frame = request.retainedDuplicate();
@@ -879,10 +1016,14 @@
                     return false;
                 }
                 operationId = owner.nextRequestId();
+                if (fetch != null) {
+                    fetch.operationId = operationId;
+                }
                 attemptFrame.setLongLE(attemptFrame.readerIndex() + VsrHeaders.REQUEST_ID_OFFSET, operationId);
                 int floorOffset = attemptFrame.readerIndex() + VsrHeaders.REQUEST_METADATA_OP_OFFSET;
                 long capturedFloor = attemptFrame.getLongLE(floorOffset);
-                if (lane.equals(LaneKey.METADATA) && Long.compareUnsigned(owner.metadataWatermark, capturedFloor) > 0) {
+                if (lanes.contains(LaneKey.METADATA)
+                        && Long.compareUnsigned(owner.metadataWatermark, capturedFloor) > 0) {
                     attemptFrame.setLongLE(floorOffset, owner.metadataWatermark);
                 }
                 return true;
@@ -917,6 +1058,110 @@
             return operationId;
         }
 
+        /** Whether the committing read of this ticket ended with an unknown outcome that still blocks its lanes. */
+        public boolean fetchRetained() {
+            synchronized (owner) {
+                return fetch != null && fetch.retained && !fetch.abandoned;
+            }
+        }
+
+        /** Reserves bounded target metadata, independent of session-bound replay frames. */
+        public void reserveFetch() {
+            synchronized (owner) {
+                if (completed) {
+                    throw new IggyServerException(IggyErrorCode.TRANSIENT_NOT_ACCEPTED.getCode());
+                }
+                if (fetch != null) {
+                    return;
+                }
+                long metadataBytes = FETCH_RECOVERY_BYTES + lanes.size() * FETCH_TARGET_BYTES;
+                if (owner.fetchReservations >= owner.maxPendingOperations
+                        || metadataBytes > owner.maxPendingBytes - owner.fetchBytes) {
+                    throw new IggyServerException(IggyErrorCode.TRANSIENT_NOT_ACCEPTED.getCode());
+                }
+                fetch = new FetchRecovery(lanes, metadataBytes);
+                owner.fetchReservations++;
+                owner.fetchBytes += metadataBytes;
+            }
+        }
+
+        /** Whether this is a poll or fetch that stores the offsets that it reads. */
+        public boolean commitsOnRead() {
+            synchronized (owner) {
+                return fetch != null;
+            }
+        }
+
+        /** Records {@code settled}, which completes once the attempt that a writer started has its outcome. */
+        public void attemptStarted(CompletableFuture<Void> settled) {
+            synchronized (owner) {
+                attemptSettled = settled;
+            }
+        }
+
+        /** Completes once the last started attempt has its outcome, or null before any attempt. */
+        public CompletableFuture<Void> attemptSettled() {
+            synchronized (owner) {
+                return attemptSettled;
+            }
+        }
+
+        /**
+         * Blocks the lanes of a committing read whose caller cancelled it after it went out, because the server can
+         * still store until it answers the cancel. That reply lifts or narrows the block, see {@link #settleFetch}.
+         */
+        public void blockUntilSettled() {
+            synchronized (owner) {
+                if (fetch == null || !sent || fetchSettled || completed || fetch.retained) {
+                    return;
+                }
+                retainFetch(lanes);
+                blockedOnCancel = true;
+            }
+            owner.changed();
+        }
+
+        /** Settles a valid table before record decoding, retaining only uncertain target sections. */
+        public void settleFetch(List<Long> uncertainPartitions) {
+            synchronized (owner) {
+                fetchSettled = true;
+                List<LaneKey> uncertain = lanes.stream()
+                        .filter(lane -> uncertainPartitions.contains(lane.partition()))
+                        .toList();
+                if (blockedOnCancel) {
+                    narrowFetch(uncertain);
+                } else if (!uncertain.isEmpty()) {
+                    retainFetch(uncertain);
+                }
+            }
+        }
+
+        /** Keeps the block of a cancelled committing read only on {@code uncertain}, which its reply left unknown. */
+        private void narrowFetch(List<LaneKey> uncertain) {
+            blockedOnCancel = false;
+            if (fetch.abandoned) {
+                return;
+            }
+            fetch.lanes.stream()
+                    .filter(lane -> !uncertain.contains(lane))
+                    .forEach(lane -> owner.blockedFetchLanes.remove(lane, fetch));
+            fetch.lanes = uncertain;
+            if (uncertain.isEmpty()) {
+                fetch.retained = false;
+                owner.retainedFetches.remove(operationId, fetch);
+            }
+        }
+
+        private void retainFetch(List<LaneKey> affected) {
+            if (fetch.retained || fetch.abandoned || completed) {
+                return;
+            }
+            fetch.retained = true;
+            fetch.lanes = affected;
+            owner.retainedFetches.put(operationId, fetch);
+            affected.forEach(lane -> owner.blockedFetchLanes.put(lane, fetch));
+        }
+
         /**
          * Prepares the next write of the call that holds this ticket and started in {@code lineage}, and says
          * whether the ticket moved to a new session, so that the call draws a new request id. A frame that went out
@@ -1001,6 +1246,9 @@
         public void settleSent(boolean uncertain) {
             synchronized (owner) {
                 sent = uncertain;
+                if (!uncertain && blockedOnCancel) {
+                    narrowFetch(List.of());
+                }
             }
         }
 
@@ -1033,15 +1281,34 @@
             }
         }
 
+        private boolean retainUnknownFetch() {
+            if (fetch == null || !sent || fetchSettled) {
+                return false;
+            }
+            retainFetch(lanes);
+            return true;
+        }
+
+        private boolean retainMutation() {
+            if (completed || abandoned || !mutation || !sent || generation != owner.generation) {
+                return false;
+            }
+            retained = true;
+            resolving = false;
+            owner.retained.put(operationId, this);
+            return true;
+        }
+
         public RuntimeException retain() {
             boolean saved;
+            boolean fetchUnknown;
             synchronized (owner) {
-                saved = !completed && !abandoned && mutation && sent && generation == owner.generation;
-                if (saved) {
-                    retained = true;
-                    resolving = false;
-                    owner.retained.put(operationId, this);
-                }
+                fetchUnknown = retainUnknownFetch();
+                saved = retainMutation();
+            }
+            if (fetchUnknown) {
+                complete();
+                return new IggyOperationException(IggyErrorCode.FETCH_PROGRESS_UNCERTAIN, operationId);
             }
             if (saved) {
                 owner.changed();
@@ -1062,10 +1329,18 @@
                 if (completed) {
                     return;
                 }
+                if (fetch != null) {
+                    if (sent && !fetchSettled) {
+                        retainFetch(lanes);
+                    }
+                    if (!fetch.retained && !fetch.abandoned) {
+                        owner.fetchReservations--;
+                        owner.fetchBytes -= fetch.bytes;
+                    }
+                }
                 completed = true;
-                owner.lanes.remove(lane, this);
-                if (mutationLane != null) {
-                    owner.lanes.remove(mutationLane, this);
+                for (LaneKey lane : lanes) {
+                    owner.lanes.remove(lane, this);
                 }
                 owner.retained.remove(operationId, this);
                 if (mutation && !abandoned) {
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/vsr/VsrLoginCodec.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/vsr/VsrLoginCodec.java
index fb26fc5..e0a973b 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/vsr/VsrLoginCodec.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/vsr/VsrLoginCodec.java
@@ -45,7 +45,7 @@
      * protocol version through its current version, including patch components.
      * Versions outside this range, including newer patches, are rejected.
      */
-    static final int PROTOCOL_VERSION = 12 << 10; // 0.12.0
+    static final int PROTOCOL_VERSION = 12 << 10 | 1; // 0.12.1
 
     static final String SDK_NAME = "java-sdk";
 
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/vsr/VsrRequestEncoder.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/vsr/VsrRequestEncoder.java
index 8d8d069..c79e911 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/vsr/VsrRequestEncoder.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/vsr/VsrRequestEncoder.java
@@ -22,6 +22,7 @@
 import io.netty.buffer.ByteBuf;
 import io.netty.buffer.ByteBufAllocator;
 import org.apache.iggy.partition.PartitionContext;
+import org.apache.iggy.serde.CommandCode;
 
 import java.security.SecureRandom;
 import java.util.Arrays;
@@ -134,16 +135,20 @@
             frame.setLongLE(VsrHeaders.REQUEST_ID_OFFSET, requestId);
             frame.setByte(VsrHeaders.REQUEST_OPERATION_OFFSET, operation);
             frame.setLongLE(VsrHeaders.REQUEST_SESSION_OFFSET, sessionId);
-            frame.setLongLE(
-                    VsrHeaders.REQUEST_INCARNATION_OFFSET, context.incarnation().longValue());
-            frame.setLongLE(
-                    VsrHeaders.REQUEST_OWNER_GENERATION_OFFSET,
-                    context.ownerGeneration().longValue());
-            frame.setLongLE(
-                    VsrHeaders.REQUEST_METADATA_OP_OFFSET,
-                    context.equals(PartitionContext.EMPTY)
-                            ? session.metadataWatermark()
-                            : context.metadataOp().longValue());
+            // A fetch carries its fences per target and does not inherit the session's metadata watermark.
+            if (commandCode != CommandCode.Messages.FETCH_MESSAGES.getValue()) {
+                frame.setLongLE(
+                        VsrHeaders.REQUEST_INCARNATION_OFFSET,
+                        context.incarnation().longValue());
+                frame.setLongLE(
+                        VsrHeaders.REQUEST_OWNER_GENERATION_OFFSET,
+                        context.ownerGeneration().longValue());
+                frame.setLongLE(
+                        VsrHeaders.REQUEST_METADATA_OP_OFFSET,
+                        context.equals(PartitionContext.EMPTY)
+                                ? session.metadataWatermark()
+                                : context.metadataOp().longValue());
+            }
             if (operation == VsrOperation.NON_REPLICATED) {
                 frame.setIntLE(VsrHeaders.REQUEST_RESERVED_CODE_OFFSET, commandCode);
             }
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/vsr/VsrResponseHandler.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/vsr/VsrResponseHandler.java
index bb5fef8..042853d 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/vsr/VsrResponseHandler.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/vsr/VsrResponseHandler.java
@@ -90,9 +90,8 @@
                 VsrHeaders.readRequestOperation(requestFrame),
                 VsrHeaders.readRequestId(requestFrame),
                 RequestIdentity.from(requestFrame));
-        if (commandCode == CommandCode.Messages.POLL_DEFERRED.getValue()) {
-            pending.maxResponseBody =
-                    requestFrame.getUnsignedIntLE(requestFrame.writerIndex() - Long.BYTES - Integer.BYTES);
+        if (commandCode == CommandCode.Messages.POLL_DEFERRED.getValue()
+                || commandCode == CommandCode.Messages.FETCH_MESSAGES.getValue()) {
             pending.cancellation = new byte[VsrHeaders.HEADER_SIZE];
             requestFrame.getBytes(requestFrame.readerIndex(), pending.cancellation);
             ByteBuffer.wrap(pending.cancellation)
@@ -424,6 +423,21 @@
         public void keepLineage() {
             keepsLineage = true;
         }
+
+        /** Bounds the reply body, before the request is written, to what the request asked for. */
+        public void limitResponseBody(long bytes) {
+            maxResponseBody = bytes;
+        }
+
+        /**
+         * Asks the server to stop a request that stores offsets, and keeps the request registered: the server answers
+         * it once, refused when the cancel won, or with what it read and stored.
+         */
+        public synchronized void cancelOnServer(Channel channel) {
+            if (!future.isDone()) {
+                abandon(channel);
+            }
+        }
     }
 
     private record RequestIdentity(
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/ConsumerOffsetsClient.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/ConsumerOffsetsClient.java
index 3e5b042..f59d4c0 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/ConsumerOffsetsClient.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/ConsumerOffsetsClient.java
@@ -23,6 +23,7 @@
 import org.apache.iggy.consumeroffset.ConsumerOffsetInfo;
 import org.apache.iggy.identifier.StreamId;
 import org.apache.iggy.identifier.TopicId;
+import org.apache.iggy.message.Checkpoint;
 import org.apache.iggy.partition.PartitionContext;
 
 import java.math.BigInteger;
@@ -67,6 +68,15 @@
             BigInteger offset,
             PartitionContext context);
 
+    /**
+     * Stores the offset of the message that {@code checkpoint} follows, under the history of its partition, so a store
+     * for a partition that was deleted and recreated since is refused.
+     */
+    default void storeConsumerPosition(StreamId streamId, TopicId topicId, Consumer consumer, Checkpoint checkpoint) {
+        storeConsumerOffset(
+                streamId, topicId, checkpoint.partitionId(), consumer, checkpoint.offset(), checkpoint.context());
+    }
+
     default Optional<ConsumerOffsetInfo> getConsumerOffset(
             Long streamId, Long topicId, Optional<Long> partitionId, Long consumerId) {
         return getConsumerOffset(StreamId.of(streamId), TopicId.of(topicId), partitionId, Consumer.of(consumerId));
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/MessagesClient.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/MessagesClient.java
index abea7f6..90e84b2 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/MessagesClient.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/MessagesClient.java
@@ -23,9 +23,11 @@
 import org.apache.iggy.exception.IggyOperationNotSupportedException;
 import org.apache.iggy.identifier.StreamId;
 import org.apache.iggy.identifier.TopicId;
+import org.apache.iggy.message.CommitPolicy;
 import org.apache.iggy.message.DeferredPollOptions;
 import org.apache.iggy.message.Message;
 import org.apache.iggy.message.Partitioning;
+import org.apache.iggy.message.PollOptions;
 import org.apache.iggy.message.PolledMessages;
 import org.apache.iggy.message.PollingStrategy;
 import org.apache.iggy.message.SendMessagesResponse;
@@ -35,6 +37,31 @@
 
 public interface MessagesClient {
 
+    /**
+     * Polls one partition with the limits and the commit policy of {@code options}, as
+     * {@link org.apache.iggy.client.async.MessagesClient#pollMessages(StreamId, TopicId, long, Consumer,
+     * PollingStrategy, PollOptions)} describes. Over HTTP a poll with {@link CommitPolicy#onRead()} is a POST, which no
+     * proxy or client library resends, and a consumer group cannot poll. A poll that stores its offset waits for its
+     * reply also when the calling thread is interrupted, because a cancel can lose what the server stored.
+     *
+     * @param streamId    the stream identifier
+     * @param topicId     the topic identifier
+     * @param partitionId the partition, 0 to 4294967295
+     * @param consumer    the consumer whose offset {@code Next} reads and a commit stores
+     * @param strategy    where the poll starts
+     * @param options     the limits and the commit policy
+     * @return the messages of the partition
+     */
+    default PolledMessages pollMessages(
+            StreamId streamId,
+            TopicId topicId,
+            long partitionId,
+            Consumer consumer,
+            PollingStrategy strategy,
+            PollOptions options) {
+        throw new IggyOperationNotSupportedException("pollMessages", getClass().getSimpleName());
+    }
+
     default PolledMessages pollMessages(
             Long streamId,
             Long topicId,
@@ -129,9 +156,13 @@
      * consumer over HTTP: without a persistent connection the client cannot follow the group's
      * partition assignment. A group consumer is rejected before any request is sent.
      *
+     * <p>A group poll must name its partition. Without one, the TCP client fails with
+     * {@link org.apache.iggy.exception.IggyErrorCode#INVALID_COMMAND} before it sends anything.
+     * A {@link org.apache.iggy.client.blocking.reader.PartitionReader} reads the whole assignment of a group.
+     *
      * @param streamId    the stream identifier (numeric or string-based)
      * @param topicId     the topic identifier (numeric or string-based)
-     * @param partitionId optional partition ID to poll from
+     * @param partitionId the partition ID to poll from, which only a plain consumer can leave empty
      * @param consumer    the consumer identity
      * @param strategy    the polling strategy controlling where to start reading
      * @param count       the maximum number of messages to return
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/http/ConsumerOffsetsHttpClient.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/http/ConsumerOffsetsHttpClient.java
index 7ae74c5..72754eb 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/http/ConsumerOffsetsHttpClient.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/http/ConsumerOffsetsHttpClient.java
@@ -75,6 +75,8 @@
     }
 
     private void store(StreamId streamId, TopicId topicId, StoreConsumerOffset command) {
+        httpClient.checkProgress(
+                InternalHttpClient.ProgressKey.of(streamId, topicId, command.partitionId(), command.context()));
         httpClient.execute(httpClient.preparePutRequest(path(streamId, topicId), command));
     }
 
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/http/IggyHttpClient.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/http/IggyHttpClient.java
index 2dc0d20..2151fd9 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/http/IggyHttpClient.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/http/IggyHttpClient.java
@@ -124,6 +124,16 @@
         throw new IggyOperationNotSupportedException("sendBinaryRequestToReplica", "HTTP");
     }
 
+    /**
+     * Lifts the block of a committing poll whose outcome stayed unknown, without reversing what the server stored.
+     *
+     * @throws org.apache.iggy.exception.IggyOperationException with {@code OPERATION_NOT_RETAINED} for an operation
+     *         that blocks nothing
+     */
+    public void abandonOperation(long operationId) {
+        internalHttpClient.abandon(operationId);
+    }
+
     @Override
     public SystemClient system() {
         return systemClient;
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/http/InternalHttpClient.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/http/InternalHttpClient.java
index d44a33e..2744410 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/http/InternalHttpClient.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/http/InternalHttpClient.java
@@ -45,6 +45,7 @@
 import org.apache.hc.core5.http.protocol.HttpContext;
 import org.apache.hc.core5.io.CloseMode;
 import org.apache.hc.core5.io.ModalCloseable;
+import org.apache.hc.core5.net.URIBuilder;
 import org.apache.hc.core5.ssl.SSLContextBuilder;
 import org.apache.hc.core5.util.TimeValue;
 import org.apache.hc.core5.util.Timeout;
@@ -54,9 +55,13 @@
 import org.apache.iggy.exception.IggyException;
 import org.apache.iggy.exception.IggyInvalidArgumentException;
 import org.apache.iggy.exception.IggyMalformedResponseException;
+import org.apache.iggy.exception.IggyOperationException;
 import org.apache.iggy.exception.IggyServerException;
 import org.apache.iggy.exception.IggyTimeoutException;
 import org.apache.iggy.exception.IggyTlsException;
+import org.apache.iggy.identifier.StreamId;
+import org.apache.iggy.identifier.TopicId;
+import org.apache.iggy.partition.PartitionContext;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import tools.jackson.core.JacksonException;
@@ -72,17 +77,26 @@
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.InterruptedIOException;
+import java.math.BigInteger;
+import java.net.URI;
 import java.net.URISyntaxException;
 import java.security.GeneralSecurityException;
 import java.time.Duration;
+import java.util.Arrays;
 import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.CancellationException;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ScheduledFuture;
 import java.util.concurrent.ScheduledThreadPoolExecutor;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.locks.ReentrantLock;
 
 final class InternalHttpClient implements Closeable {
 
@@ -90,7 +104,8 @@
 
     private static final String AUTHORIZATION = "Authorization";
     private static final String UNCERTAIN_ATTEMPT = "iggy.uncertain-attempt";
-    private static final String DEFERRED_ATTEMPTED = "iggy.deferred-attempted";
+    private static final String ATTEMPTED = "iggy.attempted";
+    private static final String AUTO_COMMIT = "auto_commit";
     private static final Set<IggyErrorCode> SETTLED_REFUSALS = EnumSet.of(
             IggyErrorCode.HISTORY_UNAVAILABLE,
             IggyErrorCode.LIFECYCLE_BUSY,
@@ -109,12 +124,20 @@
 
     private static final int BODY_CHUNK_BYTES = 8 * 1024;
 
+    /** How often a deferred exchange checks whether its caller was interrupted. */
+    private static final long INTERRUPT_CHECK_MILLIS = 50;
+
     private final String url;
     private final ObjectMapper objectMapper = ObjectMapperFactory.getInstance();
     private final CloseableHttpClient httpClient;
     private final CloseableHttpClient deferredHttpClient;
     private final ScheduledThreadPoolExecutor deadlines = createDeadlineTimer();
     private final AtomicInteger pendingDeferred = new AtomicInteger();
+    // The committing polls whose outcome is unknown, each with the partition that it blocks.
+    private final Map<Long, ProgressKey> progressBlocks = new ConcurrentHashMap<>();
+    private final AtomicLong lostPolls = new AtomicLong();
+    // One lane per partition as calls spell it, which a progress poll holds from before its send to its end.
+    private final Map<ProgressKey, ProgressLane> progressLanes = new HashMap<>();
     private Optional<String> token = Optional.empty();
 
     InternalHttpClient(
@@ -144,6 +167,7 @@
         return HttpClients.custom()
                 .setConnectionManager(connectionManagerBuilder.build())
                 .setDefaultRequestConfig(requestConfigBuilder.build())
+                .setRequestExecutor(new AttemptRecordingExecutor())
                 .setRetryStrategy(new UncertainAttempts())
                 .build();
     }
@@ -151,10 +175,10 @@
     /**
      * A deferred poll declares its own total budget. The library's timeouts
      * restart for every wait, so a read that starts near the deadline would
-     * outlive it. A timer cancels the exchange at the deadline instead, which
-     * ends the connection lease, the connect, the wait for headers and any body
-     * read. Automatic retries are off: the server answers both
-     * {@code TRANSIENT_NOT_ACCEPTED} and the ambiguous
+     * outlive it. A timer cancels the exchange at the deadline instead, or as
+     * soon as the caller is interrupted. The cancel ends the connection lease,
+     * the connect, the wait for headers and any body read. Automatic retries are
+     * off: the server answers both {@code TRANSIENT_NOT_ACCEPTED} and the ambiguous
      * {@code TRANSIENT_NOT_COMMITTED} with HTTP 503, and a proxy can answer it
      * too, so only a parsed server code may authorize a replay.
      *
@@ -171,6 +195,7 @@
             throw deferredFailure(false, new IggyClientException("Too many pending deferred HTTP polls"));
         }
         ScheduledFuture<?> abort = null;
+        ScheduledFuture<?> interruptWatch = null;
         var context = HttpClientContext.create();
         try {
             long remainingNanos = deadlineNanos - System.nanoTime();
@@ -179,6 +204,7 @@
             }
             HttpUriRequestBase exchange = cancellable(request);
             abort = deadlines.schedule(exchange::cancel, remainingNanos, TimeUnit.NANOSECONDS);
+            interruptWatch = cancelOnInterrupt(exchange, Thread.currentThread());
             // Rounded up to whole milliseconds, so neither fires before the deadline and reports it as a
             // connection failure. Their defaults would end a long readiness wait early.
             Timeout budget = Timeout.ofMilliseconds(TimeUnit.NANOSECONDS.toMillis(remainingNanos) + 1);
@@ -202,15 +228,105 @@
                 return objectMapper.readValue(body, clazz);
             });
         } catch (IOException | CancellationException | IggyTimeoutException failure) {
-            throw deferredFailure(Boolean.TRUE.equals(context.getAttribute(DEFERRED_ATTEMPTED)), failure);
-        } finally {
-            if (abort != null) {
-                abort.cancel(false);
+            boolean attempted = Boolean.TRUE.equals(context.getAttribute(ATTEMPTED));
+            if (attempted && Method.POST.isSame(request.getMethod())) {
+                // A poll that stores its offset is a POST, and the server can have stored before the answer was lost.
+                var unknownProgress = new IggyServerException(IggyErrorCode.FETCH_PROGRESS_UNCERTAIN.getCode());
+                unknownProgress.initCause(failure);
+                throw unknownProgress;
             }
+            throw deferredFailure(attempted, failure);
+        } finally {
+            cancelTimer(abort);
+            cancelTimer(interruptWatch);
             pendingDeferred.decrementAndGet();
         }
     }
 
+    /** A blocking socket read ignores an interrupt, so the timer cancels the exchange for its caller. */
+    private ScheduledFuture<?> cancelOnInterrupt(HttpUriRequestBase exchange, Thread caller) {
+        return deadlines.scheduleWithFixedDelay(
+                () -> {
+                    if (caller.isInterrupted()) {
+                        exchange.cancel();
+                    }
+                },
+                INTERRUPT_CHECK_MILLIS,
+                INTERRUPT_CHECK_MILLIS,
+                TimeUnit.MILLISECONDS);
+    }
+
+    private static void cancelTimer(ScheduledFuture<?> timer) {
+        if (timer != null) {
+            timer.cancel(false);
+        }
+    }
+
+    /**
+     * Refuses a progress poll or an offset store of a partition that a committing poll with an unknown outcome
+     * blocks.
+     */
+    void checkProgress(ProgressKey key) {
+        progressBlocks.entrySet().stream()
+                .filter(block -> block.getValue().covers(key))
+                .findFirst()
+                .ifPresent(block -> {
+                    throw new IggyOperationException(
+                            IggyErrorCode.PARTITION_LANE_BLOCKED, block.getKey(), key.partition());
+                });
+    }
+
+    /** Blocks {@code key} after a committing poll whose outcome is unknown, until the caller abandons it. */
+    IggyOperationException loseProgress(ProgressKey key, Throwable cause) {
+        long operation = lostPolls.incrementAndGet();
+        progressBlocks.put(operation, key);
+        var lost = new IggyOperationException(IggyErrorCode.FETCH_PROGRESS_UNCERTAIN, operation);
+        lost.initCause(cause);
+        return lost;
+    }
+
+    void abandon(long operation) {
+        if (progressBlocks.remove(operation) == null) {
+            throw new IggyOperationException(IggyErrorCode.OPERATION_NOT_RETAINED, operation);
+        }
+    }
+
+    /**
+     * Waits until {@code deadlineNanos} for the lane of the partition of {@code key}, so the block check and the arm
+     * of one progress poll have no other progress poll of the partition in between.
+     */
+    ProgressLane enterProgressLane(ProgressKey key, long deadlineNanos) {
+        ProgressLane lane;
+        synchronized (progressLanes) {
+            lane = progressLanes.computeIfAbsent(key.lane(), ProgressLane::new);
+            lane.holders++;
+        }
+        boolean entered = false;
+        try {
+            entered = lane.gate.tryLock(Math.max(0, deadlineNanos - System.nanoTime()), TimeUnit.NANOSECONDS);
+        } catch (InterruptedException interrupted) {
+            Thread.currentThread().interrupt();
+        }
+        if (!entered) {
+            release(lane);
+            throw deferredFailure(false, new IggyTimeoutException("Another poll of the partition held its lane"));
+        }
+        return lane;
+    }
+
+    void leaveProgressLane(ProgressLane lane) {
+        lane.gate.unlock();
+        release(lane);
+    }
+
+    private void release(ProgressLane lane) {
+        synchronized (progressLanes) {
+            if (--lane.holders == 0) {
+                progressLanes.remove(lane.key, lane);
+            }
+        }
+    }
+
     static IggyServerException deferredFailure(boolean possiblySent, Throwable failure) {
         var error = new IggyServerException(
                 (possiblySent ? IggyErrorCode.TRANSIENT_NOT_COMMITTED : IggyErrorCode.TRANSIENT_NOT_ACCEPTED)
@@ -367,7 +483,7 @@
         applyTlsCertificate(connectionManagerBuilder, tlsCertificate);
         return HttpClients.custom()
                 .setConnectionManager(connectionManagerBuilder.build())
-                .setRequestExecutor(new DeferredRequestExecutor())
+                .setRequestExecutor(new AttemptRecordingExecutor())
                 .disableAutomaticRetries()
                 .evictIdleConnections(TimeValue.ofSeconds(30))
                 .build();
@@ -391,6 +507,9 @@
         } catch (IggyServerException refusal) {
             throw settle(request, context, refusal);
         } catch (IOException e) {
+            if (committingPoll(request) && Boolean.TRUE.equals(context.getAttribute(ATTEMPTED))) {
+                throw deferredFailure(true, e);
+            }
             throw new IggyConnectionException("HTTP request failed", e);
         }
     }
@@ -402,9 +521,9 @@
      */
     private static IggyServerException settle(
             ClassicHttpRequest request, HttpContext context, IggyServerException refusal) {
-        // Reads commit nothing, so only a write can leave an uncertain outcome behind.
-        boolean uncertain =
-                !Method.GET.isSame(request.getMethod()) && Boolean.TRUE.equals(context.getAttribute(UNCERTAIN_ATTEMPT));
+        // Reads commit nothing, so only a write or a committing poll can leave an uncertain outcome behind.
+        boolean uncertain = (!Method.GET.isSame(request.getMethod()) || committingPoll(request))
+                && Boolean.TRUE.equals(context.getAttribute(UNCERTAIN_ATTEMPT));
         if (!uncertain || !SETTLED_REFUSALS.contains(refusal.getErrorCode())) {
             return refusal;
         }
@@ -413,6 +532,21 @@
         return unknownOutcome;
     }
 
+    /** The server stores the offset of a poll with auto-commit when it reads, so such a GET is a write. */
+    private static boolean committingPoll(HttpRequest request) {
+        if (!Method.GET.isSame(request.getMethod())) {
+            return false;
+        }
+        try {
+            return new URIBuilder(request.getUri())
+                    .getQueryParams().stream()
+                            .anyMatch(parameter -> AUTO_COMMIT.equals(parameter.getName())
+                                    && Boolean.parseBoolean(parameter.getValue()));
+        } catch (URISyntaxException unreadable) {
+            return true;
+        }
+    }
+
     ClassicHttpRequest prepareGetRequest(String path, NameValuePair... params) {
         return ClassicRequestBuilder.get(url + path)
                 .setHeader(AUTHORIZATION, getBearerToken())
@@ -420,6 +554,22 @@
                 .build();
     }
 
+    /** A POST whose parameters ride in the query, as the GET of the same route has them, with an empty body. */
+    ClassicHttpRequest preparePostQueryRequest(String path, NameValuePair... params) {
+        URI uri;
+        try {
+            uri = new URIBuilder(url + path)
+                    .addParameters(
+                            Arrays.stream(params).filter(Objects::nonNull).toList())
+                    .build();
+        } catch (URISyntaxException invalid) {
+            throw new IggyInvalidArgumentException("Invalid request URI " + url + path, invalid);
+        }
+        return ClassicRequestBuilder.post(uri)
+                .setHeader(AUTHORIZATION, getBearerToken())
+                .build();
+    }
+
     ClassicHttpRequest preparePostRequest(String path, Object body) {
         var builder = ClassicRequestBuilder.post(url + path).setHeader(AUTHORIZATION, getBearerToken());
         return addRequestBody(builder, body);
@@ -468,7 +618,7 @@
         return statusCode >= 200 && statusCode < 300;
     }
 
-    private static final class DeferredRequestExecutor extends HttpRequestExecutor {
+    private static final class AttemptRecordingExecutor extends HttpRequestExecutor {
         @Override
         public ClassicHttpResponse execute(
                 ClassicHttpRequest request,
@@ -477,19 +627,24 @@
                 HttpContext context)
                 throws IOException, HttpException {
             // The lease and connection already exist. A failed header write may still reach the server.
-            context.setAttribute(DEFERRED_ATTEMPTED, Boolean.TRUE);
+            context.setAttribute(ATTEMPTED, Boolean.TRUE);
             return super.execute(request, connection, callback, context);
         }
     }
 
     /**
      * Resends what the default strategy resends: a 429 or a 503 answer to any request, and an I/O
-     * failure of an idempotent one. Each resend marks the request uncertain unless the server
-     * provably never took the attempt before it.
+     * failure of an idempotent one, which a committing poll is not. Each resend marks the request
+     * uncertain unless the server provably never took the attempt before it.
      */
     private static final class UncertainAttempts extends DefaultHttpRequestRetryStrategy {
 
         @Override
+        protected boolean handleAsIdempotent(HttpRequest request) {
+            return super.handleAsIdempotent(request) && !committingPoll(request);
+        }
+
+        @Override
         public boolean retryRequest(HttpResponse response, int execCount, HttpContext context) {
             boolean retry = super.retryRequest(response, execCount, context);
             // Only an admission refusal proves the attempt was not taken. A 503 can mean either.
@@ -510,4 +665,46 @@
             return retry;
         }
     }
+
+    /**
+     * The partition of a progress call. HTTP has no partition id independent of the spelling of the stream and the
+     * topic, so a key names them as the call spells them, with the incarnation of the partition when the call carries
+     * its context, which names one partition in every spelling.
+     */
+    record ProgressKey(String stream, String topic, long partition, Optional<BigInteger> incarnation) {
+        // The partition that the server reads for a call that names none.
+        private static final long DEFAULT_PARTITION = 0;
+
+        static ProgressKey of(
+                StreamId stream, TopicId topic, Optional<Long> partition, Optional<PartitionContext> context) {
+            return new ProgressKey(
+                    stream.toString(),
+                    topic.toString(),
+                    partition.orElse(DEFAULT_PARTITION),
+                    // Incarnation 0 is a context that was never read.
+                    context.map(PartitionContext::incarnation).filter(incarnation -> incarnation.signum() != 0));
+        }
+
+        /** Whether a block of this key covers a call of {@code other}. */
+        boolean covers(ProgressKey other) {
+            return partition == other.partition
+                    && ((stream.equals(other.stream) && topic.equals(other.topic))
+                            || (incarnation.isPresent() && incarnation.equals(other.incarnation)));
+        }
+
+        ProgressKey lane() {
+            return new ProgressKey(stream, topic, partition, Optional.empty());
+        }
+    }
+
+    /** The lane of one partition, which one progress poll at a time holds. */
+    static final class ProgressLane {
+        private final ProgressKey key;
+        private final ReentrantLock gate = new ReentrantLock(true);
+        private int holders;
+
+        private ProgressLane(ProgressKey key) {
+            this.key = key;
+        }
+    }
 }
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/http/MessagesHttpClient.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/http/MessagesHttpClient.java
index 875c9f1..55fe830 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/http/MessagesHttpClient.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/http/MessagesHttpClient.java
@@ -32,13 +32,17 @@
 import org.apache.iggy.exception.IggyOperationNotSupportedException;
 import org.apache.iggy.exception.IggyServerException;
 import org.apache.iggy.exception.IggyTimeoutException;
+import org.apache.iggy.exception.IggyValidationException;
 import org.apache.iggy.identifier.StreamId;
 import org.apache.iggy.identifier.TopicId;
+import org.apache.iggy.message.CommitErrorScope;
 import org.apache.iggy.message.DeferredPollOptions;
 import org.apache.iggy.message.Message;
 import org.apache.iggy.message.Partitioning;
 import org.apache.iggy.message.PartitioningKind;
+import org.apache.iggy.message.PollOptions;
 import org.apache.iggy.message.PolledMessages;
+import org.apache.iggy.message.PollingKind;
 import org.apache.iggy.message.PollingStrategy;
 import org.apache.iggy.message.SendMessagesResponse;
 import org.apache.iggy.partition.Partition;
@@ -79,11 +83,52 @@
     public PolledMessages pollMessages(
             StreamId streamId,
             TopicId topicId,
+            long partitionId,
+            Consumer consumer,
+            PollingStrategy strategy,
+            PollOptions options) {
+        strategy.requirePartition(partitionId);
+        options.validate();
+        if (options.autoCommit().isPresent()) {
+            throw new IggyValidationException(
+                    IggyErrorCode.INVALID_CONFIGURATION,
+                    IggyErrorCode.INVALID_CONFIGURATION.getCode(),
+                    "A poll of one partition has nothing to count for an automatic commit",
+                    Optional.empty(),
+                    Optional.empty());
+        }
+        return pollMessagesDeferred(
+                streamId,
+                topicId,
+                Optional.of(partitionId),
+                consumer,
+                strategy,
+                options.count(),
+                options.commitsOnRead(),
+                options.deferredOptions());
+    }
+
+    @Override
+    public PolledMessages pollMessages(
+            StreamId streamId,
+            TopicId topicId,
             Optional<Long> partitionId,
             Consumer consumer,
             PollingStrategy strategy,
             Long count,
             boolean autoCommit) {
+        if (autoCommit) {
+            // The server refuses a GET that stores its offset, which a proxy could resend.
+            return pollMessagesDeferred(
+                    streamId,
+                    topicId,
+                    partitionId,
+                    consumer,
+                    strategy,
+                    count,
+                    true,
+                    DeferredPollOptions.defaults().withMaxWait(Duration.ZERO));
+        }
         var request = httpClient.prepareGetRequest(
                 path(streamId, topicId),
                 new BasicNameValuePair("consumer_id", consumer.id().toString()),
@@ -99,7 +144,7 @@
     }
 
     @Override
-    @SuppressWarnings("checkstyle:ParameterNumber")
+    @SuppressWarnings({"checkstyle:ParameterNumber", "checkstyle:CyclomaticComplexity", "checkstyle:NPathComplexity"})
     public PolledMessages pollMessagesDeferred(
             StreamId streamId,
             TopicId topicId,
@@ -116,28 +161,55 @@
             throw new IggyOperationNotSupportedException(
                     "A consumer group cannot poll over HTTP, which cannot follow the group's partition assignment");
         }
+        var progress = InternalHttpClient.ProgressKey.of(streamId, topicId, partitionId, strategy.context());
+        // A poll that stores its offset or starts at it advances the progress of its partition.
+        boolean advances = autoCommit || strategy.kind() == PollingKind.Next;
+        if (advances) {
+            // Before the lane wait, so a poll behind a parked one sees the block of a lost one at once.
+            httpClient.checkProgress(progress);
+        }
         long startNanos = System.nanoTime();
         long requestDeadlineNanos = startNanos + options.requestTimeout().toNanos();
-        long maxBodyBytes = Math.multiplyExact(options.maxBytes(), JSON_BODY_EXPANSION);
+        // A first message larger than maxBytes comes alone in the reply.
+        long maxBodyBytes = Math.multiplyExact(
+                Math.max(options.maxBytes(), DeferredPollOptions.MAX_RECORD_POLL_REPLY_SIZE), JSON_BODY_EXPANSION);
         long retryIntervalMillis = INITIAL_RETRY_INTERVAL_MILLIS;
-        while (true) {
-            // Both budgets carry across retries rather than restarting.
-            DeferredPollOptions remaining = options.remaining(Duration.ofNanos(System.nanoTime() - startNanos))
-                    .orElseThrow(() -> InternalHttpClient.deferredFailure(
-                            false, new IggyTimeoutException("Deferred poll exceeded its request timeout")));
-            ClassicHttpRequest request =
-                    deferredRequest(streamId, topicId, partitionId, consumer, strategy, count, autoCommit, remaining);
-            try {
-                return httpClient.executeDeferred(request, PolledMessages.class, requestDeadlineNanos, maxBodyBytes);
-            } catch (IggyServerException refused) {
-                // Only an explicit non-admission proves nothing was served. An
-                // opaque 503 can be an ambiguous not-committed or a proxy fault.
-                if (refused.getRawErrorCode() != IggyErrorCode.TRANSIENT_NOT_ACCEPTED.getCode()
-                        && refused.getRawErrorCode() != IggyErrorCode.DEFERRED_POLL_CAPACITY_EXCEEDED.getCode()) {
-                    throw refused;
+        InternalHttpClient.ProgressLane lane =
+                advances ? httpClient.enterProgressLane(progress, requestDeadlineNanos) : null;
+        try {
+            while (true) {
+                if (advances) {
+                    // Another poll can block the partition during the lane wait or a retry pause.
+                    httpClient.checkProgress(progress);
                 }
-                sleepBeforeRetry(retryIntervalMillis, requestDeadlineNanos, refused);
-                retryIntervalMillis = Math.min(retryIntervalMillis * 2, MAX_RETRY_INTERVAL_MILLIS);
+                // Both budgets carry across retries rather than restarting.
+                DeferredPollOptions remaining = options.remaining(Duration.ofNanos(System.nanoTime() - startNanos))
+                        .orElseThrow(() -> InternalHttpClient.deferredFailure(
+                                false, new IggyTimeoutException("Deferred poll exceeded its request timeout")));
+                ClassicHttpRequest request = deferredRequest(
+                        streamId, topicId, partitionId, consumer, strategy, count, autoCommit, remaining);
+                try {
+                    return httpClient.executeDeferred(
+                            request, PolledMessages.class, requestDeadlineNanos, maxBodyBytes);
+                } catch (IggyServerException refused) {
+                    if (autoCommit && !CommitErrorScope.REQUEST.provesNothingStored(refused.getRawErrorCode())) {
+                        // The server can have stored the offset, so the partition waits for an abandon.
+                        throw httpClient.loseProgress(
+                                progress, refused.getCause() != null ? refused.getCause() : refused);
+                    }
+                    // Only an explicit non-admission proves nothing was served. An
+                    // opaque 503 can be an ambiguous not-committed or a proxy fault.
+                    if (refused.getRawErrorCode() != IggyErrorCode.TRANSIENT_NOT_ACCEPTED.getCode()
+                            && refused.getRawErrorCode() != IggyErrorCode.DEFERRED_POLL_CAPACITY_EXCEEDED.getCode()) {
+                        throw refused;
+                    }
+                    sleepBeforeRetry(retryIntervalMillis, requestDeadlineNanos, refused);
+                    retryIntervalMillis = Math.min(retryIntervalMillis * 2, MAX_RETRY_INTERVAL_MILLIS);
+                }
+            }
+        } finally {
+            if (lane != null) {
+                httpClient.leaveProgressLane(lane);
             }
         }
     }
@@ -152,21 +224,26 @@
             Long count,
             boolean autoCommit,
             DeferredPollOptions options) {
-        return httpClient.prepareGetRequest(
-                path(streamId, topicId) + "/deferred",
-                new BasicNameValuePair("consumer_id", consumer.id().toString()),
-                partitionId
-                        .map(id -> new BasicNameValuePair("partition_id", id.toString()))
-                        .orElse(null),
-                new BasicNameValuePair("kind", strategy.kind().name().toLowerCase()),
-                new BasicNameValuePair("value", strategy.value().toString()),
-                contextParameter(strategy),
-                new BasicNameValuePair("count", count.toString()),
-                new BasicNameValuePair("auto_commit", Boolean.toString(autoCommit)),
-                new BasicNameValuePair("wait_us", Long.toString(options.maxWaitMicros())),
-                new BasicNameValuePair("min_count", Long.toString(options.minCount())),
-                new BasicNameValuePair("max_bytes", Long.toString(options.maxBytes())),
-                new BasicNameValuePair("request_timeout_us", Long.toString(options.requestTimeoutMicros())));
+        NameValuePair[] query = {
+            new BasicNameValuePair("consumer_id", consumer.id().toString()),
+            partitionId
+                    .map(id -> new BasicNameValuePair("partition_id", id.toString()))
+                    .orElse(null),
+            new BasicNameValuePair("kind", strategy.kind().name().toLowerCase()),
+            new BasicNameValuePair("value", strategy.value().toString()),
+            contextParameter(strategy),
+            new BasicNameValuePair("count", count.toString()),
+            new BasicNameValuePair("auto_commit", Boolean.toString(autoCommit)),
+            new BasicNameValuePair("wait_us", Long.toString(options.maxWaitMicros())),
+            new BasicNameValuePair("min_count", Long.toString(options.minCount())),
+            new BasicNameValuePair("max_bytes", Long.toString(options.maxBytes())),
+            new BasicNameValuePair("request_timeout_us", Long.toString(options.requestTimeoutMicros()))
+        };
+        String deferredPath = path(streamId, topicId) + "/deferred";
+        // A poll that stores its offset changes state, so it is a POST that no proxy or library resends.
+        return autoCommit
+                ? httpClient.preparePostQueryRequest(deferredPath, query)
+                : httpClient.prepareGetRequest(deferredPath, query);
     }
 
     // Without the context the server would read a recreated partition instead of refusing the poll.
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/reader/PartitionReader.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/reader/PartitionReader.java
new file mode 100644
index 0000000..cecdbb4
--- /dev/null
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/reader/PartitionReader.java
@@ -0,0 +1,125 @@
+/*
+ * 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.
+ */
+
+package org.apache.iggy.client.blocking.reader;
+
+import org.apache.iggy.client.async.reader.PartitionReaderBuilder;
+import org.apache.iggy.client.async.reader.ReadException;
+import org.apache.iggy.exception.IggyClientException;
+import org.apache.iggy.message.Checkpoint;
+import org.apache.iggy.message.PolledMessages;
+import org.apache.iggy.message.PollingStrategy;
+
+import java.io.Closeable;
+import java.time.Duration;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+/**
+ * A blocking view of {@link org.apache.iggy.client.async.reader.PartitionReader}. A call that waits for a batch keeps
+ * the batch in the reader when its thread is interrupted or its timeout ends, so no message is lost.
+ */
+public final class PartitionReader implements Closeable {
+    private final org.apache.iggy.client.async.reader.PartitionReader reader;
+
+    private PartitionReader(org.apache.iggy.client.async.reader.PartitionReader reader) {
+        this.reader = reader;
+    }
+
+    /** Builds the reader of {@code builder} and waits for it, a group reader until it joined its group. */
+    public static PartitionReader build(PartitionReaderBuilder builder) {
+        return new PartitionReader(resolve(builder.build()));
+    }
+
+    /** Wraps an async reader, which the blocking one closes. */
+    public static PartitionReader of(org.apache.iggy.client.async.reader.PartitionReader reader) {
+        return new PartitionReader(reader);
+    }
+
+    /**
+     * Waits for the next batch of one partition.
+     *
+     * @throws ReadException for an error of a read, after which the reader goes on
+     */
+    public PolledMessages next() {
+        return resolve(reader.next());
+    }
+
+    /** Waits up to {@code timeout} for the next batch, and returns empty when none came. */
+    public Optional<PolledMessages> next(Duration timeout) {
+        CompletableFuture<PolledMessages> next = reader.next();
+        try {
+            return Optional.of(next.get(timeout.toNanos(), TimeUnit.NANOSECONDS));
+        } catch (TimeoutException ended) {
+            next.cancel(false);
+            return Optional.empty();
+        } catch (InterruptedException interrupted) {
+            next.cancel(false);
+            Thread.currentThread().interrupt();
+            throw new IggyClientException("Interrupted while waiting for a batch", interrupted);
+        } catch (ExecutionException failed) {
+            throw unwrap(failed.getCause());
+        }
+    }
+
+    /** See {@link org.apache.iggy.client.async.reader.PartitionReader#commit}. */
+    public void commit(Checkpoint checkpoint) {
+        resolve(reader.commit(checkpoint));
+    }
+
+    /** See {@link org.apache.iggy.client.async.reader.PartitionReader#mark}. */
+    public void mark(Checkpoint checkpoint) {
+        resolve(reader.mark(checkpoint));
+    }
+
+    /** See {@link org.apache.iggy.client.async.reader.PartitionReader#seek}. */
+    public void seek(long partitionId, PollingStrategy start) {
+        resolve(reader.seek(partitionId, start));
+    }
+
+    public List<Long> partitions() {
+        return reader.partitions();
+    }
+
+    /** See {@link org.apache.iggy.client.async.reader.PartitionReader#closeAsync()}. */
+    @Override
+    public void close() {
+        resolve(reader.closeAsync());
+    }
+
+    private static <T> T resolve(CompletableFuture<T> future) {
+        try {
+            return future.get();
+        } catch (InterruptedException interrupted) {
+            future.cancel(false);
+            Thread.currentThread().interrupt();
+            throw new IggyClientException("Interrupted while waiting for the reader", interrupted);
+        } catch (ExecutionException failed) {
+            throw unwrap(failed.getCause());
+        }
+    }
+
+    private static RuntimeException unwrap(Throwable cause) {
+        return cause instanceof RuntimeException runtime ? runtime : new IggyClientException("Read failed", cause);
+    }
+}
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/tcp/IggyTcpClient.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/tcp/IggyTcpClient.java
index b559198..af3facf 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/tcp/IggyTcpClient.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/tcp/IggyTcpClient.java
@@ -20,6 +20,7 @@
 package org.apache.iggy.client.blocking.tcp;
 
 import org.apache.iggy.client.ConnectionInfo;
+import org.apache.iggy.client.async.reader.PartitionReaderBuilder;
 import org.apache.iggy.client.async.tcp.AsyncIggyTcpClient;
 import org.apache.iggy.client.blocking.ConsumerGroupsClient;
 import org.apache.iggy.client.blocking.ConsumerOffsetsClient;
@@ -31,8 +32,11 @@
 import org.apache.iggy.client.blocking.SystemClient;
 import org.apache.iggy.client.blocking.TopicsClient;
 import org.apache.iggy.client.blocking.UsersClient;
+import org.apache.iggy.consumergroup.Consumer;
 import org.apache.iggy.exception.IggyMissingCredentialsException;
 import org.apache.iggy.exception.IggyNotConnectedException;
+import org.apache.iggy.identifier.StreamId;
+import org.apache.iggy.identifier.TopicId;
 import org.apache.iggy.user.IdentityInfo;
 
 import java.io.Closeable;
@@ -135,6 +139,14 @@
         FutureUtil.resolve(asyncClient.abandonOperation(operationId));
     }
 
+    /**
+     * Drops the connections without a sign-out, see {@link AsyncIggyTcpClient#disconnect()}. {@link #connect()} and a
+     * sign-in continue the client.
+     */
+    public void disconnect() {
+        FutureUtil.resolve(asyncClient.disconnect());
+    }
+
     @Override
     public void close() {
         FutureUtil.resolve(asyncClient.close());
@@ -196,6 +208,19 @@
         return consumerOffsetsClient;
     }
 
+    /**
+     * Starts a reader of the partitions of {@code topicId}, as {@link AsyncIggyTcpClient#reader} does. Build a blocking
+     * reader with {@link org.apache.iggy.client.blocking.reader.PartitionReader#build}.
+     *
+     * @param streamId the stream identifier
+     * @param topicId  the topic identifier
+     * @param consumer the consumer or consumer group whose offsets the reader reads and stores
+     * @return the builder of the reader
+     */
+    public PartitionReaderBuilder reader(StreamId streamId, TopicId topicId, Consumer consumer) {
+        return asyncClient.reader(streamId, topicId, consumer);
+    }
+
     @Override
     public MessagesClient messages() {
         if (messagesClient == null) {
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/tcp/MessagesTcpClient.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/tcp/MessagesTcpClient.java
index f8baa26..cb2f78a 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/tcp/MessagesTcpClient.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/tcp/MessagesTcpClient.java
@@ -26,6 +26,7 @@
 import org.apache.iggy.message.DeferredPollOptions;
 import org.apache.iggy.message.Message;
 import org.apache.iggy.message.Partitioning;
+import org.apache.iggy.message.PollOptions;
 import org.apache.iggy.message.PolledMessages;
 import org.apache.iggy.message.PollingStrategy;
 import org.apache.iggy.message.SendMessagesResponse;
@@ -55,6 +56,19 @@
     }
 
     @Override
+    public PolledMessages pollMessages(
+            StreamId streamId,
+            TopicId topicId,
+            long partitionId,
+            Consumer consumer,
+            PollingStrategy strategy,
+            PollOptions options) {
+        var polled = delegate.pollMessages(streamId, topicId, partitionId, consumer, strategy, options);
+        // A cancel of a committing poll can lose what the server committed, so only its reply ends the wait.
+        return options.commitsOnRead() ? FutureUtil.resolve(polled) : FutureUtil.resolveInterruptibly(polled);
+    }
+
+    @Override
     @SuppressWarnings("checkstyle:ParameterNumber")
     public PolledMessages pollMessagesDeferred(
             StreamId streamId,
@@ -65,8 +79,10 @@
             Long count,
             boolean autoCommit,
             DeferredPollOptions options) {
-        return FutureUtil.resolveInterruptibly(delegate.pollMessagesDeferred(
-                streamId, topicId, partitionId, consumer, strategy, count, autoCommit, options));
+        var polled = delegate.pollMessagesDeferred(
+                streamId, topicId, partitionId, consumer, strategy, count, autoCommit, options);
+        // A cancel of a committing poll can lose what the server committed, so only its reply ends the wait.
+        return autoCommit ? FutureUtil.resolve(polled) : FutureUtil.resolveInterruptibly(polled);
     }
 
     @Override
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/exception/IggyErrorCode.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/exception/IggyErrorCode.java
index bf04e6a..d2c7db8 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/exception/IggyErrorCode.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/exception/IggyErrorCode.java
@@ -32,6 +32,7 @@
 public enum IggyErrorCode {
     // General errors
     ERROR(1),
+    INVALID_CONFIGURATION(2),
     INVALID_COMMAND(3),
     INVALID_FORMAT(4),
     FEATURE_UNAVAILABLE(5),
@@ -125,6 +126,7 @@
     TOO_MANY_MESSAGES(4023),
     EMPTY_MESSAGE_PAYLOAD(4024),
     INVALID_MESSAGE_CHECKSUM(4027),
+    INVALID_OFFSET(4100),
 
     // Resource option errors
     UNSUPPORTED_OPTION_KEY(4041),
@@ -144,6 +146,7 @@
     CONSUMER_GROUP_NAME_ALREADY_EXISTS(5004),
     INVALID_CONSUMER_GROUP_NAME(5005),
     CONSUMER_GROUP_MEMBER_NOT_FOUND(5006),
+    CONSUMER_GROUP_PARTITION_NOT_OWNED(5009),
     TOO_MANY_CONSUMER_GROUPS(5010),
 
     // VSR protocol errors
@@ -153,6 +156,8 @@
     OPERATION_UNCERTAIN(14006),
     LANE_BLOCKED(14007),
     OPERATION_NOT_RETAINED(14008),
+    PARTITION_LANE_BLOCKED(14009),
+    FETCH_PROGRESS_UNCERTAIN(14010),
 
     // Unknown error code
     UNKNOWN(-1);
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/exception/IggyOperationException.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/exception/IggyOperationException.java
index ae61ec5..daf0810 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/exception/IggyOperationException.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/exception/IggyOperationException.java
@@ -20,23 +20,42 @@
 package org.apache.iggy.exception;
 
 import java.util.Optional;
+import java.util.OptionalLong;
 
-/** A local recovery outcome carrying the exact unsigned wire operation id. */
+/** A local recovery outcome carrying an unsigned recovery id, also for fetches without replayable receipts. */
 public final class IggyOperationException extends IggyServerException {
     private final long operationId;
+    private final OptionalLong partitionId;
 
     public IggyOperationException(IggyErrorCode code, long operationId) {
+        this(code, operationId, OptionalLong.empty());
+    }
+
+    public IggyOperationException(IggyErrorCode code, long operationId, long partitionId) {
+        this(code, operationId, OptionalLong.of(partitionId));
+    }
+
+    private IggyOperationException(IggyErrorCode code, long operationId, OptionalLong partitionId) {
         super(
                 code,
                 code.getCode(),
-                "Operation " + Long.toUnsignedString(operationId),
+                code == IggyErrorCode.PARTITION_LANE_BLOCKED && partitionId.isPresent()
+                        ? "Partition " + partitionId.getAsLong() + " blocked by operation "
+                                + Long.toUnsignedString(operationId)
+                        : "Operation " + Long.toUnsignedString(operationId),
                 Optional.empty(),
                 Optional.empty());
         this.operationId = operationId;
+        this.partitionId = partitionId;
     }
 
-    /** Returns the u64 wire bits. Negative values are valid operation ids. */
+    /** Returns the unsigned recovery id bits. Negative values are valid ids. Fetch ids cannot be replayed. */
     public long getOperationId() {
         return operationId;
     }
+
+    /** Returns the blocked partition as an unsigned 32-bit value, if this outcome names a partition. */
+    public OptionalLong getPartitionId() {
+        return partitionId;
+    }
 }
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/exception/IggyValidationException.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/exception/IggyValidationException.java
index e46aa52..0c97d8b 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/exception/IggyValidationException.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/exception/IggyValidationException.java
@@ -32,6 +32,7 @@
 public class IggyValidationException extends IggyServerException {
 
     private static final Set<IggyErrorCode> CODES = EnumSet.of(
+            IggyErrorCode.INVALID_CONFIGURATION,
             IggyErrorCode.INVALID_COMMAND,
             IggyErrorCode.INVALID_FORMAT,
             IggyErrorCode.FEATURE_UNAVAILABLE,
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/message/AutoCommit.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/message/AutoCommit.java
new file mode 100644
index 0000000..64f48e7
--- /dev/null
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/message/AutoCommit.java
@@ -0,0 +1,79 @@
+/*
+ * 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.
+ */
+
+package org.apache.iggy.message;
+
+import org.apache.iggy.exception.IggyInvalidArgumentException;
+
+import java.time.Duration;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * When {@link CommitPolicy.Auto} stores. It needs an {@code interval} or an {@code every}. It also stores on close, on
+ * shutdown and before a rebalance takes a partition away. A store only moves forward, and a position of an older
+ * generation of its partition, after a seek or a new assignment, is dropped.
+ *
+ * @param interval store the latest counted position at this interval, when it moved
+ * @param every    also store it on this event
+ * @param after    when a message counts
+ */
+public record AutoCommit(Optional<Duration> interval, Optional<CommitEvery> every, CommitAfter after) {
+    private static final Duration DEFAULT_INTERVAL = Duration.ofSeconds(5);
+    private static final long DEFAULT_MESSAGES = 10_000;
+
+    public AutoCommit {
+        Objects.requireNonNull(interval, "interval");
+        Objects.requireNonNull(every, "every");
+        Objects.requireNonNull(after, "after");
+        interval.ifPresent(period -> {
+            if (period.isNegative() || period.isZero()) {
+                throw new IggyInvalidArgumentException("An automatic commit interval must be positive, not " + period);
+            }
+        });
+    }
+
+    /** Every 10,000 messages or 5 s, whichever comes first, after {@link CommitAfter#PROCESSING}. */
+    public static AutoCommit defaults() {
+        return new AutoCommit(
+                Optional.of(DEFAULT_INTERVAL),
+                Optional.of(CommitEvery.messages(DEFAULT_MESSAGES)),
+                CommitAfter.PROCESSING);
+    }
+
+    public AutoCommit withInterval(Duration interval) {
+        return new AutoCommit(Optional.of(interval), every, after);
+    }
+
+    public AutoCommit withoutInterval() {
+        return new AutoCommit(Optional.empty(), every, after);
+    }
+
+    public AutoCommit withEvery(CommitEvery every) {
+        return new AutoCommit(interval, Optional.of(every), after);
+    }
+
+    public AutoCommit withoutEvery() {
+        return new AutoCommit(interval, Optional.empty(), after);
+    }
+
+    public AutoCommit withAfter(CommitAfter after) {
+        return new AutoCommit(interval, every, after);
+    }
+}
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/message/Checkpoint.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/message/Checkpoint.java
new file mode 100644
index 0000000..07013a0
--- /dev/null
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/message/Checkpoint.java
@@ -0,0 +1,79 @@
+/*
+ * 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.
+ */
+
+package org.apache.iggy.message;
+
+import org.apache.iggy.partition.PartitionContext;
+
+import java.math.BigInteger;
+import java.util.Objects;
+
+/**
+ * The position after one delivered message: its partition, its offset and the history of the partition it came from.
+ * Only a poll or a reader makes one, with {@link PolledMessages#checkpoint()} or {@link PolledMessages#checkpointAt}.
+ * Storing it stores the offset of that message for the consumer.
+ */
+public final class Checkpoint {
+    private final long partitionId;
+    private final BigInteger offset;
+    private final PartitionContext context;
+
+    Checkpoint(long partitionId, BigInteger offset, PartitionContext context) {
+        this.partitionId = partitionId;
+        this.offset = Objects.requireNonNull(offset, "offset");
+        this.context = Objects.requireNonNull(context, "context");
+    }
+
+    public long partitionId() {
+        return partitionId;
+    }
+
+    /** The offset of the delivered message. */
+    public BigInteger offset() {
+        return offset;
+    }
+
+    /** The history of the partition that the message came from, which a store sends so it fails on another one. */
+    public PartitionContext context() {
+        return context;
+    }
+
+    /** The continuation after this message, bound to its partition. */
+    public PollingStrategy next() {
+        return PollingStrategy.continuation(partitionId, offset.add(BigInteger.ONE), context);
+    }
+
+    @Override
+    public boolean equals(Object other) {
+        return other instanceof Checkpoint checkpoint
+                && partitionId == checkpoint.partitionId
+                && offset.equals(checkpoint.offset)
+                && context.equals(checkpoint.context);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(partitionId, offset, context);
+    }
+
+    @Override
+    public String toString() {
+        return "Checkpoint[partitionId=" + partitionId + ", offset=" + offset + ", context=" + context + "]";
+    }
+}
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/message/CommitAfter.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/message/CommitAfter.java
new file mode 100644
index 0000000..bd10e13
--- /dev/null
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/message/CommitAfter.java
@@ -0,0 +1,35 @@
+/*
+ * 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.
+ */
+
+package org.apache.iggy.message;
+
+/** When a message counts for {@link CommitPolicy.Auto}. */
+public enum CommitAfter {
+    /**
+     * Once the application asks for the next message or batch, returns from its handler with success, or closes the
+     * reader. A handler that fails leaves its message uncounted.
+     */
+    PROCESSING,
+    /** When the message is handed over. */
+    DELIVERY,
+    /**
+     * Only when the application marks its checkpoint, so messages processed concurrently count once they are done.
+     */
+    MARKED
+}
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/message/CommitErrorScope.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/message/CommitErrorScope.java
new file mode 100644
index 0000000..49399a1
--- /dev/null
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/message/CommitErrorScope.java
@@ -0,0 +1,57 @@
+/*
+ * 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.
+ */
+
+package org.apache.iggy.message;
+
+import org.apache.iggy.exception.IggyErrorCode;
+
+/** Where a poll or fetch that stores the offsets that it reads reported an error. */
+public enum CommitErrorScope {
+    /** The whole reply is the error. */
+    REQUEST,
+    /** One section of a fetch reply carries the error status. */
+    SECTION;
+
+    /**
+     * Whether {@code status}, reported in this scope, proves that the server stored no offset. Any other status, a
+     * lost reply and a transport failure after the send leave the outcome unknown.
+     */
+    public boolean provesNothingStored(long status) {
+        return switch (IggyErrorCode.fromCode((int) status)) {
+            // The server refuses a first message larger than the byte limit before it stores an offset, and a read
+            // that needs a new offset key above the limit returns no messages.
+            case TRANSIENT_NOT_ACCEPTED,
+                    INVALID_SIZE_BYTES,
+                    HISTORY_UNAVAILABLE,
+                    NOT_PRIMARY,
+                    PARTITION_NOT_FOUND,
+                    TOO_MANY_CONSUMER_OFFSETS,
+                    CONSUMER_GROUP_PARTITION_NOT_OWNED -> true;
+            case FEATURE_UNAVAILABLE,
+                    INVALID_IDENTIFIER,
+                    DEFERRED_POLL_CAPACITY_EXCEEDED,
+                    STREAM_ID_NOT_FOUND,
+                    STREAM_NAME_NOT_FOUND,
+                    TOPIC_ID_NOT_FOUND,
+                    TOPIC_NAME_NOT_FOUND,
+                    INVALID_OFFSET -> this == REQUEST;
+            default -> false;
+        };
+    }
+}
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/message/CommitEvery.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/message/CommitEvery.java
new file mode 100644
index 0000000..cabdbc0
--- /dev/null
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/message/CommitEvery.java
@@ -0,0 +1,53 @@
+/*
+ * 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.
+ */
+
+package org.apache.iggy.message;
+
+import org.apache.iggy.exception.IggyInvalidArgumentException;
+
+/** The event on which {@link AutoCommit} also stores. */
+public sealed interface CommitEvery permits CommitEvery.Message, CommitEvery.Messages, CommitEvery.Batch {
+
+    /** Each counted message, or each counted batch of a partition reader. */
+    static CommitEvery message() {
+        return new Message();
+    }
+
+    /** This many counted messages of a partition since its last store. */
+    static CommitEvery messages(long count) {
+        return new Messages(count);
+    }
+
+    /** The last message of each batch. */
+    static CommitEvery batch() {
+        return new Batch();
+    }
+
+    record Message() implements CommitEvery {}
+
+    record Messages(long count) implements CommitEvery {
+        public Messages {
+            if (count < 1 || count > 0xFFFF_FFFFL) {
+                throw new IggyInvalidArgumentException("A commit every " + count + " messages needs 1 to 4294967295");
+            }
+        }
+    }
+
+    record Batch() implements CommitEvery {}
+}
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/message/CommitPolicy.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/message/CommitPolicy.java
new file mode 100644
index 0000000..6f30012
--- /dev/null
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/message/CommitPolicy.java
@@ -0,0 +1,67 @@
+/*
+ * 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.
+ */
+
+package org.apache.iggy.message;
+
+import java.util.Objects;
+
+/** When the offsets of the messages that a read returns are stored. */
+public sealed interface CommitPolicy permits CommitPolicy.Manual, CommitPolicy.OnRead, CommitPolicy.Auto {
+
+    /** Nothing is stored. The application commits checkpoints. The default. */
+    static CommitPolicy manual() {
+        return Manual.INSTANCE;
+    }
+
+    /**
+     * The server stores the offset of the last message of each read as part of the read, in one round trip. While the
+     * primary stays up a message is delivered at most once. A failover before the store replicates can deliver the
+     * last batch again.
+     */
+    static CommitPolicy onRead() {
+        return OnRead.INSTANCE;
+    }
+
+    /**
+     * The client stores the latest counted position, at least once with {@link CommitAfter#PROCESSING}. A one-shot
+     * poll refuses it, because it has nothing to count. A partition reader and a consumer accept it.
+     */
+    static CommitPolicy auto(AutoCommit autoCommit) {
+        return new Auto(autoCommit);
+    }
+
+    /** {@link #auto(AutoCommit)} with {@link AutoCommit#defaults()}. */
+    static CommitPolicy auto() {
+        return new Auto(AutoCommit.defaults());
+    }
+
+    record Manual() implements CommitPolicy {
+        private static final Manual INSTANCE = new Manual();
+    }
+
+    record OnRead() implements CommitPolicy {
+        private static final OnRead INSTANCE = new OnRead();
+    }
+
+    record Auto(AutoCommit autoCommit) implements CommitPolicy {
+        public Auto {
+            Objects.requireNonNull(autoCommit, "autoCommit");
+        }
+    }
+}
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/message/DecodeFailure.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/message/DecodeFailure.java
new file mode 100644
index 0000000..4f000f7
--- /dev/null
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/message/DecodeFailure.java
@@ -0,0 +1,34 @@
+/*
+ * 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.
+ */
+
+package org.apache.iggy.message;
+
+import java.math.BigInteger;
+import java.util.Optional;
+
+/**
+ * A record of a reply that does not decode.
+ *
+ * @param error  why the record does not decode
+ * @param offset the offset of the record, known when its frame decoded and a later check failed. Empty when the
+ *               framing is malformed, so no record can be isolated.
+ * @param record the record as the reply carried it, the frame header, the payload and the user headers. For
+ *               malformed framing, the remaining bytes of the reply from where decoding stopped.
+ */
+public record DecodeFailure(RuntimeException error, Optional<BigInteger> offset, byte[] record) {}
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/message/DeferredPollOptions.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/message/DeferredPollOptions.java
index 61fb956..6a811a4 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/message/DeferredPollOptions.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/message/DeferredPollOptions.java
@@ -41,6 +41,8 @@
  * <p>{@code maxBytes} bounds the encoded response body, including the 40-byte
  * poll prefix and all batch framing, but excluding the 256-byte transport
  * header. It is not a payload-only limit, and not a bound on server disk work.
+ * A first message larger than it comes alone in the reply, up to
+ * {@link #MAX_RECORD_SIZE}.
  * The smallest limit, {@link #MIN_MAX_BYTES}, adds one batch header and one
  * message header to the prefix, the least room any message needs.
  *
@@ -72,6 +74,20 @@
             + DeferredPollOptions.BATCH_HEADER_SIZE
             + DeferredPollOptions.BATCH_MESSAGE_HEADER_SIZE;
 
+    /**
+     * Mirrors {@code MAX_RECORD_SIZE}: the largest record of one message in a read reply, with its batch and message
+     * headers. A read returns a first record up to this size even above its {@code maxBytes}, so every reply limit of
+     * the client holds it.
+     */
+    public static final long MAX_RECORD_SIZE = DeferredPollOptions.BATCH_HEADER_SIZE
+            + DeferredPollOptions.BATCH_MESSAGE_HEADER_SIZE
+            + DeferredPollOptions.MAX_PAYLOAD_SIZE
+            + DeferredPollOptions.MAX_USER_HEADERS_SIZE;
+
+    /** The largest poll reply body that holds one record of {@link #MAX_RECORD_SIZE}. */
+    public static final long MAX_RECORD_POLL_REPLY_SIZE =
+            DeferredPollOptions.POLL_RESPONSE_HEADER_SIZE + MAX_RECORD_SIZE;
+
     public static final Duration DEFAULT_MAX_WAIT = Duration.ofSeconds(1);
     public static final long DEFAULT_MIN_COUNT = 1;
     public static final long DEFAULT_MAX_BYTES = 1024L * 1024L;
@@ -88,6 +104,8 @@
 
     private static final long BATCH_HEADER_SIZE = 256;
     private static final long BATCH_MESSAGE_HEADER_SIZE = 48;
+    private static final long MAX_PAYLOAD_SIZE = 64_000_000;
+    private static final long MAX_USER_HEADERS_SIZE = 100_000;
 
     public DeferredPollOptions {
         requireMicrosecondDuration(maxWait, "maxWait");
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/message/FetchedMessages.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/message/FetchedMessages.java
new file mode 100644
index 0000000..83ac0f2
--- /dev/null
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/message/FetchedMessages.java
@@ -0,0 +1,99 @@
+/*
+ * 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.
+ */
+
+package org.apache.iggy.message;
+
+import io.netty.buffer.Unpooled;
+import org.apache.iggy.exception.IggyErrorCode;
+import org.apache.iggy.exception.IggyOperationException;
+import org.apache.iggy.exception.IggyServerException;
+import org.apache.iggy.partition.PartitionContext;
+import org.apache.iggy.serde.BytesDeserializer;
+
+import java.math.BigInteger;
+import java.util.Optional;
+import java.util.OptionalLong;
+
+/**
+ * One section of a fetch reply: what one partition read, or why it read nothing. A section
+ * whose status is not {@link #STATUS_OK} holds the error code of its partition, no records and
+ * zero in every other field, so the next fetch of that partition keeps the start point and the
+ * context that it sent.
+ *
+ * @param partitionId   the partition of this section
+ * @param status        {@link #STATUS_OK}, or the error code of this partition
+ * @param currentOffset the current offset of the partition, as in a poll reply
+ * @param count         the number of messages in {@code records}
+ * @param context       the partition context for the strategy of the next fetch of this partition
+ * @param nextOffset    the offset where the next fetch of this partition starts
+ * @param records       the batch records as stored, decoded by {@link #decode()}
+ * @param recoveryOperationId local recovery id when this section leaves committing progress uncertain
+ */
+public record FetchedMessages(
+        long partitionId,
+        long status,
+        BigInteger currentOffset,
+        long count,
+        PartitionContext context,
+        BigInteger nextOffset,
+        byte[] records,
+        OptionalLong recoveryOperationId) {
+    public static final long STATUS_OK = 0;
+
+    public FetchedMessages(
+            long partitionId,
+            long status,
+            BigInteger currentOffset,
+            long count,
+            PartitionContext context,
+            BigInteger nextOffset,
+            byte[] records) {
+        this(partitionId, status, currentOffset, count, context, nextOffset, records, OptionalLong.empty());
+    }
+
+    /** A local recovery identity for uncertain progress, independent of the original wire status. */
+    public Optional<IggyOperationException> recoveryError() {
+        return recoveryOperationId.isPresent()
+                ? Optional.of(new IggyOperationException(
+                        IggyErrorCode.FETCH_PROGRESS_UNCERTAIN, recoveryOperationId.getAsLong()))
+                : Optional.empty();
+    }
+
+    /**
+     * Decodes the records of this section.
+     *
+     * @return the messages of this section, with its partition, current offset and context
+     * @throws IggyServerException for a section whose status is not {@link #STATUS_OK}, with
+     *                             that status as its code
+     */
+    public PolledMessages decode() {
+        recoveryError().ifPresent(error -> {
+            throw error;
+        });
+        if (status != STATUS_OK) {
+            throw IggyServerException.fromTcpResponse(status, new byte[0]);
+        }
+        return new PolledMessages(
+                partitionId,
+                currentOffset,
+                count,
+                BytesDeserializer.readMessages(Unpooled.wrappedBuffer(records), count),
+                context);
+    }
+}
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/message/PollOptions.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/message/PollOptions.java
new file mode 100644
index 0000000..729ad8f
--- /dev/null
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/message/PollOptions.java
@@ -0,0 +1,128 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iggy.message;
+
+import org.apache.iggy.exception.IggyErrorCode;
+import org.apache.iggy.exception.IggyValidationException;
+
+import java.time.Duration;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * The limits of one read, and who stores its offsets. A read is a poll of one partition, or a fetch of several
+ * partitions of one primary that a reader sends, whose sections share the limits.
+ *
+ * @param count    most messages in one batch, 1 to 4294967295, default 1000
+ * @param maxWait  zero answers at once, at most {@link DeferredPollOptions#MAX_WAIT}, default zero
+ * @param minCount messages that end the wait, 1 to {@code count}, default 1
+ * @param maxBytes the largest reply body, at least {@link DeferredPollOptions#MIN_MAX_BYTES}, default 1 MiB. The
+ *                 server returns a first message larger than this alone in its reply
+ * @param commit   who stores the offsets of what the poll reads, default {@link CommitPolicy#manual()}
+ */
+public record PollOptions(long count, Duration maxWait, long minCount, long maxBytes, CommitPolicy commit) {
+    public static final long DEFAULT_COUNT = 1000;
+
+    /** The request timeout is the wait plus this margin. */
+    public static final Duration REQUEST_TIMEOUT_MARGIN = Duration.ofSeconds(10);
+
+    private static final PollOptions DEFAULTS = new PollOptions(
+            DEFAULT_COUNT,
+            Duration.ZERO,
+            DeferredPollOptions.DEFAULT_MIN_COUNT,
+            DeferredPollOptions.DEFAULT_MAX_BYTES,
+            CommitPolicy.manual());
+
+    public PollOptions {
+        Objects.requireNonNull(maxWait, "maxWait");
+        Objects.requireNonNull(commit, "commit");
+    }
+
+    /** 1000 messages at most, answered at once, up to 1 MiB, nothing stored. */
+    public static PollOptions defaults() {
+        return DEFAULTS;
+    }
+
+    /** The defaults with a wait of up to {@code maxWait} for {@code minCount} messages. */
+    public static PollOptions waitFor(Duration maxWait) {
+        return DEFAULTS.withMaxWait(maxWait);
+    }
+
+    public PollOptions withCount(long count) {
+        return new PollOptions(count, maxWait, minCount, maxBytes, commit);
+    }
+
+    public PollOptions withMaxWait(Duration maxWait) {
+        return new PollOptions(count, maxWait, minCount, maxBytes, commit);
+    }
+
+    public PollOptions withMinCount(long minCount) {
+        return new PollOptions(count, maxWait, minCount, maxBytes, commit);
+    }
+
+    public PollOptions withMaxBytes(long maxBytes) {
+        return new PollOptions(count, maxWait, minCount, maxBytes, commit);
+    }
+
+    public PollOptions withCommit(CommitPolicy commit) {
+        return new PollOptions(count, maxWait, minCount, maxBytes, commit);
+    }
+
+    /** The limit of the whole request: {@code maxWait} plus {@link #REQUEST_TIMEOUT_MARGIN}. */
+    public Duration requestTimeout() {
+        return maxWait.plus(REQUEST_TIMEOUT_MARGIN);
+    }
+
+    /** Whether the server stores the offset of each read. */
+    public boolean commitsOnRead() {
+        return commit instanceof CommitPolicy.OnRead;
+    }
+
+    /** The automatic commit settings, under {@link CommitPolicy.Auto}. */
+    public Optional<AutoCommit> autoCommit() {
+        return commit instanceof CommitPolicy.Auto auto ? Optional.of(auto.autoCommit()) : Optional.empty();
+    }
+
+    /**
+     * Refuses what the server would refuse, and an automatic commit that would store only on close.
+     *
+     * @throws org.apache.iggy.exception.IggyInvalidArgumentException for limits outside their ranges
+     * @throws IggyValidationException with {@link IggyErrorCode#INVALID_CONFIGURATION} for an automatic commit
+     *                                 without an interval or an event
+     */
+    public void validate() {
+        if (autoCommit()
+                .filter(auto -> auto.interval().isEmpty() && auto.every().isEmpty())
+                .isPresent()) {
+            throw new IggyValidationException(
+                    IggyErrorCode.INVALID_CONFIGURATION,
+                    IggyErrorCode.INVALID_CONFIGURATION.getCode(),
+                    "An automatic commit needs an interval or an event, or it would store only on close",
+                    Optional.empty(),
+                    Optional.empty());
+        }
+        deferredOptions().validate(count);
+    }
+
+    /** The wire limits of the poll, with the request timeout of {@link #requestTimeout()}. */
+    public DeferredPollOptions deferredOptions() {
+        return new DeferredPollOptions(maxWait, minCount, maxBytes, requestTimeout());
+    }
+}
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/message/PolledMessages.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/message/PolledMessages.java
index 1cfb880..bd4b160 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/message/PolledMessages.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/message/PolledMessages.java
@@ -23,6 +23,39 @@
 
 import java.math.BigInteger;
 import java.util.List;
+import java.util.Optional;
 
+/**
+ * One batch of one partition.
+ *
+ * @param partitionId   the partition of the batch
+ * @param currentOffset the offset of the last message of the partition when the server read it
+ * @param count         the number of messages
+ * @param messages      the messages in offset order
+ * @param context       the history of the partition that the messages came from
+ */
 public record PolledMessages(
-        Long partitionId, BigInteger currentOffset, Long count, List<Message> messages, PartitionContext context) {}
+        Long partitionId, BigInteger currentOffset, Long count, List<Message> messages, PartitionContext context) {
+
+    /** The position after the last message, or empty for an empty batch. */
+    public Optional<Checkpoint> checkpoint() {
+        return messages.isEmpty() ? Optional.empty() : checkpointAt(messages.size() - 1);
+    }
+
+    /** The position after the message at {@code index}, or empty for an index outside the batch. */
+    public Optional<Checkpoint> checkpointAt(int index) {
+        if (index < 0 || index >= messages.size()) {
+            return Optional.empty();
+        }
+        return Optional.of(
+                new Checkpoint(partitionId, messages.get(index).header().offset(), context));
+    }
+
+    /**
+     * Where the next poll of this partition starts: after the last message. Empty for an empty batch, whose start the
+     * caller keeps. A poll or a seek of another partition refuses it.
+     */
+    public Optional<PollingStrategy> continuation() {
+        return checkpoint().map(Checkpoint::next);
+    }
+}
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/message/PollingStrategy.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/message/PollingStrategy.java
index 438d32d..5d27627 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/message/PollingStrategy.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/message/PollingStrategy.java
@@ -19,22 +19,43 @@
 
 package org.apache.iggy.message;
 
+import org.apache.iggy.exception.IggyErrorCode;
+import org.apache.iggy.exception.IggyValidationException;
 import org.apache.iggy.partition.PartitionContext;
 
 import java.math.BigInteger;
 import java.util.Objects;
 import java.util.Optional;
 
-public record PollingStrategy(PollingKind kind, BigInteger value, Optional<PartitionContext> context) {
+/**
+ * Where a poll starts.
+ *
+ * @param kind        how {@code value} is read
+ * @param value       the offset or timestamp of {@code kind}, zero for the other kinds
+ * @param context     the history of the partition that an offset came from
+ * @param partitionId the partition of a continuation, which a poll or a seek of another partition refuses
+ */
+public record PollingStrategy(
+        PollingKind kind, BigInteger value, Optional<PartitionContext> context, Optional<Long> partitionId) {
 
     public PollingStrategy {
         Objects.requireNonNull(context, "context");
+        Objects.requireNonNull(partitionId, "partitionId");
+    }
+
+    public PollingStrategy(PollingKind kind, BigInteger value, Optional<PartitionContext> context) {
+        this(kind, value, context, Optional.empty());
     }
 
     public PollingStrategy(PollingKind kind, BigInteger value) {
         this(kind, value, Optional.empty());
     }
 
+    /** The offset {@code next} of {@code partitionId} under {@code context}, as a poll or a checkpoint continues. */
+    static PollingStrategy continuation(long partitionId, BigInteger next, PartitionContext context) {
+        return new PollingStrategy(PollingKind.Offset, next, Optional.of(context), Optional.of(partitionId));
+    }
+
     public static PollingStrategy offset(BigInteger value) {
         return new PollingStrategy(PollingKind.Offset, value);
     }
@@ -68,6 +89,23 @@
      * @return a copy of this strategy that carries {@code context}
      */
     public PollingStrategy withContext(PartitionContext context) {
-        return new PollingStrategy(kind, value, Optional.of(context));
+        return new PollingStrategy(kind, value, Optional.of(context), partitionId);
+    }
+
+    /**
+     * Refuses a continuation of another partition.
+     *
+     * @param partition the partition that the strategy is used for
+     * @throws IggyValidationException with {@link IggyErrorCode#INVALID_COMMAND} for a continuation of another partition
+     */
+    public void requirePartition(long partition) {
+        if (partitionId.isPresent() && partitionId.get() != partition) {
+            throw new IggyValidationException(
+                    IggyErrorCode.INVALID_COMMAND,
+                    IggyErrorCode.INVALID_COMMAND.getCode(),
+                    "A continuation of partition " + partitionId.get() + " cannot start partition " + partition,
+                    Optional.empty(),
+                    Optional.empty());
+        }
     }
 }
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/serde/BytesDeserializer.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/serde/BytesDeserializer.java
index ed543f7..95b77f7 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/serde/BytesDeserializer.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/serde/BytesDeserializer.java
@@ -20,6 +20,7 @@
 package org.apache.iggy.serde;
 
 import io.netty.buffer.ByteBuf;
+import io.netty.buffer.ByteBufUtil;
 import org.apache.commons.lang3.ArrayUtils;
 import org.apache.iggy.cluster.ClusterMetadata;
 import org.apache.iggy.cluster.ClusterNode;
@@ -34,6 +35,8 @@
 import org.apache.iggy.exception.IggyInvalidArgumentException;
 import org.apache.iggy.exception.IggyMalformedResponseException;
 import org.apache.iggy.message.BytesMessageId;
+import org.apache.iggy.message.DecodeFailure;
+import org.apache.iggy.message.FetchedMessages;
 import org.apache.iggy.message.HeaderKey;
 import org.apache.iggy.message.HeaderKind;
 import org.apache.iggy.message.HeaderValue;
@@ -85,8 +88,16 @@
  */
 public final class BytesDeserializer {
 
+    /** The section count that opens a fetch reply. */
+    public static final int FETCH_SECTIONS_COUNT_BYTES = Integer.BYTES;
+    /** A fetch section: partition id, status, current offset, message count, context, next offset, records length. */
+    public static final int FETCH_SECTION_BYTES = 4 * Integer.BYTES + 2 * Long.BYTES + PartitionContext.ENCODED_SIZE;
+
     private static final int CONSUMER_GROUP_ASSIGNMENT_ENTRY_BYTES = Integer.BYTES;
+    private static final int READY_PARTITION_BYTES = Integer.BYTES;
     private static final int SEND_CONFIRMATION_BYTES = 3 * Integer.BYTES + Long.BYTES;
+    // A fetch names at most this many partitions, MAX_PARTITIONS_PER_REQUEST in core/binary_protocol/src/lib.rs.
+    private static final int MAX_FETCH_SECTIONS = 1000;
     private static final int MIN_CLUSTER_NODE_BYTES = 19;
     // A one-character key (length byte plus a byte of name), kind and flags
     // bytes, and empty length-prefixed default and description.
@@ -256,14 +267,103 @@
         var currentOffset = readU64AsBigInteger(response);
         var messagesCount = response.readUnsignedIntLE();
         var context = readPartitionContext(response);
+        return new PolledMessages(
+                partitionId, currentOffset, messagesCount, readMessages(response, messagesCount), context);
+    }
+
+    /** Reads the batch records of a poll reply or of a fetch section, which must hold {@code count} messages. */
+    public static List<Message> readMessages(ByteBuf records, long count) {
         var messages = new ArrayList<Message>();
-        while (response.isReadable()) {
-            readBatchRecord(response, messages);
+        while (records.isReadable()) {
+            readBatchRecord(records, messages);
         }
-        if (messages.size() != messagesCount) {
+        if (messages.size() != count) {
             throw new IggyMalformedResponseException("Poll count does not match its messages");
         }
-        return new PolledMessages(partitionId, currentOffset, messagesCount, messages, context);
+        return messages;
+    }
+
+    /**
+     * Reads the batch records of a poll reply or of a fetch section into {@code messages} up to the first message that
+     * does not decode, and returns its failure with the bytes received from it on. The messages before it stay in
+     * {@code messages}. Every failure of this decoder is malformed framing, no check runs after a frame header, so the
+     * failure never names an offset.
+     */
+    public static Optional<DecodeFailure> readMessagesUntilMalformed(ByteBuf records, List<Message> messages) {
+        while (records.isReadable()) {
+            int batchStart = records.readerIndex();
+            BatchHeader batch;
+            try {
+                batch = readBatchHeader(records);
+            } catch (RuntimeException malformed) {
+                return Optional.of(framingFailure(malformed, records, batchStart));
+            }
+            int blobStart = records.readerIndex();
+            ByteBuf blob = records.readSlice(batch.blobLength());
+            while (blob.isReadable()) {
+                int frameStart = blobStart + blob.readerIndex();
+                try {
+                    messages.add(readBatchMessage(blob, batch));
+                } catch (RuntimeException malformed) {
+                    return Optional.of(framingFailure(malformed, records, frameStart));
+                }
+            }
+        }
+        return Optional.empty();
+    }
+
+    private static DecodeFailure framingFailure(RuntimeException malformed, ByteBuf records, int position) {
+        return new DecodeFailure(
+                malformed, Optional.empty(), ByteBufUtil.getBytes(records, position, records.writerIndex() - position));
+    }
+
+    /**
+     * Reads a fetch reply: the section table, then the records of each section in table order. The
+     * records of each section stay undecoded until {@link FetchedMessages#decode()}.
+     */
+    public static List<FetchedMessages> readFetchedMessages(ByteBuf response) {
+        if (response.readableBytes() < FETCH_SECTIONS_COUNT_BYTES) {
+            throw new IggyMalformedResponseException(
+                    "fetch reply has " + response.readableBytes() + " bytes, too few for its count");
+        }
+        long sectionsCount = response.readUnsignedIntLE();
+        if (sectionsCount > MAX_FETCH_SECTIONS) {
+            throw new IggyMalformedResponseException(
+                    "fetch reply has " + sectionsCount + " sections, more than a request has targets");
+        }
+        int tableBytes = (int) sectionsCount * FETCH_SECTION_BYTES;
+        if (response.readableBytes() < tableBytes) {
+            throw new IggyMalformedResponseException("fetch reply ends inside its section table");
+        }
+        ByteBuf records = response.duplicate().skipBytes(tableBytes);
+        List<FetchedMessages> sections = new ArrayList<>((int) sectionsCount);
+        for (long section = 0; section < sectionsCount; section++) {
+            var partitionId = response.readUnsignedIntLE();
+            var status = response.readUnsignedIntLE();
+            var currentOffset = readU64AsBigInteger(response);
+            var messagesCount = response.readUnsignedIntLE();
+            var context = readPartitionContext(response);
+            var nextOffset = readU64AsBigInteger(response);
+            var recordsLength = response.readUnsignedIntLE();
+            if (status != FetchedMessages.STATUS_OK && recordsLength != 0) {
+                throw new IggyMalformedResponseException(
+                        "fetch section of partition " + partitionId + " has status " + status + " and records");
+            }
+            if (recordsLength > records.readableBytes()) {
+                throw new IggyMalformedResponseException(
+                        "fetch section records of partition " + partitionId + " run past the reply");
+            }
+            byte[] sectionRecords = new byte[(int) recordsLength];
+            records.readBytes(sectionRecords);
+            sections.add(new FetchedMessages(
+                    partitionId, status, currentOffset, messagesCount, context, nextOffset, sectionRecords));
+        }
+        if (records.isReadable()) {
+            throw new IggyMalformedResponseException(
+                    "fetch reply has " + records.readableBytes() + " bytes past its sections");
+        }
+        response.skipBytes(response.readableBytes());
+        return sections;
     }
 
     /**
@@ -272,6 +372,14 @@
      * first frame's offset delta is not necessarily zero.
      */
     private static void readBatchRecord(ByteBuf response, List<Message> messages) {
+        BatchHeader batch = readBatchHeader(response);
+        ByteBuf blob = response.readSlice(batch.blobLength());
+        while (blob.isReadable()) {
+            messages.add(readBatchMessage(blob, batch));
+        }
+    }
+
+    private static BatchHeader readBatchHeader(ByteBuf response) {
         if (response.readableBytes() < BytesSerializer.BATCH_HEADER_SIZE) {
             throw new IggyMalformedResponseException(
                     "Truncated batch header: " + response.readableBytes() + " bytes left");
@@ -289,14 +397,10 @@
             throw new IggyMalformedResponseException("Batch length " + batchLength + " exceeds remaining payload of "
                     + response.readableBytes() + " bytes");
         }
-        ByteBuf blob = response.readSlice(blobLength.intValueExact());
-        while (blob.isReadable()) {
-            messages.add(readBatchMessage(blob, baseOffset, baseTimestamp, batchOriginTimestamp));
-        }
+        return new BatchHeader(baseOffset, baseTimestamp, batchOriginTimestamp, blobLength.intValueExact());
     }
 
-    private static Message readBatchMessage(
-            ByteBuf blob, BigInteger baseOffset, BigInteger baseTimestamp, BigInteger batchOriginTimestamp) {
+    private static Message readBatchMessage(ByteBuf blob, BatchHeader batch) {
         if (blob.readableBytes() < MessageHeader.SIZE) {
             throw new IggyMalformedResponseException(
                     "Truncated message frame header: " + blob.readableBytes() + " bytes left");
@@ -318,9 +422,9 @@
         var header = new MessageHeader(
                 checksum,
                 id,
-                baseOffset.add(BigInteger.valueOf(offsetDelta)),
-                baseTimestamp,
-                batchOriginTimestamp.add(BigInteger.valueOf(timestampDelta)),
+                batch.baseOffset().add(BigInteger.valueOf(offsetDelta)),
+                batch.baseTimestamp(),
+                batch.originTimestamp().add(BigInteger.valueOf(timestampDelta)),
                 userHeadersLength,
                 payloadLength,
                 BigInteger.ZERO);
@@ -824,5 +928,8 @@
         return new byte[size.intValue()];
     }
 
+    private record BatchHeader(
+            BigInteger baseOffset, BigInteger baseTimestamp, BigInteger originTimestamp, int blobLength) {}
+
     private record UserHeaderField(HeaderKind kind, byte[] value) {}
 }
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/serde/CommandCode.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/serde/CommandCode.java
index 593b17d..e885261 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/serde/CommandCode.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/serde/CommandCode.java
@@ -100,8 +100,8 @@
         // 103 and 104 are retired and must never be reused.
         GET_PARTITION_ROUTE(105),
         POLL_DEFERRED(106),
-        WAIT_FOR_MESSAGES(107),
-        CANCEL_DEFERRED_REQUEST(108);
+        CANCEL_DEFERRED_REQUEST(108),
+        FETCH_MESSAGES(109);
 
         private final int value;
 
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/ProtocolParityTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/ProtocolParityTest.java
index 6ebe141..ad03ab9 100644
--- a/foreign/java/java-sdk/src/test/java/org/apache/iggy/ProtocolParityTest.java
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/ProtocolParityTest.java
@@ -85,8 +85,8 @@
             Map.entry(CommandCode.Messages.SEND, "SEND_MESSAGES_CODE"),
             Map.entry(CommandCode.Messages.GET_PARTITION_ROUTE, "GET_PARTITION_ROUTE_CODE"),
             Map.entry(CommandCode.Messages.POLL_DEFERRED, "POLL_MESSAGES_DEFERRED_CODE"),
-            Map.entry(CommandCode.Messages.WAIT_FOR_MESSAGES, "WAIT_FOR_MESSAGES_CODE"),
             Map.entry(CommandCode.Messages.CANCEL_DEFERRED_REQUEST, "CANCEL_DEFERRED_REQUEST_CODE"),
+            Map.entry(CommandCode.Messages.FETCH_MESSAGES, "FETCH_MESSAGES_CODE"),
             Map.entry(CommandCode.ConsumerOffset.GET, "GET_CONSUMER_OFFSET_CODE"),
             Map.entry(CommandCode.ConsumerOffset.STORE, "STORE_CONSUMER_OFFSET_CODE"),
             Map.entry(CommandCode.ConsumerOffset.DELETE, "DELETE_CONSUMER_OFFSET_CODE"),
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/DeferredPollCompatibilityTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/DeferredPollCompatibilityTest.java
index fd759fd..9598a21 100644
--- a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/DeferredPollCompatibilityTest.java
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/DeferredPollCompatibilityTest.java
@@ -26,6 +26,7 @@
 import org.apache.iggy.message.DeferredPollOptions;
 import org.apache.iggy.message.Message;
 import org.apache.iggy.message.Partitioning;
+import org.apache.iggy.message.PollOptions;
 import org.apache.iggy.message.PolledMessages;
 import org.apache.iggy.message.PollingStrategy;
 import org.apache.iggy.message.SendMessagesResponse;
@@ -65,6 +66,14 @@
                         false,
                         DeferredPollOptions.defaults()))
                 .isInstanceOf(IggyOperationNotSupportedException.class);
+        assertThatThrownBy(() -> client.pollMessages(
+                        StreamId.of(1L),
+                        TopicId.of(1L),
+                        0L,
+                        Consumer.of(0L),
+                        PollingStrategy.next(),
+                        PollOptions.defaults()))
+                .isInstanceOf(IggyOperationNotSupportedException.class);
         assertThat(client.immediatePolls).isZero();
     }
 
@@ -84,6 +93,16 @@
                         .get())
                 .isInstanceOf(ExecutionException.class)
                 .hasCauseInstanceOf(IggyOperationNotSupportedException.class);
+        assertThatThrownBy(() -> client.pollMessages(
+                                StreamId.of(1L),
+                                TopicId.of(1L),
+                                0L,
+                                Consumer.of(0L),
+                                PollingStrategy.next(),
+                                PollOptions.defaults())
+                        .get())
+                .isInstanceOf(ExecutionException.class)
+                .hasCauseInstanceOf(IggyOperationNotSupportedException.class);
         assertThat(client.immediatePolls).isZero();
     }
 
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/AsyncPartitionReaderTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/AsyncPartitionReaderTest.java
new file mode 100644
index 0000000..b8dc26c
--- /dev/null
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/AsyncPartitionReaderTest.java
@@ -0,0 +1,354 @@
+/*
+ * 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.
+ */
+
+package org.apache.iggy.client.async;
+
+import org.apache.iggy.client.BaseIntegrationTest;
+import org.apache.iggy.client.async.reader.IggyConsumer;
+import org.apache.iggy.client.async.reader.PartitionReader;
+import org.apache.iggy.client.async.reader.ReceivedMessage;
+import org.apache.iggy.client.async.tcp.AsyncIggyTcpClient;
+import org.apache.iggy.consumergroup.Consumer;
+import org.apache.iggy.consumergroup.ConsumerGroupDetails;
+import org.apache.iggy.identifier.ConsumerId;
+import org.apache.iggy.identifier.StreamId;
+import org.apache.iggy.identifier.TopicId;
+import org.apache.iggy.message.AutoCommit;
+import org.apache.iggy.message.CommitAfter;
+import org.apache.iggy.message.CommitEvery;
+import org.apache.iggy.message.CommitPolicy;
+import org.apache.iggy.message.DeferredPollOptions;
+import org.apache.iggy.message.Message;
+import org.apache.iggy.message.Partitioning;
+import org.apache.iggy.message.PollOptions;
+import org.apache.iggy.message.PolledMessages;
+import org.apache.iggy.message.PollingStrategy;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigInteger;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** The partition reader and the poll of one partition against a server. */
+class AsyncPartitionReaderTest extends BaseIntegrationTest {
+    private static final int TIMEOUT_SECONDS = 15;
+    private static final long PARTITIONS = 3;
+    private static final int MESSAGES_PER_PARTITION = 5;
+    // More than the reads that the late message waits for when the reads take turns.
+    private static final int BACKLOG_MESSAGES = 50;
+    private static final PollOptions SHORT_WAIT = PollOptions.waitFor(Duration.ofSeconds(1));
+
+    private AsyncIggyTcpClient client;
+    private StreamId stream;
+    private TopicId topic;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        client = AsyncIggyTcpClient.builder()
+                .host(serverHost())
+                .port(serverTcpPort())
+                .credentials("iggy", "iggy")
+                .build();
+        client.connect().get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+        client.login().get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+        String name = "reader-" + UUID.randomUUID();
+        client.streams().createStream(name).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+        stream = StreamId.of(name);
+        client.topics().createTopic(stream, "orders", PARTITIONS).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+        topic = TopicId.of("orders");
+        for (long partition = 0; partition < PARTITIONS; partition++) {
+            List<Message> messages = new ArrayList<>();
+            for (int index = 0; index < MESSAGES_PER_PARTITION; index++) {
+                messages.add(Message.of(partition + ":" + index));
+            }
+            client.messages()
+                    .sendMessages(stream, topic, Partitioning.partitionId(partition), messages)
+                    .get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+        }
+    }
+
+    @AfterEach
+    void tearDown() throws Exception {
+        try {
+            client.streams().deleteStream(stream).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+        } finally {
+            client.close().get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+        }
+    }
+
+    @Test
+    void shouldReadEveryMessageOfEveryPartitionOnceInOffsetOrder() throws Exception {
+        PartitionReader reader = client.reader(stream, topic, Consumer.of(1L))
+                .partitions(List.of(0L, 1L, 2L))
+                .start(PollingStrategy.first())
+                .options(SHORT_WAIT)
+                .build()
+                .get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+        try {
+            Map<Long, List<String>> read = readAll(reader, PARTITIONS * MESSAGES_PER_PARTITION);
+
+            for (long partition = 0; partition < PARTITIONS; partition++) {
+                List<String> expected = new ArrayList<>();
+                for (int index = 0; index < MESSAGES_PER_PARTITION; index++) {
+                    expected.add(partition + ":" + index);
+                }
+                assertThat(read.get(partition)).containsExactlyElementsOf(expected);
+            }
+        } finally {
+            reader.closeAsync().get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+        }
+    }
+
+    /** Under OnRead the server stores each read, and the close moves the offset back to the last delivered message. */
+    @Test
+    void shouldStoreWhatItDeliversUnderOnReadAndResumeFromIt() throws Exception {
+        Consumer consumer = Consumer.of(2L);
+        PartitionReader first = client.reader(stream, topic, consumer)
+                .partitions(List.of(0L))
+                .options(SHORT_WAIT.withCount(2).withCommit(CommitPolicy.onRead()))
+                .build()
+                .get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+        PolledMessages delivered = first.next().get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+        first.closeAsync().get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+        assertThat(texts(delivered)).containsExactly("0:0", "0:1");
+        assertThat(storedOffset(consumer, 0)).contains(BigInteger.ONE);
+
+        PartitionReader second = client.reader(stream, topic, consumer)
+                .partitions(List.of(0L))
+                .options(SHORT_WAIT.withCommit(CommitPolicy.onRead()))
+                .build()
+                .get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+        try {
+            assertThat(texts(second.next().get(TIMEOUT_SECONDS, TimeUnit.SECONDS)))
+                    .containsExactly("0:2", "0:3", "0:4");
+        } finally {
+            second.closeAsync().get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+        }
+        assertThat(storedOffset(consumer, 0)).contains(BigInteger.valueOf(4));
+    }
+
+    @Test
+    void shouldStoreEachBatchAtDeliveryUnderAnAutomaticCommit() throws Exception {
+        Consumer consumer = Consumer.of(3L);
+        AutoCommit eachBatch = new AutoCommit(Optional.empty(), Optional.of(CommitEvery.batch()), CommitAfter.DELIVERY);
+        PartitionReader reader = client.reader(stream, topic, consumer)
+                .partitions(List.of(1L))
+                .start(PollingStrategy.first())
+                .options(SHORT_WAIT.withCount(3).withCommit(CommitPolicy.auto(eachBatch)))
+                .build()
+                .get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+        try {
+            reader.next().get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+
+            long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(TIMEOUT_SECONDS);
+            while (!storedOffset(consumer, 1).equals(Optional.of(BigInteger.TWO)) && System.nanoTime() < deadline) {
+                TimeUnit.MILLISECONDS.sleep(20);
+            }
+            assertThat(storedOffset(consumer, 1)).contains(BigInteger.TWO);
+        } finally {
+            reader.closeAsync().get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+        }
+    }
+
+    @Test
+    void shouldReadTheAssignmentOfAGroupMember() throws Exception {
+        ConsumerGroupDetails group = client.consumerGroups()
+                .createConsumerGroup(stream, topic, "readers")
+                .get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+        PartitionReader reader = client.reader(stream, topic, Consumer.group(ConsumerId.of(group.id())))
+                .options(SHORT_WAIT.withCommit(CommitPolicy.onRead()))
+                .build()
+                .get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+        try {
+            Map<Long, List<String>> read = readAll(reader, PARTITIONS * MESSAGES_PER_PARTITION);
+
+            assertThat(read.keySet()).containsExactlyInAnyOrder(0L, 1L, 2L);
+            assertThat(reader.partitions()).containsExactlyInAnyOrder(0L, 1L, 2L);
+        } finally {
+            reader.closeAsync().get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+        }
+    }
+
+    /** A group consumer stores what its handler processed, and the next member resumes after it. */
+    @Test
+    void shouldResumeAConsumerGroupAfterWhatItsHandlerProcessed() throws Exception {
+        ConsumerGroupDetails group = client.consumerGroups()
+                .createConsumerGroup(stream, topic, "workers")
+                .get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+        Consumer member = Consumer.group(ConsumerId.of(group.id()));
+        AutoCommit afterProcessing =
+                new AutoCommit(Optional.empty(), Optional.of(CommitEvery.message()), CommitAfter.PROCESSING);
+        PollOptions options = SHORT_WAIT.withCommit(CommitPolicy.auto(afterProcessing));
+        IggyConsumer first = IggyConsumer.build(
+                        client.reader(stream, topic, member).options(options))
+                .get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+        List<String> handled = new ArrayList<>();
+        CompletableFuture<Void> enough = new CompletableFuture<>();
+        first.consume(message -> {
+            handled.add(new String(message.message().payload(), StandardCharsets.UTF_8));
+            if (handled.size() == 6) {
+                enough.complete(null);
+                return CompletableFuture.failedFuture(new IllegalStateException("stop after six"));
+            }
+            return CompletableFuture.completedFuture(null);
+        });
+        enough.get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+        first.shutdown().get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+
+        IggyConsumer second = IggyConsumer.build(
+                        client.reader(stream, topic, member).options(options))
+                .get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+        List<String> rest = new ArrayList<>();
+        try {
+            while (rest.size() < PARTITIONS * MESSAGES_PER_PARTITION - 5) {
+                ReceivedMessage message = second.next().get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+                rest.add(new String(message.message().payload(), StandardCharsets.UTF_8));
+            }
+        } finally {
+            second.shutdown().get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+        }
+        List<String> all = new ArrayList<>(handled.subList(0, 5));
+        all.addAll(rest);
+        assertThat(all)
+                .as("the sixth message failed its handler, so the next member reads it again")
+                .hasSize((int) (PARTITIONS * MESSAGES_PER_PARTITION))
+                .doesNotHaveDuplicates()
+                .contains(handled.get(5));
+    }
+
+    /** A partition with a backlog holds messages at every read, and another one that gets a message is read too. */
+    @Test
+    void shouldReadAPartitionThatGetsAMessageWhileAnotherKeepsABacklog() throws Exception {
+        List<Message> backlog = new ArrayList<>();
+        for (int index = 0; index < BACKLOG_MESSAGES; index++) {
+            backlog.add(Message.of("backlog"));
+        }
+        client.messages()
+                .sendMessages(stream, topic, Partitioning.partitionId(0L), backlog)
+                .get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+        PartitionReader reader = client.reader(stream, topic, Consumer.of(6L))
+                .partitions(List.of(0L, 2L))
+                .start(PollingStrategy.offset(BigInteger.valueOf(MESSAGES_PER_PARTITION)))
+                .options(SHORT_WAIT.withCount(1))
+                .prefetchMessages(1)
+                .build()
+                .get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+        try {
+            assertThat(reader.next().get(TIMEOUT_SECONDS, TimeUnit.SECONDS).partitionId())
+                    .isZero();
+            client.messages()
+                    .sendMessages(stream, topic, Partitioning.partitionId(2L), List.of(Message.of("late")))
+                    .get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+
+            List<String> late = List.of();
+            for (int read = 0; read < BACKLOG_MESSAGES && late.isEmpty(); read++) {
+                PolledMessages batch = reader.next().get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+                if (batch.partitionId() == 2L) {
+                    late = texts(batch);
+                }
+            }
+
+            assertThat(late).containsExactly("late");
+        } finally {
+            reader.closeAsync().get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+        }
+    }
+
+    /** The server returns a first message larger than the byte limit alone. */
+    @Test
+    void shouldPollAFirstMessageLargerThanTheByteLimit() throws Exception {
+        byte[] large = new byte[4096];
+        client.messages()
+                .sendMessages(
+                        stream,
+                        topic,
+                        Partitioning.partitionId(2L),
+                        List.of(Message.of(new String(large, StandardCharsets.UTF_8))))
+                .get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+
+        PolledMessages polled = client.messages()
+                .pollMessages(
+                        stream,
+                        topic,
+                        2L,
+                        Consumer.of(4L),
+                        PollingStrategy.offset(BigInteger.valueOf(MESSAGES_PER_PARTITION)),
+                        PollOptions.defaults().withMaxBytes(DeferredPollOptions.MIN_MAX_BYTES))
+                .get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+
+        assertThat(polled.messages()).hasSize(1);
+        assertThat(polled.messages().get(0).payload()).hasSize(large.length);
+    }
+
+    @Test
+    void shouldStoreTheOffsetOfAPollWithOnRead() throws Exception {
+        Consumer consumer = Consumer.of(5L);
+
+        PolledMessages polled = client.messages()
+                .pollMessages(
+                        stream,
+                        topic,
+                        0L,
+                        consumer,
+                        PollingStrategy.next(),
+                        PollOptions.defaults().withCount(2).withCommit(CommitPolicy.onRead()))
+                .get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+
+        assertThat(texts(polled)).containsExactly("0:0", "0:1");
+        assertThat(storedOffset(consumer, 0)).contains(BigInteger.ONE);
+        assertThat(polled.continuation()).isPresent();
+    }
+
+    private Map<Long, List<String>> readAll(PartitionReader reader, long messages) throws Exception {
+        Map<Long, List<String>> read = new HashMap<>();
+        long count = 0;
+        while (count < messages) {
+            PolledMessages batch = reader.next().get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+            read.computeIfAbsent(batch.partitionId(), partition -> new ArrayList<>())
+                    .addAll(texts(batch));
+            count += batch.messages().size();
+        }
+        return read;
+    }
+
+    private Optional<BigInteger> storedOffset(Consumer consumer, long partition) throws Exception {
+        return client.consumerOffsets()
+                .getConsumerOffset(stream, topic, Optional.of(partition), consumer)
+                .get(TIMEOUT_SECONDS, TimeUnit.SECONDS)
+                .map(info -> info.storedOffset());
+    }
+
+    private static List<String> texts(PolledMessages batch) {
+        return batch.messages().stream()
+                .map(message -> new String(message.payload(), StandardCharsets.UTF_8))
+                .toList();
+    }
+}
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/reader/FakeTransport.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/reader/FakeTransport.java
new file mode 100644
index 0000000..1c4df38
--- /dev/null
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/reader/FakeTransport.java
@@ -0,0 +1,312 @@
+/*
+ * 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.
+ */
+
+package org.apache.iggy.client.async.reader;
+
+import io.netty.buffer.ByteBuf;
+import io.netty.buffer.ByteBufUtil;
+import io.netty.buffer.Unpooled;
+import org.apache.iggy.consumergroup.ConsumerGroupAssignment;
+import org.apache.iggy.consumeroffset.ConsumerOffsetInfo;
+import org.apache.iggy.message.DeferredPollOptions;
+import org.apache.iggy.message.FetchedMessages;
+import org.apache.iggy.message.PollingStrategy;
+import org.apache.iggy.partition.PartitionContext;
+
+import java.math.BigInteger;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.OptionalLong;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Function;
+
+/** A transport whose reads the test answers one by one, and which records every store, abandon and group change. */
+final class FakeTransport implements ReaderTransport {
+    static final Duration TIMEOUT = Duration.ofSeconds(5);
+    static final PartitionContext CONTEXT =
+            new PartitionContext(BigInteger.valueOf(7), BigInteger.valueOf(1), BigInteger.valueOf(1));
+    private static final int BATCH_HEADER_SIZE = 256;
+    private static final int MESSAGE_HEADER_SIZE = 48;
+
+    final Map<Long, Long> starts = new ConcurrentHashMap<>();
+    // The answer to the probe of a partition, instead of one message at its start.
+    final Map<Long, FetchedMessages> probes = new ConcurrentHashMap<>();
+    final List<Long> deleted = new CopyOnWriteArrayList<>();
+    final BlockingQueue<Read> reads = new LinkedBlockingQueue<>();
+    final List<Store> stores = new CopyOnWriteArrayList<>();
+    final BlockingQueue<RuntimeException> storeFailures = new LinkedBlockingQueue<>();
+    final List<Long> abandoned = new CopyOnWriteArrayList<>();
+    final List<List<Long>> groupings = new CopyOnWriteArrayList<>();
+    final BlockingQueue<CompletableFuture<Optional<ConsumerGroupAssignment>>> assignments = new LinkedBlockingQueue<>();
+    final List<String> membership = new CopyOnWriteArrayList<>();
+    volatile RuntimeException leaveFailure;
+    // The reply to the cancel of the committing reads, which the test holds back.
+    volatile CompletableFuture<Void> settled = CompletableFuture.completedFuture(null);
+    // The operations of the committing reads whose outcome stayed unknown after the cancel.
+    final List<Long> lost = new CopyOnWriteArrayList<>();
+    // Every abandon and store in order.
+    final List<String> events = new CopyOnWriteArrayList<>();
+    private final boolean group;
+    private volatile Function<List<Long>, List<List<Long>>> grouper = partitions -> List.of(partitions);
+
+    FakeTransport(boolean group) {
+        this.group = group;
+    }
+
+    FakeTransport groupedBy(Function<List<Long>, List<List<Long>>> grouper) {
+        this.grouper = grouper;
+        return this;
+    }
+
+    /** The next read that the reader sends. */
+    Read nextRead() throws InterruptedException {
+        Read read = reads.poll(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        if (read == null) {
+            throw new AssertionError("The reader sent no read");
+        }
+        return read;
+    }
+
+    /** Whether the reader sends no read within {@code wait}. */
+    boolean readsNothingFor(Duration wait) throws InterruptedException {
+        Read read = reads.poll(wait.toMillis(), TimeUnit.MILLISECONDS);
+        if (read != null) {
+            reads.add(read);
+        }
+        return read == null;
+    }
+
+    @Override
+    public boolean group() {
+        return group;
+    }
+
+    @Override
+    public CompletableFuture<List<List<Long>>> groups(List<Long> partitions) {
+        groupings.add(partitions);
+        try {
+            return CompletableFuture.completedFuture(grouper.apply(partitions));
+        } catch (RuntimeException refused) {
+            return CompletableFuture.failedFuture(refused);
+        }
+    }
+
+    @Override
+    public CompletableFuture<List<FetchedMessages>> read(
+            Map<Long, PollingStrategy> targets, long count, boolean commit, DeferredPollOptions options) {
+        if (!commit
+                && count == 1
+                && targets.size() == 1
+                && targets.values().iterator().next().kind() == org.apache.iggy.message.PollingKind.Next) {
+            long partition = targets.keySet().iterator().next();
+            FetchedMessages probe = probes.get(partition);
+            return CompletableFuture.completedFuture(
+                    List.of(probe != null ? probe : section(partition, starts.getOrDefault(partition, 0L))));
+        }
+        Read read = new Read(
+                Collections.unmodifiableMap(new LinkedHashMap<>(targets)), count, commit, new CompletableFuture<>());
+        reads.add(read);
+        return read.reply;
+    }
+
+    @Override
+    public CompletableFuture<Void> store(long partition, BigInteger offset, PartitionContext context) {
+        RuntimeException failure = storeFailures.poll();
+        if (failure != null) {
+            return CompletableFuture.failedFuture(failure);
+        }
+        stores.add(new Store(partition, offset.longValue()));
+        events.add("store " + partition + " " + offset);
+        return CompletableFuture.completedFuture(null);
+    }
+
+    @Override
+    public CompletableFuture<Void> delete(long partition, PartitionContext context) {
+        deleted.add(partition);
+        return CompletableFuture.completedFuture(null);
+    }
+
+    @Override
+    public CompletableFuture<Optional<ConsumerOffsetInfo>> storedOffset(long partition) {
+        return CompletableFuture.completedFuture(Optional.empty());
+    }
+
+    @Override
+    public CompletableFuture<Void> join() {
+        membership.add("join");
+        return CompletableFuture.completedFuture(null);
+    }
+
+    @Override
+    public CompletableFuture<Optional<ConsumerGroupAssignment>> assignment() {
+        CompletableFuture<Optional<ConsumerGroupAssignment>> assignment = new CompletableFuture<>();
+        assignments.add(assignment);
+        return assignment;
+    }
+
+    @Override
+    public CompletableFuture<Void> leave() {
+        membership.add("leave");
+        RuntimeException failure = leaveFailure;
+        return failure == null ? CompletableFuture.completedFuture(null) : CompletableFuture.failedFuture(failure);
+    }
+
+    @Override
+    public CompletableFuture<Void> abandon(long operationId) {
+        abandoned.add(operationId);
+        return CompletableFuture.completedFuture(null);
+    }
+
+    @Override
+    public CompletableFuture<Void> cancelledReadsSettled() {
+        return settled;
+    }
+
+    @Override
+    public CompletableFuture<Void> abandonLost() {
+        lost.forEach(operation -> {
+            abandoned.add(operation);
+            events.add("abandon " + operation);
+        });
+        return CompletableFuture.completedFuture(null);
+    }
+
+    /** An OK section of {@code partition} with one message at each of {@code offsets}. */
+    static FetchedMessages section(long partition, long... offsets) {
+        return section(partition, CONTEXT, offsets);
+    }
+
+    /** An OK section of {@code partition} in the history of {@code context}. */
+    static FetchedMessages section(long partition, PartitionContext context, long... offsets) {
+        ByteBuf records = Unpooled.buffer();
+        for (long offset : offsets) {
+            records.writeBytes(record(offset));
+        }
+        long next = offsets[offsets.length - 1] + 1;
+        // The read ends at the head of the partition, its last offset.
+        return new FetchedMessages(
+                partition,
+                FetchedMessages.STATUS_OK,
+                BigInteger.valueOf(next - 1),
+                offsets.length,
+                context,
+                BigInteger.valueOf(next),
+                ByteBufUtil.getBytes(records));
+    }
+
+    /** An OK section of {@code partition} that ends before the head {@code head} of the partition. */
+    static FetchedMessages backlogged(long partition, long head, long... offsets) {
+        FetchedMessages section = section(partition, offsets);
+        return new FetchedMessages(
+                partition,
+                section.status(),
+                BigInteger.valueOf(head),
+                section.count(),
+                section.context(),
+                section.nextOffset(),
+                section.records());
+    }
+
+    /** An OK section of {@code partition} without messages, whose next offset is {@code next}. */
+    static FetchedMessages empty(long partition, long next) {
+        return new FetchedMessages(
+                partition,
+                FetchedMessages.STATUS_OK,
+                BigInteger.valueOf(Math.max(0, next - 1)),
+                0,
+                CONTEXT,
+                BigInteger.valueOf(next),
+                new byte[0]);
+    }
+
+    /** A section of a committing read of {@code partition} whose outcome {@code operation} left unknown. */
+    static FetchedMessages lost(long partition, long operation) {
+        return new FetchedMessages(
+                partition,
+                11001,
+                BigInteger.ZERO,
+                0,
+                CONTEXT,
+                BigInteger.ZERO,
+                new byte[0],
+                OptionalLong.of(operation));
+    }
+
+    /** A section of {@code partition} that refuses its read with {@code status}. */
+    static FetchedMessages refused(long partition, long status) {
+        return new FetchedMessages(partition, status, BigInteger.ZERO, 0, CONTEXT, BigInteger.ZERO, new byte[0]);
+    }
+
+    /** One batch record with one message at {@code offset}. */
+    static ByteBuf record(long offset) {
+        return batch(offset, frame(0, "message " + offset, 0));
+    }
+
+    /** One batch record based at {@code baseOffset} holding {@code frames}. */
+    static ByteBuf batch(long baseOffset, byte[]... frames) {
+        ByteBuf blob = Unpooled.buffer();
+        for (byte[] frame : frames) {
+            blob.writeBytes(frame);
+        }
+        return Unpooled.buffer()
+                .writeLongLE(0)
+                .writeLongLE(baseOffset)
+                .writeLongLE(0)
+                .writeLongLE(0)
+                .writeLongLE(BATCH_HEADER_SIZE + blob.readableBytes())
+                .writeZero(BATCH_HEADER_SIZE - 5 * Long.BYTES)
+                .writeBytes(blob);
+    }
+
+    /** One message frame at {@code offsetDelta} of its batch, with {@code reserved} in the reserved field. */
+    static byte[] frame(long offsetDelta, String payload, long reserved) {
+        byte[] bytes = payload.getBytes(StandardCharsets.UTF_8);
+        return ByteBufUtil.getBytes(Unpooled.buffer()
+                .writeLongLE(0)
+                .writeZero(16)
+                .writeIntLE((int) offsetDelta)
+                .writeIntLE(0)
+                .writeIntLE(0)
+                .writeIntLE(bytes.length)
+                .writeLongLE(reserved)
+                .writeBytes(bytes));
+    }
+
+    record Read(
+            Map<Long, PollingStrategy> targets,
+            long count,
+            boolean commit,
+            CompletableFuture<List<FetchedMessages>> reply) {
+        void answer(FetchedMessages... sections) {
+            reply.complete(List.of(sections));
+        }
+    }
+
+    record Store(long partition, long offset) {}
+}
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/reader/IggyConsumerTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/reader/IggyConsumerTest.java
new file mode 100644
index 0000000..6f2c094
--- /dev/null
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/reader/IggyConsumerTest.java
@@ -0,0 +1,204 @@
+/*
+ * 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.
+ */
+
+package org.apache.iggy.client.async.reader;
+
+import org.apache.iggy.client.async.reader.FakeTransport.Store;
+import org.apache.iggy.exception.IggyClientException;
+import org.apache.iggy.message.AutoCommit;
+import org.apache.iggy.message.CommitAfter;
+import org.apache.iggy.message.CommitEvery;
+import org.apache.iggy.message.CommitPolicy;
+import org.apache.iggy.message.PollOptions;
+import org.apache.iggy.message.PollingStrategy;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigInteger;
+import java.time.Duration;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.TimeUnit;
+
+import static org.apache.iggy.client.async.reader.FakeTransport.TIMEOUT;
+import static org.apache.iggy.client.async.reader.FakeTransport.section;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** The consumer counts each message as its commit policy says. */
+class IggyConsumerTest {
+    private static final PollOptions WAIT = PollOptions.waitFor(Duration.ofSeconds(1));
+
+    @Test
+    void shouldCountAMessageWhenTheNextIsAskedForAndTheLastOneAtShutdown() throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        IggyConsumer consumer = consumer(transport, auto(CommitAfter.PROCESSING));
+        transport.nextRead().answer(section(1, 0, 1));
+
+        assertThat(offset(consumer.next())).isZero();
+        assertThat(offset(consumer.next())).isEqualTo(1);
+        awaitStores(transport, 1);
+        assertThat(transport.stores).containsExactly(new Store(1, 0));
+
+        consumer.shutdown().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+
+        assertThat(transport.stores).containsExactly(new Store(1, 0), new Store(1, 1));
+    }
+
+    @Test
+    void shouldCountEachMessageAtDelivery() throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        IggyConsumer consumer = consumer(transport, auto(CommitAfter.DELIVERY));
+        transport.nextRead().answer(section(1, 3, 4));
+
+        offset(consumer.next());
+
+        awaitStores(transport, 1);
+        assertThat(transport.stores).containsExactly(new Store(1, 3));
+        consumer.shutdown().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+    }
+
+    /** A handler that fails leaves its message uncounted and ends the loop with its error. */
+    @Test
+    void shouldCountAfterASuccessfulHandlerAndEndTheLoopOnAFailedOne() throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        IggyConsumer consumer = consumer(transport, auto(CommitAfter.PROCESSING));
+        transport.nextRead().answer(section(1, 0, 1, 2));
+        List<Long> handled = new CopyOnWriteArrayList<>();
+        IllegalStateException failure = new IllegalStateException("handler failed");
+
+        CompletableFuture<Void> loop = consumer.consume(message -> {
+            long offset = message.checkpoint().offset().longValue();
+            handled.add(offset);
+            return offset == 2 ? CompletableFuture.failedFuture(failure) : CompletableFuture.completedFuture(null);
+        });
+
+        assertThatThrownBy(() -> loop.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS))
+                .hasCause(failure);
+        assertThat(handled).containsExactly(0L, 1L, 2L);
+        consumer.shutdown().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        assertThat(transport.stores).containsExactly(new Store(1, 0), new Store(1, 1));
+    }
+
+    /** Under OnRead the server stored the whole batch, so the shutdown moves the offset back to the last one taken. */
+    @Test
+    void shouldMoveTheOffsetBackToTheLastMessageHandedOverAtShutdownUnderOnRead() throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        IggyConsumer consumer = consumer(transport, WAIT.withCommit(CommitPolicy.onRead()));
+        CompletableFuture<ReceivedMessage> first = consumer.next();
+        transport.nextRead().answer(section(1, 0, 1, 2, 3));
+        offset(first);
+        offset(consumer.next());
+
+        consumer.shutdown().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+
+        assertThat(transport.stores).containsExactly(new Store(1, 1));
+        assertThatThrownBy(() -> consumer.next().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS))
+                .hasCauseInstanceOf(IggyClientException.class);
+    }
+
+    @Test
+    void shouldKeepTheMessageWhenNextIsCancelledBeforeTheReadCompletes() throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        IggyConsumer consumer = consumer(transport, WAIT.withCommit(CommitPolicy.onRead()));
+        try {
+            CompletableFuture<ReceivedMessage> cancelled = consumer.next();
+            FakeTransport.Read read = transport.nextRead();
+            assertThat(cancelled.cancel(false)).isTrue();
+            CompletableFuture<ReceivedMessage> resumed = consumer.next();
+            read.answer(section(1, 0, 1));
+
+            assertThat(offset(resumed)).isZero();
+            assertThat(offset(consumer.next())).isEqualTo(1);
+        } finally {
+            consumer.shutdown().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    @Test
+    void shouldShutDownWhileTheHandlerLoopWaitsForMessages() throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        IggyConsumer consumer = consumer(transport, WAIT.withCommit(CommitPolicy.onRead()));
+        CompletableFuture<Void> running = consumer.consume(message -> {
+            throw new AssertionError("No message was delivered");
+        });
+        FakeTransport.Read read = transport.nextRead();
+        try {
+            consumer.shutdown().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            assertThat(running).isDone();
+        } finally {
+            running.cancel(false);
+            read.reply().complete(List.of());
+            consumer.shutdown().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    /** A seek drops only the batches of its own partition, so a batch of another one that the reader handed over stays. */
+    @Test
+    void shouldKeepABatchOfAnotherPartitionThatArrivesWhileASeekRuns() throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        IggyConsumer consumer = consumer(transport, WAIT, 1L, 2L);
+        try {
+            CompletableFuture<ReceivedMessage> pending = consumer.next();
+            FakeTransport.Read read = transport.nextRead();
+            consumer.seek(1L, PollingStrategy.offset(BigInteger.TEN)).get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+
+            read.answer(section(2, 5));
+
+            ReceivedMessage message = pending.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            assertThat(message.partitionId()).isEqualTo(2L);
+            assertThat(message.checkpoint().offset()).isEqualTo(BigInteger.valueOf(5));
+        } finally {
+            consumer.shutdown().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    private static PollOptions auto(CommitAfter after) {
+        return WAIT.withCommit(
+                CommitPolicy.auto(new AutoCommit(Optional.empty(), Optional.of(CommitEvery.message()), after)));
+    }
+
+    private static IggyConsumer consumer(FakeTransport transport, PollOptions options) throws Exception {
+        return consumer(transport, options, 1L);
+    }
+
+    private static IggyConsumer consumer(FakeTransport transport, PollOptions options, Long... partitions)
+            throws Exception {
+        return IggyConsumer.build(PartitionReader.builder(transport)
+                        .partitions(List.of(partitions))
+                        .options(options)
+                        .retryInterval(Duration.ofMillis(100)))
+                .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+    }
+
+    private static long offset(CompletableFuture<ReceivedMessage> message) throws Exception {
+        return message.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)
+                .checkpoint()
+                .offset()
+                .longValue();
+    }
+
+    private static void awaitStores(FakeTransport transport, int count) throws InterruptedException {
+        long deadline = System.nanoTime() + TIMEOUT.toNanos();
+        while (transport.stores.size() < count && System.nanoTime() < deadline) {
+            TimeUnit.MILLISECONDS.sleep(5);
+        }
+    }
+}
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/reader/PartitionReaderTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/reader/PartitionReaderTest.java
new file mode 100644
index 0000000..bd4875e
--- /dev/null
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/reader/PartitionReaderTest.java
@@ -0,0 +1,1446 @@
+/*
+ * 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.
+ */
+
+package org.apache.iggy.client.async.reader;
+
+import io.netty.buffer.ByteBufUtil;
+import io.netty.buffer.Unpooled;
+import org.apache.iggy.client.async.reader.FakeTransport.Read;
+import org.apache.iggy.client.async.reader.FakeTransport.Store;
+import org.apache.iggy.consumergroup.ConsumerGroupAssignment;
+import org.apache.iggy.exception.IggyClientException;
+import org.apache.iggy.exception.IggyErrorCode;
+import org.apache.iggy.exception.IggyOperationException;
+import org.apache.iggy.exception.IggyServerException;
+import org.apache.iggy.exception.IggyValidationException;
+import org.apache.iggy.message.AutoCommit;
+import org.apache.iggy.message.Checkpoint;
+import org.apache.iggy.message.CommitAfter;
+import org.apache.iggy.message.CommitEvery;
+import org.apache.iggy.message.CommitPolicy;
+import org.apache.iggy.message.FetchedMessages;
+import org.apache.iggy.message.Message;
+import org.apache.iggy.message.PollOptions;
+import org.apache.iggy.message.PolledMessages;
+import org.apache.iggy.message.PollingKind;
+import org.apache.iggy.message.PollingStrategy;
+import org.apache.iggy.partition.PartitionContext;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.math.BigInteger;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+
+import static org.apache.iggy.client.async.reader.FakeTransport.TIMEOUT;
+import static org.apache.iggy.client.async.reader.FakeTransport.refused;
+import static org.apache.iggy.client.async.reader.FakeTransport.section;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** The reader against a transport whose reads the test answers. */
+class PartitionReaderTest {
+    private static final Duration QUIET = Duration.ofMillis(300);
+    private static final Duration RETRY_INTERVAL = Duration.ofMillis(100);
+    private static final PollOptions MANUAL = PollOptions.waitFor(Duration.ofSeconds(1));
+    private static final PollOptions ON_READ = MANUAL.withCommit(CommitPolicy.onRead());
+
+    @Test
+    void shouldReadEachGroupWithOneRequestAndHandOverBatchesInArrivalOrder() throws Exception {
+        FakeTransport transport =
+                new FakeTransport(false).groupedBy(partitions -> List.of(List.of(1L, 3L), List.of(2L)));
+        PartitionReader reader = standalone(transport, MANUAL, 1L, 2L, 3L);
+        try {
+            Read first = transport.nextRead();
+            Read second = transport.nextRead();
+            assertThat(List.of(first.targets().keySet(), second.targets().keySet()))
+                    .containsExactlyInAnyOrder(Set.of(1L, 3L), Set.of(2L));
+            Read groupOfTwo = first.targets().containsKey(2L) ? first : second;
+            Read groupOfOneAndThree = groupOfTwo == first ? second : first;
+
+            groupOfTwo.answer(section(2, 5, 6));
+            groupOfOneAndThree.answer(section(3, 0), section(1, 9));
+
+            assertThat(offsets(next(reader))).containsExactly(5L, 6L);
+            assertThat(offsets(next(reader))).containsExactly(0L);
+            assertThat(offsets(next(reader))).containsExactly(9L);
+            Read again = transport.nextRead();
+            assertThat(again.targets().get(2L))
+                    .as("a partition continues after its last message, bound to it")
+                    .isEqualTo(new PollingStrategy(
+                            PollingKind.Offset,
+                            BigInteger.valueOf(7),
+                            Optional.of(FakeTransport.CONTEXT),
+                            Optional.of(2L)));
+        } finally {
+            reader.closeAsync().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    @Test
+    void shouldGroupThePartitionsAgainAfterAPrimaryRefusesARead() throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        PartitionReader reader = standalone(transport, MANUAL, 1L, 2L);
+        try {
+            transport.nextRead().reply().completeExceptionally(serverError(IggyErrorCode.NOT_PRIMARY));
+            transport.nextRead().answer(section(1, 0));
+
+            assertThat(offsets(next(reader))).containsExactly(0L);
+            assertThat(transport.groupings).hasSize(2);
+        } finally {
+            reader.closeAsync().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    /**
+     * Under OnRead a read stores its offset, so the reader reads only for a waiting call. After a lost reply, the next
+     * read starts after what the lost read stored, and the reader reads those messages again without a store.
+     */
+    @Test
+    void shouldReadOnDemandUnderOnReadAndReadAgainWhatALostReplyStored() throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        PartitionReader reader = standalone(transport, ON_READ, 1L);
+        try {
+            assertThat(transport.readsNothingFor(QUIET)).isTrue();
+            CompletableFuture<PolledMessages> first = reader.next();
+            Read read = transport.nextRead();
+            assertThat(read.commit()).isTrue();
+            read.answer(section(1, 0, 1));
+            assertThat(offsets(first.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)))
+                    .containsExactly(0L, 1L);
+
+            CompletableFuture<PolledMessages> second = reader.next();
+            transport
+                    .nextRead()
+                    .reply()
+                    .completeExceptionally(new IggyOperationException(IggyErrorCode.FETCH_PROGRESS_UNCERTAIN, 42));
+            Read afterLoss = transport.nextRead();
+            assertThat(afterLoss.targets().get(1L).kind()).isEqualTo(PollingKind.Next);
+            assertThat(transport.abandoned).containsExactly(42L);
+            afterLoss.answer(section(1, 5, 6));
+            Read gap = transport.nextRead();
+            assertThat(gap.commit()).isFalse();
+            assertThat(gap.targets().get(1L).kind()).isEqualTo(PollingKind.Offset);
+            assertThat(gap.targets().get(1L).value()).isEqualTo(BigInteger.TWO);
+            assertThat(gap.count()).isEqualTo(3);
+            gap.answer(section(1, 2, 3, 4, 5));
+
+            assertThat(offsets(second.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)))
+                    .containsExactly(2L, 3L, 4L);
+            assertThat(offsets(next(reader))).containsExactly(5L, 6L);
+        } finally {
+            reader.closeAsync().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    @Test
+    void shouldStoreACommittedCheckpointUnderManualAndRefuseOneUnderOnRead() throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        PartitionReader manual = standalone(transport, MANUAL, 1L);
+        try {
+            transport.nextRead().answer(section(1, 3, 4));
+            PolledMessages batch = next(manual);
+
+            manual.commit(batch.checkpointAt(0).orElseThrow()).get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            manual.commit(batch.checkpointAt(0).orElseThrow()).get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+
+            assertThat(transport.stores).containsExactly(new Store(1, 3));
+        } finally {
+            manual.closeAsync().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+        FakeTransport onReadTransport = new FakeTransport(false);
+        onReadTransport.starts.put(1L, 3L);
+        PartitionReader onRead = standalone(onReadTransport, ON_READ, 1L);
+        try {
+            CompletableFuture<PolledMessages> polled = onRead.next();
+            onReadTransport.nextRead().answer(section(1, 3));
+            var checkpoint = polled.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)
+                    .checkpoint()
+                    .orElseThrow();
+
+            assertInvalidCommand(onRead.commit(checkpoint));
+            assertInvalidCommand(onRead.mark(checkpoint));
+        } finally {
+            onRead.closeAsync().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    @Test
+    void shouldCountABatchOnceTheNextIsAskedForAndStoreTheCountedPositionOnClose() throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        AutoCommit everyThousand =
+                new AutoCommit(Optional.empty(), Optional.of(CommitEvery.messages(1000)), CommitAfter.PROCESSING);
+        PartitionReader reader = standalone(transport, MANUAL.withCommit(CommitPolicy.auto(everyThousand)), 1L);
+        transport.nextRead().answer(section(1, 0, 1));
+        next(reader);
+        transport.nextRead().answer(section(1, 2));
+        next(reader);
+
+        reader.closeAsync().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+
+        assertThat(transport.stores)
+                .as("the last batch counts at the close, after the first counted at the second call")
+                .containsExactly(new Store(1, 2));
+    }
+
+    @Test
+    void shouldStoreEachBatchAtDeliveryUnderAutoCommitEveryBatch() throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        AutoCommit eachBatch = new AutoCommit(Optional.empty(), Optional.of(CommitEvery.batch()), CommitAfter.DELIVERY);
+        PartitionReader reader = standalone(transport, MANUAL.withCommit(CommitPolicy.auto(eachBatch)), 1L);
+        try {
+            transport.nextRead().answer(section(1, 0, 1));
+            next(reader);
+
+            awaitStores(transport, 1);
+            assertThat(transport.stores).containsExactly(new Store(1, 1));
+        } finally {
+            reader.closeAsync().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    @Test
+    void shouldCountOnlyMarkedCheckpointsUnderAutoCommitAfterMarks() throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        AutoCommit marked = new AutoCommit(Optional.empty(), Optional.of(CommitEvery.message()), CommitAfter.MARKED);
+        PartitionReader reader = standalone(transport, MANUAL.withCommit(CommitPolicy.auto(marked)), 1L);
+        try {
+            transport.nextRead().answer(section(1, 0, 1, 2));
+            PolledMessages batch = next(reader);
+            assertThat(transport.stores).isEmpty();
+
+            reader.mark(batch.checkpointAt(1).orElseThrow()).get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+
+            awaitStores(transport, 1);
+            assertThat(transport.stores).containsExactly(new Store(1, 1));
+        } finally {
+            reader.closeAsync().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    /** Under OnRead the server stored what the reader holds undelivered, so the close moves the offset back. */
+    @Test
+    void shouldMoveTheOffsetOfUndeliveredBatchesBackOnCloseUnderOnRead() throws Exception {
+        FakeTransport transport = new FakeTransport(false).groupedBy(partitions -> List.of(List.of(1L), List.of(2L)));
+        transport.starts.put(2L, 4L);
+        PartitionReader reader = standalone(transport, ON_READ, 1L, 2L);
+        CompletableFuture<PolledMessages> polled = reader.next();
+        Read first = transport.nextRead();
+        Read second = transport.nextRead();
+        Read partitionOne = first.targets().containsKey(1L) ? first : second;
+        Read partitionTwo = partitionOne == first ? second : first;
+        partitionOne.answer(section(1, 0, 1));
+        assertThat(offsets(polled.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)))
+                .containsExactly(0L, 1L);
+        partitionTwo.answer(section(2, 4, 5));
+        assertThat(transport.readsNothingFor(QUIET)).isTrue();
+
+        reader.closeAsync().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+
+        assertThat(transport.stores)
+                .as("partition 2 started at its next offset and delivered nothing")
+                .containsExactly(new Store(2, 3));
+    }
+
+    @Test
+    void shouldStopAPartitionWhoseHistoryIsGoneUntilASeek() throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        PartitionReader reader = standalone(transport, MANUAL, 1L);
+        try {
+            transport.nextRead().answer(section(1, 0));
+            next(reader);
+            transport.nextRead().answer(refused(1, IggyErrorCode.HISTORY_UNAVAILABLE.getCode()));
+
+            assertThatThrownBy(() -> next(reader))
+                    .hasCauseInstanceOf(ReadException.class)
+                    .satisfies(error -> assertThat(((ReadException) error.getCause()).errorCode())
+                            .hasValue(IggyErrorCode.HISTORY_UNAVAILABLE.getCode()));
+            assertThat(transport.readsNothingFor(QUIET)).isTrue();
+
+            reader.seek(1L, PollingStrategy.first()).get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            Read afterSeek = transport.nextRead();
+            assertThat(afterSeek.targets().get(1L).value()).isZero();
+        } finally {
+            reader.closeAsync().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    @Test
+    void shouldHandOverTheMessagesBeforeOneThatDoesNotDecodeAndNameTheContinuationAfterIt() throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        PartitionReader reader = standalone(transport, MANUAL, 1L);
+        try {
+            byte[] records = ByteBufUtil.getBytes(
+                    Unpooled.wrappedBuffer(FakeTransport.record(4), Unpooled.wrappedBuffer(new byte[] {1, 2, 3})));
+            transport
+                    .nextRead()
+                    .answer(new FetchedMessages(
+                            1,
+                            FetchedMessages.STATUS_OK,
+                            BigInteger.valueOf(6),
+                            2,
+                            FakeTransport.CONTEXT,
+                            BigInteger.valueOf(6),
+                            records));
+
+            assertThat(offsets(next(reader))).containsExactly(4L);
+            ReadException error = readError(reader);
+            assertThat(error.skip()).map(PollingStrategy::value).contains(BigInteger.valueOf(6));
+            assertThat(error.offset()).isEmpty();
+            assertThat(error.record()).containsExactly(1, 2, 3);
+        } finally {
+            reader.closeAsync().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    @Test
+    void shouldCarryAMalformedSingleRecordReplyAsReceivedWithoutAnOffset() throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        PartitionReader reader = standalone(transport, MANUAL, 1L);
+        try {
+            // A batch header whose length runs past the reply.
+            byte[] records = ByteBufUtil.getBytes(FakeTransport.record(4).setLongLE(32, 1_000));
+            transport
+                    .nextRead()
+                    .answer(new FetchedMessages(
+                            1,
+                            FetchedMessages.STATUS_OK,
+                            BigInteger.valueOf(5),
+                            1,
+                            FakeTransport.CONTEXT,
+                            BigInteger.valueOf(5),
+                            records));
+
+            ReadException error = readError(reader);
+            assertThat(error.offset()).isEmpty();
+            assertThat(error.record()).isEqualTo(records);
+            assertThat(error.skip()).map(PollingStrategy::value).contains(BigInteger.valueOf(5));
+            assertThat(error.skip()).map(PollingStrategy::partitionId).contains(Optional.of(1L));
+        } finally {
+            reader.closeAsync().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    @Test
+    void shouldHandOverTheHealthyFramesOfABatchRecordBeforeOneWithANonzeroReservedField() throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        PartitionReader reader = standalone(transport, MANUAL, 1L);
+        try {
+            byte[] bad = FakeTransport.frame(1, "message 5", 1);
+            byte[] rest =
+                    ByteBufUtil.getBytes(Unpooled.wrappedBuffer(bad, ByteBufUtil.getBytes(FakeTransport.record(6))));
+            byte[] records = ByteBufUtil.getBytes(Unpooled.wrappedBuffer(
+                    FakeTransport.batch(4, FakeTransport.frame(0, "message 4", 0), bad), FakeTransport.record(6)));
+            transport
+                    .nextRead()
+                    .answer(new FetchedMessages(
+                            1,
+                            FetchedMessages.STATUS_OK,
+                            BigInteger.valueOf(7),
+                            3,
+                            FakeTransport.CONTEXT,
+                            BigInteger.valueOf(7),
+                            records));
+
+            assertThat(offsets(next(reader))).containsExactly(4L);
+            ReadException error = readError(reader);
+            // The frame is refused by its header, so no offset is known and the record runs to the section end.
+            assertThat(error.offset()).isEmpty();
+            assertThat(error.record()).isEqualTo(rest);
+            assertThat(error.skip()).map(PollingStrategy::value).contains(BigInteger.valueOf(6));
+            assertThat(transport.readsNothingFor(QUIET)).isTrue();
+
+            reader.seek(1L, error.skip().orElseThrow()).get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            assertThat(transport.nextRead().targets().get(1L).value()).isEqualTo(BigInteger.valueOf(6));
+        } finally {
+            reader.closeAsync().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    @Test
+    void shouldRefuseAContinuationOfAnotherPartitionAndAPartitionItDoesNotRead() throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        PartitionReader reader = standalone(transport, MANUAL, 1L, 2L);
+        try {
+            transport.nextRead().answer(section(1, 0));
+            PolledMessages batch = next(reader);
+
+            assertThatThrownBy(() -> reader.seek(2L, batch.continuation().orElseThrow()))
+                    .isInstanceOf(IggyValidationException.class);
+            assertInvalidCommand(reader.seek(3L, PollingStrategy.first()));
+        } finally {
+            reader.closeAsync().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    /** A rebalance takes a partition away only after its counted position is stored. */
+    @Test
+    void shouldFollowTheAssignmentOfItsMemberAndStoreTheCountedPositionOfAPartitionThatLeaves() throws Exception {
+        FakeTransport transport = new FakeTransport(true)
+                .groupedBy(partitions -> partitions.stream().map(List::of).toList());
+        AutoCommit atDelivery =
+                new AutoCommit(Optional.empty(), Optional.of(CommitEvery.messages(1000)), CommitAfter.DELIVERY);
+        PartitionReader reader = PartitionReader.builder(transport)
+                .options(MANUAL.withCommit(CommitPolicy.auto(atDelivery)))
+                .retryInterval(RETRY_INTERVAL)
+                .build()
+                .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        try {
+            assignment(transport, 1L, 2L);
+            Read first = transport.nextRead();
+            Read second = transport.nextRead();
+            Read partitionOne = first.targets().containsKey(1L) ? first : second;
+            partitionOne.answer(section(1, 7));
+            next(reader);
+            assertThat(reader.partitions()).containsExactly(1L, 2L);
+
+            assignment(transport, 2L);
+
+            awaitStores(transport, 1);
+            assertThat(transport.stores).containsExactly(new Store(1, 7));
+            assertThat(reader.partitions()).containsExactly(2L);
+        } finally {
+            closeGroupReader(reader, transport);
+        }
+        assertThat(transport.membership).containsExactly("join", "leave");
+    }
+
+    /** A read that reaches its count serves its first targets, so the partitions served last go last in the next read. */
+    @Test
+    void shouldPutThePartitionsServedLastAtTheEndOfTheNextRead() throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        PartitionReader reader = standalone(transport, MANUAL, 1L, 3L, 5L);
+        try {
+            List<Long> delivered = new ArrayList<>();
+            for (int turn = 0; turn < 3; turn++) {
+                Read read = transport.nextRead();
+                Map.Entry<Long, PollingStrategy> first =
+                        read.targets().entrySet().iterator().next();
+                read.answer(section(first.getKey(), first.getValue().value().longValue()));
+                delivered.add(next(reader).partitionId());
+            }
+
+            assertThat(delivered).containsExactly(1L, 3L, 5L);
+        } finally {
+            reader.closeAsync().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    /** Under OnRead the server stored the batches that the reader holds, so a partition that leaves hands them over. */
+    @Test
+    void shouldHandOverAQueuedBatchOfAPartitionThatLeavesUnderOnRead() throws Exception {
+        FakeTransport transport = new FakeTransport(true);
+        transport.starts.put(2L, 4L);
+        PartitionReader reader = member(transport, ON_READ);
+        try {
+            assignment(transport, 1L, 2L);
+            CompletableFuture<PolledMessages> polled = reader.next();
+            transport.nextRead().answer(section(1, 0, 1), section(2, 4, 5));
+            assertThat(offsets(polled.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)))
+                    .containsExactly(0L, 1L);
+
+            assignment(transport, 1L);
+            CompletableFuture<PolledMessages> stored = reader.next();
+
+            assertThat(transport.readsNothingFor(QUIET))
+                    .as("the queued batch of the partition that left is handed over without a read")
+                    .isTrue();
+            PolledMessages batch = stored.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            assertThat(batch.partitionId()).isEqualTo(2L);
+            assertThat(offsets(batch)).containsExactly(4L, 5L);
+        } finally {
+            closeGroupReader(reader, transport);
+        }
+    }
+
+    /** Under OnRead the server stored a read in flight, so its reply reaches the application after its partition left. */
+    @Test
+    void shouldHandOverAReadThatEndsAfterItsPartitionLeftUnderOnRead() throws Exception {
+        FakeTransport transport = new FakeTransport(true)
+                .groupedBy(partitions -> partitions.stream().map(List::of).toList());
+        transport.starts.put(2L, 4L);
+        PartitionReader reader = member(transport, ON_READ);
+        try {
+            assignment(transport, 1L, 2L);
+            CompletableFuture<PolledMessages> polled = reader.next();
+            Read first = transport.nextRead();
+            Read second = transport.nextRead();
+            Read partitionOne = first.targets().containsKey(1L) ? first : second;
+            Read partitionTwo = partitionOne == first ? second : first;
+            partitionOne.answer(section(1, 0, 1));
+            assertThat(offsets(polled.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)))
+                    .containsExactly(0L, 1L);
+
+            assignment(transport, 1L);
+            partitionTwo.answer(section(2, 4, 5));
+            CompletableFuture<PolledMessages> stored = reader.next();
+
+            assertThat(transport.readsNothingFor(QUIET))
+                    .as("the reply of the partition that left is handed over without a read")
+                    .isTrue();
+            PolledMessages batch = stored.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            assertThat(batch.partitionId()).isEqualTo(2L);
+            assertThat(offsets(batch)).containsExactly(4L, 5L);
+        } finally {
+            closeGroupReader(reader, transport);
+        }
+    }
+
+    /**
+     * Under OnRead a partition that leaves while it reads again what a lost read stored reports those messages as lost
+     * and still hands over the batch that the server stored after them.
+     */
+    @Test
+    void shouldHandOverTheBatchAfterALostReadWhenItsPartitionLeavesUnderOnRead() throws Exception {
+        FakeTransport transport = new FakeTransport(true);
+        PartitionReader reader = member(transport, ON_READ);
+        try {
+            assignment(transport, 1L);
+            CompletableFuture<PolledMessages> first = reader.next();
+            transport.nextRead().answer(section(1, 0, 1));
+            first.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            CompletableFuture<PolledMessages> second = reader.next();
+            transport
+                    .nextRead()
+                    .reply()
+                    .completeExceptionally(new IggyOperationException(IggyErrorCode.FETCH_PROGRESS_UNCERTAIN, 42));
+            transport.nextRead().answer(section(1, 5, 6));
+            assertThat(transport.nextRead().commit()).isFalse();
+
+            assignment(transport, 2L);
+
+            assertThatThrownBy(() -> second.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS))
+                    .hasCauseInstanceOf(ReadException.class)
+                    .satisfies(error -> assertThat(((ReadException) error.getCause()).errorCode())
+                            .hasValue(IggyErrorCode.FETCH_PROGRESS_UNCERTAIN.getCode()));
+            assertThat(offsets(next(reader))).containsExactly(5L, 6L);
+        } finally {
+            closeGroupReader(reader, transport);
+        }
+    }
+
+    /** A failed grouping waits the retry interval, however often the application asks for a batch. */
+    @Test
+    void shouldWaitTheRetryIntervalBeforeItGroupsAgainAfterAFailedGrouping() throws Exception {
+        List<Long> attempts = new CopyOnWriteArrayList<>();
+        FakeTransport transport = new FakeTransport(false).groupedBy(partitions -> {
+            attempts.add(System.nanoTime());
+            if (attempts.size() <= 2) {
+                throw new IggyClientException("No route to the partition yet");
+            }
+            return List.of(partitions);
+        });
+        PartitionReader reader = standalone(transport, MANUAL, 1L);
+        try {
+            CompletableFuture<PolledMessages> polled = reader.next();
+            reader.next();
+            reader.next();
+
+            long deadline = System.nanoTime() + TIMEOUT.toNanos();
+            while (attempts.size() < 3 && System.nanoTime() < deadline) {
+                TimeUnit.MILLISECONDS.sleep(5);
+            }
+            assertThat(attempts).hasSizeGreaterThanOrEqualTo(3);
+            assertThat(attempts.get(1) - attempts.get(0)).isGreaterThanOrEqualTo(RETRY_INTERVAL.toNanos());
+            assertThat(attempts.get(2) - attempts.get(1)).isGreaterThanOrEqualTo(RETRY_INTERVAL.toNanos());
+            transport.nextRead().answer(section(1, 0));
+            assertThat(offsets(polled.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)))
+                    .containsExactly(0L);
+        } finally {
+            reader.closeAsync().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    @Test
+    void shouldHandOverAQueuedBatchOfAPartitionAssignedAgainUnderOnRead() throws Exception {
+        FakeTransport transport = new FakeTransport(true);
+        transport.starts.put(2L, 4L);
+        PartitionReader reader = member(transport, ON_READ);
+        try {
+            assignment(transport, 1L, 2L);
+            CompletableFuture<PolledMessages> polled = reader.next();
+            transport.nextRead().answer(section(1, 0, 1), section(2, 4, 5));
+            assertThat(offsets(polled.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)))
+                    .containsExactly(0L, 1L);
+
+            assignment(transport, 1L);
+            assignment(transport, 1L, 2L);
+            assignment(transport, 1L, 2L);
+            CompletableFuture<PolledMessages> stored = reader.next();
+
+            assertThat(transport.readsNothingFor(QUIET))
+                    .as("the queued batch of the partition that left is handed over without a read")
+                    .isTrue();
+            PolledMessages batch = stored.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            assertThat(batch.partitionId()).isEqualTo(2L);
+            assertThat(offsets(batch)).containsExactly(4L, 5L);
+        } finally {
+            closeGroupReader(reader, transport);
+        }
+    }
+
+    @Test
+    void shouldHandOverAReadThatEndsAfterItsPartitionIsAssignedAgainUnderOnRead() throws Exception {
+        FakeTransport transport = new FakeTransport(true)
+                .groupedBy(partitions -> partitions.stream().map(List::of).toList());
+        transport.starts.put(2L, 4L);
+        PartitionReader reader = member(transport, ON_READ);
+        try {
+            assignment(transport, 1L, 2L);
+            CompletableFuture<PolledMessages> polled = reader.next();
+            Read first = transport.nextRead();
+            Read second = transport.nextRead();
+            Read partitionOne = first.targets().containsKey(1L) ? first : second;
+            Read partitionTwo = partitionOne == first ? second : first;
+            partitionOne.answer(section(1, 0, 1));
+            assertThat(offsets(polled.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)))
+                    .containsExactly(0L, 1L);
+
+            assignment(transport, 1L);
+            assignment(transport, 1L, 2L);
+            assignment(transport, 1L, 2L);
+            partitionTwo.answer(section(2, 4, 5));
+            CompletableFuture<PolledMessages> stored = reader.next();
+
+            assertThat(transport.readsNothingFor(QUIET))
+                    .as("the reply of the partition that left is handed over without a read")
+                    .isTrue();
+            PolledMessages batch = stored.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            assertThat(batch.partitionId()).isEqualTo(2L);
+            assertThat(offsets(batch)).containsExactly(4L, 5L);
+        } finally {
+            closeGroupReader(reader, transport);
+        }
+    }
+
+    @Test
+    void shouldWaitTheRetryIntervalAfterASectionlessReplyWithZeroWait() throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        PartitionReader reader = standalone(transport, PollOptions.defaults(), 1L);
+        try {
+            Read read = transport.nextRead();
+            long started = System.nanoTime();
+            read.answer();
+            transport.nextRead();
+            assertThat(System.nanoTime() - started).isGreaterThanOrEqualTo(RETRY_INTERVAL.toNanos());
+        } finally {
+            reader.closeAsync().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    @Test
+    void shouldDropAnOldStoredReplyAfterASeekAndReassignment() throws Exception {
+        FakeTransport transport = new FakeTransport(true);
+        PartitionReader reader = member(transport, ON_READ);
+        try {
+            assignment(transport, 1L);
+            CompletableFuture<PolledMessages> cancelled = reader.next();
+            Read old = transport.nextRead();
+            assertThat(cancelled.cancel(false)).isTrue();
+            assignment(transport, 2L);
+            assignment(transport, 1L);
+            assignment(transport, 1L);
+            reader.seek(1L, PollingStrategy.offset(BigInteger.TEN)).get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            transport.starts.put(1L, 10L);
+            assignment(transport, 2L);
+            assignment(transport, 1L);
+            assignment(transport, 1L);
+            old.answer(section(1, 0, 1));
+            CompletableFuture<PolledMessages> resumed = reader.next();
+            transport.nextRead().answer(section(1, 10));
+            assertThat(offsets(resumed.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)))
+                    .containsExactly(10L);
+        } finally {
+            closeGroupReader(reader, transport);
+        }
+    }
+
+    @Test
+    void shouldStopReadingUnderOnReadWhenTheWaitingCallIsCancelled() throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        PartitionReader reader = standalone(transport, ON_READ, 1L);
+        try {
+            CompletableFuture<PolledMessages> cancelled = reader.next();
+            Read read = transport.nextRead();
+            assertThat(cancelled.cancel(false)).isTrue();
+            read.answer();
+            assertThat(transport.readsNothingFor(QUIET)).isTrue();
+        } finally {
+            reader.closeAsync().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(longs = {0, 5})
+    void shouldRecoverTheFirstLostOnReadReply(long start) throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        transport.starts.put(1L, start);
+        PartitionReader reader = standalone(transport, ON_READ, 1L);
+        try {
+            CompletableFuture<PolledMessages> polled = reader.next();
+            Read first = transport.nextRead();
+            assertThat(first.commit()).isTrue();
+            first.reply().completeExceptionally(new IggyOperationException(IggyErrorCode.FETCH_PROGRESS_UNCERTAIN, 42));
+            transport.nextRead().answer(section(1, start + 2));
+            Read gap = transport.nextRead();
+            assertThat(gap.commit()).isFalse();
+            assertThat(gap.targets().get(1L).value()).isEqualTo(BigInteger.valueOf(start));
+            gap.answer(section(1, start, start + 1));
+            assertThat(offsets(polled.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)))
+                    .containsExactly(start, start + 1);
+            assertThat(offsets(next(reader))).containsExactly(start + 2);
+        } finally {
+            reader.closeAsync().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(longs = {0, 5})
+    void shouldRewindTheFirstUndeliveredOnReadToItsBaseline(long start) throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        transport.starts.put(1L, start);
+        PartitionReader reader = standalone(transport, ON_READ, 1L);
+        reader.next();
+        transport.nextRead();
+        reader.closeAsync().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        if (start == 0) {
+            assertThat(transport.deleted).containsExactly(1L);
+        } else {
+            assertThat(transport.stores).containsExactly(new Store(1, start - 1));
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(longs = {0, 5})
+    void shouldRecoverTheFirstLostOnReadReplyWhenItConsumedTheWholeBacklog(long start) throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        transport.starts.put(1L, start);
+        PartitionReader reader = standalone(transport, ON_READ, 1L);
+        try {
+            CompletableFuture<PolledMessages> polled = reader.next();
+            transport
+                    .nextRead()
+                    .reply()
+                    .completeExceptionally(new IggyOperationException(IggyErrorCode.FETCH_PROGRESS_UNCERTAIN, 42));
+            transport
+                    .nextRead()
+                    .answer(new FetchedMessages(
+                            1,
+                            FetchedMessages.STATUS_OK,
+                            BigInteger.valueOf(start + 2),
+                            0,
+                            FakeTransport.CONTEXT,
+                            BigInteger.valueOf(start + 2),
+                            new byte[0]));
+            Read gap = transport.nextRead();
+            assertThat(gap.commit()).isFalse();
+            assertThat(gap.targets().get(1L).value()).isEqualTo(BigInteger.valueOf(start));
+            gap.answer(section(1, start, start + 1));
+            assertThat(offsets(polled.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)))
+                    .containsExactly(start, start + 1);
+        } finally {
+            reader.closeAsync().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    /** A gap read that the server refused goes out again, so the messages that the lost read stored still arrive. */
+    @Test
+    void shouldReadTheGapAgainAfterTheServerRefusedItsRead() throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        PartitionReader reader = standalone(transport, ON_READ, 1L);
+        try {
+            CompletableFuture<PolledMessages> second = loseTheSecondRead(transport, reader);
+            transport.nextRead().answer(section(1, 5, 6));
+            Read refusedGap = transport.nextRead();
+            assertThat(refusedGap.commit()).isFalse();
+            refusedGap.reply().completeExceptionally(serverError(IggyErrorCode.NOT_PRIMARY));
+
+            Read gap = transport.nextRead();
+            assertThat(gap.commit()).isFalse();
+            assertThat(gap.targets().get(1L).value()).isEqualTo(BigInteger.TWO);
+            gap.answer(section(1, 2, 3, 4));
+            assertThat(offsets(second.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)))
+                    .containsExactly(2L, 3L, 4L);
+            assertThat(offsets(next(reader))).containsExactly(5L, 6L);
+        } finally {
+            reader.closeAsync().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    /** An empty gap section moves the gap to its next offset, past messages that are gone. */
+    @Test
+    void shouldMoveTheGapPastAnEmptyGapSectionAndReadTheRest() throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        PartitionReader reader = standalone(transport, ON_READ, 1L);
+        try {
+            CompletableFuture<PolledMessages> second = loseTheSecondRead(transport, reader);
+            transport.nextRead().answer(section(1, 5, 6));
+            transport.nextRead().answer(FakeTransport.empty(1, 3));
+
+            Read rest = transport.nextRead();
+            assertThat(rest.commit()).isFalse();
+            assertThat(rest.targets().get(1L).value()).isEqualTo(BigInteger.valueOf(3));
+            rest.answer(section(1, 3, 4));
+            assertThat(offsets(second.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)))
+                    .containsExactly(3L, 4L);
+            assertThat(offsets(next(reader))).containsExactly(5L, 6L);
+        } finally {
+            reader.closeAsync().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    /**
+     * A partition that another owner holds drops its position, whose owner generation is stale after a rejoin, and
+     * resolves its start again. Refusals that last are reported, and the partition keeps reading.
+     */
+    @Test
+    void shouldResolveThePositionAgainAfterTheServerSaysAnotherOwnerHoldsThePartition() throws Exception {
+        FakeTransport transport = new FakeTransport(true);
+        PartitionReader reader = member(transport, MANUAL);
+        try {
+            assignment(transport, 1L);
+            transport.nextRead().answer(section(1, 0, 1));
+            next(reader);
+            Read refusedRead = transport.nextRead();
+            assertThat(refusedRead.targets().get(1L).context()).contains(FakeTransport.CONTEXT);
+            int notOwned = IggyErrorCode.CONSUMER_GROUP_PARTITION_NOT_OWNED.getCode();
+            refusedRead.answer(refused(1, notOwned));
+
+            Read again = transport.nextRead();
+            assertThat(again.targets().get(1L)).isEqualTo(PollingStrategy.next());
+            again.answer(refused(1, notOwned));
+            for (int refusal = 2; refusal < 6; refusal++) {
+                transport.nextRead().answer(refused(1, notOwned));
+            }
+
+            assertThat(readError(reader).errorCode()).hasValue(notOwned);
+            assertThat(transport.nextRead().targets().get(1L)).isEqualTo(PollingStrategy.next());
+        } finally {
+            closeGroupReader(reader, transport);
+        }
+    }
+
+    @Test
+    void shouldStoreACommitAgainAfterItsFirstStoreFailed() throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        PartitionReader reader = standalone(transport, MANUAL, 1L);
+        try {
+            transport.nextRead().answer(section(1, 3, 4));
+            Checkpoint last = next(reader).checkpoint().orElseThrow();
+            transport.storeFailures.add(new IggyClientException("The connection dropped"));
+
+            assertThatThrownBy(() -> reader.commit(last).get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS))
+                    .hasCauseInstanceOf(IggyClientException.class);
+            reader.commit(last).get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+
+            assertThat(transport.stores).containsExactly(new Store(1, 4));
+        } finally {
+            reader.closeAsync().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    /** The server raises the metadata op of a section after any metadata change, in the same history. */
+    @Test
+    void shouldCommitAnEarlierCheckpointAfterTheServerRaisedTheMetadataOp() throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        PartitionReader reader = standalone(transport, MANUAL, 1L);
+        PartitionContext raised = new PartitionContext(
+                FakeTransport.CONTEXT.incarnation(), FakeTransport.CONTEXT.ownerGeneration(), BigInteger.TWO);
+        try {
+            transport.nextRead().answer(section(1, 0, 1));
+            PolledMessages first = next(reader);
+            transport.nextRead().answer(section(1, raised, 2, 3));
+            next(reader);
+
+            reader.commit(first.checkpointAt(0).orElseThrow()).get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+
+            assertThat(transport.stores).containsExactly(new Store(1, 0));
+        } finally {
+            reader.closeAsync().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    /** The server can have stored a read whose reply was lost, so the close moves the offset back over it. */
+    @Test
+    void shouldMoveTheOffsetBackOverALostReadOnCloseUnderOnRead() throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        PartitionReader reader = standalone(transport, ON_READ, 1L);
+        CompletableFuture<PolledMessages> first = reader.next();
+        transport.nextRead().answer(section(1, 0, 1));
+        first.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        CompletableFuture<PolledMessages> cancelled = reader.next();
+        Read lost = transport.nextRead();
+        assertThat(cancelled.cancel(false)).isTrue();
+        lost.answer(FakeTransport.lost(1, 42));
+        assertThat(transport.readsNothingFor(QUIET)).isTrue();
+
+        reader.closeAsync().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+
+        assertThat(transport.stores).containsExactly(new Store(1, 1));
+    }
+
+    /** The server stored the whole read, so the close moves the offset back before the message that did not decode. */
+    @Test
+    void shouldMoveTheOffsetBackBeforeAMessageThatDidNotDecodeOnCloseUnderOnRead() throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        PartitionReader reader = standalone(transport, ON_READ, 1L);
+        CompletableFuture<PolledMessages> first = reader.next();
+        byte[] records = ByteBufUtil.getBytes(
+                Unpooled.wrappedBuffer(FakeTransport.record(0), Unpooled.wrappedBuffer(new byte[] {1, 2, 3})));
+        transport
+                .nextRead()
+                .answer(new FetchedMessages(
+                        1,
+                        FetchedMessages.STATUS_OK,
+                        BigInteger.valueOf(3),
+                        3,
+                        FakeTransport.CONTEXT,
+                        BigInteger.valueOf(3),
+                        records));
+        assertThat(offsets(first.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)))
+                .containsExactly(0L);
+        readError(reader);
+
+        reader.closeAsync().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+
+        assertThat(transport.stores).containsExactly(new Store(1, 0));
+    }
+
+    /** A lost read is abandoned also when a seek replaced its generation, or its lane stays blocked. */
+    @Test
+    void shouldAbandonALostReadWhoseReplyArrivesAfterASeek() throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        PartitionReader reader = standalone(transport, ON_READ, 1L);
+        try {
+            CompletableFuture<PolledMessages> first = reader.next();
+            transport.nextRead().answer(section(1, 0, 1));
+            first.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            reader.next();
+            Read lost = transport.nextRead();
+            CompletableFuture<Void> sought = reader.seek(1L, PollingStrategy.offset(BigInteger.TEN));
+
+            lost.answer(FakeTransport.lost(1, 42));
+            sought.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+
+            assertThat(transport.abandoned).containsExactly(42L);
+            assertThat(transport.stores).containsExactly(new Store(1, 9));
+        } finally {
+            reader.closeAsync().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    /** A seek that waits for a read in flight when the close starts still applies, and its store goes before the rewind. */
+    @Test
+    void shouldStoreAPendingSeekOnCloseBeforeTheRewind() throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        PartitionReader reader = standalone(transport, ON_READ, 1L, 2L);
+        CompletableFuture<PolledMessages> first = reader.next();
+        transport.nextRead().answer(section(2, 0));
+        first.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        reader.next();
+        transport.nextRead();
+        CompletableFuture<Void> sought = reader.seek(1L, PollingStrategy.offset(BigInteger.TEN));
+
+        reader.closeAsync().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+
+        sought.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        assertThat(transport.stores).containsExactly(new Store(1, 9), new Store(2, 0));
+    }
+
+    /** A refused store of a seek that applies at close fails the close, and the other partitions still rewind. */
+    @Test
+    void shouldFailTheCloseWhenTheStoreOfAPendingSeekIsRefusedAndStillRewindTheOthers() throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        PartitionReader reader = standalone(transport, ON_READ, 1L, 2L);
+        CompletableFuture<PolledMessages> first = reader.next();
+        transport.nextRead().answer(section(2, 0));
+        first.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        reader.next();
+        transport.nextRead();
+        CompletableFuture<Void> sought = reader.seek(1L, PollingStrategy.offset(BigInteger.TEN));
+        transport.storeFailures.add(new IggyClientException("The connection dropped"));
+
+        assertThatThrownBy(() -> reader.closeAsync().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS))
+                .hasCauseInstanceOf(IggyClientException.class);
+
+        assertThatThrownBy(() -> sought.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS))
+                .hasCauseInstanceOf(IggyClientException.class);
+        assertThat(transport.stores).containsExactly(new Store(2, 0));
+    }
+
+    /**
+     * The server can store until it answers the cancel of a committing read, and that reply lifts the block of its
+     * partitions, so the stores of the close wait for it.
+     */
+    @Test
+    void shouldStoreOnCloseOnlyAfterTheCancelledReadsAnswered() throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        PartitionReader reader = standalone(transport, ON_READ, 1L);
+        CompletableFuture<PolledMessages> first = reader.next();
+        transport.nextRead().answer(section(1, 0));
+        first.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        reader.next();
+        transport.nextRead();
+        transport.settled = new CompletableFuture<>();
+
+        CompletableFuture<Void> closed = reader.closeAsync();
+
+        assertThat(transport.readsNothingFor(QUIET)).isTrue();
+        assertThat(closed).isNotDone();
+        assertThat(transport.stores).isEmpty();
+        transport.settled.complete(null);
+        closed.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        assertThat(transport.stores).containsExactly(new Store(1, 0));
+    }
+
+    /**
+     * A committing read whose reply never comes during the close leaves its partition blocked on the client, so the
+     * close abandons it, before its rewind store of that partition, since no reader is left to recover it.
+     */
+    @Test
+    void shouldAbandonAReadLostDuringTheCloseBeforeTheRewind() throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        PartitionReader reader = standalone(transport, ON_READ, 1L);
+        CompletableFuture<PolledMessages> first = reader.next();
+        transport.nextRead().answer(section(1, 0));
+        first.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        reader.next();
+        transport.nextRead();
+        transport.lost.add(42L);
+
+        reader.closeAsync().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+
+        assertThat(transport.events).containsExactly("abandon 42", "store 1 0");
+    }
+
+    /** The offset before a seek is the last position of the new generation, so the close moves back to it. */
+    @Test
+    void shouldMoveTheOffsetBackBeforeTheSeekOffsetOnCloseWhileAReadIsInFlight() throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        PartitionReader reader = standalone(transport, ON_READ, 1L);
+        reader.seek(1L, PollingStrategy.offset(BigInteger.TEN)).get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        reader.next();
+        assertThat(transport.nextRead().targets().get(1L).kind()).isEqualTo(PollingKind.Next);
+
+        reader.closeAsync().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+
+        assertThat(transport.stores).containsExactly(new Store(1, 9), new Store(1, 9));
+    }
+
+    /** There is no offset before 0, and the other strategies name no offset, so such a seek stores nothing. */
+    @ParameterizedTest
+    @MethodSource("startsThatStoreNothing")
+    void shouldStoreNothingForASeekThatNamesNoOffsetAboveZeroUnderOnRead(PollingStrategy start) throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        PartitionReader reader = standalone(transport, ON_READ, 1L);
+        try {
+            reader.seek(1L, start).get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            reader.next();
+
+            Read read = transport.nextRead();
+            PollingKind kind = start.kind() == PollingKind.First ? PollingKind.Offset : start.kind();
+            assertThat(read.targets().get(1L).kind()).isEqualTo(kind);
+            assertThat(read.commit()).isTrue();
+            assertThat(transport.stores).isEmpty();
+        } finally {
+            reader.closeAsync().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    static List<PollingStrategy> startsThatStoreNothing() {
+        return List.of(
+                PollingStrategy.first(),
+                PollingStrategy.offset(BigInteger.ZERO),
+                PollingStrategy.last(),
+                PollingStrategy.next(),
+                PollingStrategy.timestamp(BigInteger.TEN));
+    }
+
+    @Test
+    void shouldFailASeekWhoseStoreIsRefusedUnderOnRead() throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        PartitionReader reader = standalone(transport, ON_READ, 1L);
+        try {
+            transport.storeFailures.add(new IggyClientException("The connection dropped"));
+
+            assertThatThrownBy(() -> reader.seek(1L, PollingStrategy.offset(BigInteger.TEN))
+                            .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS))
+                    .hasCauseInstanceOf(IggyClientException.class);
+        } finally {
+            reader.closeAsync().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    @Test
+    void shouldFailAPendingSeekOfAPartitionThatLeavesTheAssignment() throws Exception {
+        FakeTransport transport = new FakeTransport(true);
+        PartitionReader reader = member(transport, ON_READ);
+        try {
+            assignment(transport, 1L, 2L);
+            reader.next();
+            Read read = transport.nextRead();
+            CompletableFuture<Void> sought = reader.seek(1L, PollingStrategy.offset(BigInteger.TEN));
+
+            assignment(transport, 2L);
+
+            assertInvalidCommand(sought);
+            read.answer();
+        } finally {
+            closeGroupReader(reader, transport);
+        }
+    }
+
+    /** A reader that abandons a lost read waits its retry interval before it reads that partition again. */
+    @ParameterizedTest
+    @ValueSource(booleans = {true, false})
+    void shouldWaitTheRetryIntervalBeforeItReadsAgainAfterALostRead(boolean wholeRead) throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        PartitionReader reader = standalone(transport, ON_READ, 1L);
+        try {
+            CompletableFuture<PolledMessages> first = reader.next();
+            transport.nextRead().answer(section(1, 0, 1));
+            first.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            reader.next();
+            Read lost = transport.nextRead();
+            long lostAt = System.nanoTime();
+            if (wholeRead) {
+                lost.reply()
+                        .completeExceptionally(new IggyOperationException(IggyErrorCode.FETCH_PROGRESS_UNCERTAIN, 42));
+            } else {
+                lost.answer(FakeTransport.lost(1, 42));
+            }
+
+            transport.nextRead();
+
+            assertThat(System.nanoTime() - lostAt).isGreaterThanOrEqualTo(RETRY_INTERVAL.toNanos());
+            assertThat(transport.abandoned).containsExactly(42L);
+        } finally {
+            reader.closeAsync().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    /**
+     * Only a lost read explains a read that starts past the expected offset. Once a later read passed the gap check, a
+     * forward jump comes from a store of the application or another session, and is delivered as it is.
+     */
+    @Test
+    void shouldCatchUpAForwardJumpOnlyWhileALostReadCanHaveStoredIt() throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        PartitionReader reader = standalone(transport, ON_READ, 1L);
+        try {
+            CompletableFuture<PolledMessages> second = loseTheSecondRead(transport, reader);
+            transport.nextRead().answer(section(1, 2, 3));
+            assertThat(offsets(second.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)))
+                    .containsExactly(2L, 3L);
+
+            CompletableFuture<PolledMessages> third = reader.next();
+            Read jump = transport.nextRead();
+            assertThat(jump.commit()).isTrue();
+            jump.answer(section(1, 9));
+
+            assertThat(offsets(third.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)))
+                    .containsExactly(9L);
+        } finally {
+            reader.closeAsync().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    /** The read of a partition that left and came back ends without freeing the partition for a second read. */
+    @Test
+    void shouldKeepOneReadOfAPartitionThatLeftAndCameBackWhileItsOldReadRuns() throws Exception {
+        FakeTransport transport = new FakeTransport(true);
+        PartitionReader reader = member(transport, MANUAL);
+        try {
+            assignment(transport, 1L);
+            Read old = transport.nextRead();
+            assignment(transport);
+            assignment(transport, 1L);
+            Read current = transport.nextRead();
+
+            old.answer(section(1, 0));
+
+            assertThat(transport.readsNothingFor(QUIET)).isTrue();
+            current.answer(section(1, 0, 1));
+            assertThat(offsets(next(reader))).containsExactly(0L, 1L);
+        } finally {
+            closeGroupReader(reader, transport);
+        }
+    }
+
+    /** The server can end a membership before the close, which then has nothing to leave. */
+    @Test
+    void shouldCloseAGroupReaderWhoseMembershipTheServerAlreadyEnded() throws Exception {
+        FakeTransport transport = new FakeTransport(true);
+        transport.leaveFailure = serverError(IggyErrorCode.CONSUMER_GROUP_MEMBER_NOT_FOUND);
+        PartitionReader reader = member(transport, MANUAL);
+        assignment(transport, 1L);
+
+        closeGroupReader(reader, transport);
+
+        assertThat(transport.membership).containsExactly("join", "leave");
+    }
+
+    @Test
+    void shouldReturnTheFailureOfTheFirstCloseToALaterClose() throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        transport.starts.put(1L, 5L);
+        PartitionReader reader = standalone(transport, ON_READ, 1L);
+        reader.next();
+        transport.nextRead();
+        transport.storeFailures.add(new IggyClientException("The connection dropped"));
+
+        assertThatThrownBy(() -> reader.closeAsync().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS))
+                .hasCauseInstanceOf(IggyClientException.class);
+        assertThatThrownBy(() -> reader.closeAsync().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS))
+                .hasCauseInstanceOf(IggyClientException.class);
+    }
+
+    /** A first message that does not decode stops its partition with the skip past it, so a seek moves on. */
+    @Test
+    void shouldNameTheSkipOfAFirstMessageThatDoesNotDecode() throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        transport.probes.put(
+                1L,
+                new FetchedMessages(
+                        1,
+                        FetchedMessages.STATUS_OK,
+                        BigInteger.valueOf(4),
+                        1,
+                        FakeTransport.CONTEXT,
+                        BigInteger.valueOf(4),
+                        new byte[] {1, 2, 3}));
+        PartitionReader reader = standalone(transport, ON_READ, 1L);
+        try {
+            ReadException error = readError(reader);
+
+            assertThat(error.skip()).map(PollingStrategy::value).contains(BigInteger.valueOf(4));
+        } finally {
+            reader.closeAsync().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    /** A lost first read from an offset can have stored from there, so the close moves the offset back before it. */
+    @Test
+    void shouldMoveTheOffsetBackBeforeALostReadFromTheStartOffsetOnCloseUnderOnRead() throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        PartitionReader reader = PartitionReader.builder(transport)
+                .partitions(List.of(1L))
+                .start(PollingStrategy.offset(BigInteger.valueOf(5)))
+                .options(ON_READ)
+                .retryInterval(RETRY_INTERVAL)
+                .build()
+                .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        CompletableFuture<PolledMessages> cancelled = reader.next();
+        Read lost = transport.nextRead();
+        assertThat(cancelled.cancel(false)).isTrue();
+        lost.reply().completeExceptionally(new IggyOperationException(IggyErrorCode.FETCH_PROGRESS_UNCERTAIN, 42));
+        assertThat(transport.readsNothingFor(QUIET)).isTrue();
+
+        reader.closeAsync().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+
+        assertThat(transport.stores).containsExactly(new Store(1, 4));
+    }
+
+    /**
+     * A read of a partition with a backlog returns at once, so the free slots spread the backlogged partitions of a
+     * group over reads of their own, each with the whole limits, and a quiet one keeps the read that may wait.
+     */
+    @Test
+    void shouldSpreadTheBacklogOfAGroupOverReadsOfTheirOwn() throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        PartitionReader reader = standalone(transport, MANUAL, 1L, 2L, 3L, 4L);
+        try {
+            Read first = transport.nextRead();
+            assertThat(first.targets()).containsOnlyKeys(1L, 2L, 3L, 4L);
+
+            first.answer(
+                    FakeTransport.backlogged(1, 100, 0),
+                    FakeTransport.backlogged(2, 100, 0),
+                    FakeTransport.backlogged(3, 100, 0),
+                    section(4, 0));
+
+            List<Read> next =
+                    List.of(transport.nextRead(), transport.nextRead(), transport.nextRead(), transport.nextRead());
+            assertThat(next)
+                    .extracting(read -> read.targets().keySet())
+                    .containsExactlyInAnyOrder(Set.of(1L), Set.of(2L), Set.of(3L), Set.of(4L));
+            assertThat(next).extracting(Read::count).containsOnly(MANUAL.count());
+            assertThat(transport.readsNothingFor(QUIET)).isTrue();
+        } finally {
+            reader.closeAsync().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    /**
+     * Under OnRead a waiting call fills the free slots too: the backlogged partitions of a group take committing reads
+     * of their own, each with the whole limits, beside the one read of its quiet partitions that may wait, and a
+     * partition is in one committing read at a time.
+     */
+    @Test
+    void shouldSpreadTheBacklogOfAGroupOverCommittingReadsOfTheirOwnUnderOnRead() throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        PartitionReader reader = standalone(transport, ON_READ, 1L, 2L, 3L, 4L);
+        try {
+            CompletableFuture<PolledMessages> first = reader.next();
+            Read read = transport.nextRead();
+            assertThat(read.targets()).containsOnlyKeys(1L, 2L, 3L, 4L);
+            read.answer(
+                    FakeTransport.backlogged(1, 100, 0),
+                    FakeTransport.backlogged(2, 100, 0),
+                    FakeTransport.backlogged(3, 100, 0),
+                    section(4, 0));
+            first.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            next(reader);
+            next(reader);
+            next(reader);
+            assertThat(transport.readsNothingFor(QUIET))
+                    .as("the reader reads for a waiting call only")
+                    .isTrue();
+
+            CompletableFuture<PolledMessages> waiting = reader.next();
+            List<Read> spread =
+                    List.of(transport.nextRead(), transport.nextRead(), transport.nextRead(), transport.nextRead());
+            assertThat(spread)
+                    .extracting(sent -> sent.targets().keySet())
+                    .containsExactlyInAnyOrder(Set.of(1L), Set.of(2L), Set.of(3L), Set.of(4L));
+            assertThat(spread).extracting(Read::commit).containsOnly(true);
+            assertThat(spread).extracting(Read::count).containsOnly(ON_READ.count());
+
+            Read quiet = spread.stream()
+                    .filter(sent -> sent.targets().containsKey(4L))
+                    .findFirst()
+                    .orElseThrow();
+            for (Read sent : spread) {
+                if (sent != quiet) {
+                    long partition = sent.targets().keySet().iterator().next();
+                    sent.answer(FakeTransport.backlogged(partition, 100, 1));
+                }
+            }
+            waiting.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            next(reader);
+            next(reader);
+            reader.next();
+
+            List<Read> again = List.of(transport.nextRead(), transport.nextRead(), transport.nextRead());
+            assertThat(again)
+                    .as("the backlog reads again beside the read that waits, which keeps its partition")
+                    .extracting(sent -> sent.targets().keySet())
+                    .containsExactlyInAnyOrder(Set.of(1L), Set.of(2L), Set.of(3L));
+            assertThat(transport.readsNothingFor(QUIET)).isTrue();
+        } finally {
+            reader.closeAsync().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    /** Delivers offsets 0 and 1, then loses the reply of the next read, and returns the call that waits for it. */
+    private static CompletableFuture<PolledMessages> loseTheSecondRead(FakeTransport transport, PartitionReader reader)
+            throws Exception {
+        CompletableFuture<PolledMessages> first = reader.next();
+        transport.nextRead().answer(section(1, 0, 1));
+        first.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        CompletableFuture<PolledMessages> second = reader.next();
+        transport
+                .nextRead()
+                .reply()
+                .completeExceptionally(new IggyOperationException(IggyErrorCode.FETCH_PROGRESS_UNCERTAIN, 42));
+        return second;
+    }
+
+    private static PartitionReader standalone(FakeTransport transport, PollOptions options, Long... partitions)
+            throws Exception {
+        return PartitionReader.builder(transport)
+                .partitions(List.of(partitions))
+                .options(options)
+                .retryInterval(RETRY_INTERVAL)
+                .build()
+                .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+    }
+
+    private static PartitionReader member(FakeTransport transport, PollOptions options) throws Exception {
+        return PartitionReader.builder(transport)
+                .options(options)
+                .retryInterval(RETRY_INTERVAL)
+                .build()
+                .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+    }
+
+    private static PolledMessages next(PartitionReader reader) throws Exception {
+        return reader.next().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+    }
+
+    private static ReadException readError(PartitionReader reader) throws Exception {
+        try {
+            next(reader);
+        } catch (ExecutionException failed) {
+            assertThat(failed.getCause()).isInstanceOf(ReadException.class);
+            return (ReadException) failed.getCause();
+        }
+        throw new AssertionError("The read did not fail");
+    }
+
+    private static List<Long> offsets(PolledMessages batch) {
+        return batch.messages().stream()
+                .map(Message::header)
+                .map(header -> header.offset().longValue())
+                .toList();
+    }
+
+    private static IggyServerException serverError(IggyErrorCode code) {
+        return IggyServerException.fromTcpResponse(code.getCode(), new byte[0]);
+    }
+
+    private static void assertInvalidCommand(CompletableFuture<?> call) {
+        assertThatThrownBy(() -> call.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS))
+                .hasCauseInstanceOf(IggyValidationException.class)
+                .satisfies(error -> assertThat(((IggyValidationException) error.getCause()).getErrorCode())
+                        .isEqualTo(IggyErrorCode.INVALID_COMMAND));
+    }
+
+    private static void awaitStores(FakeTransport transport, int count) throws InterruptedException {
+        long deadline = System.nanoTime() + TIMEOUT.toNanos();
+        while (transport.stores.size() < count && System.nanoTime() < deadline) {
+            TimeUnit.MILLISECONDS.sleep(5);
+        }
+    }
+
+    private static void assignment(FakeTransport transport, Long... partitions) throws InterruptedException {
+        CompletableFuture<Optional<ConsumerGroupAssignment>> sync =
+                transport.assignments.poll(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        assertThat(sync).as("the reader reads its assignment").isNotNull();
+        sync.complete(Optional.of(new ConsumerGroupAssignment(1, List.of(partitions))));
+    }
+
+    /** Answers an assignment read that the close leaves waiting with the same assignment. */
+    private static void closeGroupReader(PartitionReader reader, FakeTransport transport) throws Exception {
+        CompletableFuture<Void> closed = reader.closeAsync();
+        transport.assignments.forEach(
+                sync -> sync.complete(Optional.of(new ConsumerGroupAssignment(1, reader.partitions()))));
+        closed.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+    }
+}
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/reader/PartitionReaderTestAccess.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/reader/PartitionReaderTestAccess.java
new file mode 100644
index 0000000..59d647b
--- /dev/null
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/reader/PartitionReaderTestAccess.java
@@ -0,0 +1,62 @@
+/*
+ * 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.
+ */
+
+package org.apache.iggy.client.async.reader;
+
+import org.apache.iggy.message.PollOptions;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+/** A reader on a {@link FakeTransport} for the tests of other packages. */
+public final class PartitionReaderTestAccess {
+    private final FakeTransport transport;
+    private final PartitionReader reader;
+
+    private PartitionReaderTestAccess(FakeTransport transport, PartitionReader reader) {
+        this.transport = transport;
+        this.reader = reader;
+    }
+
+    public static PartitionReaderTestAccess standalone(PollOptions options, Long... partitions) throws Exception {
+        FakeTransport transport = new FakeTransport(false);
+        PartitionReader reader = PartitionReader.builder(transport)
+                .partitions(List.of(partitions))
+                .options(options)
+                .retryInterval(Duration.ofMillis(100))
+                .build()
+                .get(FakeTransport.TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        return new PartitionReaderTestAccess(transport, reader);
+    }
+
+    public PartitionReader reader() {
+        return reader;
+    }
+
+    /** The next read that the reader sends, as an opaque handle. */
+    public Object nextRead() throws InterruptedException {
+        return transport.nextRead();
+    }
+
+    /** Answers {@code read} with one message of {@code partition} at each of {@code offsets}. */
+    public void answer(Object read, long partition, long... offsets) {
+        ((FakeTransport.Read) read).answer(FakeTransport.section(partition, offsets));
+    }
+}
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientBuilderTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientBuilderTest.java
index c92df7e..a3972b6 100644
--- a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientBuilderTest.java
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientBuilderTest.java
@@ -227,6 +227,19 @@
         assertThatThrownBy(builder::build).isInstanceOf(IggyInvalidArgumentException.class);
     }
 
+    /** A read returns a first message larger than its byte limit, so every link reads a reply of the largest one. */
+    @Test
+    void shouldRejectMaximumVsrFrameSizeBelowAReplyOfTheLargestMessage() {
+        AsyncIggyTcpClientBuilder builder =
+                AsyncIggyTcpClient.builder().maxVsrFrameSize(AsyncIggyTcpClientBuilder.MIN_MAX_VSR_FRAME_SIZE - 1);
+
+        assertThatThrownBy(builder::build).isInstanceOf(IggyInvalidArgumentException.class);
+        client = AsyncIggyTcpClient.builder()
+                .maxVsrFrameSize(AsyncIggyTcpClientBuilder.MIN_MAX_VSR_FRAME_SIZE)
+                .build();
+        assertThat(client).isNotNull();
+    }
+
     @Test
     void shouldRejectNonPositiveMaximumVsrFrameSize() {
         assertThatThrownBy(() -> AsyncIggyTcpClient.builder().maxVsrFrameSize(0).build())
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientFetchMessagesTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientFetchMessagesTest.java
new file mode 100644
index 0000000..27545d1
--- /dev/null
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientFetchMessagesTest.java
@@ -0,0 +1,1966 @@
+/*
+ * 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.
+ */
+
+package org.apache.iggy.client.async.tcp;
+
+import io.netty.buffer.ByteBuf;
+import io.netty.buffer.Unpooled;
+import org.apache.iggy.client.async.tcp.AsyncIggyTcpClientTransientFailoverTest.Request;
+import org.apache.iggy.client.async.tcp.AsyncIggyTcpClientTransientFailoverTest.RequestHandler;
+import org.apache.iggy.client.async.tcp.AsyncIggyTcpClientTransientFailoverTest.Response;
+import org.apache.iggy.client.async.tcp.vsr.VsrHeaders;
+import org.apache.iggy.client.async.tcp.vsr.VsrOperation;
+import org.apache.iggy.consumergroup.Consumer;
+import org.apache.iggy.exception.IggyErrorCode;
+import org.apache.iggy.exception.IggyInvalidArgumentException;
+import org.apache.iggy.exception.IggyOperationException;
+import org.apache.iggy.exception.IggyServerException;
+import org.apache.iggy.identifier.ConsumerId;
+import org.apache.iggy.identifier.StreamId;
+import org.apache.iggy.identifier.TopicId;
+import org.apache.iggy.message.CommitPolicy;
+import org.apache.iggy.message.DeferredPollOptions;
+import org.apache.iggy.message.FetchedMessages;
+import org.apache.iggy.message.Message;
+import org.apache.iggy.message.MessageHeader;
+import org.apache.iggy.message.PollOptions;
+import org.apache.iggy.message.PolledMessages;
+import org.apache.iggy.message.PollingKind;
+import org.apache.iggy.message.PollingStrategy;
+import org.apache.iggy.partition.PartitionContext;
+import org.apache.iggy.serde.BytesDeserializer;
+import org.apache.iggy.serde.CommandCode;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.math.BigInteger;
+import java.net.InetAddress;
+import java.net.ServerSocket;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.HexFormat;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
+import java.util.stream.LongStream;
+
+import static org.apache.iggy.client.async.tcp.AsyncIggyTcpClientTransientFailoverTest.BIND_SESSION_CODE;
+import static org.apache.iggy.client.async.tcp.AsyncIggyTcpClientTransientFailoverTest.GET_CLUSTER_METADATA_CODE;
+import static org.apache.iggy.client.async.tcp.AsyncIggyTcpClientTransientFailoverTest.GET_PARTITION_ROUTE_CODE;
+import static org.apache.iggy.client.async.tcp.AsyncIggyTcpClientTransientFailoverTest.OPERATION_NON_REPLICATED;
+import static org.apache.iggy.client.async.tcp.AsyncIggyTcpClientTransientFailoverTest.OPERATION_REGISTER;
+import static org.apache.iggy.client.async.tcp.AsyncIggyTcpClientTransientFailoverTest.TRANSIENT_NOT_ACCEPTED;
+import static org.apache.iggy.client.async.tcp.AsyncIggyTcpClientTransientFailoverTest.assertServerError;
+import static org.apache.iggy.client.async.tcp.AsyncIggyTcpClientTransientFailoverTest.bindBody;
+import static org.apache.iggy.client.async.tcp.AsyncIggyTcpClientTransientFailoverTest.client;
+import static org.apache.iggy.client.async.tcp.AsyncIggyTcpClientTransientFailoverTest.emptyPoll;
+import static org.apache.iggy.client.async.tcp.AsyncIggyTcpClientTransientFailoverTest.pollRoute;
+import static org.apache.iggy.client.async.tcp.AsyncIggyTcpClientTransientFailoverTest.registerBody;
+import static org.apache.iggy.client.async.tcp.AsyncIggyTcpClientTransientFailoverTest.serve;
+import static org.apache.iggy.client.async.tcp.AsyncIggyTcpClientTransientFailoverTest.singleNodeMetadata;
+import static org.apache.iggy.client.async.tcp.AsyncIggyTcpClientTransientFailoverTest.threeNodeMetadata;
+import static org.apache.iggy.client.async.tcp.FetchMessagesTest.GOLDEN_LIMITS;
+import static org.apache.iggy.client.async.tcp.FetchMessagesTest.GOLDEN_MAX_BYTES;
+import static org.apache.iggy.client.async.tcp.FetchMessagesTest.GOLDEN_REQUEST;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * The typed fetch against mock nodes: the encoded body and its fences, the primary that gets it, the routes that its
+ * refusals mark moved, the fetches that the client never resends or cancels, and the reply that it reads.
+ */
+class AsyncIggyTcpClientFetchMessagesTest {
+    private static final int POLL_DEFERRED_CODE = CommandCode.Messages.POLL_DEFERRED.getValue();
+    private static final String DEFERRED_POLL_BODY = "0201040700000001040100000002066f72646572730103000000050000000000"
+            + "000000640000000140420f00000000000100000000001000c0d8a70000000000";
+    private static final int DEFERRED_LIMITS_BYTES = 2 * Long.BYTES + 2 * Integer.BYTES;
+    private static final Duration ROUTING_ALLOWANCE = Duration.ofSeconds(1);
+    private static final Duration LATE_REPLY = Duration.ofMillis(200);
+    private static final int FETCH_CODE = CommandCode.Messages.FETCH_MESSAGES.getValue();
+    private static final int CANCEL_CODE = CommandCode.Messages.CANCEL_DEFERRED_REQUEST.getValue();
+    private static final int NOT_PRIMARY = IggyErrorCode.NOT_PRIMARY.getCode();
+    private static final int TRANSIENT_NOT_COMMITTED = IggyErrorCode.TRANSIENT_NOT_COMMITTED.getCode();
+    private static final int HISTORY_UNAVAILABLE = IggyErrorCode.HISTORY_UNAVAILABLE.getCode();
+    private static final HexFormat HEX = HexFormat.of();
+    // The consumer, stream and topic of a fetch whose ids are all numeric, which the limits follow.
+    private static final int NUMERIC_LIMITS = 19;
+    // The routes of partition p carry the incarnation ROUTE_INCARNATION + p.
+    private static final long ROUTE_INCARNATION = 10;
+    private static final long COUNT = 100;
+    private static final StreamId STREAM = StreamId.of(1L);
+    private static final TopicId TOPIC = TopicId.of(1L);
+    private static final Consumer GROUP = Consumer.group(ConsumerId.of(7L));
+    private static final DeferredPollOptions FETCH = DeferredPollOptions.defaults();
+    // A fetch without a wait still needs the reply margin, and this budget leaves it room for a few retries.
+    private static final DeferredPollOptions IMMEDIATE =
+            FETCH.withMaxWait(Duration.ZERO).withRequestTimeout(Duration.ofSeconds(2));
+    // The limits of the golden request, whose budgets the call counts down.
+    private static final DeferredPollOptions GOLDEN =
+            new DeferredPollOptions(Duration.ofSeconds(5), 1, GOLDEN_MAX_BYTES, Duration.ofSeconds(6));
+    private static final PartitionContext GOLDEN_CONTEXT =
+            new PartitionContext(BigInteger.valueOf(3), BigInteger.valueOf(9), BigInteger.valueOf(12));
+    private static final Duration TIMEOUT = Duration.ofSeconds(5);
+    // Larger than the smallest byte limit, which the server still returns alone.
+    private static final int OVERSIZED_PAYLOAD = 2000;
+    private static final int BATCH_HEADER_SIZE = 256;
+    // A poll whose deadline comes well before the TIMEOUT of a test step.
+    private static final DeferredPollOptions SHORT_POLL =
+            FETCH.withMaxWait(Duration.ofMillis(500)).withRequestTimeout(Duration.ofMillis(1500));
+    // A poll whose deadline comes after every step of a test.
+    private static final DeferredPollOptions LONG_POLL =
+            FETCH.withMaxWait(Duration.ofSeconds(10)).withRequestTimeout(Duration.ofSeconds(20));
+
+    /**
+     * The typed call fills each target cap with the byte limit of the request, so the golden request, whose targets
+     * have smaller caps, goes out with only the caps changed. Each target carries the fence of its strategy, and the
+     * header carries none.
+     */
+    @Test
+    void shouldSendTheGoldenBodyWithEachTargetCappedByTheByteLimit() throws Exception {
+        try (ServerSocket socket = new ServerSocket(0, 4, InetAddress.getLoopbackAddress())) {
+            List<Request> fetches = new CopyOnWriteArrayList<>();
+            CompletableFuture<Void> served = singleNode(socket, request -> {
+                if (!request.is(FETCH_CODE, OPERATION_NON_REPLICATED)) {
+                    return unexpected(request);
+                }
+                fetches.add(request);
+                return Response.success(OPERATION_NON_REPLICATED, reply());
+            });
+            AsyncIggyTcpClient client = signedIn(socket);
+            try {
+                Map<Long, PollingStrategy> targets = new LinkedHashMap<>();
+                targets.put(1L, PollingStrategy.offset(BigInteger.valueOf(41)).withContext(GOLDEN_CONTEXT));
+                targets.put(2L, PollingStrategy.next().withContext(GOLDEN_CONTEXT));
+
+                assertThat(messages(client)
+                                .fetchMessages(STREAM, orders(), targets, GROUP, COUNT, true, GOLDEN)
+                                .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS))
+                        .isEmpty();
+
+                ByteBuf expected = Unpooled.wrappedBuffer(HEX.parseHex(GOLDEN_REQUEST));
+                for (int target = 0; target < targets.size(); target++) {
+                    expected.setIntLE(target(GOLDEN_LIMITS, target) + FetchMessages.TARGET_MAX_BYTES_OFFSET, (int)
+                            GOLDEN_MAX_BYTES);
+                }
+                Request fetch = fetches.get(0);
+                assertGoldenBody(
+                        fetch.body(), HEX.formatHex(expected.array()), GOLDEN_LIMITS, GOLDEN_LIMITS + Long.BYTES);
+                assertThat(List.of(fetch.incarnation(), fetch.ownerGeneration()))
+                        .containsOnly(0L);
+            } finally {
+                client.close().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            }
+            served.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    /** One fetch goes to the primary of its first target, and a target without a fence carries the one of its route. */
+    @Test
+    void shouldSendAFetchToThePrimaryOfItsFirstTargetWithTheFenceOfEachRoute() throws Exception {
+        InetAddress loopback = InetAddress.getLoopbackAddress();
+        try (ServerSocket leader = new ServerSocket(0, 4, loopback);
+                ServerSocket first = new ServerSocket(0, 4, loopback);
+                ServerSocket second = new ServerSocket(0, 4, loopback)) {
+            Supplier<ByteBuf> metadata = () -> threeNodeMetadata(
+                    leader.getLocalPort(), first.getLocalPort(), second.getLocalPort(), leader.getLocalPort());
+            CompletableFuture<Void> leaderServed = node(leader, metadata, request -> {
+                if (!request.is(GET_PARTITION_ROUTE_CODE, OPERATION_NON_REPLICATED)) {
+                    return unexpected(request);
+                }
+                int partition = lastInt(request.body());
+                int primary = partition == 1 ? first.getLocalPort() : second.getLocalPort();
+                return Response.success(
+                        OPERATION_NON_REPLICATED, pollRoute(request, 1, 1, primary, ROUTE_INCARNATION + partition));
+            });
+            List<Request> misrouted = new CopyOnWriteArrayList<>();
+            node(first, metadata, request -> {
+                misrouted.add(request);
+                return unexpected(request);
+            });
+            List<Request> fetches = new CopyOnWriteArrayList<>();
+            CompletableFuture<Void> secondServed = node(second, metadata, request -> {
+                if (!request.is(FETCH_CODE, OPERATION_NON_REPLICATED)) {
+                    return unexpected(request);
+                }
+                fetches.add(request);
+                return Response.success(OPERATION_NON_REPLICATED, reply());
+            });
+            AsyncIggyTcpClient client = signedIn(leader);
+            try {
+                Map<Long, PollingStrategy> targets = new LinkedHashMap<>();
+                targets.put(2L, PollingStrategy.next());
+                targets.put(1L, PollingStrategy.offset(BigInteger.valueOf(5)));
+
+                messages(client)
+                        .fetchMessages(STREAM, TOPIC, targets, GROUP, COUNT, false, FETCH)
+                        .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+
+                Request fetch = fetches.get(0);
+                assertThat(List.of(fetch.incarnation(), fetch.ownerGeneration()))
+                        .containsOnly(0L);
+                assertThat(targets(fetch))
+                        .containsExactly(
+                                new Target(2, PollingKind.Next.asCode(), 0, FETCH.maxBytes(), ROUTE_INCARNATION + 2, 0),
+                                new Target(
+                                        1, PollingKind.Offset.asCode(), 5, FETCH.maxBytes(), ROUTE_INCARNATION + 1, 0));
+                assertThat(misrouted).isEmpty();
+            } finally {
+                client.close().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            }
+            CompletableFuture.allOf(leaderServed, secondServed).get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    /**
+     * Each readiness group gets its own fetch, so the reply of one primary neither waits for nor ends the fetch that
+     * another primary holds.
+     */
+    @Test
+    void shouldServeTheFetchOfOneGroupWhileAnotherPrimaryHoldsItsFetch() throws Exception {
+        InetAddress loopback = InetAddress.getLoopbackAddress();
+        try (ServerSocket leader = new ServerSocket(0, 4, loopback);
+                ServerSocket first = new ServerSocket(0, 4, loopback);
+                ServerSocket second = new ServerSocket(0, 4, loopback)) {
+            Supplier<ByteBuf> metadata = () -> threeNodeMetadata(
+                    leader.getLocalPort(), first.getLocalPort(), second.getLocalPort(), leader.getLocalPort());
+            CompletableFuture<Void> leaderServed = node(leader, metadata, routes(first, second));
+            CompletableFuture<Void> firstServed = node(
+                    first,
+                    metadata,
+                    request -> request.is(FETCH_CODE, OPERATION_NON_REPLICATED)
+                            ? Response.success(OPERATION_NON_REPLICATED, emptySection(1))
+                            : unexpected(request));
+            CompletableFuture<Request> held = new CompletableFuture<>();
+            CompletableFuture<Void> released = new CompletableFuture<>();
+            List<Request> cancels = new CopyOnWriteArrayList<>();
+            CompletableFuture<Void> secondServed = node(second, metadata, request -> {
+                if (request.is(CANCEL_CODE, OPERATION_NON_REPLICATED)) {
+                    cancels.add(request);
+                    return Response.noReply();
+                }
+                if (!request.is(FETCH_CODE, OPERATION_NON_REPLICATED) || !held.complete(request)) {
+                    return unexpected(request);
+                }
+                after(released);
+                return Response.success(OPERATION_NON_REPLICATED, emptySection(2));
+            });
+            AsyncIggyTcpClient client = signedIn(leader);
+            try {
+                var messages = messages(client);
+                assertThat(messages.readinessGroups(STREAM, TOPIC, List.of(1L, 2L))
+                                .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS))
+                        .containsExactlyInAnyOrder(List.of(1L), List.of(2L));
+                var slow = messages.fetchMessages(STREAM, TOPIC, offsetsOf(2L), GROUP, COUNT, false, FETCH);
+                held.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+
+                assertThat(messages.fetchMessages(STREAM, TOPIC, offsetsOf(1L), GROUP, COUNT, false, FETCH)
+                                .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS))
+                        .extracting(FetchedMessages::partitionId)
+                        .containsExactly(1L);
+                assertThat(slow).isNotDone();
+                released.complete(null);
+
+                assertThat(slow.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS))
+                        .extracting(FetchedMessages::partitionId)
+                        .containsExactly(2L);
+            } finally {
+                client.close().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            }
+            CompletableFuture.allOf(leaderServed, firstServed, secondServed)
+                    .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            assertThat(cancels).isEmpty();
+        }
+    }
+
+    /** A NOT_PRIMARY section marks the route of its partition moved, so the next grouping looks it up again. */
+    @Test
+    void shouldLookUpTheRouteOfAPartitionWhoseSectionIsNotPrimaryAgain() throws Exception {
+        try (ServerSocket socket = new ServerSocket(0, 4, InetAddress.getLoopbackAddress())) {
+            List<Integer> lookups = new CopyOnWriteArrayList<>();
+            CompletableFuture<Void> served = node(socket, () -> singleNodeMetadata(socket.getLocalPort()), request -> {
+                if (request.is(GET_PARTITION_ROUTE_CODE, OPERATION_NON_REPLICATED)) {
+                    lookups.add(lastInt(request.body()));
+                    return Response.success(OPERATION_NON_REPLICATED, pollRoute(request, 1, 1, socket.getLocalPort()));
+                }
+                if (!request.is(FETCH_CODE, OPERATION_NON_REPLICATED)) {
+                    return unexpected(request);
+                }
+                ByteBuf reply = Unpooled.buffer().writeIntLE(2);
+                section(reply, 1, FetchedMessages.STATUS_OK, 0);
+                section(reply, 2, NOT_PRIMARY, 0);
+                return Response.success(OPERATION_NON_REPLICATED, reply);
+            });
+            AsyncIggyTcpClient client = signedIn(socket);
+            try {
+                assertThat(messages(client)
+                                .readinessGroups(STREAM, TOPIC, List.of(1L, 2L))
+                                .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS))
+                        .containsExactly(List.of(1L, 2L));
+                List<FetchedMessages> sections = messages(client)
+                        .fetchMessages(STREAM, TOPIC, offsetsOf(1L, 2L), GROUP, COUNT, false, FETCH)
+                        .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+                assertThat(sections).extracting(FetchedMessages::status).containsExactly(0L, (long) NOT_PRIMARY);
+                assertThat(lookups).containsExactlyInAnyOrder(1, 2);
+
+                messages(client)
+                        .readinessGroups(STREAM, TOPIC, List.of(1L, 2L))
+                        .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+
+                assertThat(lookups).hasSize(3).last().isEqualTo(2);
+            } finally {
+                client.close().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            }
+            served.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    /**
+     * A refusal of the whole fetch does not name the partition that moved. The call fails at once and marks the routes
+     * of every target moved, so the next fetch finds the new primary.
+     */
+    @Test
+    void shouldFailAFetchThatItsPrimaryRefusesAndLookUpEveryRouteAgain() throws Exception {
+        InetAddress loopback = InetAddress.getLoopbackAddress();
+        try (ServerSocket leader = new ServerSocket(0, 4, loopback);
+                ServerSocket moved = new ServerSocket(0, 4, loopback);
+                ServerSocket primary = new ServerSocket(0, 4, loopback)) {
+            Supplier<ByteBuf> metadata = () -> threeNodeMetadata(
+                    leader.getLocalPort(), moved.getLocalPort(), primary.getLocalPort(), leader.getLocalPort());
+            CompletableFuture<Void> leaderServed = node(
+                    leader,
+                    metadata,
+                    request -> request.is(GET_PARTITION_ROUTE_CODE, OPERATION_NON_REPLICATED)
+                            ? Response.success(OPERATION_NON_REPLICATED, pollRoute(request, 1, 1, moved.getLocalPort()))
+                            : unexpected(request));
+            List<Integer> movedLookups = new CopyOnWriteArrayList<>();
+            List<Request> refused = new CopyOnWriteArrayList<>();
+            CompletableFuture<Void> movedServed = node(moved, metadata, request -> {
+                if (request.is(FETCH_CODE, OPERATION_NON_REPLICATED)) {
+                    refused.add(request);
+                    return Response.error(OPERATION_NON_REPLICATED, NOT_PRIMARY);
+                }
+                if (!request.is(GET_PARTITION_ROUTE_CODE, OPERATION_NON_REPLICATED)) {
+                    return unexpected(request);
+                }
+                // The lookup after the refusal asks the last known primary first.
+                movedLookups.add(lastInt(request.body()));
+                return Response.success(OPERATION_NON_REPLICATED, pollRoute(request, 1, 1, primary.getLocalPort()));
+            });
+            CompletableFuture<Void> primaryServed = node(
+                    primary,
+                    metadata,
+                    request -> request.is(FETCH_CODE, OPERATION_NON_REPLICATED)
+                            ? Response.success(OPERATION_NON_REPLICATED, reply())
+                            : unexpected(request));
+            AsyncIggyTcpClient client = signedIn(leader);
+            try {
+                assertServerError(
+                        messages(client).fetchMessages(STREAM, TOPIC, offsetsOf(1L, 2L), GROUP, COUNT, true, FETCH),
+                        NOT_PRIMARY);
+                assertThat(refused).hasSize(1);
+
+                assertThat(messages(client)
+                                .fetchMessages(STREAM, TOPIC, offsetsOf(1L, 2L), GROUP, COUNT, true, FETCH)
+                                .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS))
+                        .isEmpty();
+                assertThat(movedLookups).containsExactlyInAnyOrder(1, 2);
+                assertThat(refused).hasSize(1);
+            } finally {
+                client.close().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            }
+            CompletableFuture.allOf(leaderServed, movedServed, primaryServed)
+                    .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    /**
+     * HISTORY_UNAVAILABLE means that the partitions of the fetch were recreated. The call fails, and the next fetch
+     * looks up the routes of the topic again and carries their new fences.
+     */
+    @Test
+    void shouldLookUpTheRoutesOfAFetchAgainAfterTheHistoryOfItsPartitionsChanged() throws Exception {
+        try (ServerSocket socket = new ServerSocket(0, 4, InetAddress.getLoopbackAddress())) {
+            List<Integer> lookups = new CopyOnWriteArrayList<>();
+            List<Request> fetches = new CopyOnWriteArrayList<>();
+            CompletableFuture<Void> served = node(socket, () -> singleNodeMetadata(socket.getLocalPort()), request -> {
+                if (request.is(GET_PARTITION_ROUTE_CODE, OPERATION_NON_REPLICATED)) {
+                    lookups.add(lastInt(request.body()));
+                    // The partitions have their next incarnation once the server refused the first fetch.
+                    return Response.success(
+                            OPERATION_NON_REPLICATED,
+                            pollRoute(request, 1, 1, socket.getLocalPort(), ROUTE_INCARNATION + fetches.size()));
+                }
+                if (!request.is(FETCH_CODE, OPERATION_NON_REPLICATED)) {
+                    return unexpected(request);
+                }
+                fetches.add(request);
+                return fetches.size() == 1
+                        ? Response.error(OPERATION_NON_REPLICATED, HISTORY_UNAVAILABLE)
+                        : Response.success(OPERATION_NON_REPLICATED, reply());
+            });
+            AsyncIggyTcpClient client = signedIn(socket);
+            try {
+                assertServerError(
+                        messages(client).fetchMessages(STREAM, TOPIC, offsetsOf(1L, 2L), GROUP, COUNT, false, FETCH),
+                        HISTORY_UNAVAILABLE);
+                messages(client)
+                        .fetchMessages(STREAM, TOPIC, offsetsOf(1L, 2L), GROUP, COUNT, false, FETCH)
+                        .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+
+                assertThat(lookups).containsExactlyInAnyOrder(1, 2, 1, 2);
+                assertThat(fetches).hasSize(2);
+                assertThat(targets(fetches.get(0)))
+                        .extracting(Target::incarnation)
+                        .containsOnly(ROUTE_INCARNATION);
+                assertThat(targets(fetches.get(1)))
+                        .extracting(Target::incarnation)
+                        .containsOnly(ROUTE_INCARNATION + 1);
+            } finally {
+                client.close().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            }
+            served.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    @Test
+    void shouldRefuseAFetchThatTheServerWouldRefuseBeforeSendingIt() throws Exception {
+        try (ServerSocket socket = new ServerSocket(0, 4, InetAddress.getLoopbackAddress())) {
+            CompletableFuture<Void> served = singleNode(socket, AsyncIggyTcpClientFetchMessagesTest::unexpected);
+            AsyncIggyTcpClient client = signedIn(socket);
+            try {
+                var messages = messages(client);
+                List<Map<Long, PollingStrategy>> refusedTargets = List.of(
+                        Map.of(),
+                        offsetsOf(LongStream.rangeClosed(0, FetchMessages.MAX_PARTITIONS_PER_REQUEST)
+                                .toArray()),
+                        Map.of(1L, PollingStrategy.first()),
+                        Map.of(1L, PollingStrategy.last()),
+                        Map.of(1L, PollingStrategy.timestamp(BigInteger.ONE)),
+                        offsetsOf(FetchMessages.MAX_PARTITION_ID + 1),
+                        offsetsOf(-1L));
+                for (Map<Long, PollingStrategy> targets : refusedTargets) {
+                    assertThatThrownBy(() -> messages.fetchMessages(STREAM, TOPIC, targets, GROUP, COUNT, false, FETCH))
+                            .isInstanceOf(IggyInvalidArgumentException.class);
+                }
+                assertThatThrownBy(() -> messages.fetchMessages(STREAM, TOPIC, offsetsOf(1L), GROUP, 0L, false, FETCH))
+                        .isInstanceOf(IggyInvalidArgumentException.class);
+                assertThatThrownBy(() -> messages.fetchMessages(
+                                STREAM, TOPIC, offsetsOf(1L), GROUP, 1L, false, FETCH.withMinCount(2)))
+                        .isInstanceOf(IggyInvalidArgumentException.class);
+                for (Duration timeout :
+                        List.of(DeferredPollOptions.REPLY_MARGIN.minusNanos(1000), DeferredPollOptions.REPLY_MARGIN)) {
+                    DeferredPollOptions noStartupBudget =
+                            FETCH.withMaxWait(Duration.ZERO).withRequestTimeout(timeout);
+                    assertThatThrownBy(() -> messages.fetchMessages(
+                                    STREAM, TOPIC, offsetsOf(1L), GROUP, COUNT, false, noStartupBudget))
+                            .isInstanceOf(IggyInvalidArgumentException.class);
+                }
+            } finally {
+                client.close().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            }
+            served.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    @Test
+    void shouldSendAZeroWaitFetchWithTimeLeftAfterTheReplyMargin() throws Exception {
+        try (ServerSocket socket = new ServerSocket(0, 4, InetAddress.getLoopbackAddress())) {
+            List<Request> fetches = new CopyOnWriteArrayList<>();
+            CompletableFuture<Void> served = singleNode(socket, request -> {
+                if (!request.is(FETCH_CODE, OPERATION_NON_REPLICATED)) {
+                    return unexpected(request);
+                }
+                fetches.add(request);
+                return Response.success(OPERATION_NON_REPLICATED, reply());
+            });
+            AsyncIggyTcpClient client = signedIn(socket);
+            try {
+                assertThat(messages(client)
+                                .fetchMessages(STREAM, TOPIC, offsetsOf(1L), GROUP, COUNT, false, IMMEDIATE)
+                                .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS))
+                        .isEmpty();
+                assertThat(fetches).hasSize(1);
+                Budgets sent = Budgets.of(fetches.get(0));
+                assertThat(sent.waitMicros()).isZero();
+                assertThat(sent.timeoutMicros())
+                        .isGreaterThanOrEqualTo(
+                                TimeUnit.NANOSECONDS.toMicros(DeferredPollOptions.REPLY_MARGIN.toNanos()))
+                        .isLessThan(IMMEDIATE.requestTimeoutMicros());
+            } finally {
+                client.close().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            }
+            served.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    @ParameterizedTest(name = "blocked partition={0}")
+    @ValueSource(longs = {2, 0xffff_ffffL})
+    void shouldNameTheBlockedPartitionAndResumeFetchingAfterRepair(long partition) throws Exception {
+        try (ServerSocket socket = new ServerSocket(0, 4, InetAddress.getLoopbackAddress())) {
+            CompletableFuture<Request> written = new CompletableFuture<>();
+            List<Request> stores = new CopyOnWriteArrayList<>();
+            List<Request> fetches = new CopyOnWriteArrayList<>();
+            CompletableFuture<Void> served = singleNode(socket, request -> {
+                if (request.operation() == VsrOperation.STORE_CONSUMER_OFFSET) {
+                    stores.add(request);
+                    written.complete(request);
+                    return stores.size() == 1
+                            ? Response.noReply()
+                            : Response.committed(
+                                    request.operation(), 2, Unpooled.buffer().writeIntLE(0));
+                }
+                if (request.is(FETCH_CODE, OPERATION_NON_REPLICATED)) {
+                    fetches.add(request);
+                    return Response.success(OPERATION_NON_REPLICATED, reply());
+                }
+                return unexpected(request);
+            });
+            AsyncIggyTcpClient client = signedIn(socket);
+            try {
+                var cancelled = client.consumerOffsets()
+                        .storeConsumerOffset(STREAM, TOPIC, Optional.of(partition), GROUP, BigInteger.ZERO);
+                long operation =
+                        written.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS).requestId();
+                cancelled.cancel(false);
+                Map<Long, PollingStrategy> targets = new LinkedHashMap<>();
+                targets.put(1L, PollingStrategy.next());
+                targets.put(partition, PollingStrategy.next());
+
+                assertThatThrownBy(() -> messages(client)
+                                .fetchMessages(STREAM, TOPIC, targets, GROUP, COUNT, false, FETCH)
+                                .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS))
+                        .hasCauseInstanceOf(IggyOperationException.class)
+                        .satisfies(error -> {
+                            var blocked = (IggyOperationException) error.getCause();
+                            assertThat(blocked.getErrorCode()).isEqualTo(IggyErrorCode.PARTITION_LANE_BLOCKED);
+                            assertThat(blocked.getOperationId()).isEqualTo(operation);
+                            assertThat(blocked.getPartitionId()).hasValue(partition);
+                            assertThat(blocked.getMessage()).contains("Partition " + partition);
+                        });
+                assertThat(fetches).isEmpty();
+                assertServerError(
+                        client.consumerOffsets()
+                                .storeConsumerOffset(STREAM, TOPIC, Optional.of(partition), GROUP, BigInteger.ONE),
+                        IggyErrorCode.LANE_BLOCKED.getCode());
+
+                client.resolveOperation(operation, TIMEOUT).get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+                assertThat(stores).hasSize(2).extracting(Request::requestId).containsOnly(operation);
+                assertThat(messages(client)
+                                .fetchMessages(STREAM, TOPIC, targets, GROUP, COUNT, false, FETCH)
+                                .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS))
+                        .isEmpty();
+                assertThat(fetches).hasSize(1);
+            } finally {
+                client.close().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            }
+            served.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    /**
+     * A fetch that commits or starts a partition at its next offset can have moved the partition on the server when
+     * its reply is lost, so the client never resends it. A fetch that only reads goes out again until its budget ends.
+     */
+    @ParameterizedTest
+    @CsvSource({"true, Offset, false", "false, Next, false", "false, Offset, true"})
+    void shouldResendOnlyAFetchThatCannotHaveMovedItsPartitions(boolean autoCommit, PollingKind kind, boolean resent)
+            throws Exception {
+        try (ServerSocket socket = new ServerSocket(0, 4, InetAddress.getLoopbackAddress())) {
+            List<Request> fetches = new CopyOnWriteArrayList<>();
+            CompletableFuture<Void> served = singleNode(socket, request -> {
+                if (!request.is(FETCH_CODE, OPERATION_NON_REPLICATED)) {
+                    return unexpected(request);
+                }
+                fetches.add(request);
+                return Response.error(OPERATION_NON_REPLICATED, TRANSIENT_NOT_COMMITTED);
+            });
+            AsyncIggyTcpClient client = signedIn(socket);
+            try {
+                PollingStrategy strategy =
+                        kind == PollingKind.Next ? PollingStrategy.next() : PollingStrategy.offset(BigInteger.ONE);
+                var fetch = messages(client)
+                        .fetchMessages(STREAM, TOPIC, Map.of(1L, strategy), GROUP, COUNT, autoCommit, IMMEDIATE);
+
+                if (resent) {
+                    assertThatThrownBy(() -> fetch.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS))
+                            .rootCause()
+                            .isInstanceOfSatisfying(
+                                    IggyServerException.class,
+                                    error -> assertThat(error.getRawErrorCode())
+                                            .isIn(TRANSIENT_NOT_COMMITTED, TRANSIENT_NOT_ACCEPTED));
+                    assertThat(fetches).hasSizeGreaterThan(1);
+                } else {
+                    assertServerError(fetch, autoCommit ? 14010 : TRANSIENT_NOT_COMMITTED);
+                    assertThat(fetches).hasSize(1);
+                    if (autoCommit) {
+                        var unknown = fetch.handle(
+                                        (sections, error) -> (IggyOperationException) PollRouter.unwrap(error))
+                                .join();
+                        assertServerError(client.resolveOperation(unknown.getOperationId(), TIMEOUT), 14010);
+                        client.abandonOperation(unknown.getOperationId()).join();
+                    }
+                }
+            } finally {
+                client.close().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            }
+            served.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    /**
+     * The server sends these refusals before it reads a partition, so the client sends the fetch again as the same
+     * request, also with auto-commit. The new attempt waits only for what is left of the call.
+     */
+    @ParameterizedTest
+    @CsvSource({
+        "TRANSIENT_NOT_ACCEPTED, false",
+        "TRANSIENT_NOT_ACCEPTED, true",
+        "DEFERRED_POLL_CAPACITY_EXCEEDED, false",
+        "DEFERRED_POLL_CAPACITY_EXCEEDED, true"
+    })
+    void shouldSendARefusedFetchAgainWithWhatIsLeftOfItsBudgets(IggyErrorCode refusal, boolean autoCommit)
+            throws Exception {
+        try (ServerSocket socket = new ServerSocket(0, 4, InetAddress.getLoopbackAddress())) {
+            List<Request> fetches = new CopyOnWriteArrayList<>();
+            CompletableFuture<Void> served = singleNode(socket, request -> {
+                if (!request.is(FETCH_CODE, OPERATION_NON_REPLICATED)) {
+                    return unexpected(request);
+                }
+                fetches.add(request);
+                return fetches.size() == 1
+                        ? Response.error(OPERATION_NON_REPLICATED, refusal.getCode())
+                        : Response.success(OPERATION_NON_REPLICATED, reply());
+            });
+            AsyncIggyTcpClient client = signedIn(socket);
+            try {
+                assertThat(messages(client)
+                                .fetchMessages(STREAM, TOPIC, offsetsOf(1L, 2L), GROUP, COUNT, autoCommit, FETCH)
+                                .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS))
+                        .isEmpty();
+
+                assertThat(fetches).hasSize(2);
+                Request first = fetches.get(0);
+                Request second = fetches.get(1);
+                assertThat(List.of(second.requestId(), second.clientLow(), second.clientHigh()))
+                        .containsExactly(first.requestId(), first.clientLow(), first.clientHigh());
+                assertThat(targets(second)).isEqualTo(targets(first));
+                Budgets sent = Budgets.of(first);
+                Budgets resent = Budgets.of(second);
+                assertThat(resent.waitMicros()).isLessThan(sent.waitMicros());
+                assertThat(resent.timeoutMicros()).isLessThan(sent.timeoutMicros());
+                assertThat(List.of(
+                                sent.timeoutMicros() - sent.waitMicros(), resent.timeoutMicros() - resent.waitMicros()))
+                        .containsOnly(FETCH.requestTimeoutMicros() - FETCH.maxWaitMicros());
+            } finally {
+                client.close().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            }
+            served.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    @Test
+    void shouldCancelAParkedFetchThatTheCallerCancels() throws Exception {
+        try (ServerSocket socket = new ServerSocket(0, 4, InetAddress.getLoopbackAddress())) {
+            CompletableFuture<Request> parked = new CompletableFuture<>();
+            CompletableFuture<Request> cancelled = new CompletableFuture<>();
+            CompletableFuture<Void> served = singleNode(socket, request -> {
+                if (request.is(CANCEL_CODE, OPERATION_NON_REPLICATED) && cancelled.complete(request)) {
+                    return Response.noReply();
+                }
+                return request.is(FETCH_CODE, OPERATION_NON_REPLICATED) && parked.complete(request)
+                        ? Response.noReply()
+                        : unexpected(request);
+            });
+            AsyncIggyTcpClient client = signedIn(socket);
+            try {
+                var fetch = messages(client).fetchMessages(STREAM, TOPIC, offsetsOf(1L), GROUP, COUNT, false, FETCH);
+                parked.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+
+                assertThat(fetch.cancel(true)).isTrue();
+
+                assertCancels(cancelled.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS), parked.join());
+            } finally {
+                client.close().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            }
+            served.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    /**
+     * A cancel can drop what a committing fetch committed, so close waits for its reply. Then close cancels the fetch
+     * that only reads.
+     */
+    @Test
+    void shouldWaitForACommittingFetchAtCloseAndCancelTheOthers() throws Exception {
+        InetAddress loopback = InetAddress.getLoopbackAddress();
+        try (ServerSocket leader = new ServerSocket(0, 4, loopback);
+                ServerSocket first = new ServerSocket(0, 4, loopback);
+                ServerSocket second = new ServerSocket(0, 4, loopback)) {
+            Supplier<ByteBuf> metadata = () -> threeNodeMetadata(
+                    leader.getLocalPort(), first.getLocalPort(), second.getLocalPort(), leader.getLocalPort());
+            CompletableFuture<Void> leaderServed = node(leader, metadata, routes(first, second));
+            CompletableFuture<Request> committing = new CompletableFuture<>();
+            CompletableFuture<Void> closing = new CompletableFuture<>();
+            List<Request> committingCancels = new CopyOnWriteArrayList<>();
+            CompletableFuture<Void> firstServed = node(first, metadata, request -> {
+                if (request.is(CANCEL_CODE, OPERATION_NON_REPLICATED)) {
+                    committingCancels.add(request);
+                    return Response.noReply();
+                }
+                if (!request.is(FETCH_CODE, OPERATION_NON_REPLICATED) || !committing.complete(request)) {
+                    return unexpected(request);
+                }
+                // The reply comes only after close began.
+                after(closing);
+                return Response.success(OPERATION_NON_REPLICATED, reply());
+            });
+            CompletableFuture<Request> reading = new CompletableFuture<>();
+            CompletableFuture<Request> readingCancel = new CompletableFuture<>();
+            CompletableFuture<Void> secondServed = node(second, metadata, request -> {
+                if (request.is(CANCEL_CODE, OPERATION_NON_REPLICATED) && readingCancel.complete(request)) {
+                    return Response.noReply();
+                }
+                return request.is(FETCH_CODE, OPERATION_NON_REPLICATED) && reading.complete(request)
+                        ? Response.noReply()
+                        : unexpected(request);
+            });
+            AsyncIggyTcpClient client = signedIn(leader);
+            var read = messages(client).fetchMessages(STREAM, TOPIC, offsetsOf(2L), GROUP, COUNT, false, FETCH);
+            reading.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            var commit = messages(client).fetchMessages(STREAM, TOPIC, offsetsOf(1L), GROUP, COUNT, true, FETCH);
+            committing.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+
+            CompletableFuture<Void> closed = client.close();
+            assertThat(closed).isNotDone();
+            closing.complete(null);
+
+            closed.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            assertThat(commit.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)).isEmpty();
+            assertCancels(readingCancel.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS), reading.join());
+            assertThat(read).isCompletedExceptionally();
+            assertThat(committingCancels).isEmpty();
+            CompletableFuture.allOf(leaderServed, firstServed, secondServed)
+                    .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    /** A link reads replies up to its frame size, so a fetch asks for no more than the link reads. */
+    @Test
+    void shouldLowerTheByteLimitOfAFetchToWhatTheLinkReads() throws Exception {
+        long linkFrame = AsyncIggyTcpClientBuilder.MIN_MAX_VSR_FRAME_SIZE;
+        long linkRecords = linkFrame
+                - VsrHeaders.HEADER_SIZE
+                - BytesDeserializer.FETCH_SECTIONS_COUNT_BYTES
+                - BytesDeserializer.FETCH_SECTION_BYTES;
+        try (ServerSocket socket = new ServerSocket(0, 4, InetAddress.getLoopbackAddress())) {
+            List<Request> fetches = new CopyOnWriteArrayList<>();
+            CompletableFuture<Void> served = singleNode(socket, request -> {
+                if (!request.is(FETCH_CODE, OPERATION_NON_REPLICATED)) {
+                    return unexpected(request);
+                }
+                fetches.add(request);
+                return Response.success(OPERATION_NON_REPLICATED, reply());
+            });
+            AsyncIggyTcpClient client = AsyncIggyTcpClient.builder()
+                    .host(socket.getInetAddress().getHostAddress())
+                    .port(socket.getLocalPort())
+                    .credentials("configured-user", "configured-password")
+                    .heartbeatInterval(Duration.ofMinutes(1))
+                    .maxVsrFrameSize(linkFrame)
+                    .build();
+            client.connect().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            client.login().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            try {
+                messages(client)
+                        .fetchMessages(
+                                STREAM, TOPIC, offsetsOf(1L), GROUP, COUNT, false, FETCH.withMaxBytes(0xFFFF_0000L))
+                        .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+
+                ByteBuffer body = ByteBuffer.wrap(fetches.get(0).body()).order(ByteOrder.LITTLE_ENDIAN);
+                assertThat(Integer.toUnsignedLong(body.getInt(NUMERIC_LIMITS + FetchMessages.MAX_BYTES_OFFSET)))
+                        .isEqualTo(linkRecords);
+                assertThat(targets(fetches.get(0)))
+                        .singleElement()
+                        .extracting(Target::maxBytes)
+                        .isEqualTo(linkRecords);
+            } finally {
+                client.close().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            }
+            served.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    private static Map<Long, PollingStrategy> offsetsOf(long... partitions) {
+        Map<Long, PollingStrategy> targets = new LinkedHashMap<>();
+        for (long partition : partitions) {
+            targets.put(partition, PollingStrategy.offset(BigInteger.ZERO));
+        }
+        return targets;
+    }
+
+    @ParameterizedTest
+    @ValueSource(ints = {1, 99})
+    void shouldRetainEveryTargetWhenTheReplyTableNamesADuplicateOrUnrequestedPartition(int invalid) throws Exception {
+        try (ServerSocket socket = new ServerSocket(0, 4, InetAddress.getLoopbackAddress())) {
+            CompletableFuture<Void> served = singleNode(socket, request -> {
+                ByteBuf response = Unpooled.buffer().writeIntLE(2);
+                section(response, 1, FetchedMessages.STATUS_OK, 0);
+                section(response, invalid, FetchedMessages.STATUS_OK, 0);
+                return Response.success(OPERATION_NON_REPLICATED, response);
+            });
+            AsyncIggyTcpClient client = signedIn(socket);
+            try {
+                var fetch =
+                        messages(client).fetchMessages(STREAM, TOPIC, offsetsOf(1L, 2L), GROUP, COUNT, true, IMMEDIATE);
+                assertServerError(fetch, IggyErrorCode.FETCH_PROGRESS_UNCERTAIN.getCode());
+                var unknown = fetch.handle((sections, error) -> (IggyOperationException) PollRouter.unwrap(error))
+                        .join();
+                try {
+                    for (long partition : List.of(1L, 2L)) {
+                        assertServerError(
+                                client.consumerOffsets()
+                                        .storeConsumerOffset(
+                                                STREAM, TOPIC, Optional.of(partition), GROUP, BigInteger.ONE),
+                                IggyErrorCode.PARTITION_LANE_BLOCKED.getCode());
+                    }
+                } finally {
+                    client.abandonOperation(unknown.getOperationId()).join();
+                }
+            } finally {
+                client.close().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            }
+            served.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(ints = {58, 80, 87, 89, 3007, 3024, 5009})
+    void shouldKeepDefinitelyRefusedSectionsFreeForTheNextProgressCall(int status) throws Exception {
+        try (ServerSocket socket = new ServerSocket(0, 4, InetAddress.getLoopbackAddress())) {
+            CompletableFuture<Void> served = singleNode(socket, request -> {
+                if (request.is(FETCH_CODE, OPERATION_NON_REPLICATED)) {
+                    ByteBuf response = Unpooled.buffer().writeIntLE(1);
+                    section(response, 1, status, 0);
+                    return Response.success(OPERATION_NON_REPLICATED, response);
+                }
+                return Response.committed(
+                        request.operation(), 2, Unpooled.buffer().writeIntLE(0));
+            });
+            AsyncIggyTcpClient client = signedIn(socket);
+            try {
+                var sections = messages(client)
+                        .fetchMessages(STREAM, TOPIC, offsetsOf(1L), GROUP, COUNT, true, IMMEDIATE)
+                        .join();
+                assertThat(sections.get(0).status()).isEqualTo(status);
+                assertThat(sections.get(0).recoveryError()).isEmpty();
+                client.consumerOffsets()
+                        .storeConsumerOffset(STREAM, TOPIC, Optional.of(1L), GROUP, BigInteger.ONE)
+                        .join();
+            } finally {
+                client.close().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            }
+            served.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    /** A request that the server refuses for capacity until its deadline ends with that refusal, as over HTTP. */
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void shouldEndARequestThatTheServerKeepsRefusingForCapacityWithThatRefusal(boolean fetch) throws Exception {
+        try (ServerSocket socket = new ServerSocket(0, 4, InetAddress.getLoopbackAddress())) {
+            List<Request> refused = new CopyOnWriteArrayList<>();
+            CompletableFuture<Void> served = singleNode(socket, request -> {
+                if (!request.is(fetch ? FETCH_CODE : POLL_DEFERRED_CODE, OPERATION_NON_REPLICATED)) {
+                    return unexpected(request);
+                }
+                refused.add(request);
+                return Response.error(
+                        OPERATION_NON_REPLICATED, IggyErrorCode.DEFERRED_POLL_CAPACITY_EXCEEDED.getCode());
+            });
+            AsyncIggyTcpClient client = signedIn(socket);
+            try {
+                var call = fetch
+                        ? messages(client).fetchMessages(STREAM, TOPIC, offsetsOf(1L), GROUP, COUNT, false, IMMEDIATE)
+                        : client.messages()
+                                .pollMessagesDeferred(
+                                        STREAM,
+                                        TOPIC,
+                                        Optional.of(1L),
+                                        GROUP,
+                                        PollingStrategy.offset(BigInteger.ZERO),
+                                        COUNT,
+                                        false,
+                                        IMMEDIATE);
+
+                assertServerError(call, IggyErrorCode.DEFERRED_POLL_CAPACITY_EXCEEDED.getCode());
+                assertThat(refused).hasSizeGreaterThan(1);
+            } finally {
+                client.close().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            }
+            served.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    /** The server refuses a fetch whose first message exceeds its byte limit before it stores an offset. */
+    @Test
+    void shouldKeepTheTargetsOfAFetchRefusedForItsByteLimitFreeForTheNextProgressCall() throws Exception {
+        try (ServerSocket socket = new ServerSocket(0, 4, InetAddress.getLoopbackAddress())) {
+            CompletableFuture<Void> served = singleNode(socket, request -> {
+                if (request.is(FETCH_CODE, OPERATION_NON_REPLICATED)) {
+                    return Response.error(OPERATION_NON_REPLICATED, IggyErrorCode.INVALID_SIZE_BYTES.getCode());
+                }
+                return Response.committed(
+                        request.operation(), 2, Unpooled.buffer().writeIntLE(0));
+            });
+            AsyncIggyTcpClient client = signedIn(socket);
+            try {
+                assertServerError(
+                        messages(client).fetchMessages(STREAM, TOPIC, offsetsOf(1L), GROUP, COUNT, true, IMMEDIATE),
+                        IggyErrorCode.INVALID_SIZE_BYTES.getCode());
+                client.consumerOffsets()
+                        .storeConsumerOffset(STREAM, TOPIC, Optional.of(1L), GROUP, BigInteger.ONE)
+                        .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            } finally {
+                client.close().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            }
+            served.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    @Test
+    void shouldKeepRecoveryAcrossReconnectAndNeverReuseItsIdentity() throws Exception {
+        try (ServerSocket socket = new ServerSocket(0, 4, InetAddress.getLoopbackAddress())) {
+            RequestHandler handler = request -> request.is(FETCH_CODE, OPERATION_NON_REPLICATED)
+                    ? Response.error(OPERATION_NON_REPLICATED, TRANSIENT_NOT_COMMITTED)
+                    : Response.committed(
+                            request.operation(), 2, Unpooled.buffer().writeIntLE(0));
+            CompletableFuture<Void> served = singleNode(socket, handler);
+            AsyncIggyTcpClient client = signedIn(socket);
+            try {
+                var first = messages(client).fetchMessages(STREAM, TOPIC, offsetsOf(1L), GROUP, COUNT, true, IMMEDIATE);
+                assertServerError(first, IggyErrorCode.FETCH_PROGRESS_UNCERTAIN.getCode());
+                long firstId = first.handle((sections, error) ->
+                                ((IggyOperationException) PollRouter.unwrap(error)).getOperationId())
+                        .join();
+                CompletableFuture<Void> reconnected = singleNode(socket, handler);
+                client.connect().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+                client.login().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+                served.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+                assertServerError(
+                        client.consumerOffsets()
+                                .storeConsumerOffset(STREAM, TOPIC, Optional.of(1L), GROUP, BigInteger.ONE),
+                        IggyErrorCode.PARTITION_LANE_BLOCKED.getCode());
+                assertServerError(
+                        client.resolveOperation(firstId, TIMEOUT), IggyErrorCode.FETCH_PROGRESS_UNCERTAIN.getCode());
+                client.abandonOperation(firstId).join();
+                var next = messages(client).fetchMessages(STREAM, TOPIC, offsetsOf(1L), GROUP, COUNT, true, IMMEDIATE);
+                assertServerError(next, IggyErrorCode.FETCH_PROGRESS_UNCERTAIN.getCode());
+                long nextId = next.handle((sections, error) ->
+                                ((IggyOperationException) PollRouter.unwrap(error)).getOperationId())
+                        .join();
+                assertThat(Long.compareUnsigned(nextId, firstId)).isPositive();
+                client.abandonOperation(nextId).join();
+                client.close().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+                reconnected.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            } finally {
+                client.close().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            }
+        }
+    }
+
+    @Test
+    void shouldBlockAnotherConnectionWhileAnUnknownCommittingFetchIsStillLive() throws Exception {
+        InetAddress loopback = InetAddress.getLoopbackAddress();
+        try (ServerSocket leader = new ServerSocket(0, 4, loopback);
+                ServerSocket first = new ServerSocket(0, 4, loopback);
+                ServerSocket second = new ServerSocket(0, 4, loopback)) {
+            Supplier<ByteBuf> metadata = () -> threeNodeMetadata(
+                    leader.getLocalPort(), first.getLocalPort(), second.getLocalPort(), leader.getLocalPort());
+            CompletableFuture<Void> leaderServed = node(leader, metadata, routes(first, second));
+            CompletableFuture<Request> parked = new CompletableFuture<>();
+            CompletableFuture<Void> finishOldRead = new CompletableFuture<>();
+            List<Request> fetches = new CopyOnWriteArrayList<>();
+            CompletableFuture<Void> firstServed = node(first, metadata, request -> {
+                fetches.add(request);
+                parked.complete(request);
+                after(finishOldRead);
+                return Response.noReply();
+            });
+            List<Request> stores = new CopyOnWriteArrayList<>();
+            CompletableFuture<Void> secondServed = node(second, metadata, request -> {
+                stores.add(request);
+                return Response.committed(
+                        request.operation(), 2, Unpooled.buffer().writeIntLE(0));
+            });
+            AsyncIggyTcpClient client = signedIn(leader);
+            try {
+                var fetch =
+                        messages(client).fetchMessages(STREAM, TOPIC, offsetsOf(1L, 2L), GROUP, COUNT, true, IMMEDIATE);
+                parked.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+                assertServerError(fetch, IggyErrorCode.FETCH_PROGRESS_UNCERTAIN.getCode());
+                var unknown = fetch.handle((sections, error) -> (IggyOperationException) PollRouter.unwrap(error))
+                        .join();
+                assertThat(finishOldRead).isNotDone();
+                for (long partition : List.of(1L, 2L)) {
+                    var store = client.consumerOffsets()
+                            .storeConsumerOffset(STREAM, TOPIC, Optional.of(partition), GROUP, BigInteger.ONE);
+                    assertThatThrownBy(store::join)
+                            .hasCauseInstanceOf(IggyOperationException.class)
+                            .satisfies(error -> {
+                                var blocked = (IggyOperationException) error.getCause();
+                                assertThat(blocked.getErrorCode()).isEqualTo(IggyErrorCode.PARTITION_LANE_BLOCKED);
+                                assertThat(blocked.getOperationId()).isEqualTo(unknown.getOperationId());
+                                assertThat(blocked.getPartitionId()).hasValue(partition);
+                            });
+                }
+                assertThat(stores).isEmpty();
+                assertServerError(
+                        client.resolveOperation(unknown.getOperationId(), TIMEOUT),
+                        IggyErrorCode.FETCH_PROGRESS_UNCERTAIN.getCode());
+                assertThat(fetches).hasSize(1);
+                client.abandonOperation(unknown.getOperationId()).join();
+                client.consumerOffsets()
+                        .storeConsumerOffset(STREAM, TOPIC, Optional.of(2L), GROUP, BigInteger.ONE)
+                        .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+                assertThat(stores).hasSize(1);
+                assertThat(finishOldRead).isNotDone();
+            } finally {
+                finishOldRead.complete(null);
+                client.close().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            }
+            CompletableFuture.allOf(leaderServed, firstServed, secondServed)
+                    .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    /**
+     * A cancel stops a committing fetch on the server, which answers it once: refused when the cancel won, with its
+     * sections when the read stored first. Either reply settles the fetch, so its partitions stay free.
+     */
+    @ParameterizedTest(name = "server answers the cancel with status {0}")
+    @ValueSource(ints = {0, 58})
+    void shouldCancelACommittingFetchOnTheServerAndSettleItWithItsReply(int status) throws Exception {
+        try (ServerSocket socket = new ServerSocket(0, 4, InetAddress.getLoopbackAddress())) {
+            CompletableFuture<Request> parked = new CompletableFuture<>();
+            CompletableFuture<Request> cancelled = new CompletableFuture<>();
+            List<Request> stores = new CopyOnWriteArrayList<>();
+            CompletableFuture<Void> served = singleNode(socket, request -> {
+                if (request.is(FETCH_CODE, OPERATION_NON_REPLICATED) && parked.complete(request)) {
+                    return Response.noReply();
+                }
+                if (request.is(CANCEL_CODE, OPERATION_NON_REPLICATED) && cancelled.complete(request)) {
+                    // The cancel names the request id of the fetch, so this reply answers the fetch.
+                    return status == 0
+                            ? Response.success(OPERATION_NON_REPLICATED, emptySection(1))
+                            : Response.error(OPERATION_NON_REPLICATED, status);
+                }
+                if (request.operation() == VsrOperation.STORE_CONSUMER_OFFSET) {
+                    stores.add(request);
+                    return Response.committed(
+                            request.operation(), 2, Unpooled.buffer().writeIntLE(0));
+                }
+                return unexpected(request);
+            });
+            AsyncIggyTcpClient client = signedIn(socket);
+            try {
+                var fetch = messages(client).fetchMessages(STREAM, TOPIC, offsetsOf(1L), GROUP, COUNT, true, FETCH);
+                parked.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+
+                assertThat(fetch.cancel(true)).isTrue();
+
+                assertCancels(cancelled.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS), parked.join());
+                // The reply to the cancel lifts the block of the fetch.
+                awaitStored(client);
+                assertThat(stores).hasSize(1);
+            } finally {
+                client.close().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            }
+            served.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(ints = {3, 57, 59, 11001})
+    void shouldKeepHealthySectionsWhileRetainingOnlyUncertainTargets(int status) throws Exception {
+        try (ServerSocket socket = new ServerSocket(0, 4, InetAddress.getLoopbackAddress())) {
+            List<Request> stores = new CopyOnWriteArrayList<>();
+            CompletableFuture<Void> served = singleNode(socket, request -> {
+                if (request.is(FETCH_CODE, OPERATION_NON_REPLICATED)) {
+                    ByteBuf response = Unpooled.buffer().writeIntLE(2);
+                    section(response, 1, FetchedMessages.STATUS_OK, 1);
+                    response.setIntLE(Integer.BYTES * 3 + Long.BYTES, 1);
+                    section(response, 2, status, 0);
+                    return Response.success(OPERATION_NON_REPLICATED, response.writeByte(42));
+                }
+                stores.add(request);
+                return Response.committed(
+                        request.operation(), 2, Unpooled.buffer().writeIntLE(0));
+            });
+            AsyncIggyTcpClient client = signedIn(socket);
+            try {
+                var sections = messages(client)
+                        .fetchMessages(STREAM, TOPIC, offsetsOf(1L, 2L), GROUP, COUNT, true, IMMEDIATE)
+                        .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+                assertThat(sections).hasSize(2);
+                assertThat(sections.get(0).records()).containsExactly((byte) 42);
+                assertThat(sections.get(0).recoveryError()).isEmpty();
+                assertThatThrownBy(sections.get(0)::decode).isNotInstanceOf(IggyOperationException.class);
+                var unknown = sections.get(1).recoveryError().orElseThrow();
+                assertThat(unknown.getErrorCode()).isEqualTo(IggyErrorCode.FETCH_PROGRESS_UNCERTAIN);
+                assertThat(sections.get(1).status()).isEqualTo(status);
+                assertThatThrownBy(sections.get(1)::decode).isInstanceOf(IggyOperationException.class);
+                client.consumerOffsets()
+                        .storeConsumerOffset(STREAM, TOPIC, Optional.of(1L), GROUP, BigInteger.ONE)
+                        .join();
+                assertServerError(
+                        client.consumerOffsets()
+                                .storeConsumerOffset(STREAM, TOPIC, Optional.of(2L), GROUP, BigInteger.ONE),
+                        IggyErrorCode.PARTITION_LANE_BLOCKED.getCode());
+                assertThat(stores).hasSize(1);
+                client.abandonOperation(unknown.getOperationId()).join();
+            } finally {
+                client.close().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            }
+            served.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void shouldPreserveRawSectionsAndNameTheirRecoveryOnTheNextProgressCall(boolean resolveZero) throws Exception {
+        try (ServerSocket socket = new ServerSocket(0, 4, InetAddress.getLoopbackAddress())) {
+            ByteBuf encoded = Unpooled.buffer().writeIntLE(2);
+            section(encoded, 1, FetchedMessages.STATUS_OK, 1);
+            section(encoded, 2, TRANSIENT_NOT_COMMITTED, 0);
+            encoded.writeByte(42);
+            byte[] wireReply = new byte[encoded.readableBytes()];
+            encoded.readBytes(wireReply);
+            encoded.release();
+            CompletableFuture<Void> served = singleNode(
+                    socket,
+                    request -> request.is(FETCH_CODE, OPERATION_NON_REPLICATED)
+                            ? Response.success(OPERATION_NON_REPLICATED, Unpooled.wrappedBuffer(wireReply))
+                            : unexpected(request));
+            AsyncIggyTcpClient client = signedIn(socket);
+            try {
+                byte[] request = HEX.parseHex(GOLDEN_REQUEST);
+                if (resolveZero) {
+                    ByteBuffer fields = ByteBuffer.wrap(request).order(ByteOrder.LITTLE_ENDIAN);
+                    for (int index = 0; index < 2; index++) {
+                        fields.putLong(target(GOLDEN_LIMITS, index) + FetchMessages.INCARNATION_OFFSET, 0);
+                    }
+                }
+                assertThat(client.sendBinaryRequest(FETCH_CODE, request).join()).isEqualTo(wireReply);
+                var store = resolveZero
+                        ? client.consumerOffsets()
+                                .storeConsumerOffset(STREAM, orders(), Optional.of(2L), GROUP, BigInteger.ONE)
+                        : client.consumerOffsets()
+                                .storeConsumerOffset(
+                                        STREAM,
+                                        orders(),
+                                        2L,
+                                        GROUP,
+                                        BigInteger.ONE,
+                                        new PartitionContext(
+                                                BigInteger.valueOf(3), BigInteger.valueOf(9), BigInteger.ZERO));
+                assertServerError(store, IggyErrorCode.PARTITION_LANE_BLOCKED.getCode());
+                var blocked = store.handle((ignored, error) -> (IggyOperationException) PollRouter.unwrap(error))
+                        .join();
+                assertThat(blocked.getPartitionId()).hasValue(2);
+                assertServerError(
+                        client.resolveOperation(blocked.getOperationId(), TIMEOUT),
+                        IggyErrorCode.FETCH_PROGRESS_UNCERTAIN.getCode());
+                client.abandonOperation(blocked.getOperationId()).join();
+            } finally {
+                client.close().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            }
+            served.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    @Test
+    void shouldResolveZeroIncarnationsBeforeRetainingTypedFetchTargets() throws Exception {
+        try (ServerSocket socket = new ServerSocket(0, 4, InetAddress.getLoopbackAddress())) {
+            List<Request> stores = new CopyOnWriteArrayList<>();
+            CompletableFuture<Void> served = singleNode(socket, request -> {
+                if (request.is(FETCH_CODE, OPERATION_NON_REPLICATED)) {
+                    return Response.error(OPERATION_NON_REPLICATED, TRANSIENT_NOT_COMMITTED);
+                }
+                stores.add(request);
+                return Response.committed(
+                        request.operation(), 2, Unpooled.buffer().writeIntLE(0));
+            });
+            AsyncIggyTcpClient client = signedIn(socket);
+            try {
+                var fetch = messages(client)
+                        .fetchMessages(
+                                STREAM,
+                                TOPIC,
+                                Map.of(1L, PollingStrategy.next().withContext(PartitionContext.EMPTY)),
+                                GROUP,
+                                COUNT,
+                                true,
+                                IMMEDIATE);
+                assertServerError(fetch, IggyErrorCode.FETCH_PROGRESS_UNCERTAIN.getCode());
+                var unknown = fetch.handle((sections, error) -> (IggyOperationException) PollRouter.unwrap(error))
+                        .join();
+                try {
+                    assertServerError(
+                            client.consumerOffsets()
+                                    .storeConsumerOffset(STREAM, TOPIC, Optional.of(1L), GROUP, BigInteger.ONE),
+                            IggyErrorCode.PARTITION_LANE_BLOCKED.getCode());
+                    assertServerError(
+                            client.consumerOffsets()
+                                    .storeConsumerOffset(
+                                            STREAM, TOPIC, 1L, GROUP, BigInteger.ONE, PartitionContext.EMPTY),
+                            IggyErrorCode.PARTITION_LANE_BLOCKED.getCode());
+                    assertThat(stores).isEmpty();
+                } finally {
+                    client.abandonOperation(unknown.getOperationId()).join();
+                }
+                client.consumerOffsets()
+                        .storeConsumerOffset(STREAM, TOPIC, 1L, GROUP, BigInteger.ONE, PartitionContext.EMPTY)
+                        .join();
+                assertThat(stores)
+                        .singleElement()
+                        .satisfies(store -> assertThat(store.incarnation()).isZero());
+            } finally {
+                client.close().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            }
+            served.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    /** A poll reply of {@code partition} with {@code records}. */
+    private static ByteBuf pollReply(int partition, ByteBuf records) {
+        ByteBuf reply = Unpooled.buffer()
+                .writeIntLE(partition)
+                .writeLongLE(0)
+                .writeIntLE(1)
+                .writeZero(PartitionContext.ENCODED_SIZE)
+                .writeBytes(records);
+        records.release();
+        return reply;
+    }
+
+    /** One batch record with one message at {@code offset} whose payload has {@code payloadBytes} bytes. */
+    private static ByteBuf record(long offset, int payloadBytes) {
+        int frame = MessageHeader.SIZE + payloadBytes;
+        return Unpooled.buffer()
+                .writeLongLE(0)
+                .writeLongLE(offset)
+                .writeLongLE(0)
+                .writeLongLE(0)
+                .writeLongLE(BATCH_HEADER_SIZE + frame)
+                .writeZero(BATCH_HEADER_SIZE - 5 * Long.BYTES)
+                .writeLongLE(0)
+                .writeZero(16)
+                .writeIntLE(0)
+                .writeIntLE(0)
+                .writeIntLE(0)
+                .writeIntLE(payloadBytes)
+                .writeLongLE(0)
+                .writeZero(payloadBytes);
+    }
+
+    private static Message messageAt(long offset) {
+        Message plain = Message.of("payload");
+        return new Message(
+                new MessageHeader(
+                        BigInteger.ZERO,
+                        plain.header().id(),
+                        BigInteger.valueOf(offset),
+                        BigInteger.ZERO,
+                        BigInteger.ZERO,
+                        0L,
+                        (long) plain.payload().length,
+                        BigInteger.ZERO),
+                plain.payload(),
+                plain.userHeaders());
+    }
+
+    /** A reply without sections, as a fetch whose wait found nothing gets. */
+    private static ByteBuf reply() {
+        return Unpooled.buffer().writeIntLE(0);
+    }
+
+    /** A reply with one section of {@code partition}, which holds no records. */
+    static ByteBuf emptySection(int partition) {
+        ByteBuf reply = Unpooled.buffer().writeIntLE(1);
+        section(reply, partition, FetchedMessages.STATUS_OK, 0);
+        return reply;
+    }
+
+    /** Appends one section table entry, all of whose other fields are zero. */
+    private static void section(ByteBuf reply, int partition, long status, int recordsLength) {
+        reply.writeIntLE(partition)
+                .writeIntLE((int) status)
+                .writeLongLE(0)
+                .writeIntLE(0)
+                .writeZero(PartitionContext.ENCODED_SIZE)
+                .writeLongLE(0)
+                .writeIntLE(recordsLength);
+    }
+
+    private static int target(int limits, int index) {
+        return limits + FetchMessages.LIMITS_BYTES + index * FetchMessages.TARGET_BYTES;
+    }
+
+    private static List<Target> targets(Request fetch) {
+        ByteBuffer body = ByteBuffer.wrap(fetch.body()).order(ByteOrder.LITTLE_ENDIAN);
+        int count = body.getInt(NUMERIC_LIMITS + FetchMessages.TARGETS_COUNT_OFFSET);
+        List<Target> targets = new ArrayList<>(count);
+        for (int index = 0; index < count; index++) {
+            int target = target(NUMERIC_LIMITS, index);
+            targets.add(new Target(
+                    Integer.toUnsignedLong(body.getInt(target)),
+                    Byte.toUnsignedInt(body.get(target + FetchMessages.STRATEGY_KIND_OFFSET)),
+                    body.getLong(target + FetchMessages.STRATEGY_KIND_OFFSET + Byte.BYTES),
+                    Integer.toUnsignedLong(body.getInt(target + FetchMessages.TARGET_MAX_BYTES_OFFSET)),
+                    body.getLong(target + FetchMessages.INCARNATION_OFFSET),
+                    body.getLong(target + FetchMessages.INCARNATION_OFFSET + Long.BYTES)));
+        }
+        return targets;
+    }
+
+    private record Target(
+            long partition, int kind, long value, long maxBytes, long incarnation, long ownerGeneration) {}
+
+    private record Budgets(long waitMicros, long timeoutMicros) {
+        static Budgets of(Request fetch) {
+            ByteBuffer body = ByteBuffer.wrap(fetch.body()).order(ByteOrder.LITTLE_ENDIAN);
+            return new Budgets(body.getLong(NUMERIC_LIMITS), body.getLong(NUMERIC_LIMITS + Long.BYTES));
+        }
+    }
+
+    @Test
+    void shouldSendTheGoldenDeferredPollBodyWithBudgetsCountedDownFromTheCall() throws Exception {
+        try (ServerSocket socket = new ServerSocket(0, 4, InetAddress.getLoopbackAddress())) {
+            List<Request> sent = new CopyOnWriteArrayList<>();
+            CompletableFuture<Void> served = singleNode(socket, request -> {
+                sent.add(request);
+                return Response.success(OPERATION_NON_REPLICATED, emptyPoll(3));
+            });
+            AsyncIggyTcpClient client = signedIn(socket);
+            try {
+                client.messages()
+                        .pollMessagesDeferred(
+                                STREAM,
+                                orders(),
+                                Optional.of(3L),
+                                GROUP,
+                                PollingStrategy.next(),
+                                100L,
+                                true,
+                                DeferredPollOptions.defaults())
+                        .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+                assertThat(sent).extracting(Request::commandCode).containsExactly(POLL_DEFERRED_CODE);
+                int limits = HEX.parseHex(DEFERRED_POLL_BODY).length - DEFERRED_LIMITS_BYTES;
+                assertGoldenBody(
+                        sent.get(0).body(), DEFERRED_POLL_BODY, limits, limits + DEFERRED_LIMITS_BYTES - Long.BYTES);
+            } finally {
+                client.close().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            }
+            served.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    /**
+     * The reply to the cancel of a committing poll tells whether it stored its offset. Until it arrives the poll blocks
+     * its partition, so the next store fails at once naming it, and the reply lifts the block.
+     */
+    @Test
+    void shouldFailTheNextStoreAtOnceUntilTheReplyToTheCancelOfACommittingPoll() throws Exception {
+        try (ServerSocket socket = new ServerSocket(0, 4, InetAddress.getLoopbackAddress())) {
+            CompletableFuture<Request> parked = new CompletableFuture<>();
+            CompletableFuture<Request> cancelled = new CompletableFuture<>();
+            CompletableFuture<Void> release = new CompletableFuture<>();
+            List<Request> stores = new CopyOnWriteArrayList<>();
+            CompletableFuture<Void> served = singleNode(socket, request -> {
+                if (request.is(POLL_DEFERRED_CODE, OPERATION_NON_REPLICATED) && parked.complete(request)) {
+                    return Response.noReply();
+                }
+                if (request.is(CANCEL_CODE, OPERATION_NON_REPLICATED) && cancelled.complete(request)) {
+                    after(release);
+                    // The cancel won, so the poll stored nothing.
+                    return Response.error(OPERATION_NON_REPLICATED, TRANSIENT_NOT_ACCEPTED);
+                }
+                if (request.operation() == VsrOperation.STORE_CONSUMER_OFFSET) {
+                    stores.add(request);
+                    return Response.committed(
+                            request.operation(), 2, Unpooled.buffer().writeIntLE(0));
+                }
+                return unexpected(request);
+            });
+            AsyncIggyTcpClient client = signedIn(socket);
+            try {
+                var poll = client.messages()
+                        .pollMessagesDeferred(
+                                STREAM, TOPIC, Optional.of(1L), GROUP, PollingStrategy.next(), COUNT, true, LONG_POLL);
+                parked.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+
+                assertThat(poll.cancel(true)).isTrue();
+                assertCancels(cancelled.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS), parked.join());
+                assertBlockedBy(store(client), parked.join().requestId());
+                release.complete(null);
+
+                awaitStored(client);
+                assertThat(stores).hasSize(1);
+            } finally {
+                release.complete(null);
+                client.close().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            }
+            served.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    /**
+     * Without a reply to its cancel by the deadline of the poll, the outcome of the poll stays unknown, so its block
+     * holds until the caller abandons it.
+     */
+    @Test
+    void shouldKeepTheBlockOfACancelledCommittingPollWithoutAReplyUntilItIsAbandoned() throws Exception {
+        try (ServerSocket socket = new ServerSocket(0, 4, InetAddress.getLoopbackAddress())) {
+            CompletableFuture<Request> parked = new CompletableFuture<>();
+            CompletableFuture<Request> cancelled = new CompletableFuture<>();
+            List<Request> stores = new CopyOnWriteArrayList<>();
+            CompletableFuture<Void> served = singleNode(socket, request -> {
+                if (request.is(POLL_DEFERRED_CODE, OPERATION_NON_REPLICATED) && parked.complete(request)) {
+                    return Response.noReply();
+                }
+                if (request.is(CANCEL_CODE, OPERATION_NON_REPLICATED) && cancelled.complete(request)) {
+                    return Response.noReply();
+                }
+                if (request.operation() == VsrOperation.STORE_CONSUMER_OFFSET) {
+                    stores.add(request);
+                    return Response.committed(
+                            request.operation(), 2, Unpooled.buffer().writeIntLE(0));
+                }
+                return unexpected(request);
+            });
+            AsyncIggyTcpClient client = signedIn(socket);
+            try {
+                long deadline = System.nanoTime() + SHORT_POLL.requestTimeout().toNanos();
+                var poll = committingPoll(client);
+                parked.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+
+                assertThat(poll.cancel(true)).isTrue();
+                assertCancels(cancelled.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS), parked.join());
+                assertBlockedBy(store(client), parked.join().requestId());
+                TimeUnit.NANOSECONDS.sleep(Math.max(0, deadline - System.nanoTime()));
+                assertBlockedBy(store(client), parked.join().requestId());
+                client.abandonOperation(parked.join().requestId()).get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+                store(client).get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+
+                assertThat(stores).hasSize(1);
+            } finally {
+                client.close().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            }
+            served.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    /** The server can have stored the offset of a committing poll whose reply is lost, which only a read shows. */
+    @ParameterizedTest(name = "deferred={0}")
+    @ValueSource(booleans = {false, true})
+    void shouldReportAnUnknownOutcomeOfACommittingPollAsUncertainProgress(boolean deferred) throws Exception {
+        try (ServerSocket socket = new ServerSocket(0, 4, InetAddress.getLoopbackAddress())) {
+            CompletableFuture<Void> served = singleNode(socket, request -> {
+                if (request.is(POLL_DEFERRED_CODE, OPERATION_NON_REPLICATED)
+                        || request.is(CommandCode.Messages.POLL.getValue(), OPERATION_NON_REPLICATED)) {
+                    return Response.disconnect();
+                }
+                return unexpected(request);
+            });
+            AsyncIggyTcpClient client = signedIn(socket);
+            try {
+                var poll = deferred
+                        ? client.messages()
+                                .pollMessagesDeferred(
+                                        STREAM,
+                                        TOPIC,
+                                        Optional.of(1L),
+                                        GROUP,
+                                        PollingStrategy.next(),
+                                        COUNT,
+                                        true,
+                                        SHORT_POLL)
+                        : client.messages()
+                                .pollMessages(
+                                        STREAM, TOPIC, Optional.of(1L), GROUP, PollingStrategy.next(), COUNT, true);
+
+                assertThatThrownBy(() -> poll.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS))
+                        .hasCauseInstanceOf(IggyOperationException.class)
+                        .satisfies(error -> assertThat(((IggyOperationException) error.getCause()).getErrorCode())
+                                .isEqualTo(IggyErrorCode.FETCH_PROGRESS_UNCERTAIN));
+            } finally {
+                client.close().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            }
+            served.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    /**
+     * A committing poll refused with a certain status stored nothing and frees its partition. Any other refusal leaves
+     * its outcome unknown and blocks the partition until the caller abandons the poll.
+     */
+    @ParameterizedTest
+    @CsvSource({"3024, false", "72, true", "11001, true"})
+    void shouldEndACommittingPollWithItsCertainRefusalOrAsUnknownProgress(int status, boolean uncertain)
+            throws Exception {
+        try (ServerSocket socket = new ServerSocket(0, 4, InetAddress.getLoopbackAddress())) {
+            CompletableFuture<Void> served = singleNode(
+                    socket,
+                    request -> request.is(POLL_DEFERRED_CODE, OPERATION_NON_REPLICATED)
+                            ? Response.error(OPERATION_NON_REPLICATED, status)
+                            : Response.committed(
+                                    request.operation(), 2, Unpooled.buffer().writeIntLE(0)));
+            AsyncIggyTcpClient client = signedIn(socket);
+            try {
+                var poll = committingPoll(client);
+                if (!uncertain) {
+                    assertServerError(poll, status);
+                } else {
+                    long operation = assertUncertainProgress(poll);
+                    assertBlockedBy(store(client), operation);
+                    client.abandonOperation(operation).get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+                }
+                store(client).get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            } finally {
+                client.close().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            }
+            served.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    /**
+     * The block of a lost committing poll stays across a reconnect and a new session of the client, so neither a
+     * committing poll nor a store of its partition goes out until the caller abandons the poll.
+     */
+    @Test
+    void shouldBlockThePartitionOfALostCommittingPollAcrossAReconnectUntilItIsAbandoned() throws Exception {
+        try (ServerSocket socket = new ServerSocket(0, 4, InetAddress.getLoopbackAddress())) {
+            RequestHandler handler = request -> request.is(POLL_DEFERRED_CODE, OPERATION_NON_REPLICATED)
+                    ? Response.disconnect()
+                    : Response.committed(
+                            request.operation(), 2, Unpooled.buffer().writeIntLE(0));
+            CompletableFuture<Void> served = singleNode(socket, handler);
+            AsyncIggyTcpClient client = signedIn(socket);
+            try {
+                long operation = assertUncertainProgress(committingPoll(client));
+                CompletableFuture<Void> reconnected = singleNode(socket, handler);
+                client.connect().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+                client.login().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+                served.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+
+                assertBlockedBy(committingPoll(client), operation);
+                assertBlockedBy(store(client), operation);
+                assertServerError(
+                        client.resolveOperation(operation, TIMEOUT), IggyErrorCode.FETCH_PROGRESS_UNCERTAIN.getCode());
+                client.abandonOperation(operation).get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+                store(client).get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+                client.close().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+                reconnected.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            } finally {
+                client.close().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            }
+        }
+    }
+
+    /** A disconnect leaves the outcome of a committing poll that waits unknown, so the poll blocks its partition. */
+    @Test
+    void shouldLeaveACommittingPollThatWaitsUncertainWhenTheClientDisconnects() throws Exception {
+        try (ServerSocket socket = new ServerSocket(0, 4, InetAddress.getLoopbackAddress())) {
+            CompletableFuture<Request> parked = new CompletableFuture<>();
+            RequestHandler handler = request -> {
+                if (request.is(POLL_DEFERRED_CODE, OPERATION_NON_REPLICATED)) {
+                    parked.complete(request);
+                    return Response.noReply();
+                }
+                return Response.committed(
+                        request.operation(), 2, Unpooled.buffer().writeIntLE(0));
+            };
+            CompletableFuture<Void> served = singleNode(socket, handler);
+            AsyncIggyTcpClient client = signedIn(socket);
+            try {
+                var poll = committingPoll(client);
+                parked.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+                CompletableFuture<Void> reconnected = singleNode(socket, handler);
+
+                client.disconnect().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+
+                long operation = assertUncertainProgress(poll);
+                client.connect().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+                client.login().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+                served.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+                assertBlockedBy(store(client), operation);
+                client.abandonOperation(operation).get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+                store(client).get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+                client.close().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+                reconnected.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            } finally {
+                client.close().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            }
+        }
+    }
+
+    /** A poll of one partition is a deferred poll whose limits and commit come from its options. */
+    @Test
+    void shouldSendAPollOfOnePartitionAsADeferredPollWithItsOptions() throws Exception {
+        try (ServerSocket socket = new ServerSocket(0, 4, InetAddress.getLoopbackAddress())) {
+            List<Request> sent = new CopyOnWriteArrayList<>();
+            CompletableFuture<Void> served = singleNode(socket, request -> {
+                sent.add(request);
+                return Response.success(OPERATION_NON_REPLICATED, emptyPoll(3));
+            });
+            AsyncIggyTcpClient client = signedIn(socket);
+            try {
+                client.messages()
+                        .pollMessages(
+                                STREAM,
+                                orders(),
+                                3L,
+                                GROUP,
+                                PollingStrategy.next(),
+                                PollOptions.waitFor(Duration.ofSeconds(1))
+                                        .withCount(100)
+                                        .withCommit(CommitPolicy.onRead()))
+                        .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+                assertThat(sent).extracting(Request::commandCode).containsExactly(POLL_DEFERRED_CODE);
+                int limits = HEX.parseHex(DEFERRED_POLL_BODY).length - DEFERRED_LIMITS_BYTES;
+                assertGoldenBody(
+                        sent.get(0).body(), DEFERRED_POLL_BODY, limits, limits + DEFERRED_LIMITS_BYTES - Long.BYTES);
+            } finally {
+                client.close().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            }
+            served.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    @Test
+    void shouldRefuseAnAutomaticCommitAndAContinuationOfAnotherPartitionBeforeSending() throws Exception {
+        try (ServerSocket socket = new ServerSocket(0, 4, InetAddress.getLoopbackAddress())) {
+            List<Request> sent = new CopyOnWriteArrayList<>();
+            CompletableFuture<Void> served = singleNode(socket, request -> {
+                sent.add(request);
+                return unexpected(request);
+            });
+            AsyncIggyTcpClient client = signedIn(socket);
+            try {
+                var auto = PollOptions.defaults().withCommit(CommitPolicy.auto());
+                var continuation = new PolledMessages(2L, BigInteger.ONE, 1L, List.of(messageAt(1)), GOLDEN_CONTEXT)
+                        .continuation()
+                        .orElseThrow();
+
+                assertThatThrownBy(() ->
+                                client.messages().pollMessages(STREAM, TOPIC, 1L, GROUP, PollingStrategy.next(), auto))
+                        .isInstanceOfSatisfying(
+                                IggyServerException.class,
+                                error -> assertThat(error.getErrorCode())
+                                        .isEqualTo(IggyErrorCode.INVALID_CONFIGURATION));
+                assertThatThrownBy(() -> client.messages()
+                                .pollMessages(STREAM, TOPIC, 1L, GROUP, continuation, PollOptions.defaults()))
+                        .isInstanceOfSatisfying(
+                                IggyServerException.class,
+                                error -> assertThat(error.getErrorCode()).isEqualTo(IggyErrorCode.INVALID_COMMAND));
+                assertThat(sent).isEmpty();
+            } finally {
+                client.close().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            }
+            served.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    /** The server returns a first message larger than the byte limit alone in its reply, so the client reads it. */
+    @Test
+    void shouldReadAFirstMessageLargerThanTheByteLimitOfAPoll() throws Exception {
+        try (ServerSocket socket = new ServerSocket(0, 4, InetAddress.getLoopbackAddress())) {
+            CompletableFuture<Void> served = singleNode(
+                    socket,
+                    request -> request.is(POLL_DEFERRED_CODE, OPERATION_NON_REPLICATED)
+                            ? Response.success(OPERATION_NON_REPLICATED, pollReply(3, record(5, OVERSIZED_PAYLOAD)))
+                            : unexpected(request));
+            AsyncIggyTcpClient client = signedIn(socket);
+            try {
+                var polled = client.messages()
+                        .pollMessages(
+                                STREAM,
+                                TOPIC,
+                                3L,
+                                GROUP,
+                                PollingStrategy.offset(BigInteger.valueOf(5)),
+                                PollOptions.defaults().withMaxBytes(DeferredPollOptions.MIN_MAX_BYTES))
+                        .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+
+                assertThat(polled.messages()).hasSize(1);
+                assertThat(polled.messages().get(0).payload()).hasSize(OVERSIZED_PAYLOAD);
+            } finally {
+                client.close().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            }
+            served.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    @Test
+    void shouldReadAFirstMessageLargerThanTheByteLimitOfAFetch() throws Exception {
+        try (ServerSocket socket = new ServerSocket(0, 4, InetAddress.getLoopbackAddress())) {
+            CompletableFuture<Void> served = singleNode(socket, request -> {
+                if (!request.is(FETCH_CODE, OPERATION_NON_REPLICATED)) {
+                    return unexpected(request);
+                }
+                ByteBuf records = record(5, OVERSIZED_PAYLOAD);
+                ByteBuf reply = Unpooled.buffer().writeIntLE(1);
+                reply.writeIntLE(1)
+                        .writeIntLE((int) FetchedMessages.STATUS_OK)
+                        .writeLongLE(5)
+                        .writeIntLE(1)
+                        .writeZero(PartitionContext.ENCODED_SIZE)
+                        .writeLongLE(6)
+                        .writeIntLE(records.readableBytes())
+                        .writeBytes(records);
+                records.release();
+                return Response.success(OPERATION_NON_REPLICATED, reply);
+            });
+            AsyncIggyTcpClient client = signedIn(socket);
+            try {
+                var sections = messages(client)
+                        .fetchMessages(
+                                STREAM,
+                                TOPIC,
+                                offsetsOf(1L),
+                                GROUP,
+                                COUNT,
+                                false,
+                                FETCH.withMaxBytes(DeferredPollOptions.MIN_MAX_BYTES))
+                        .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+
+                assertThat(sections).hasSize(1);
+                assertThat(sections.get(0).decode().messages().get(0).payload()).hasSize(OVERSIZED_PAYLOAD);
+            } finally {
+                client.close().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            }
+            served.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    @Test
+    void shouldGroupThePartitionsOfEachPrimaryInTheOrderOfTheCall() throws Exception {
+        InetAddress loopback = InetAddress.getLoopbackAddress();
+        try (ServerSocket leader = new ServerSocket(0, 4, loopback);
+                ServerSocket first = new ServerSocket(0, 4, loopback);
+                ServerSocket second = new ServerSocket(0, 4, loopback)) {
+            Supplier<ByteBuf> metadata = () -> threeNodeMetadata(
+                    leader.getLocalPort(), first.getLocalPort(), second.getLocalPort(), leader.getLocalPort());
+            // The leader fails on any request but a route lookup, and nothing serves the primaries.
+            CompletableFuture<Void> served = node(leader, metadata, routes(first, second));
+            AsyncIggyTcpClient client = signedIn(leader);
+            try {
+                for (long partition : new long[] {FetchMessages.MAX_PARTITION_ID + 1, -1}) {
+                    assertThatThrownBy(() -> messages(client).readinessGroups(STREAM, TOPIC, List.of(partition)))
+                            .isInstanceOf(IggyInvalidArgumentException.class);
+                }
+                assertThat(messages(client)
+                                .readinessGroups(STREAM, TOPIC, List.of(2L, 1L, 3L))
+                                .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS))
+                        .containsExactly(List.of(2L, 3L), List.of(1L));
+                assertThat(messages(client)
+                                .readinessGroups(STREAM, TOPIC, List.of())
+                                .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS))
+                        .isEmpty();
+            } finally {
+                client.close().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            }
+            served.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    @Test
+    void shouldSplitThePartitionsOfOnePrimaryIntoFetchSizedGroups() throws Exception {
+        try (ServerSocket socket = new ServerSocket(0, 4, InetAddress.getLoopbackAddress())) {
+            CompletableFuture<Void> served = singleNode(socket, AsyncIggyTcpClientFetchMessagesTest::unexpected);
+            AsyncIggyTcpClient client = AsyncIggyTcpClient.builder()
+                    .host(socket.getInetAddress().getHostAddress())
+                    .port(socket.getLocalPort())
+                    .credentials("configured-user", "configured-password")
+                    .requestTimeout(FETCH.requestTimeout())
+                    .heartbeatInterval(Duration.ofMinutes(1))
+                    .build();
+            client.connect().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            client.login().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            try {
+                int limit = FetchMessages.MAX_PARTITIONS_PER_REQUEST;
+                List<Long> partitions = LongStream.rangeClosed(0, limit).boxed().toList();
+                assertThat(messages(client)
+                                .readinessGroups(STREAM, TOPIC, partitions)
+                                .get(FETCH.requestTimeout().toMillis(), TimeUnit.MILLISECONDS))
+                        .containsExactly(partitions.subList(0, limit), partitions.subList(limit, partitions.size()));
+            } finally {
+                client.close().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            }
+            served.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    /** A leader whose route lookups name {@code first} the primary of partition 1 and {@code second} of the others. */
+    static RequestHandler routes(ServerSocket first, ServerSocket second) {
+        return request -> request.is(GET_PARTITION_ROUTE_CODE, OPERATION_NON_REPLICATED)
+                ? Response.success(
+                        OPERATION_NON_REPLICATED,
+                        pollRoute(
+                                request,
+                                1,
+                                1,
+                                lastInt(request.body()) == 1 ? first.getLocalPort() : second.getLocalPort()))
+                : unexpected(request);
+    }
+
+    /** Holds a reply of a mock node until {@code earlier} ended and {@link #LATE_REPLY} passed, so it comes second. */
+    static void after(CompletableFuture<Void> earlier) {
+        earlier.thenRunAsync(() -> {}, CompletableFuture.delayedExecutor(LATE_REPLY.toMillis(), TimeUnit.MILLISECONDS))
+                .orTimeout(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)
+                .join();
+    }
+
+    /** A node of one replica that is the primary of every partition, so data requests share the sign-in link. */
+    static CompletableFuture<Void> singleNode(ServerSocket socket, RequestHandler data) {
+        return node(
+                socket,
+                () -> singleNodeMetadata(socket.getLocalPort()),
+                request -> request.is(GET_PARTITION_ROUTE_CODE, OPERATION_NON_REPLICATED)
+                        ? Response.success(OPERATION_NON_REPLICATED, pollRoute(request, 1, 1, socket.getLocalPort()))
+                        : data.handle(request));
+    }
+
+    /** A node that signs in, binds data links and names the cluster, and gives every other request to {@code data}. */
+    static CompletableFuture<Void> node(ServerSocket socket, Supplier<ByteBuf> metadata, RequestHandler data) {
+        return serve(socket, request -> {
+            if (request.operation() == OPERATION_REGISTER) {
+                return Response.success(OPERATION_REGISTER, registerBody(1));
+            }
+            if (request.is(GET_CLUSTER_METADATA_CODE, OPERATION_NON_REPLICATED)) {
+                return Response.success(OPERATION_NON_REPLICATED, metadata.get());
+            }
+            if (request.is(BIND_SESSION_CODE, OPERATION_NON_REPLICATED)) {
+                return Response.success(OPERATION_NON_REPLICATED, bindBody(request));
+            }
+            return data.handle(request);
+        });
+    }
+
+    static AsyncIggyTcpClient signedIn(ServerSocket socket) throws Exception {
+        AsyncIggyTcpClient client = client(socket);
+        client.connect().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        client.login().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        return client;
+    }
+
+    static Response unexpected(Request request) {
+        throw new IllegalStateException("Unexpected request: " + request);
+    }
+
+    static void assertGoldenBody(byte[] sent, String golden, int waitOffset, int timeoutOffset) {
+        byte[] expected = HEX.parseHex(golden);
+        ByteBuffer sentFields = ByteBuffer.wrap(sent).order(ByteOrder.LITTLE_ENDIAN);
+        ByteBuffer goldenFields = ByteBuffer.wrap(expected).order(ByteOrder.LITTLE_ENDIAN);
+        for (int budget : new int[] {waitOffset, timeoutOffset}) {
+            // Each attempt carries what is left of both budgets of the call.
+            assertThat(sentFields.getLong(budget))
+                    .isLessThanOrEqualTo(goldenFields.getLong(budget))
+                    .isGreaterThan(
+                            goldenFields.getLong(budget) - TimeUnit.NANOSECONDS.toMicros(ROUTING_ALLOWANCE.toNanos()));
+        }
+        byte[] masked = sent.clone();
+        System.arraycopy(expected, waitOffset, masked, waitOffset, Long.BYTES);
+        System.arraycopy(expected, timeoutOffset, masked, timeoutOffset, Long.BYTES);
+        assertThat(HEX.formatHex(masked)).isEqualTo(golden);
+    }
+
+    /** The cancel of a parked request is a header with no body that names the same client and request. */
+    static void assertCancels(Request cancel, Request parked) {
+        assertThat(cancel.body()).isEmpty();
+        assertThat(List.of(cancel.requestId(), cancel.clientLow(), cancel.clientHigh()))
+                .containsExactly(parked.requestId(), parked.clientLow(), parked.clientHigh());
+    }
+
+    /** The messages client of {@code client}, whose fetch and readiness groups a reader uses. */
+    static MessagesTcpClient messages(AsyncIggyTcpClient client) {
+        return (MessagesTcpClient) client.messages();
+    }
+
+    static TopicId orders() {
+        return TopicId.of("orders");
+    }
+
+    static int lastInt(byte[] body) {
+        return ByteBuffer.wrap(body).order(ByteOrder.LITTLE_ENDIAN).getInt(body.length - Integer.BYTES);
+    }
+
+    private static CompletableFuture<PolledMessages> committingPoll(AsyncIggyTcpClient client) {
+        return client.messages()
+                .pollMessagesDeferred(
+                        STREAM, TOPIC, Optional.of(1L), GROUP, PollingStrategy.next(), COUNT, true, SHORT_POLL);
+    }
+
+    private static CompletableFuture<Void> store(AsyncIggyTcpClient client) {
+        return client.consumerOffsets().storeConsumerOffset(STREAM, TOPIC, Optional.of(1L), GROUP, BigInteger.ONE);
+    }
+
+    /** Stores an offset of partition 1 once the block that a reply lifts is gone. */
+    private static void awaitStored(AsyncIggyTcpClient client) throws Exception {
+        long deadline = System.nanoTime() + TIMEOUT.toNanos();
+        while (true) {
+            try {
+                store(client).get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+                return;
+            } catch (ExecutionException blocked) {
+                if (System.nanoTime() - deadline > 0) {
+                    throw blocked;
+                }
+                TimeUnit.MILLISECONDS.sleep(10);
+            }
+        }
+    }
+
+    /** Asserts that {@code call} failed with an unknown progress and returns its operation. */
+    private static long assertUncertainProgress(CompletableFuture<?> call) {
+        assertServerError(call, IggyErrorCode.FETCH_PROGRESS_UNCERTAIN.getCode());
+        return call.handle((ignored, error) -> ((IggyOperationException) PollRouter.unwrap(error)).getOperationId())
+                .join();
+    }
+
+    /** Asserts that {@code call} failed because the lost {@code operation} blocks partition 1. */
+    private static void assertBlockedBy(CompletableFuture<?> call, long operation) {
+        assertServerError(call, IggyErrorCode.PARTITION_LANE_BLOCKED.getCode());
+        IggyOperationException blocked = call.handle(
+                        (ignored, error) -> (IggyOperationException) PollRouter.unwrap(error))
+                .join();
+        assertThat(blocked.getOperationId()).isEqualTo(operation);
+        assertThat(blocked.getPartitionId()).hasValue(1);
+    }
+}
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientPartitionContextTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientPartitionContextTest.java
index 065957a..808f853 100644
--- a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientPartitionContextTest.java
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientPartitionContextTest.java
@@ -26,6 +26,7 @@
 import org.apache.iggy.consumergroup.Consumer;
 import org.apache.iggy.exception.IggyErrorCode;
 import org.apache.iggy.exception.IggyOperationException;
+import org.apache.iggy.exception.IggyValidationException;
 import org.apache.iggy.identifier.ConsumerId;
 import org.apache.iggy.identifier.StreamId;
 import org.apache.iggy.identifier.TopicId;
@@ -93,7 +94,6 @@
     private static final int PARTITION_NOT_OWNED = 5009;
     private static final int ASSIGNED_PARTITION = 7;
     private static final int DEFERRED_PARAMETERS_BYTES = 2 * Long.BYTES + 2 * Integer.BYTES;
-    private static final int RESYNC_REQUIRED_PARTITION = -1;
     // An offset write status of the fake primary: the write is stored.
     private static final int STORED = 0;
     private static final long ROUTE_INCARNATION = 1;
@@ -620,13 +620,11 @@
         }
     }
 
+    /** A 106 names one partition, so the client refuses a deferred group poll without one, as the server would. */
     @Test
-    void shouldReduceDeferredPollBudgetsWhenAGroupResyncStartsAnotherAttempt() throws Exception {
+    void shouldRefuseADeferredGroupPollWithoutAPartitionBeforeSendingIt() throws Exception {
         InetAddress loopback = InetAddress.getLoopbackAddress();
         try (ServerSocket coordinatorSocket = new ServerSocket(0, 4, loopback)) {
-            AtomicInteger syncs = new AtomicInteger();
-            List<Long> waits = new CopyOnWriteArrayList<>();
-            List<Long> budgets = new CopyOnWriteArrayList<>();
             CompletableFuture<Void> coordinator = serve(coordinatorSocket, request -> {
                 if (request.operation() == OPERATION_REGISTER) {
                     return Response.success(OPERATION_REGISTER, registerBody(1));
@@ -635,52 +633,27 @@
                     return Response.success(
                             OPERATION_NON_REPLICATED, singleNodeMetadata(coordinatorSocket.getLocalPort()));
                 }
-                if (request.is(GROUP_SYNC_CODE, OPERATION_NON_REPLICATED)) {
-                    return Response.success(
-                            OPERATION_NON_REPLICATED,
-                            Unpooled.buffer()
-                                    .writeLongLE(syncs.incrementAndGet())
-                                    .writeIntLE(1)
-                                    .writeIntLE(0));
-                }
-                if (request.is(GET_PARTITION_ROUTE_CODE, OPERATION_NON_REPLICATED)) {
-                    return Response.success(
-                            OPERATION_NON_REPLICATED,
-                            pollRoute(request, 1, 1, coordinatorSocket.getLocalPort(), ROUTE_INCARNATION));
-                }
-                if (request.is(CommandCode.Messages.POLL_DEFERRED.getValue(), OPERATION_NON_REPLICATED)) {
-                    ByteBuffer body = ByteBuffer.wrap(request.body()).order(ByteOrder.LITTLE_ENDIAN);
-                    waits.add(body.getLong(body.limit() - DEFERRED_PARAMETERS_BYTES));
-                    budgets.add(body.getLong(body.limit() - Long.BYTES));
-                    return Response.success(
-                            OPERATION_NON_REPLICATED, emptyPoll(waits.size() == 1 ? RESYNC_REQUIRED_PARTITION : 0));
-                }
                 throw new IllegalStateException("Unexpected coordinator request: " + request);
             });
             AsyncIggyTcpClient client = client(coordinatorSocket);
-            var options = DeferredPollOptions.defaults();
             try {
                 client.connect().get(5, TimeUnit.SECONDS);
                 client.login().get(5, TimeUnit.SECONDS);
-                client.messages()
-                        .pollMessagesDeferred(
-                                StreamId.of(1L),
-                                TopicId.of(1L),
-                                Optional.empty(),
-                                Consumer.group(ConsumerId.of(7L)),
-                                PollingStrategy.next(),
-                                1L,
-                                false,
-                                options)
-                        .get(5, TimeUnit.SECONDS);
-                assertThat(syncs).hasValue(2);
-                assertThat(waits).hasSize(2);
-                assertThat(waits.get(0)).isLessThan(options.maxWaitMicros());
-                assertThat(waits.get(1)).isLessThan(waits.get(0));
-                assertThat(budgets.get(1)).isLessThan(budgets.get(0));
-                assertThat(budgets.get(0) - waits.get(0))
-                        .isEqualTo(options.requestTimeoutMicros() - options.maxWaitMicros());
-                assertThat(budgets.get(1) - waits.get(1)).isEqualTo(budgets.get(0) - waits.get(0));
+
+                assertThatThrownBy(() -> client.messages()
+                                .pollMessagesDeferred(
+                                        StreamId.of(1L),
+                                        TopicId.of(1L),
+                                        Optional.empty(),
+                                        Consumer.group(ConsumerId.of(7L)),
+                                        PollingStrategy.next(),
+                                        1L,
+                                        false,
+                                        DeferredPollOptions.defaults()))
+                        .isInstanceOfSatisfying(IggyValidationException.class, error -> {
+                            assertThat(error.getRawErrorCode()).isEqualTo(IggyErrorCode.INVALID_COMMAND.getCode());
+                            assertThat(error.getMessage()).contains("partition reader");
+                        });
             } finally {
                 client.close().get(5, TimeUnit.SECONDS);
             }
@@ -920,6 +893,29 @@
         }
     }
 
+    @Test
+    void shouldDeleteTheReaderOffsetWithItsCapturedContext() throws Exception {
+        try (OffsetCluster cluster = new OffsetCluster(1, STORED)) {
+            AsyncIggyTcpClient client = client(cluster.coordinatorSocket);
+            try {
+                client.connect().get(5, TimeUnit.SECONDS);
+                client.login().get(5, TimeUnit.SECONDS);
+                ((ConsumerOffsetsTcpClient) client.consumerOffsets())
+                        .deleteConsumerOffset(StreamId.of(1L), TopicId.of(1L), 0L, Consumer.of(7L), CALLER_CONTEXT)
+                        .get(5, TimeUnit.SECONDS);
+            } finally {
+                client.close().get(5, TimeUnit.SECONDS);
+            }
+            cluster.awaitServed();
+            assertThat(cluster.primaryWrites)
+                    .extracting(Request::operation)
+                    .containsExactly(OPERATION_DELETE_CONSUMER_OFFSET);
+            assertThat(cluster.primaryWrites)
+                    .extracting(AsyncIggyTcpClientPartitionContextTest::stampedContext)
+                    .containsExactly(CALLER_CONTEXT);
+        }
+    }
+
     private static CompletableFuture<PolledMessages> pollPartition(
             AsyncIggyTcpClient client, PollingStrategy strategy) {
         return client.messages()
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientRawRequestTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientRawRequestTest.java
index b54f177..3399615 100644
--- a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientRawRequestTest.java
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientRawRequestTest.java
@@ -32,6 +32,7 @@
 import org.apache.iggy.message.DeferredPollOptions;
 import org.apache.iggy.message.Message;
 import org.apache.iggy.message.Partitioning;
+import org.apache.iggy.message.PollingKind;
 import org.apache.iggy.message.PollingStrategy;
 import org.junit.jupiter.api.Test;
 
@@ -45,6 +46,7 @@
 import java.nio.ByteOrder;
 import java.time.Duration;
 import java.util.Arrays;
+import java.util.HexFormat;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
@@ -53,6 +55,7 @@
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
 
+import static org.apache.iggy.client.async.tcp.AsyncIggyTcpClientFetchMessagesTest.assertGoldenBody;
 import static org.apache.iggy.client.async.tcp.AsyncIggyTcpClientTransientFailoverTest.BIND_SESSION_CODE;
 import static org.apache.iggy.client.async.tcp.AsyncIggyTcpClientTransientFailoverTest.GET_CLUSTER_METADATA_CODE;
 import static org.apache.iggy.client.async.tcp.AsyncIggyTcpClientTransientFailoverTest.GET_PARTITION_ROUTE_CODE;
@@ -71,6 +74,9 @@
 import static org.apache.iggy.client.async.tcp.AsyncIggyTcpClientTransientFailoverTest.singleNodeMetadata;
 import static org.apache.iggy.client.async.tcp.AsyncIggyTcpClientTransientFailoverTest.transientResult;
 import static org.apache.iggy.client.async.tcp.AsyncIggyTcpClientTransientFailoverTest.writeResponse;
+import static org.apache.iggy.client.async.tcp.FetchMessagesTest.GOLDEN_LIMITS;
+import static org.apache.iggy.client.async.tcp.FetchMessagesTest.GOLDEN_MAX_BYTES;
+import static org.apache.iggy.client.async.tcp.FetchMessagesTest.GOLDEN_REQUEST;
 import static org.apache.iggy.serde.BytesSerializer.encodeMessagesBatchInto;
 import static org.apache.iggy.serde.BytesSerializer.toBytes;
 import static org.apache.iggy.serde.BytesSerializer.toBytesAsU64;
@@ -81,8 +87,8 @@
     private static final int SEND_CODE = 101;
     private static final int GET_PARTITION_ROUTE_CODE = 105;
     private static final int POLL_DEFERRED_CODE = 106;
-    private static final int WAIT_FOR_MESSAGES_CODE = 107;
     private static final int CANCEL_DEFERRED_REQUEST_CODE = 108;
+    private static final int FETCH_MESSAGES_CODE = 109;
     private static final int STORE_OFFSET_CODE = 121;
     private static final int DELETE_OFFSET_CODE = 122;
     private static final int GET_OFFSET_ROUTING_CODE = 105;
@@ -394,11 +400,68 @@
     }
 
     @Test
-    void shouldRefuseRawReadinessWaitsAndDeferredCancelsWithoutSendingThem() throws Exception {
+    void shouldRouteRawFetchesLikeTypedFetches() throws Exception {
+        ByteBuf sections = Unpooled.buffer().writeIntLE(0);
+        assertRawRequests(
+                request -> code(request) == FETCH_MESSAGES_CODE
+                        ? Response.success(OPERATION_NON_REPLICATED, sections.retainedDuplicate())
+                        : null,
+                (client, requests) -> {
+                    byte[] reply = client.sendBinaryRequest(
+                                    FETCH_MESSAGES_CODE, HexFormat.of().parseHex(GOLDEN_REQUEST))
+                            .get(5, TimeUnit.SECONDS);
+
+                    assertThat(reply).isEqualTo(bytes(sections));
+                    assertThat(requests)
+                            .extracting(AsyncIggyTcpClientRawRequestTest::code)
+                            .containsExactly(GET_PARTITION_ROUTE_CODE, FETCH_MESSAGES_CODE);
+                    Request fetch = requests.get(1);
+                    // The targets keep the fences and caps of the payload, so the header carries no fence.
+                    assertGoldenBody(fetch.body(), GOLDEN_REQUEST, GOLDEN_LIMITS, GOLDEN_LIMITS + Long.BYTES);
+                    assertThat(List.of(fetch.incarnation(), fetch.ownerGeneration()))
+                            .containsOnly(0L);
+                });
+    }
+
+    @Test
+    void shouldRejectMalformedRawFetchesWithoutSendingThem() throws Exception {
+        int limits = GOLDEN_LIMITS;
+        int targets = limits + FetchMessages.LIMITS_BYTES;
+        int second = targets + FetchMessages.TARGET_BYTES;
+        byte[] golden = HexFormat.of().parseHex(GOLDEN_REQUEST);
         assertRawRequests(request -> null, (client, requests) -> {
-            CompletableFuture<byte[]> wait = client.sendBinaryRequest(WAIT_FOR_MESSAGES_CODE, new byte[0]);
-            assertServerError(wait, FEATURE_UNAVAILABLE);
-            assertThatThrownBy(wait::join).rootCause().hasMessageContaining("readiness waits");
+            assertMalformed(client.sendBinaryRequest(FETCH_MESSAGES_CODE, Arrays.copyOf(golden, golden.length - 1)));
+            ByteBuf noTargets = Unpooled.wrappedBuffer(Arrays.copyOf(golden, targets));
+            assertMalformed(client.sendBinaryRequest(
+                    FETCH_MESSAGES_CODE, bytes(noTargets.setIntLE(limits + FetchMessages.TARGETS_COUNT_OFFSET, 0))));
+            assertMalformed(client.sendBinaryRequest(FETCH_MESSAGES_CODE, bytes(golden().setIntLE(second, 1))));
+            assertMalformed(client.sendBinaryRequest(
+                    FETCH_MESSAGES_CODE,
+                    bytes(golden().setByte(second + FetchMessages.STRATEGY_KIND_OFFSET, PollingKind.First.asCode()))));
+            assertMalformed(client.sendBinaryRequest(
+                    FETCH_MESSAGES_CODE,
+                    bytes(golden().setIntLE(
+                                    targets + FetchMessages.TARGET_MAX_BYTES_OFFSET, (int) GOLDEN_MAX_BYTES + 1))));
+            assertMalformed(client.sendBinaryRequest(
+                    FETCH_MESSAGES_CODE,
+                    bytes(golden().setIntLE(
+                                    targets + FetchMessages.TARGET_MAX_BYTES_OFFSET,
+                                    (int) DeferredPollOptions.MIN_MAX_BYTES - 1))));
+            assertMalformed(client.sendBinaryRequest(
+                    FETCH_MESSAGES_CODE, bytes(golden().setIntLE(limits + FetchMessages.COUNT_OFFSET, 0))));
+            long replyMarginMicros = TimeUnit.NANOSECONDS.toMicros(DeferredPollOptions.REPLY_MARGIN.toNanos());
+            for (long timeout : List.of(replyMarginMicros - 1, replyMarginMicros)) {
+                assertMalformed(client.sendBinaryRequest(
+                        FETCH_MESSAGES_CODE,
+                        bytes(golden().setLongLE(limits, 0).setLongLE(limits + Long.BYTES, timeout))));
+            }
+            assertThat(requests).isEmpty();
+        });
+    }
+
+    @Test
+    void shouldRefuseRawDeferredCancelsWithoutSendingThem() throws Exception {
+        assertRawRequests(request -> null, (client, requests) -> {
             assertServerError(client.sendBinaryRequest(CANCEL_DEFERRED_REQUEST_CODE, new byte[0]), INVALID_COMMAND);
             assertThat(requests).isEmpty();
         });
@@ -708,6 +771,10 @@
         return target;
     }
 
+    private static ByteBuf golden() {
+        return Unpooled.wrappedBuffer(HexFormat.of().parseHex(GOLDEN_REQUEST));
+    }
+
     private static byte[] bytes(ByteBuf... parts) {
         ByteBuf payload = Unpooled.wrappedBuffer(parts);
         try {
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientReaderTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientReaderTest.java
new file mode 100644
index 0000000..acfed19
--- /dev/null
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientReaderTest.java
@@ -0,0 +1,149 @@
+/*
+ * 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.
+ */
+
+package org.apache.iggy.client.async.tcp;
+
+import io.netty.buffer.Unpooled;
+import org.apache.iggy.client.async.tcp.AsyncIggyTcpClientTransientFailoverTest.Request;
+import org.apache.iggy.client.async.tcp.AsyncIggyTcpClientTransientFailoverTest.RequestHandler;
+import org.apache.iggy.client.async.tcp.AsyncIggyTcpClientTransientFailoverTest.Response;
+import org.apache.iggy.consumergroup.Consumer;
+import org.apache.iggy.identifier.StreamId;
+import org.apache.iggy.identifier.TopicId;
+import org.apache.iggy.message.CommitPolicy;
+import org.apache.iggy.message.PollOptions;
+import org.apache.iggy.message.PollingStrategy;
+import org.apache.iggy.serde.CommandCode;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.math.BigInteger;
+import java.net.InetAddress;
+import java.net.ServerSocket;
+import java.time.Duration;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+
+import static org.apache.iggy.client.async.tcp.AsyncIggyTcpClientFetchMessagesTest.assertCancels;
+import static org.apache.iggy.client.async.tcp.AsyncIggyTcpClientFetchMessagesTest.emptySection;
+import static org.apache.iggy.client.async.tcp.AsyncIggyTcpClientFetchMessagesTest.signedIn;
+import static org.apache.iggy.client.async.tcp.AsyncIggyTcpClientFetchMessagesTest.singleNode;
+import static org.apache.iggy.client.async.tcp.AsyncIggyTcpClientFetchMessagesTest.unexpected;
+import static org.apache.iggy.client.async.tcp.AsyncIggyTcpClientTransientFailoverTest.OPERATION_NON_REPLICATED;
+
+/** A partition reader on the TCP client against a mock node. */
+class AsyncIggyTcpClientReaderTest {
+    private static final int FETCH_CODE = CommandCode.Messages.FETCH_MESSAGES.getValue();
+    private static final int CANCEL_CODE = CommandCode.Messages.CANCEL_DEFERRED_REQUEST.getValue();
+    private static final Duration TIMEOUT = Duration.ofSeconds(5);
+
+    /**
+     * The close of a reader under OnRead cancels its parked committing fetch on the server and ends with the one reply
+     * to that cancel, instead of waiting out the wait of the fetch.
+     */
+    @ParameterizedTest(name = "server answers the cancel with status {0}")
+    @ValueSource(ints = {0, 58})
+    void shouldCloseAnOnReadReaderWithTheReplyToTheCancelOfItsParkedFetch(int status) throws Exception {
+        try (ServerSocket socket = new ServerSocket(0, 4, InetAddress.getLoopbackAddress())) {
+            CompletableFuture<Request> parked = new CompletableFuture<>();
+            CompletableFuture<Request> cancelled = new CompletableFuture<>();
+            CompletableFuture<Void> served = singleNode(socket, request -> {
+                if (request.is(FETCH_CODE, OPERATION_NON_REPLICATED) && parked.complete(request)) {
+                    return Response.noReply();
+                }
+                if (request.is(CANCEL_CODE, OPERATION_NON_REPLICATED) && cancelled.complete(request)) {
+                    return status == 0
+                            ? Response.success(OPERATION_NON_REPLICATED, emptySection(1))
+                            : Response.error(OPERATION_NON_REPLICATED, status);
+                }
+                return unexpected(request);
+            });
+            AsyncIggyTcpClient client = signedIn(socket);
+            try {
+                var reader = client.reader(StreamId.of(1L), TopicId.of(1L), Consumer.of(7L))
+                        .partitions(List.of(1L))
+                        .start(PollingStrategy.offset(BigInteger.ZERO))
+                        .options(PollOptions.waitFor(Duration.ofSeconds(30)).withCommit(CommitPolicy.onRead()))
+                        .build()
+                        .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+                reader.next();
+                parked.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+
+                reader.closeAsync().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+
+                assertCancels(cancelled.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS), parked.join());
+            } finally {
+                client.close().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            }
+            served.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    /**
+     * A parked committing fetch whose cancel loses the link ends with an unknown outcome and blocks its partition on
+     * the client, so the close of its reader abandons it, and a store of the partition on the same client goes through
+     * after it.
+     */
+    @Test
+    void shouldAbandonAParkedFetchWhoseCancelLosesTheLinkWhenTheReaderCloses() throws Exception {
+        try (ServerSocket socket = new ServerSocket(0, 4, InetAddress.getLoopbackAddress())) {
+            CompletableFuture<Request> parked = new CompletableFuture<>();
+            RequestHandler handler = request -> {
+                if (request.is(FETCH_CODE, OPERATION_NON_REPLICATED) && parked.complete(request)) {
+                    return Response.noReply();
+                }
+                if (request.is(CANCEL_CODE, OPERATION_NON_REPLICATED)) {
+                    return Response.disconnect();
+                }
+                return Response.committed(
+                        request.operation(), 2, Unpooled.buffer().writeIntLE(0));
+            };
+            CompletableFuture<Void> served = singleNode(socket, handler);
+            AsyncIggyTcpClient client = signedIn(socket);
+            try {
+                var reader = client.reader(StreamId.of(1L), TopicId.of(1L), Consumer.of(7L))
+                        .partitions(List.of(1L))
+                        .start(PollingStrategy.offset(BigInteger.ZERO))
+                        .options(PollOptions.waitFor(Duration.ofSeconds(30)).withCommit(CommitPolicy.onRead()))
+                        .build()
+                        .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+                reader.next();
+                parked.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+                CompletableFuture<Void> reconnected = singleNode(socket, handler);
+
+                reader.closeAsync().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+
+                served.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+                client.connect().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+                client.login().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+                client.consumerOffsets()
+                        .storeConsumerOffset(
+                                StreamId.of(1L), TopicId.of(1L), Optional.of(1L), Consumer.of(7L), BigInteger.ONE)
+                        .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+                client.close().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+                reconnected.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            } finally {
+                client.close().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            }
+        }
+    }
+}
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientRecoveryTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientRecoveryTest.java
index 05dc9ed..3be4c7f 100644
--- a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientRecoveryTest.java
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientRecoveryTest.java
@@ -1007,7 +1007,8 @@
     }
 
     @Test
-    void shouldEndAnEvictedProgressPollWith57WithoutReplayOrRetention() throws Exception {
+    @SuppressWarnings({"checkstyle:CyclomaticComplexity", "checkstyle:NPathComplexity"})
+    void shouldEndAnEvictedProgressPollWithoutReplay() throws Exception {
         try (ServerSocket socket = new ServerSocket(0, 2, InetAddress.getLoopbackAddress())) {
             List<Request> polls = new CopyOnWriteArrayList<>();
             var server = serve(socket, 2, request -> {
@@ -1051,11 +1052,21 @@
                             : org.apache.iggy.message.PollingStrategy.next();
                     var polled = client.messages()
                             .pollMessages(stream, topic, Optional.of(0L), consumer, strategy, 1L, autoCommit);
-                    AsyncIggyTcpClientTransientFailoverTest.assertServerError(polled, 57);
+                    // The server can have stored the offset of a committing poll, which only a read shows.
+                    AsyncIggyTcpClientTransientFailoverTest.assertServerError(
+                            polled, autoCommit ? IggyErrorCode.FETCH_PROGRESS_UNCERTAIN.getCode() : 57);
                     assertThat(polls).hasSize(autoCommit ? 2 : 1);
                     long requestId = polls.get(polls.size() - 1).requestId();
+                    // A committing poll is never replayed. Its unknown outcome blocks the partition until abandoned.
                     assertThat(outcome(client.resolveOperation(requestId, Duration.ofSeconds(1))))
-                            .isEqualTo("IggyOperationException " + IggyErrorCode.OPERATION_NOT_RETAINED.getCode());
+                            .isEqualTo("IggyOperationException "
+                                    + (autoCommit
+                                                    ? IggyErrorCode.FETCH_PROGRESS_UNCERTAIN
+                                                    : IggyErrorCode.OPERATION_NOT_RETAINED)
+                                            .getCode());
+                    if (autoCommit) {
+                        client.abandonOperation(requestId).get(5, TimeUnit.SECONDS);
+                    }
                 }
             } finally {
                 client.close().get(5, TimeUnit.SECONDS);
@@ -1258,14 +1269,23 @@
                             : org.apache.iggy.message.PollingStrategy.next();
                     var polled = client.messages()
                             .pollMessages(stream, topic, Optional.of(0L), consumer, strategy, 1L, autoCommit);
-                    AsyncIggyTcpClientTransientFailoverTest.assertServerError(polled, 57);
+                    // The server can have stored the offset of a committing poll, which only a read shows.
+                    AsyncIggyTcpClientTransientFailoverTest.assertServerError(
+                            polled, autoCommit ? IggyErrorCode.FETCH_PROGRESS_UNCERTAIN.getCode() : 57);
                     assertThat(polls).hasSize(index + 1);
                     long requestId = polls.get(index).requestId();
+                    // A committing poll is never replayed. Its unknown outcome blocks the partition until abandoned.
                     assertThatThrownBy(() -> client.resolveOperation(requestId, Duration.ofSeconds(1))
                                     .join())
                             .hasCauseInstanceOf(IggyOperationException.class)
                             .satisfies(error -> assertThat(((IggyOperationException) error.getCause()).getErrorCode())
-                                    .isEqualTo(IggyErrorCode.OPERATION_NOT_RETAINED));
+                                    .isEqualTo(
+                                            autoCommit
+                                                    ? IggyErrorCode.FETCH_PROGRESS_UNCERTAIN
+                                                    : IggyErrorCode.OPERATION_NOT_RETAINED));
+                    if (autoCommit) {
+                        client.abandonOperation(requestId).get(5, TimeUnit.SECONDS);
+                    }
                     client.consumerOffsets()
                             .storeConsumerOffset(stream, topic, Optional.of(0L), consumer, BigInteger.valueOf(index))
                             .get(5, TimeUnit.SECONDS);
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientReplicaRequestTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientReplicaRequestTest.java
index 5c7bc14..3b2b071 100644
--- a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientReplicaRequestTest.java
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientReplicaRequestTest.java
@@ -57,7 +57,7 @@
     private static final int LOGIN_CODE = 38;
     private static final int LOGOUT_CODE = 39;
     private static final int POLL_DEFERRED_CODE = 106;
-    private static final int WAIT_FOR_MESSAGES_CODE = 107;
+    private static final int FETCH_MESSAGES_CODE = 109;
     private static final int CANCEL_DEFERRED_REQUEST_CODE = 108;
     private static final int GET_OFFSET_CODE = 120;
     private static final int NOT_PRIMARY = 89;
@@ -183,7 +183,7 @@
                         CREATE_STREAM_CODE,
                         POLL_CODE,
                         POLL_DEFERRED_CODE,
-                        WAIT_FOR_MESSAGES_CODE,
+                        FETCH_MESSAGES_CODE,
                         CANCEL_DEFERRED_REQUEST_CODE,
                         GET_OFFSET_CODE,
                         LOGIN_CODE,
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientTransientFailoverTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientTransientFailoverTest.java
index 036cc71..20f38ac 100644
--- a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientTransientFailoverTest.java
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientTransientFailoverTest.java
@@ -25,6 +25,7 @@
 import org.apache.iggy.consumergroup.Consumer;
 import org.apache.iggy.exception.IggyConnectionException;
 import org.apache.iggy.exception.IggyErrorCode;
+import org.apache.iggy.exception.IggyOperationException;
 import org.apache.iggy.exception.IggyServerException;
 import org.apache.iggy.identifier.ConsumerId;
 import org.apache.iggy.identifier.StreamId;
@@ -952,6 +953,10 @@
                     firstPoll.get(5, TimeUnit.SECONDS);
                     if (cancel) {
                         assertThat(result.cancel(false)).isTrue();
+                        // No reply proves the outcome of the cancelled poll, so it blocks its partition.
+                        var blocked = poll(client, Optional.empty(), true);
+                        assertServerError(blocked, IggyErrorCode.PARTITION_LANE_BLOCKED.getCode());
+                        client.abandonOperation(operationOf(blocked)).get(5, TimeUnit.SECONDS);
                         poll(client, Optional.empty(), true).get(5, TimeUnit.SECONDS);
                     } else {
                         result.get(5, TimeUnit.SECONDS);
@@ -1158,11 +1163,11 @@
                 try {
                     client.connect().get(5, TimeUnit.SECONDS);
                     client.users().login("manual-user", "manual-password").get(5, TimeUnit.SECONDS);
-                    assertThatThrownBy(() -> poll(client, Optional.of(0L), true).get(5, TimeUnit.SECONDS))
-                            .hasCauseInstanceOf(IggyServerException.class)
-                            .satisfies(error -> assertThat(((IggyServerException) error.getCause()).getRawErrorCode())
-                                    .isEqualTo(failure == -1 ? TRANSIENT_NOT_COMMITTED : failure));
+                    var lost = poll(client, Optional.of(0L), true);
+                    // The poll stores its offset, so an unknown outcome is uncertain progress.
+                    assertServerError(lost, IggyErrorCode.FETCH_PROGRESS_UNCERTAIN.getCode());
                     assertThat(polls).hasValue(1);
+                    client.abandonOperation(operationOf(lost)).get(5, TimeUnit.SECONDS);
                     client.users().updateUser(UserId.of(1L), RENAME_USER).get(5, TimeUnit.SECONDS);
                     client.users()
                             .changePassword(UserId.of("renamed-user"), "manual-password", "new-password")
@@ -1180,6 +1185,12 @@
         }
     }
 
+    /** The operation that a failed call names. */
+    static long operationOf(CompletableFuture<?> call) {
+        return call.handle((ignored, error) -> ((IggyOperationException) PollRouter.unwrap(error)).getOperationId())
+                .join();
+    }
+
     static AsyncIggyTcpClient client(ServerSocket coordinator) {
         return client(coordinator.getInetAddress().getHostAddress(), coordinator.getLocalPort());
     }
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncTcpConnectionConcurrencyTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncTcpConnectionConcurrencyTest.java
index 2706665..25322cb 100644
--- a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncTcpConnectionConcurrencyTest.java
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncTcpConnectionConcurrencyTest.java
@@ -47,6 +47,7 @@
 import java.nio.charset.StandardCharsets;
 import java.time.Duration;
 import java.util.Arrays;
+import java.util.List;
 import java.util.Optional;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ExecutionException;
@@ -312,7 +313,7 @@
                 var lane = new ConsensusSession.LaneKey(1, 0);
                 owner.configurePendingOperations(1, frameBytes);
                 ConsensusSession.Ticket ticket = owner.acquire(
-                                lane,
+                                List.of(lane),
                                 true,
                                 frameBytes,
                                 System.nanoTime() + TimeUnit.SECONDS.toNanos(2),
@@ -344,7 +345,7 @@
                 owner.abandon(OPERATION_ID);
                 assertThat(frame.refCnt()).isZero();
                 owner.acquire(
-                                lane,
+                                List.of(lane),
                                 true,
                                 frameBytes,
                                 System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(200),
@@ -472,10 +473,10 @@
 
                 var firstState = new AsyncTcpConnection.TransientFailoverState(
                         new PartitionContext(BigInteger.valueOf(7), BigInteger.ZERO, BigInteger.ONE));
-                firstState.setLane(new ConsensusSession.LaneKey(7, 3));
+                firstState.setLanes(List.of(new ConsensusSession.LaneKey(7, 3)));
                 var secondState = new AsyncTcpConnection.TransientFailoverState(
                         new PartitionContext(BigInteger.valueOf(8), BigInteger.ZERO, BigInteger.ONE));
-                secondState.setLane(new ConsensusSession.LaneKey(8, 4));
+                secondState.setLanes(List.of(new ConsensusSession.LaneKey(8, 4)));
                 CompletableFuture<ByteBuf> first = connection.sendRouted(
                         SEND_MESSAGES_CODE,
                         sendMessagesPayload(3),
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/DeferredPollingFixtureTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/DeferredPollingFixtureTest.java
new file mode 100644
index 0000000..a3a7818
--- /dev/null
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/DeferredPollingFixtureTest.java
@@ -0,0 +1,294 @@
+/*
+ * 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.
+ */
+
+package org.apache.iggy.client.async.tcp;
+
+import io.netty.buffer.ByteBuf;
+import io.netty.buffer.Unpooled;
+import org.apache.iggy.consumergroup.Consumer;
+import org.apache.iggy.identifier.ConsumerId;
+import org.apache.iggy.identifier.StreamId;
+import org.apache.iggy.identifier.TopicId;
+import org.apache.iggy.message.CommitErrorScope;
+import org.apache.iggy.message.DeferredPollOptions;
+import org.apache.iggy.message.FetchedMessages;
+import org.apache.iggy.message.Message;
+import org.apache.iggy.message.PolledMessages;
+import org.apache.iggy.message.PollingKind;
+import org.apache.iggy.message.PollingStrategy;
+import org.apache.iggy.partition.PartitionContext;
+import org.apache.iggy.serde.BytesDeserializer;
+import org.apache.iggy.serde.BytesSerializer;
+import org.apache.iggy.serde.CommandCode;
+import org.junit.jupiter.api.Test;
+import tools.jackson.databind.JsonNode;
+import tools.jackson.databind.json.JsonMapper;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.math.BigInteger;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.time.temporal.ChronoUnit;
+import java.util.HexFormat;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * The deferred polling vectors that the Rust encoders generate, shared with every SDK, so the wire here is the
+ * server's contract and not this SDK's encoder run twice.
+ */
+class DeferredPollingFixtureTest {
+    private static final JsonNode ROOT = load();
+    private static final HexFormat HEX = HexFormat.of();
+
+    @Test
+    void shouldMatchTheConstantsOfTheServer() {
+        JsonNode constants = ROOT.get("constants");
+
+        assertThat(constants.get("codes").get("poll_messages_deferred").asInt())
+                .isEqualTo(CommandCode.Messages.POLL_DEFERRED.getValue());
+        assertThat(constants.get("codes").get("fetch_messages").asInt())
+                .isEqualTo(CommandCode.Messages.FETCH_MESSAGES.getValue());
+        assertThat(constants.get("codes").get("cancel_deferred_request").asInt())
+                .isEqualTo(CommandCode.Messages.CANCEL_DEFERRED_REQUEST.getValue());
+        assertThat(constants.get("min_deferred_poll_max_bytes").asLong()).isEqualTo(DeferredPollOptions.MIN_MAX_BYTES);
+        assertThat(constants.get("max_record_size").asLong()).isEqualTo(DeferredPollOptions.MAX_RECORD_SIZE);
+        assertThat(constants.get("max_record_reply_size").asLong())
+                .isEqualTo(AsyncIggyTcpClientBuilder.MIN_MAX_VSR_FRAME_SIZE);
+        assertThat(constants.get("fetch_section_header_size").asInt()).isEqualTo(BytesDeserializer.FETCH_SECTION_BYTES);
+    }
+
+    @Test
+    void shouldEncodeEachDeferredPollOfTheFixture() {
+        for (JsonNode vector : ROOT.get("deferred_polls")) {
+            JsonNode request = vector.get("request");
+            ByteBuf body = MessagesTcpClient.pollBody(
+                    streamId(request.get("stream_id")),
+                    topicId(request.get("topic_id")),
+                    Optional.of(request.get("partition_id").asLong()),
+                    consumer(request.get("consumer")),
+                    strategy(request.get("strategy")),
+                    request.get("count").asLong(),
+                    request.get("auto_commit").asBoolean(),
+                    options(request));
+
+            assertThat(hex(body))
+                    .as(vector.get("case").asString())
+                    .isEqualTo(vector.get("body_hex").asString());
+        }
+    }
+
+    /** This SDK sends the byte limit of a fetch as the cap of each target, so the vector caps are set after it. */
+    @Test
+    void shouldEncodeEachFetchOfTheFixture() {
+        for (JsonNode vector : ROOT.get("fetches")) {
+            JsonNode request = vector.get("request");
+            Map<Long, PollingStrategy> targets = new LinkedHashMap<>();
+            for (JsonNode target : request.get("targets")) {
+                PartitionContext fence = new PartitionContext(
+                        BigInteger.valueOf(target.get("incarnation").asLong()),
+                        BigInteger.valueOf(target.get("owner_generation").asLong()),
+                        BigInteger.ZERO);
+                targets.put(
+                        target.get("partition_id").asLong(),
+                        strategy(target.get("strategy")).withContext(fence));
+            }
+            ByteBuf body = FetchMessages.payload(
+                    PollRouter.takeBytes(BytesSerializer.toBytes(consumer(request.get("consumer")))),
+                    topic(request),
+                    targets,
+                    request.get("count").asLong(),
+                    request.get("auto_commit").asBoolean(),
+                    options(request));
+            int first = body.writerIndex() - targets.size() * FetchMessages.TARGET_BYTES;
+            int index = 0;
+            for (JsonNode target : request.get("targets")) {
+                body.setIntLE(
+                        first + index++ * FetchMessages.TARGET_BYTES + FetchMessages.TARGET_MAX_BYTES_OFFSET,
+                        target.get("max_bytes").asInt());
+            }
+
+            assertThat(hex(body))
+                    .as(vector.get("case").asString())
+                    .isEqualTo(vector.get("body_hex").asString());
+        }
+    }
+
+    @Test
+    void shouldDecodeEachFetchReplyOfTheFixture() {
+        for (JsonNode vector : ROOT.get("fetch_replies")) {
+            List<FetchedMessages> sections = BytesDeserializer.readFetchedMessages(
+                    Unpooled.wrappedBuffer(HEX.parseHex(vector.get("body_hex").asString())));
+            JsonNode expected = vector.get("sections");
+
+            assertThat(sections).as(vector.get("case").asString()).hasSize(expected.size());
+            for (int index = 0; index < sections.size(); index++) {
+                FetchedMessages section = sections.get(index);
+                JsonNode fields = expected.get(index);
+                JsonNode context = fields.get("context");
+                assertThat(List.of(
+                                section.partitionId(),
+                                section.status(),
+                                section.currentOffset().longValue(),
+                                section.count(),
+                                section.nextOffset().longValue(),
+                                (long) section.records().length))
+                        .containsExactly(
+                                fields.get("partition_id").asLong(),
+                                fields.get("status").asLong(),
+                                fields.get("current_offset").asLong(),
+                                fields.get("messages_count").asLong(),
+                                fields.get("next_offset").asLong(),
+                                fields.get("records_len").asLong());
+                assertThat(section.context())
+                        .isEqualTo(new PartitionContext(
+                                BigInteger.valueOf(context.get("incarnation").asLong()),
+                                BigInteger.valueOf(
+                                        context.get("owner_generation").asLong()),
+                                BigInteger.valueOf(context.get("metadata_op").asLong())));
+                if (section.status() == FetchedMessages.STATUS_OK) {
+                    assertMessages(section.decode(), fields.get("messages"));
+                }
+            }
+        }
+    }
+
+    @Test
+    void shouldRefuseEachRefusedFetchReplyOfTheFixture() {
+        for (JsonNode vector : ROOT.get("refused").get("fetch_replies")) {
+            byte[] body = HEX.parseHex(vector.get("body_hex").asString());
+
+            assertThatThrownBy(() -> BytesDeserializer.readFetchedMessages(Unpooled.wrappedBuffer(body))
+                            .forEach(FetchedMessages::decode))
+                    .as(vector.get("case").asString())
+                    .isInstanceOf(RuntimeException.class);
+        }
+    }
+
+    /** A committing poll or fetch that fails with a certain status stored nothing, any other leaves it unknown. */
+    @Test
+    void shouldClassifyEachCommitOutcomeOfTheFixture() {
+        JsonNode outcomes = ROOT.get("commit_outcomes");
+
+        for (CommitErrorScope scope : CommitErrorScope.values()) {
+            JsonNode listed = outcomes.get(scope.name().toLowerCase(Locale.ROOT));
+            assertThat(listed).as(scope.name()).isNotEmpty();
+            for (JsonNode outcome : listed) {
+                assertThat(scope.provesNothingStored(outcome.get("status").asLong()))
+                        .as("%s %s %s", scope, outcome.get("status"), outcome.get("error"))
+                        .isEqualTo(outcome.get("certain").asBoolean());
+            }
+        }
+    }
+
+    private static void assertMessages(PolledMessages polled, JsonNode expected) {
+        assertThat(polled.messages()).hasSize(expected.size());
+        for (int index = 0; index < expected.size(); index++) {
+            Message message = polled.messages().get(index);
+            JsonNode fields = expected.get(index);
+            assertThat(List.of(
+                            message.header().offset().toString(),
+                            message.header().id().toBigInteger().toString(),
+                            message.header().timestamp().toString(),
+                            message.header().originTimestamp().toString(),
+                            new String(message.payload(), StandardCharsets.UTF_8)))
+                    .containsExactly(
+                            fields.get("offset").asString(),
+                            fields.get("id").asString(),
+                            fields.get("timestamp").asString(),
+                            fields.get("origin_timestamp").asString(),
+                            fields.get("payload").asString());
+        }
+    }
+
+    private static StreamId streamId(JsonNode identifier) {
+        return numeric(identifier)
+                ? StreamId.of(identifier.get("value").asLong())
+                : StreamId.of(identifier.get("value").asString());
+    }
+
+    private static TopicId topicId(JsonNode identifier) {
+        return numeric(identifier)
+                ? TopicId.of(identifier.get("value").asLong())
+                : TopicId.of(identifier.get("value").asString());
+    }
+
+    private static boolean numeric(JsonNode identifier) {
+        return "numeric".equals(identifier.get("kind").asString());
+    }
+
+    private static byte[] topic(JsonNode request) {
+        ByteBuf topic = BytesSerializer.toBytes(streamId(request.get("stream_id")));
+        ByteBuf topicPart = BytesSerializer.toBytes(topicId(request.get("topic_id")));
+        try {
+            topic.writeBytes(topicPart);
+        } finally {
+            topicPart.release();
+        }
+        return PollRouter.takeBytes(topic);
+    }
+
+    private static Consumer consumer(JsonNode consumer) {
+        JsonNode id = consumer.get("id");
+        ConsumerId consumerId = "numeric".equals(id.get("kind").asString())
+                ? ConsumerId.of(id.get("value").asLong())
+                : ConsumerId.of(id.get("value").asString());
+        return consumer.get("kind").asInt() == 2 ? Consumer.group(consumerId) : Consumer.of(consumerId);
+    }
+
+    private static PollingStrategy strategy(JsonNode strategy) {
+        PollingKind kind = PollingKind.values()[strategy.get("kind").asInt() - 1];
+        return new PollingStrategy(
+                kind, BigInteger.valueOf(strategy.get("value").asLong()));
+    }
+
+    private static DeferredPollOptions options(JsonNode request) {
+        return new DeferredPollOptions(
+                Duration.of(request.get("wait_us").asLong(), ChronoUnit.MICROS),
+                request.get("min_count").asLong(),
+                request.get("max_bytes").asLong(),
+                Duration.of(request.get("request_timeout_us").asLong(), ChronoUnit.MICROS));
+    }
+
+    private static String hex(ByteBuf buffer) {
+        try {
+            byte[] bytes = new byte[buffer.readableBytes()];
+            buffer.getBytes(buffer.readerIndex(), bytes);
+            return HEX.formatHex(bytes);
+        } finally {
+            buffer.release();
+        }
+    }
+
+    private static JsonNode load() {
+        try (InputStream input = DeferredPollingFixtureTest.class.getResourceAsStream("/deferred_polling.json")) {
+            return JsonMapper.builder().build().readTree(input);
+        } catch (IOException error) {
+            throw new UncheckedIOException(error);
+        }
+    }
+}
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/FetchMessagesTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/FetchMessagesTest.java
new file mode 100644
index 0000000..61a34db
--- /dev/null
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/FetchMessagesTest.java
@@ -0,0 +1,177 @@
+/*
+ * 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.
+ */
+
+package org.apache.iggy.client.async.tcp;
+
+import io.netty.buffer.ByteBuf;
+import io.netty.buffer.Unpooled;
+import org.apache.iggy.client.async.tcp.vsr.ConsensusSession.LaneKey;
+import org.apache.iggy.client.async.tcp.vsr.VsrHeaders;
+import org.apache.iggy.exception.IggyInvalidArgumentException;
+import org.apache.iggy.message.DeferredPollOptions;
+import org.apache.iggy.message.PollingKind;
+import org.apache.iggy.message.PollingStrategy;
+import org.apache.iggy.serde.BytesDeserializer;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigInteger;
+import java.time.Duration;
+import java.util.HexFormat;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** The fields that the client reads from and writes into the body of a fetch, on the golden request of the contract. */
+class FetchMessagesTest {
+    // Made by the Rust encoder: consumer group 7, stream 1, topic "orders", wait 5 s, timeout 6 s, min_count 1,
+    // count 100, max_bytes 1 MiB and auto commit, then partition 1 at Offset(41) and partition 2 at Next, each with
+    // max_bytes 512 KiB, incarnation 3 and owner generation 9.
+    static final String GOLDEN_REQUEST =
+            "0201040700000001040100000002066f7264657273404b4c0000000000808d5b00000000000100000064000000000010"
+                    + "000102000000010000000129000000000000000000080003000000000000000900000000000000020000000500000000"
+                    + "000000000000080003000000000000000900000000000000";
+    // The consumer, stream and topic of the golden request, which the limits follow.
+    static final int GOLDEN_LIMITS = 21;
+    static final long GOLDEN_MAX_BYTES = 1024 * 1024;
+    private static final long GOLDEN_INCARNATION = 3;
+    private static final int GOLDEN_TARGETS = 2;
+    // More than one record of the largest size and a whole fetch table.
+    private static final long LARGE_MAX_BYTES = 0xFFFF_0000L;
+
+    @Test
+    void shouldAcceptOneMicrosecondOfStartupBudgetForAZeroWaitFetch() {
+        var options = DeferredPollOptions.defaults()
+                .withMaxWait(Duration.ZERO)
+                .withRequestTimeout(DeferredPollOptions.REPLY_MARGIN.plusNanos(1000));
+
+        assertThatCode(() -> FetchMessages.validate(Map.of(1L, PollingStrategy.offset(BigInteger.ZERO)), 1, options))
+                .doesNotThrowAnyException();
+    }
+
+    @Test
+    void shouldTakeTheProgressLaneOfEveryTargetOfACommittingFetch() {
+        ByteBuf payload = golden();
+        setKind(payload, 1, PollingKind.Offset);
+
+        assertThat(FetchMessages.progressLanes(payload))
+                .containsExactly(new LaneKey(GOLDEN_INCARNATION, 1, true), new LaneKey(GOLDEN_INCARNATION, 2, true));
+    }
+
+    @Test
+    void shouldTakeTheProgressLanesOfAFetchThatStartsAPartitionAtItsNextOffset() {
+        ByteBuf payload = golden();
+        payload.setByte(GOLDEN_LIMITS + FetchMessages.AUTO_COMMIT_OFFSET, 0);
+
+        assertThat(FetchMessages.progressLanes(payload))
+                .containsExactly(new LaneKey(GOLDEN_INCARNATION, 1, true), new LaneKey(GOLDEN_INCARNATION, 2, true));
+    }
+
+    @Test
+    void shouldTakeNoLaneForAFetchThatOnlyReads() {
+        ByteBuf payload = golden();
+        payload.setByte(GOLDEN_LIMITS + FetchMessages.AUTO_COMMIT_OFFSET, 0);
+        setKind(payload, 1, PollingKind.Offset);
+
+        assertThat(FetchMessages.progressLanes(payload)).isEmpty();
+    }
+
+    /** The server returns a first message larger than the byte limit alone, so the reply can hold one such record. */
+    @Test
+    void shouldBoundTheReplyByTheSectionTableAndTheLargestRecord() {
+        assertThat(FetchMessages.replyLimit(frame())).isEqualTo(tableBytes() + DeferredPollOptions.MAX_RECORD_SIZE);
+    }
+
+    @Test
+    void shouldKeepTheByteLimitsOfAFetchWhoseReplyFitsTheLink() {
+        ByteBuf frame = frame();
+
+        FetchMessages.fitReply(frame, VsrHeaders.HEADER_SIZE + GOLDEN_LIMITS, FetchMessages.replyLimit(frame));
+
+        assertThat(HexFormat.of().formatHex(body(frame))).isEqualTo(GOLDEN_REQUEST);
+    }
+
+    @Test
+    void shouldLowerTheByteLimitsUntilTheWholeReplyFitsTheLink() {
+        ByteBuf frame = frame();
+        setMaxBytes(frame, LARGE_MAX_BYTES);
+
+        FetchMessages.fitReply(
+                frame, VsrHeaders.HEADER_SIZE + GOLDEN_LIMITS, tableBytes() + DeferredPollOptions.MAX_RECORD_SIZE);
+
+        ByteBuf body = Unpooled.wrappedBuffer(body(frame));
+        assertThat(body.getUnsignedIntLE(GOLDEN_LIMITS + FetchMessages.MAX_BYTES_OFFSET))
+                .isEqualTo(DeferredPollOptions.MAX_RECORD_SIZE);
+        for (int target = 0; target < GOLDEN_TARGETS; target++) {
+            assertThat(body.getUnsignedIntLE(target(target) + FetchMessages.TARGET_MAX_BYTES_OFFSET))
+                    .isEqualTo(DeferredPollOptions.MAX_RECORD_SIZE);
+        }
+        assertThat(FetchMessages.replyLimit(frame)).isEqualTo(tableBytes() + DeferredPollOptions.MAX_RECORD_SIZE);
+    }
+
+    @Test
+    void shouldRefuseAFetchWhoseLinkCannotHoldTheLargestRecord() {
+        ByteBuf frame = frame();
+
+        assertThatThrownBy(() -> FetchMessages.fitReply(
+                        frame,
+                        VsrHeaders.HEADER_SIZE + GOLDEN_LIMITS,
+                        tableBytes() + DeferredPollOptions.MAX_RECORD_SIZE - 1))
+                .isInstanceOf(IggyInvalidArgumentException.class);
+        FetchMessages.fitReply(
+                frame, VsrHeaders.HEADER_SIZE + GOLDEN_LIMITS, tableBytes() + DeferredPollOptions.MAX_RECORD_SIZE);
+        assertThat(HexFormat.of().formatHex(body(frame))).isEqualTo(GOLDEN_REQUEST);
+    }
+
+    /** Sets the byte limit of the request and of each target of the golden fetch in {@code frame}. */
+    private static void setMaxBytes(ByteBuf frame, long maxBytes) {
+        frame.setIntLE(VsrHeaders.HEADER_SIZE + GOLDEN_LIMITS + FetchMessages.MAX_BYTES_OFFSET, (int) maxBytes);
+        for (int target = 0; target < GOLDEN_TARGETS; target++) {
+            frame.setIntLE(
+                    VsrHeaders.HEADER_SIZE + target(target) + FetchMessages.TARGET_MAX_BYTES_OFFSET, (int) maxBytes);
+        }
+    }
+
+    private static ByteBuf golden() {
+        return Unpooled.wrappedBuffer(HexFormat.of().parseHex(GOLDEN_REQUEST));
+    }
+
+    private static ByteBuf frame() {
+        return Unpooled.wrappedBuffer(Unpooled.buffer().writeZero(VsrHeaders.HEADER_SIZE), golden());
+    }
+
+    private static byte[] body(ByteBuf frame) {
+        byte[] body = new byte[frame.readableBytes() - VsrHeaders.HEADER_SIZE];
+        frame.getBytes(VsrHeaders.HEADER_SIZE, body);
+        return body;
+    }
+
+    private static int target(int index) {
+        return GOLDEN_LIMITS + FetchMessages.LIMITS_BYTES + index * FetchMessages.TARGET_BYTES;
+    }
+
+    private static void setKind(ByteBuf payload, int index, PollingKind kind) {
+        payload.setByte(target(index) + FetchMessages.STRATEGY_KIND_OFFSET, kind.asCode());
+    }
+
+    private static long tableBytes() {
+        return BytesDeserializer.FETCH_SECTIONS_COUNT_BYTES + GOLDEN_TARGETS * BytesDeserializer.FETCH_SECTION_BYTES;
+    }
+}
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/PollRouterTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/PollRouterTest.java
index 85970a6..1e81e62 100644
--- a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/PollRouterTest.java
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/PollRouterTest.java
@@ -53,6 +53,7 @@
 import org.apache.iggy.message.SendMessagesResponse;
 import org.apache.iggy.partition.PartitionContext;
 import org.apache.iggy.serde.BytesDeserializer;
+import org.apache.iggy.serde.BytesSerializer;
 import org.apache.iggy.serde.CommandCode;
 import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.BeforeAll;
@@ -71,12 +72,14 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Deque;
+import java.util.HexFormat;
 import java.util.List;
 import java.util.Optional;
 import java.util.concurrent.Callable;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ConcurrentLinkedDeque;
 import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.ExecutionException;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.concurrent.atomic.AtomicReference;
@@ -136,6 +139,24 @@
         ResourceLeakDetector.setLevel(leakDetection);
     }
 
+    @Test
+    void shouldReadBackTheDeferredLimitsThatItWritesAfterAPollBody() {
+        var options = new DeferredPollOptions(Duration.ofMillis(1500), 3, 4096, Duration.ofSeconds(9));
+        ByteBuf request = Unpooled.buffer().writeBytes(new byte[] {1, 2, 3});
+        try {
+            PollRouter.writeDeferredOptions(request, options);
+
+            assertThat(request.readableBytes()).isEqualTo(3 + PollRouter.DEFERRED_PARAMETERS_BYTES);
+            assertThat(PollRouter.deferredOptions(request)).isEqualTo(options);
+            assertThat(PollRouter.deferredMaxBytes(request)).isEqualTo(options.maxBytes());
+            assertThat(request.getLongLE(PollRouter.deferredWait(request))).isEqualTo(options.maxWaitMicros());
+            assertThat(request.getLongLE(PollRouter.deferredTimeout(request)))
+                    .isEqualTo(options.requestTimeoutMicros());
+        } finally {
+            request.release();
+        }
+    }
+
     @ParameterizedTest
     @EnumSource(
             value = IggyErrorCode.class,
@@ -287,7 +308,12 @@
                 () -> new ConnectionInfo(InetAddress.getLoopbackAddress().getHostAddress(), PRIMARY_PORT + 1));
         var holder = coordinator
                 .sessionOwner()
-                .acquire(PARTITION_LANE, true, LANE_FRAME_BYTES, System.nanoTime() + NEVER.toNanos(), group.next())
+                .acquire(
+                        List.of(PARTITION_LANE),
+                        true,
+                        LANE_FRAME_BYTES,
+                        System.nanoTime() + NEVER.toNanos(),
+                        group.next())
                 .join();
         try {
             var sent = sendMessage(coordinator, router);
@@ -412,7 +438,7 @@
             var next = coordinator
                     .sessionOwner()
                     .acquire(
-                            new ConsensusSession.LaneKey(INCARNATION, 0),
+                            List.of(new ConsensusSession.LaneKey(INCARNATION, 0)),
                             true,
                             frameBytes,
                             System.nanoTime() + NEVER.toNanos(),
@@ -550,6 +576,41 @@
     }
 
     @Test
+    void shouldShareTheDeferredRequestSlotsWithFetches() throws Exception {
+        IoEventLoopGroup group = new MultiThreadIoEventLoopGroup(1, NioIoHandler.newFactory());
+        ManualTimers timers = new ManualTimers();
+        Coordinator coordinator = new Coordinator(group, timers);
+        Primary primary = new Primary(group);
+        PollRouter router = new PollRouter(() -> coordinator, endpoint -> primary);
+        try {
+            List<CompletableFuture<ByteBuf>> polls = new ArrayList<>();
+            for (int index = 0; index < DEFERRED_POLL_CAPACITY; index++) {
+                primary.replies.add(new CompletableFuture<>());
+                polls.add(routedPoll(router, tracked(pollPayload()), true));
+            }
+            var fetched = new CompletableFuture<ByteBuf>();
+            primary.replies.add(fetched);
+            ByteBuf payload =
+                    tracked(Unpooled.wrappedBuffer(HexFormat.of().parseHex(FetchMessagesTest.GOLDEN_REQUEST)));
+            byte[] topic = PollRouter.takeBytes(Unpooled.wrappedBuffer(
+                    BytesSerializer.toBytes(StreamId.of(1L)), BytesSerializer.toBytes(TopicId.of("orders"))));
+            var fetch = router.fetchRequest(payload, topic, 1, router.operationDeadline(), router.startCall());
+            assertThat(primary.sentContexts).hasSize(DEFERRED_POLL_CAPACITY);
+            assertThat(fetch).isNotDone();
+
+            assertThat(polls.get(0).cancel(false)).isTrue();
+            await(() -> primary.sentContexts.size() == DEFERRED_POLL_CAPACITY + 1, "the queued fetch", TIMEOUT);
+            fetched.complete(Unpooled.buffer().writeIntLE(0));
+            fetch.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS).release();
+            assertThat(payload.refCnt()).isZero();
+        } finally {
+            router.clearSession().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            timers.shutdownGracefully(0, 1, TimeUnit.SECONDS).get(5, TimeUnit.SECONDS);
+            group.shutdownGracefully(0, 1, TimeUnit.SECONDS).get(5, TimeUnit.SECONDS);
+        }
+    }
+
+    @Test
     void shouldNotSendAQueuedDeferredPollAfterItsDeadline() throws Exception {
         IoEventLoopGroup group = new MultiThreadIoEventLoopGroup(1, NioIoHandler.newFactory());
         ManualTimers timers = new ManualTimers();
@@ -925,7 +986,8 @@
                     return Response.noReply();
                 }
                 if (request.is(CommandCode.Messages.CANCEL_DEFERRED_REQUEST.getValue(), OPERATION_NON_REPLICATED)) {
-                    return Response.noReply();
+                    // The cancel names the poll, so this answers the poll: the cancel won and nothing was stored.
+                    return Response.error(OPERATION_NON_REPLICATED, IggyErrorCode.TRANSIENT_NOT_ACCEPTED.getCode());
                 }
                 if (request.operation() == VsrOperation.SEND_MESSAGES) {
                     return Response.success(request.operation(), Unpooled.EMPTY_BUFFER);
@@ -977,10 +1039,27 @@
                 assertThat(offsetRequests.get()).isZero();
                 assertThat(offsets).allSatisfy(offset -> assertThat(offset).isNotDone());
                 assertThat(poll.cancel(false)).isTrue();
-                for (var offset : offsets) {
-                    offset.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS).release();
+                // Until the reply to its cancel, the cancelled poll blocks the writes of its partition, and an
+                // inspection goes after it.
+                for (var write : List.of(offsets.get(0), offsets.get(2))) {
+                    assertThatThrownBy(() -> write.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS))
+                            .hasCauseInstanceOf(IggyOperationException.class)
+                            .satisfies(error -> assertThat(((IggyOperationException) error.getCause()).getErrorCode())
+                                    .isEqualTo(IggyErrorCode.PARTITION_LANE_BLOCKED));
                 }
-                assertThat(offsetRequests.get()).isEqualTo(offsets.size());
+                offsets.get(1).get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS).release();
+                long deadline = System.nanoTime() + TIMEOUT.toNanos();
+                while (offsetRequests.get() < 2 && System.nanoTime() - deadline < 0) {
+                    try {
+                        router.partitionRequest(
+                                        CommandCode.ConsumerOffset.STORE.getValue(), offsetPayload(), Optional.empty())
+                                .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)
+                                .release();
+                    } catch (ExecutionException blocked) {
+                        TimeUnit.MILLISECONDS.sleep(10);
+                    }
+                }
+                assertThat(offsetRequests.get()).isEqualTo(2);
             } finally {
                 router.clearSession().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
                 parent.close().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
@@ -1464,7 +1543,7 @@
         var owner = connection.sessionOwner();
         owner.configurePendingOperations(1, frameBytes);
         var ticket = owner.acquire(
-                        new ConsensusSession.LaneKey(INCARNATION, 0, true),
+                        List.of(new ConsensusSession.LaneKey(INCARNATION, 0, true)),
                         true,
                         frameBytes,
                         System.nanoTime() + NEVER.toNanos(),
@@ -1749,7 +1828,7 @@
             }
             if (laneTimers != null && commandCode == CommandCode.Messages.SEND.getValue()) {
                 return sessionOwner()
-                        .acquire(PARTITION_LANE, true, LANE_FRAME_BYTES, deadline, laneTimers)
+                        .acquire(List.of(PARTITION_LANE), true, LANE_FRAME_BYTES, deadline, laneTimers)
                         .thenApply(ticket -> {
                             ticket.complete();
                             return Unpooled.EMPTY_BUFFER;
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/vsr/ConsensusSessionTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/vsr/ConsensusSessionTest.java
index 32e76ce..96bc168 100644
--- a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/vsr/ConsensusSessionTest.java
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/vsr/ConsensusSessionTest.java
@@ -28,7 +28,10 @@
 import org.apache.iggy.exception.IggyOperationException;
 import org.apache.iggy.exception.IggyServerException;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 
+import java.util.List;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.TimeUnit;
 
@@ -80,7 +83,7 @@
         long deadline = System.nanoTime() + DEADLINE;
         try {
             var ticket = session.acquire(
-                            ConsensusSession.LaneKey.METADATA,
+                            List.of(ConsensusSession.LaneKey.METADATA),
                             true,
                             VsrHeaders.HEADER_SIZE,
                             deadline,
@@ -105,18 +108,18 @@
         EmbeddedChannel channel = new EmbeddedChannel();
         var lane = new ConsensusSession.LaneKey(7, 1);
         var first = session.acquire(
-                        lane, true, VsrHeaders.HEADER_SIZE, System.nanoTime() + DEADLINE, channel.eventLoop())
+                        List.of(lane), true, VsrHeaders.HEADER_SIZE, System.nanoTime() + DEADLINE, channel.eventLoop())
                 .join();
         first.startWrite();
-        var second =
-                session.acquire(lane, true, VsrHeaders.HEADER_SIZE, System.nanoTime() + DEADLINE, channel.eventLoop());
-        var third =
-                session.acquire(lane, true, VsrHeaders.HEADER_SIZE, System.nanoTime() + DEADLINE, channel.eventLoop());
-        var fourth =
-                session.acquire(lane, true, VsrHeaders.HEADER_SIZE, System.nanoTime() + DEADLINE, channel.eventLoop());
+        var second = session.acquire(
+                List.of(lane), true, VsrHeaders.HEADER_SIZE, System.nanoTime() + DEADLINE, channel.eventLoop());
+        var third = session.acquire(
+                List.of(lane), true, VsrHeaders.HEADER_SIZE, System.nanoTime() + DEADLINE, channel.eventLoop());
+        var fourth = session.acquire(
+                List.of(lane), true, VsrHeaders.HEADER_SIZE, System.nanoTime() + DEADLINE, channel.eventLoop());
         try {
             var independent = session.acquire(
-                            new ConsensusSession.LaneKey(8, 1),
+                            List.of(new ConsensusSession.LaneKey(8, 1)),
                             true,
                             VsrHeaders.HEADER_SIZE,
                             System.nanoTime() + DEADLINE,
@@ -144,6 +147,150 @@
         }
     }
 
+    /**
+     * A fetch takes the progress lanes of all its partitions together. It waits behind the requests ahead of it on any
+     * of them, and a later store on one of them waits behind the fetch.
+     */
+    @Test
+    void shouldAdmitAFetchAfterTheRequestsAheadOfItOnEachOfItsLanes() {
+        ConsensusSession session = new ConsensusSession();
+        session.bind(42);
+        EmbeddedChannel channel = new EmbeddedChannel();
+        var first = new ConsensusSession.LaneKey(7, 1, true);
+        var second = new ConsensusSession.LaneKey(7, 2, true);
+        var poll = session.acquire(
+                        List.of(second),
+                        false,
+                        VsrHeaders.HEADER_SIZE,
+                        System.nanoTime() + DEADLINE,
+                        channel.eventLoop())
+                .join();
+        var fetch = session.acquire(
+                List.of(first, second),
+                false,
+                VsrHeaders.HEADER_SIZE,
+                System.nanoTime() + DEADLINE,
+                channel.eventLoop());
+        var store = session.acquire(
+                List.of(first), true, VsrHeaders.HEADER_SIZE, System.nanoTime() + DEADLINE, channel.eventLoop());
+        try {
+            assertThat(fetch).isNotDone();
+            assertThat(store).isNotDone();
+
+            poll.complete();
+
+            assertThat(fetch).isDone();
+            assertThat(store).isNotDone();
+            fetch.join().complete();
+            assertThat(store).isDone();
+            store.join().complete();
+        } finally {
+            fetch.cancel(false);
+            store.cancel(false);
+            session.reset();
+            channel.finishAndReleaseAll();
+        }
+    }
+
+    @Test
+    void shouldNameARetainedPartitionAndReleaseEarlierAdmissionsUntilRepair() {
+        ConsensusSession session = new ConsensusSession();
+        session.bind(42);
+        EmbeddedChannel channel = new EmbeddedChannel();
+        var first = new ConsensusSession.LaneKey(7, 1, true);
+        var second = new ConsensusSession.LaneKey(7, 0xffff_ffffL, true);
+        long deadline = System.nanoTime() + DEADLINE;
+        try {
+            var holder = session.acquire(List.of(second), true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop())
+                    .join();
+            long operation = Long.MIN_VALUE;
+            freezeAndSend(holder, operation);
+            var fetch = session.acquire(
+                    List.of(first, second), false, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop());
+            var earlier = session.acquire(List.of(first), true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop());
+            assertThat(fetch).isNotDone();
+            assertThat(earlier).isNotDone();
+
+            holder.retain();
+
+            assertThatThrownBy(fetch::join)
+                    .hasCauseInstanceOf(IggyOperationException.class)
+                    .satisfies(error -> {
+                        var blocked = (IggyOperationException) error.getCause();
+                        assertThat(blocked.getErrorCode()).isEqualTo(IggyErrorCode.LANE_BLOCKED);
+                        assertThat(blocked.getOperationId()).isEqualTo(operation);
+                        assertThat(blocked.getPartitionId()).hasValue(second.partition());
+                        assertThat(blocked.getReason()).isEqualTo("Operation " + Long.toUnsignedString(operation));
+                    });
+            assertThat(earlier).isDone();
+            earlier.join().complete();
+            assertThat(session.resume(operation, deadline, channel.eventLoop()).join())
+                    .isSameAs(holder);
+            holder.complete();
+            var repaired = session.acquire(
+                    List.of(first, second), false, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop());
+            assertThat(repaired).isDone();
+            repaired.join().complete();
+        } finally {
+            session.reset();
+            channel.finishAndReleaseAll();
+        }
+    }
+
+    /**
+     * Two fetches that name the same partitions in opposite orders both get their lanes, one after the other, because
+     * every request takes its lanes in ascending partition order.
+     */
+    @Test
+    void shouldAdmitFetchesThatNameTheirPartitionsInOppositeOrders() {
+        ConsensusSession session = new ConsensusSession();
+        session.bind(42);
+        EmbeddedChannel channel = new EmbeddedChannel();
+        var first = new ConsensusSession.LaneKey(7, 1, true);
+        var second = new ConsensusSession.LaneKey(7, 2, true);
+        var firstHolder = session.acquire(
+                        List.of(first),
+                        false,
+                        VsrHeaders.HEADER_SIZE,
+                        System.nanoTime() + DEADLINE,
+                        channel.eventLoop())
+                .join();
+        var secondHolder = session.acquire(
+                        List.of(second),
+                        false,
+                        VsrHeaders.HEADER_SIZE,
+                        System.nanoTime() + DEADLINE,
+                        channel.eventLoop())
+                .join();
+        var ascending = session.acquire(
+                List.of(first, second),
+                false,
+                VsrHeaders.HEADER_SIZE,
+                System.nanoTime() + DEADLINE,
+                channel.eventLoop());
+        var descending = session.acquire(
+                List.of(second, first),
+                false,
+                VsrHeaders.HEADER_SIZE,
+                System.nanoTime() + DEADLINE,
+                channel.eventLoop());
+        try {
+            firstHolder.complete();
+            secondHolder.complete();
+
+            assertThat(ascending).isDone();
+            assertThat(descending).isNotDone();
+            ascending.join().complete();
+            assertThat(descending).isDone();
+            descending.join().complete();
+        } finally {
+            ascending.cancel(false);
+            descending.cancel(false);
+            session.reset();
+            channel.finishAndReleaseAll();
+        }
+    }
+
     @Test
     void shouldKeepRetainedLaneBlockedWhileAnotherPartitionProgresses() {
         ConsensusSession session = new ConsensusSession();
@@ -152,14 +299,19 @@
         try {
             var lane = new ConsensusSession.LaneKey(7, 1);
             var ticket = session.acquire(
-                            lane, true, VsrHeaders.HEADER_SIZE, System.nanoTime() + DEADLINE, channel.eventLoop())
+                            List.of(lane),
+                            true,
+                            VsrHeaders.HEADER_SIZE,
+                            System.nanoTime() + DEADLINE,
+                            channel.eventLoop())
                     .join();
             freezeAndSend(ticket, 9);
             var uncertain = (IggyOperationException) ticket.retain();
             assertThat(uncertain.getOperationId()).isEqualTo(9);
             assertThat(uncertain.getErrorCode()).isEqualTo(IggyErrorCode.OPERATION_UNCERTAIN);
+            assertThat(uncertain.getPartitionId()).isEmpty();
             assertThatThrownBy(() -> session.acquire(
-                                    lane,
+                                    List.of(lane),
                                     true,
                                     VsrHeaders.HEADER_SIZE,
                                     System.nanoTime() + DEADLINE,
@@ -169,7 +321,7 @@
                     .satisfies(error -> assertThat(((IggyOperationException) error.getCause()).getErrorCode())
                             .isEqualTo(IggyErrorCode.LANE_BLOCKED));
             var independent = session.acquire(
-                            new ConsensusSession.LaneKey(8, 1),
+                            List.of(new ConsensusSession.LaneKey(8, 1)),
                             true,
                             VsrHeaders.HEADER_SIZE,
                             System.nanoTime() + DEADLINE,
@@ -181,7 +333,11 @@
                     .isSameAs(ticket);
             ticket.complete();
             var next = session.acquire(
-                            lane, true, VsrHeaders.HEADER_SIZE, System.nanoTime() + DEADLINE, channel.eventLoop())
+                            List.of(lane),
+                            true,
+                            VsrHeaders.HEADER_SIZE,
+                            System.nanoTime() + DEADLINE,
+                            channel.eventLoop())
                     .join();
             next.complete();
         } finally {
@@ -198,7 +354,7 @@
         EmbeddedChannel channel = new EmbeddedChannel();
         try {
             var ticket = session.acquire(
-                            ConsensusSession.LaneKey.METADATA,
+                            List.of(ConsensusSession.LaneKey.METADATA),
                             true,
                             VsrHeaders.HEADER_SIZE,
                             System.nanoTime() + DEADLINE,
@@ -210,13 +366,13 @@
             session.abandon(9);
             long before = session.currentRequestId();
             CompletableFuture<ConsensusSession.Ticket> cancelled = session.acquire(
-                    ConsensusSession.LaneKey.METADATA,
+                    List.of(ConsensusSession.LaneKey.METADATA),
                     true,
                     VsrHeaders.HEADER_SIZE,
                     System.nanoTime() + DEADLINE,
                     channel.eventLoop());
             CompletableFuture<ConsensusSession.Ticket> queued = session.acquire(
-                    ConsensusSession.LaneKey.METADATA,
+                    List.of(ConsensusSession.LaneKey.METADATA),
                     true,
                     VsrHeaders.HEADER_SIZE,
                     System.nanoTime() + DEADLINE,
@@ -242,7 +398,11 @@
         try {
             var lane = new ConsensusSession.LaneKey(7, 1);
             var poll = session.acquire(
-                            lane, false, VsrHeaders.HEADER_SIZE, System.nanoTime() + DEADLINE, channel.eventLoop())
+                            List.of(lane),
+                            false,
+                            VsrHeaders.HEADER_SIZE,
+                            System.nanoTime() + DEADLINE,
+                            channel.eventLoop())
                     .join();
             freezeAndSend(poll, 9);
             assertThat(((org.apache.iggy.exception.IggyServerException) poll.retain()).getRawErrorCode())
@@ -250,7 +410,12 @@
             assertOperationError(
                     session.resume(9, System.nanoTime() + DEADLINE, channel.eventLoop()),
                     IggyErrorCode.OPERATION_NOT_RETAINED);
-            session.acquire(lane, true, VsrHeaders.HEADER_SIZE, System.nanoTime() + DEADLINE, channel.eventLoop())
+            session.acquire(
+                            List.of(lane),
+                            true,
+                            VsrHeaders.HEADER_SIZE,
+                            System.nanoTime() + DEADLINE,
+                            channel.eventLoop())
                     .join()
                     .complete();
         } finally {
@@ -269,16 +434,19 @@
         var progress = new ConsensusSession.LaneKey(7, 1, true);
         long deadline = System.nanoTime() + DEADLINE;
         try {
-            var poll = session.acquire(progress, false, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop())
+            var poll = session.acquire(List.of(progress), false, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop())
                     .join();
-            var store = session.acquire(progress, true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop());
+            var store = session.acquire(List.of(progress), true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop());
             assertThat(store).isNotDone();
-            var producer = session.acquire(mutation, true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop());
+            var producer =
+                    session.acquire(List.of(mutation), true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop());
             assertThat(producer).isDone();
 
             poll.complete();
-            var laterProducer = session.acquire(mutation, true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop());
-            var offsetRead = session.acquire(progress, false, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop());
+            var laterProducer =
+                    session.acquire(List.of(mutation), true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop());
+            var offsetRead =
+                    session.acquire(List.of(progress), false, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop());
             assertThat(store).isNotDone();
             assertThat(laterProducer).isNotDone();
             assertThat(offsetRead).isNotDone();
@@ -290,11 +458,15 @@
             laterProducer.join().complete();
             offsetRead.join().complete();
 
-            var blockedMutation = session.acquire(mutation, true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop())
+            var blockedMutation = session.acquire(
+                            List.of(mutation), true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop())
                     .join();
-            var cancelledStore = session.acquire(progress, true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop());
-            var queuedRead = session.acquire(progress, false, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop());
-            var queuedProducer = session.acquire(mutation, true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop());
+            var cancelledStore =
+                    session.acquire(List.of(progress), true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop());
+            var queuedRead =
+                    session.acquire(List.of(progress), false, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop());
+            var queuedProducer =
+                    session.acquire(List.of(mutation), true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop());
             assertThat(queuedRead).isNotDone();
             assertThat(queuedProducer).isNotDone();
             cancelledStore.cancel(false);
@@ -318,24 +490,29 @@
         var progress = new ConsensusSession.LaneKey(7, 1, true);
         long deadline = System.nanoTime() + DEADLINE;
         try {
-            var store = session.acquire(progress, true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop())
+            var store = session.acquire(List.of(progress), true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop())
                     .join();
             freezeAndSend(store, 9);
             store.retain();
             for (var lane : new ConsensusSession.LaneKey[] {mutation, progress}) {
                 assertOperationError(
-                        session.acquire(lane, lane == mutation, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop()),
+                        session.acquire(
+                                List.of(lane), lane == mutation, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop()),
                         IggyErrorCode.LANE_BLOCKED);
             }
             assertThat(session.resume(9, deadline, channel.eventLoop()).join()).isSameAs(store);
             ByteBuf alias = store.frame();
             session.abandon(9);
-            var poll = session.acquire(progress, false, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop());
-            var send = session.acquire(mutation, true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop());
+            var poll = session.acquire(List.of(progress), false, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop());
+            var send = session.acquire(List.of(mutation), true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop());
             assertThat(poll).isNotDone();
             assertThat(send).isNotDone();
             var independent = session.acquire(
-                    new ConsensusSession.LaneKey(8, 1), true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop());
+                    List.of(new ConsensusSession.LaneKey(8, 1)),
+                    true,
+                    VsrHeaders.HEADER_SIZE,
+                    deadline,
+                    channel.eventLoop());
             assertThat(independent).isDone();
             independent.join().complete();
             store.complete();
@@ -344,7 +521,7 @@
             assertThat(alias.refCnt()).isPositive();
             alias.release();
             store.finishWrite();
-            session.acquire(mutation, true, 2L * VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop())
+            session.acquire(List.of(mutation), true, 2L * VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop())
                     .join()
                     .complete();
         } finally {
@@ -362,27 +539,27 @@
         var progress = new ConsensusSession.LaneKey(7, 1, true);
         long deadline = System.nanoTime() + DEADLINE;
         try {
-            var send = session.acquire(mutation, true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop())
+            var send = session.acquire(List.of(mutation), true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop())
                     .join();
             freezeAndSend(send, 9);
             send.retain();
-            var poll = session.acquire(progress, false, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop());
+            var poll = session.acquire(List.of(progress), false, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop());
             assertThat(poll).isDone();
             poll.join().complete();
             assertOperationError(
-                    session.acquire(progress, true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop()),
+                    session.acquire(List.of(progress), true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop()),
                     IggyErrorCode.LANE_BLOCKED);
             session.abandon(9);
-            var store = session.acquire(progress, true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop())
+            var store = session.acquire(List.of(progress), true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop())
                     .join();
             freezeAndSend(store, 10);
             store.retain();
             session.reset();
             session.bind(43);
-            session.acquire(progress, true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop())
+            session.acquire(List.of(progress), true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop())
                     .join()
                     .complete();
-            session.acquire(mutation, true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop())
+            session.acquire(List.of(mutation), true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop())
                     .join()
                     .complete();
         } finally {
@@ -544,7 +721,7 @@
 
     private static CompletableFuture<ConsensusSession.Ticket> acquireMutation(
             ConsensusSession session, EmbeddedChannel channel, ConsensusSession.LaneKey lane, long bytes) {
-        return session.acquire(lane, true, bytes, System.nanoTime() + DEADLINE, channel.eventLoop());
+        return session.acquire(List.of(lane), true, bytes, System.nanoTime() + DEADLINE, channel.eventLoop());
     }
 
     /** Replays a retained write and keeps the outcome that its replay proved. */
@@ -564,7 +741,7 @@
     private static ConsensusSession.Ticket retainWrite(
             ConsensusSession session, EmbeddedChannel channel, long requestId) {
         var ticket = session.acquire(
-                        ConsensusSession.LaneKey.METADATA,
+                        List.of(ConsensusSession.LaneKey.METADATA),
                         true,
                         VsrHeaders.HEADER_SIZE,
                         System.nanoTime() + DEADLINE,
@@ -582,6 +759,111 @@
                         .isEqualTo(code));
     }
 
+    @Test
+    void shouldKeepEveryUnknownFetchTargetAndItsBudgetAcrossSessionReset() {
+        ConsensusSession session = new ConsensusSession();
+        session.configurePendingOperations(1, VsrHeaders.HEADER_SIZE);
+        session.bind(42);
+        EmbeddedChannel channel = new EmbeddedChannel();
+        var first = new ConsensusSession.LaneKey(7, 1, true);
+        var second = new ConsensusSession.LaneKey(7, 0xffff_ffffL, true);
+        var independent = new ConsensusSession.LaneKey(7, 3, true);
+        long deadline = System.nanoTime() + DEADLINE;
+        long operation = Long.MIN_VALUE;
+        try {
+            var ticket = session.acquire(
+                            List.of(first, second), false, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop())
+                    .join();
+            ticket.reserveFetch();
+            freezeAndSend(ticket, operation);
+            var waitingFirst =
+                    session.acquire(List.of(first), true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop());
+            var waitingSecond =
+                    session.acquire(List.of(second), true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop());
+
+            session.reset();
+            session.bind(43);
+
+            assertOperationError(waitingFirst, IggyErrorCode.PARTITION_LANE_BLOCKED);
+            assertOperationError(waitingSecond, IggyErrorCode.PARTITION_LANE_BLOCKED);
+            assertThat(session.unresolvedFetch().getOperationId()).isEqualTo(operation);
+            assertOperationError(
+                    session.resume(operation, deadline, channel.eventLoop()), IggyErrorCode.FETCH_PROGRESS_UNCERTAIN);
+            session.acquire(List.of(second), false, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop(), true)
+                    .join()
+                    .complete();
+            session.acquire(
+                            List.of(new ConsensusSession.LaneKey(8, 1, true)),
+                            false,
+                            VsrHeaders.HEADER_SIZE,
+                            deadline,
+                            channel.eventLoop())
+                    .join()
+                    .complete();
+            var fresh = session.acquire(
+                            List.of(independent), false, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop())
+                    .join();
+            assertThatThrownBy(fresh::reserveFetch)
+                    .isInstanceOfSatisfying(
+                            IggyServerException.class,
+                            error -> assertThat(error.getErrorCode()).isEqualTo(IggyErrorCode.TRANSIENT_NOT_ACCEPTED));
+            session.abandon(operation);
+            fresh.reserveFetch();
+            fresh.complete();
+            assertThat(session.unresolvedFetch()).isNull();
+            var next = session.acquire(
+                            List.of(first, second), false, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop())
+                    .join();
+            next.reserveFetch();
+            next.complete();
+        } finally {
+            session.reset();
+            channel.finishAndReleaseAll();
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void shouldKeepOnlyUncertainSectionsWhenExplicitSessionIsolationEndsAClassifiedFetch(boolean partial) {
+        ConsensusSession session = new ConsensusSession();
+        session.bind(42);
+        EmbeddedChannel channel = new EmbeddedChannel();
+        var lane = new ConsensusSession.LaneKey(7, 1, true);
+        var second = new ConsensusSession.LaneKey(7, 2, true);
+        long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
+        try {
+            var ticket = session.acquire(
+                            List.of(lane, second), false, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop())
+                    .join();
+            ticket.reserveFetch();
+            freezeAndSend(ticket, 9);
+            ticket.settleFetch(partial ? List.of(2L) : List.of());
+
+            assertThat(ticket.retain())
+                    .isInstanceOfSatisfying(
+                            IggyServerException.class,
+                            error -> assertThat(error.getErrorCode()).isEqualTo(IggyErrorCode.TRANSIENT_NOT_COMMITTED));
+            session.reset();
+            session.bind(43);
+
+            session.acquire(List.of(lane), true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop())
+                    .join()
+                    .complete();
+            if (partial) {
+                assertOperationError(
+                        session.acquire(List.of(second), true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop()),
+                        IggyErrorCode.PARTITION_LANE_BLOCKED);
+                assertOperationError(
+                        session.resume(9, deadline, channel.eventLoop()), IggyErrorCode.FETCH_PROGRESS_UNCERTAIN);
+                session.abandon(9);
+            }
+            assertThat(session.unresolvedFetch()).isNull();
+        } finally {
+            session.reset();
+            channel.finishAndReleaseAll();
+        }
+    }
+
     private static void freezeAndSend(ConsensusSession.Ticket ticket, long requestId) {
         ByteBuf frame = Unpooled.buffer(VsrHeaders.HEADER_SIZE).writeZero(VsrHeaders.HEADER_SIZE);
         frame.setLongLE(VsrHeaders.REQUEST_ID_OFFSET, requestId);
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/vsr/VsrOperationTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/vsr/VsrOperationTest.java
index 08514fe..1aba28d 100644
--- a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/vsr/VsrOperationTest.java
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/vsr/VsrOperationTest.java
@@ -64,6 +64,7 @@
         }
         assertThat(VsrLoginCodec.PROTOCOL_VERSION)
                 .isEqualTo(constants.get("protocol_version").get("packed").asInt())
-                .isEqualTo(constants.get("protocol_version_min").get("packed").asInt());
+                .isGreaterThanOrEqualTo(
+                        constants.get("protocol_version_min").get("packed").asInt());
     }
 }
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/vsr/VsrRequestEncoderTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/vsr/VsrRequestEncoderTest.java
index d514d58..86fc929 100644
--- a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/vsr/VsrRequestEncoderTest.java
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/vsr/VsrRequestEncoderTest.java
@@ -24,10 +24,12 @@
 import io.netty.buffer.Unpooled;
 import org.apache.iggy.exception.IggyNotConnectedException;
 import org.apache.iggy.partition.PartitionContext;
+import org.apache.iggy.serde.CommandCode;
 import org.junit.jupiter.api.Test;
 
 import java.math.BigInteger;
 import java.nio.charset.StandardCharsets;
+import java.util.List;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -218,6 +220,58 @@
     }
 
     @Test
+    void shouldKeepFetchHeaderContextZeroAfterObservingMetadata() {
+        session.bind(42);
+        session.observeMetadata(52);
+        PartitionContext context =
+                new PartitionContext(BigInteger.valueOf(17), BigInteger.valueOf(9), BigInteger.valueOf(49));
+
+        for (PartitionContext supplied : List.of(PartitionContext.EMPTY, context)) {
+            ByteBuf frame = encoder.encode(
+                    alloc, CommandCode.Messages.FETCH_MESSAGES.getValue(), Unpooled.EMPTY_BUFFER, supplied);
+            try {
+                assertThat(frame.getLongLE(VsrHeaders.REQUEST_INCARNATION_OFFSET))
+                        .isZero();
+                assertThat(frame.getLongLE(VsrHeaders.REQUEST_OWNER_GENERATION_OFFSET))
+                        .isZero();
+                assertThat(frame.getLongLE(VsrHeaders.REQUEST_METADATA_OP_OFFSET))
+                        .isZero();
+                assertThat(frame.getLongLE(VsrHeaders.REQUEST_SESSION_OFFSET)).isEqualTo(42);
+            } finally {
+                frame.release();
+            }
+        }
+    }
+
+    @Test
+    void shouldKeepMetadataFencesForDeferredPollsAndMetadataReads() {
+        session.bind(42);
+        session.observeMetadata(52);
+        PartitionContext context =
+                new PartitionContext(BigInteger.valueOf(17), BigInteger.valueOf(9), BigInteger.valueOf(49));
+        ByteBuf fencedPoll =
+                encoder.encode(alloc, CommandCode.Messages.POLL_DEFERRED.getValue(), Unpooled.EMPTY_BUFFER, context);
+        ByteBuf poll = encoder.encode(alloc, CommandCode.Messages.POLL_DEFERRED.getValue(), Unpooled.EMPTY_BUFFER);
+        ByteBuf metadata = encoder.encode(alloc, GET_CLUSTER_METADATA_CODE, Unpooled.EMPTY_BUFFER);
+        try {
+            assertThat(fencedPoll.getLongLE(VsrHeaders.REQUEST_INCARNATION_OFFSET))
+                    .isEqualTo(17);
+            assertThat(fencedPoll.getLongLE(VsrHeaders.REQUEST_OWNER_GENERATION_OFFSET))
+                    .isEqualTo(9);
+            assertThat(fencedPoll.getLongLE(VsrHeaders.REQUEST_METADATA_OP_OFFSET))
+                    .isEqualTo(49);
+            assertThat(poll.getLongLE(VsrHeaders.REQUEST_METADATA_OP_OFFSET)).isEqualTo(52);
+            assertThat(metadata.getLongLE(VsrHeaders.REQUEST_METADATA_OP_OFFSET))
+                    .isEqualTo(52);
+            assertThat(metadata.getLongLE(VsrHeaders.REQUEST_SESSION_OFFSET)).isEqualTo(42);
+        } finally {
+            fencedPoll.release();
+            poll.release();
+            metadata.release();
+        }
+    }
+
+    @Test
     void shouldRetainClientIdAndSecretWhenRegistrationIsRepeated() {
         ByteBuf firstLogin = loginUserPayload();
         encoder.encode(alloc, LOGIN_USER_CODE, firstLogin).release();
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/vsr/VsrResponseHandlerTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/vsr/VsrResponseHandlerTest.java
index 88ea188..412cf25 100644
--- a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/vsr/VsrResponseHandlerTest.java
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/vsr/VsrResponseHandlerTest.java
@@ -31,10 +31,12 @@
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
 import org.junit.jupiter.params.provider.EnumSource;
 import org.junit.jupiter.params.provider.ValueSource;
 
 import java.nio.charset.StandardCharsets;
+import java.util.List;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.TimeUnit;
@@ -65,20 +67,17 @@
     }
 
     @ParameterizedTest
-    @ValueSource(booleans = {false, true})
-    void shouldCancelOnlyAfterTheDeferredFrameHasBeenSent(boolean cancelBeforeWrite) {
+    @CsvSource({"false, POLL_DEFERRED", "true, POLL_DEFERRED", "false, FETCH_MESSAGES", "true, FETCH_MESSAGES"})
+    void shouldCancelOnlyAfterTheDeferredFrameHasBeenSent(boolean cancelBeforeWrite, CommandCode.Messages deferred) {
         long requestId = 7;
         long sessionId = 9;
+        int command = deferred.getValue();
         CompletableFuture<ByteBuf> future = new CompletableFuture<>();
         ByteBuf request = requestFrame(VsrOperation.NON_REPLICATED, requestId);
         request.setLongLE(VsrHeaders.REQUEST_SESSION_OFFSET, sessionId);
-        request.setIntLE(VsrHeaders.REQUEST_RESERVED_CODE_OFFSET, CommandCode.Messages.POLL_DEFERRED.getValue());
+        request.setIntLE(VsrHeaders.REQUEST_RESERVED_CODE_OFFSET, command);
         var pending = handler.registerRequest(
-                channel,
-                request,
-                future,
-                System.nanoTime() + TimeUnit.MINUTES.toNanos(1),
-                CommandCode.Messages.POLL_DEFERRED.getValue());
+                channel, request, future, System.nanoTime() + TimeUnit.MINUTES.toNanos(1), command);
         try {
             if (cancelBeforeWrite) {
                 future.cancel(false);
@@ -92,10 +91,11 @@
             channel.runPendingTasks();
             ByteBuf original = channel.readOutbound();
             assertThat(original.getIntLE(VsrHeaders.REQUEST_RESERVED_CODE_OFFSET))
-                    .isEqualTo(CommandCode.Messages.POLL_DEFERRED.getValue());
+                    .isEqualTo(command);
             original.release();
             ByteBuf cancellation = channel.readOutbound();
             assertThat(cancellation.readableBytes()).isEqualTo(VsrHeaders.HEADER_SIZE);
+            assertThat(cancellation.getIntLE(VsrHeaders.SIZE_OFFSET)).isEqualTo(VsrHeaders.HEADER_SIZE);
             assertThat(cancellation.getIntLE(VsrHeaders.REQUEST_RESERVED_CODE_OFFSET))
                     .isEqualTo(CommandCode.Messages.CANCEL_DEFERRED_REQUEST.getValue());
             assertThat(VsrHeaders.readRequestId(cancellation)).isEqualTo(requestId);
@@ -103,6 +103,15 @@
                     .isEqualTo(sessionId);
             cancellation.release();
             assertThat((Object) channel.readOutbound()).isNull();
+
+            ByteBuf lateReply = replyFrame(
+                    VsrOperation.NON_REPLICATED, requestId, Unpooled.buffer().writeIntLE(0));
+            channel.writeInbound(lateReply);
+            assertThat(lateReply.refCnt())
+                    .as("a reply after the cancel is dropped")
+                    .isZero();
+            assertThat((Object) channel.readOutbound()).isNull();
+            assertThat(evictions.get()).isZero();
             assertThat(channel.isActive()).isTrue();
         } finally {
             request.release();
@@ -231,7 +240,11 @@
     void shouldBindSessionOnRegisterReply() throws Exception {
         long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
         var ticket = session.acquire(
-                        ConsensusSession.LaneKey.METADATA, true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop())
+                        List.of(ConsensusSession.LaneKey.METADATA),
+                        true,
+                        VsrHeaders.HEADER_SIZE,
+                        deadline,
+                        channel.eventLoop())
                 .join();
         var heldMonitor = new AtomicBoolean();
         var waiting = ticket.awaitSession(session.lineage(), deadline, channel.eventLoop())
@@ -276,10 +289,18 @@
         session.bind(42);
         long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
         var first = session.acquire(
-                        ConsensusSession.LaneKey.METADATA, true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop())
+                        List.of(ConsensusSession.LaneKey.METADATA),
+                        true,
+                        VsrHeaders.HEADER_SIZE,
+                        deadline,
+                        channel.eventLoop())
                 .join();
         var waiting = session.acquire(
-                ConsensusSession.LaneKey.METADATA, true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop());
+                List.of(ConsensusSession.LaneKey.METADATA),
+                true,
+                VsrHeaders.HEADER_SIZE,
+                deadline,
+                channel.eventLoop());
         var heldMonitor = new AtomicBoolean();
         var continued = waiting.whenComplete((ticket, error) -> heldMonitor.set(Thread.holdsLock(session)));
         var logout = enqueue(VsrOperation.LOGOUT, 7);
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/SystemClientBaseTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/SystemClientBaseTest.java
index f827133..91c50a4 100644
--- a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/SystemClientBaseTest.java
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/SystemClientBaseTest.java
@@ -150,21 +150,25 @@
     }
 
     @Test
-    void shouldGetClusterMetadataForSingleNode() {
+    void shouldGetClusterMetadata() {
         // when
         var metadata = systemClient.getClusterMetadata();
 
         // then
         assertThat(metadata).isNotNull();
-        // The server runs standalone (cluster disabled), so it synthesizes
-        // itself as the sole leader of a single-node roster.
-        assertThat(metadata.name()).isEqualTo("single-node");
-        assertThat(metadata.nodes()).hasSize(1);
-        var node = metadata.nodes().get(0);
-        assertThat(node.name()).isEqualTo("iggy-node");
-        assertThat(node.role()).isEqualTo(ClusterNodeRole.Leader);
-        assertThat(node.status()).isEqualTo(ClusterNodeStatus.Healthy);
-        assertThat(node.endpoints().tcp()).isPositive();
+        assertThat(metadata.nodes()).isNotEmpty();
+        assertThat(metadata.nodes())
+                .allSatisfy(node -> assertThat(node.endpoints().tcp()).isPositive());
+        assertThat(metadata.nodes())
+                .filteredOn(node -> node.role() == ClusterNodeRole.Leader)
+                .hasSize(1);
+        if (metadata.nodes().size() == 1) {
+            // A server with the cluster disabled synthesizes itself as the sole leader of a single-node roster.
+            assertThat(metadata.name()).isEqualTo("single-node");
+            var node = metadata.nodes().get(0);
+            assertThat(node.name()).isEqualTo("iggy-node");
+            assertThat(node.status()).isEqualTo(ClusterNodeStatus.Healthy);
+        }
     }
 
     @Test
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/http/MessagesHttpClientSendContextTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/http/MessagesHttpClientSendContextTest.java
index 3229b1d..9d9a6ed 100644
--- a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/http/MessagesHttpClientSendContextTest.java
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/http/MessagesHttpClientSendContextTest.java
@@ -64,8 +64,10 @@
     private static final String GET_TOPIC = "GET /streams/1/topics/2";
     private static final String POST_MESSAGES = "POST /streams/1/topics/2/messages";
     private static final String POLL_MESSAGES = "GET /streams/1/topics/2/messages";
+    private static final String POST_DEFERRED_POLL = "POST /streams/1/topics/2/messages/deferred";
     private static final String DELETE_STREAM = "DELETE /streams/1";
     private static final int TRANSIENT_NOT_COMMITTED = 57;
+    private static final int FETCH_PROGRESS_UNCERTAIN = 14010;
     private static final int HISTORY_UNAVAILABLE = 87;
     private static final int UNAUTHORIZED = 41;
 
@@ -243,6 +245,47 @@
     }
 
     @Test
+    void shouldReportAnUnknownOutcomeWithoutResendingACommittingPollThatLostItsAnswer() throws IOException {
+        try (var server = new FakeServer();
+                var client = new IggyHttpClient(server.url())) {
+            // The server stores the offset when it reads, so a resend from Next would skip the batch.
+            server.scriptedAnswers.add(HttpExchange::close);
+
+            assertThatThrownBy(() -> poll(client, PollingStrategy.next(), true))
+                    .isInstanceOfSatisfying(
+                            IggyServerException.class,
+                            error -> assertThat(error.getRawErrorCode()).isEqualTo(FETCH_PROGRESS_UNCERTAIN));
+            assertThat(server.requests).containsExactly(POST_DEFERRED_POLL);
+        }
+    }
+
+    @Test
+    void shouldResendAPollThatOnlyReadsAfterItLostItsAnswer() throws IOException {
+        try (var server = new FakeServer();
+                var client = new IggyHttpClient(server.url())) {
+            server.scriptedAnswers.add(HttpExchange::close);
+
+            poll(client, PollingStrategy.offset(BigInteger.valueOf(5)), false);
+
+            assertThat(server.requests).containsExactly(POLL_MESSAGES, POLL_MESSAGES);
+        }
+    }
+
+    /** A proxy or a client library may resend a GET, so a poll that stores its offset is a POST. */
+    @Test
+    void shouldPostACommittingPollAndNeverResendIt() throws IOException {
+        try (var server = new FakeServer();
+                var client = new IggyHttpClient(server.url())) {
+            server.scriptedAnswers.add(exchange -> FakeServer.reply(exchange, HttpStatus.SC_SERVICE_UNAVAILABLE, ""));
+
+            assertThatThrownBy(() -> poll(client, PollingStrategy.next(), true));
+            assertThat(server.requests).containsExactly(POST_DEFERRED_POLL);
+            assertThat(queryParameter(server.polls.get(0), "auto_commit")).contains("true");
+            assertThat(queryParameter(server.polls.get(0), "wait_us")).contains("0");
+        }
+    }
+
+    @Test
     void shouldSendTheCallerPollContextAsAQueryParameter() throws IOException {
         try (var server = new FakeServer();
                 var client = new IggyHttpClient(server.url())) {
@@ -263,7 +306,11 @@
     }
 
     private static void poll(IggyHttpClient client, PollingStrategy strategy) {
-        client.messages().pollMessages(1L, 2L, Optional.of(3L), 7L, strategy, 10L, false);
+        poll(client, strategy, false);
+    }
+
+    private static void poll(IggyHttpClient client, PollingStrategy strategy, boolean autoCommit) {
+        client.messages().pollMessages(1L, 2L, Optional.of(3L), 7L, strategy, 10L, autoCommit);
     }
 
     private static Optional<String> queryParameter(String query, String name) {
@@ -344,6 +391,9 @@
                     exchange.getRequestMethod() + " " + exchange.getRequestURI().getPath();
             byte[] body = exchange.getRequestBody().readAllBytes();
             requests.add(request);
+            if (request.equals(POLL_MESSAGES) || request.equals(POST_DEFERRED_POLL)) {
+                polls.add(exchange.getRequestURI().getRawQuery());
+            }
             HttpHandler scripted = scriptedAnswers.poll();
             if (scripted != null) {
                 scripted.handle(exchange);
@@ -377,8 +427,7 @@
                         reply(exchange, 201, "{\"confirmations\": []}");
                     }
                 }
-                case POLL_MESSAGES -> {
-                    polls.add(exchange.getRequestURI().getRawQuery());
+                case POLL_MESSAGES, POST_DEFERRED_POLL -> {
                     reply(exchange, 200, """
                             {"partition_id": 3, "current_offset": 0, "count": 0, "messages": [],
                              "context": {"incarnation": 103, "owner_generation": 0, "metadata_op": 52}}
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/http/MessagesHttpDeferredTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/http/MessagesHttpDeferredTest.java
index a63cc90..159ab8a 100644
--- a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/http/MessagesHttpDeferredTest.java
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/http/MessagesHttpDeferredTest.java
@@ -26,12 +26,15 @@
 import org.apache.iggy.exception.IggyErrorCode;
 import org.apache.iggy.exception.IggyException;
 import org.apache.iggy.exception.IggyMalformedResponseException;
+import org.apache.iggy.exception.IggyOperationException;
 import org.apache.iggy.exception.IggyOperationNotSupportedException;
 import org.apache.iggy.exception.IggyServerException;
 import org.apache.iggy.exception.IggyTimeoutException;
 import org.apache.iggy.identifier.StreamId;
 import org.apache.iggy.identifier.TopicId;
+import org.apache.iggy.message.CommitPolicy;
 import org.apache.iggy.message.DeferredPollOptions;
+import org.apache.iggy.message.PollOptions;
 import org.apache.iggy.message.PolledMessages;
 import org.apache.iggy.message.PollingStrategy;
 import org.apache.iggy.partition.PartitionContext;
@@ -64,6 +67,7 @@
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.catchThrowable;
 
 /**
  * The HTTP deferred poll against a peer that can misbehave: the route and its
@@ -176,7 +180,8 @@
     void shouldDiscardARefusedBodyInsteadOfWaitingForTheRest() throws Exception {
         var release = new CountDownLatch(1);
         var peer = new FakePeer(exchange -> {
-            exchange.sendResponseHeaders(200, 1024 * 1024);
+            exchange.sendResponseHeaders(
+                    200, Math.multiplyExact(DeferredPollOptions.MAX_RECORD_POLL_REPLY_SIZE, 16L) + 1);
             OutputStream body = exchange.getResponseBody();
             body.write(new byte[8 * 1024]);
             body.flush();
@@ -237,6 +242,45 @@
     }
 
     @Test
+    void shouldEndAPollWaitingForItsHeadersWhenTheCallerIsInterrupted() throws Exception {
+        var arrived = new CountDownLatch(1);
+        var release = new CountDownLatch(1);
+        var peer = new FakePeer(exchange -> {
+            arrived.countDown();
+            hold(release, STALL);
+        });
+        Thread caller = Thread.currentThread();
+        var interrupter = Executors.newSingleThreadExecutor();
+        try (var server = peer.start();
+                var client = client(peer)) {
+            try {
+                interrupter.execute(() -> {
+                    hold(arrived, STALL);
+                    caller.interrupt();
+                });
+                var options = DeferredPollOptions.defaults()
+                        .withMaxWait(Duration.ofSeconds(10))
+                        .withRequestTimeout(Duration.ofSeconds(11));
+                long startNanos = System.nanoTime();
+
+                assertThatThrownBy(() -> poll(client, Consumer.of(3L), options))
+                        .isInstanceOf(IggyServerException.class)
+                        .extracting(error -> ((IggyServerException) error).getErrorCode())
+                        .isEqualTo(IggyErrorCode.TRANSIENT_NOT_COMMITTED);
+
+                assertThat(Duration.ofNanos(System.nanoTime() - startNanos))
+                        .as("an interrupt ends the wait long before the request timeout")
+                        .isLessThan(Duration.ofSeconds(2));
+                assertThat(caller.isInterrupted()).isTrue();
+            } finally {
+                Thread.interrupted();
+                release.countDown();
+                interrupter.shutdownNow();
+            }
+        }
+    }
+
+    @Test
     void shouldAbortADeferredPollWaitingForAConnectionLease() throws Exception {
         var parked = new CountDownLatch(DEFERRED_CONNECTION_LIMIT);
         var release = new CountDownLatch(1);
@@ -336,7 +380,7 @@
     }
 
     @Test
-    void shouldReportALostAutoCommitReplyAsNotCommittedWithoutRetrying() throws Exception {
+    void shouldReportALostCommittingReplyAsUncertainProgressWithoutRetrying() throws Exception {
         var peer = new FakePeer(HttpExchange::close);
         try (var server = peer.start();
                 var client = client(peer)) {
@@ -353,12 +397,175 @@
                     .isInstanceOf(IggyServerException.class)
                     .hasCauseInstanceOf(IOException.class)
                     .extracting(error -> ((IggyServerException) error).getErrorCode())
-                    .isEqualTo(IggyErrorCode.TRANSIENT_NOT_COMMITTED);
-            assertThat(peer.queries).hasSize(1);
+                    .isEqualTo(IggyErrorCode.FETCH_PROGRESS_UNCERTAIN);
+            assertThat(peer.methods).containsExactly("POST");
             assertThat(peer.queries.get(0).get("auto_commit")).isEqualTo("true");
         }
     }
 
+    /**
+     * The server can have stored the offset of a committing poll whose answer is lost, so its partition takes no
+     * committing poll and no offset store on this client until the caller abandons that poll.
+     */
+    @Test
+    void shouldBlockThePartitionOfALostCommittingPollUntilItIsAbandoned() throws Exception {
+        var answers = new AtomicInteger();
+        var peer = new FakePeer(exchange -> {
+            if (answers.getAndIncrement() > 0) {
+                respond(exchange, 200, EMPTY_POLL);
+            }
+        });
+        try (var server = peer.start();
+                var client = client(peer)) {
+            var lost = (IggyOperationException) catchThrowable(() -> committingPoll(client));
+            assertThat(lost.getErrorCode()).isEqualTo(IggyErrorCode.FETCH_PROGRESS_UNCERTAIN);
+
+            assertBlockedBy(catchThrowable(() -> committingPoll(client)), lost.getOperationId());
+            assertBlockedBy(
+                    catchThrowable(() -> client.consumerOffsets()
+                            .storeConsumerOffset(
+                                    StreamId.of(1L), TopicId.of(1L), Optional.of(0L), Consumer.of(3L), BigInteger.ONE)),
+                    lost.getOperationId());
+            assertThat(peer.methods).containsExactly("POST");
+            client.abandonOperation(lost.getOperationId());
+
+            assertThat(committingPoll(client).messages()).isEmpty();
+            assertThat(peer.methods).containsExactly("POST", "POST");
+        }
+    }
+
+    /**
+     * Progress polls of one partition take turns, so a committing poll that starts while another one of its partition
+     * waits for its answer goes out only after it, and then meets the block that the lost answer left.
+     */
+    @Test
+    void shouldLetASecondCommittingPollWaitForTheFirstAndMeetItsBlock() throws Exception {
+        var firstArrived = new CountDownLatch(1);
+        var secondArrived = new CountDownLatch(1);
+        var release = new CountDownLatch(1);
+        var requests = new AtomicInteger();
+        var peer = new FakePeer(exchange -> {
+            if (requests.getAndIncrement() == 0) {
+                firstArrived.countDown();
+                hold(release, STALL);
+                return;
+            }
+            secondArrived.countDown();
+            respond(exchange, 200, EMPTY_POLL);
+        });
+        var callers = Executors.newFixedThreadPool(2);
+        try (var server = peer.start(Executors.newCachedThreadPool());
+                var client = client(peer)) {
+            Future<Throwable> first = callers.submit(() -> catchThrowable(() -> committingPoll(client)));
+            assertThat(firstArrived.await(STALL.toMillis(), TimeUnit.MILLISECONDS))
+                    .isTrue();
+            Future<Throwable> second = callers.submit(() -> catchThrowable(() -> committingPoll(client)));
+            // The second poll gets the time to go out, which only a missing lane lets it do.
+            assertThat(secondArrived.await(DEADLINE_SLACK.toMillis(), TimeUnit.MILLISECONDS))
+                    .isFalse();
+            release.countDown();
+
+            var lost = (IggyOperationException) first.get(STALL.toMillis(), TimeUnit.MILLISECONDS);
+            assertThat(lost.getErrorCode()).isEqualTo(IggyErrorCode.FETCH_PROGRESS_UNCERTAIN);
+            assertBlockedBy(second.get(STALL.toMillis(), TimeUnit.MILLISECONDS), lost.getOperationId());
+            assertThat(requests).hasValue(1);
+        } finally {
+            release.countDown();
+            callers.shutdownNow();
+        }
+    }
+
+    /**
+     * A block covers its partition for every consumer of the client, and in any spelling of the stream and topic for
+     * a call that carries the context of the same incarnation.
+     */
+    @Test
+    void shouldBlockAnotherConsumerAndAnotherSpellingOfTheSameIncarnation() throws Exception {
+        var peer = new FakePeer(exchange -> {});
+        var context = new PartitionContext(BigInteger.valueOf(7), BigInteger.ONE, BigInteger.ONE);
+        try (var server = peer.start();
+                var client = client(peer)) {
+            var lost = (IggyOperationException) catchThrowable(() -> client.messages()
+                    .pollMessagesDeferred(
+                            StreamId.of(1L),
+                            TopicId.of(1L),
+                            Optional.of(0L),
+                            Consumer.of(3L),
+                            PollingStrategy.next().withContext(context),
+                            10L,
+                            true,
+                            DeferredPollOptions.defaults()));
+            assertThat(lost.getErrorCode()).isEqualTo(IggyErrorCode.FETCH_PROGRESS_UNCERTAIN);
+
+            assertBlockedBy(
+                    catchThrowable(() -> client.consumerOffsets()
+                            .storeConsumerOffset(
+                                    StreamId.of(1L), TopicId.of(1L), Optional.of(0L), Consumer.of(4L), BigInteger.ONE)),
+                    lost.getOperationId());
+            assertBlockedBy(
+                    catchThrowable(() -> client.consumerOffsets()
+                            .storeConsumerOffset(
+                                    StreamId.of("orders"),
+                                    TopicId.of("events"),
+                                    0L,
+                                    Consumer.of(4L),
+                                    BigInteger.ONE,
+                                    context)),
+                    lost.getOperationId());
+            assertThat(peer.methods).containsExactly("POST");
+        }
+    }
+
+    @ParameterizedTest(name = "on read={0}")
+    @ValueSource(booleans = {false, true})
+    void shouldSendAPollOfOnePartitionWithItsOptionsAndPostItWhenItStores(boolean onRead) throws Exception {
+        var peer = new FakePeer(exchange -> respond(exchange, 200, EMPTY_POLL));
+        try (var server = peer.start();
+                var client = client(peer)) {
+            var options = PollOptions.waitFor(Duration.ofSeconds(2))
+                    .withCount(7)
+                    .withMinCount(2)
+                    .withMaxBytes(4096)
+                    .withCommit(onRead ? CommitPolicy.onRead() : CommitPolicy.manual());
+
+            client.messages()
+                    .pollMessages(
+                            StreamId.of(1L), TopicId.of(1L), 0L, Consumer.of(3L), PollingStrategy.next(), options);
+
+            assertThat(peer.methods).containsExactly(onRead ? "POST" : "GET");
+            assertThat(peer.paths).containsExactly("/streams/1/topics/1/messages/deferred");
+            Map<String, String> query = peer.queries.get(0);
+            assertThat(query.get("auto_commit")).isEqualTo(Boolean.toString(onRead));
+            assertThat(query.get("count")).isEqualTo("7");
+            assertThat(query.get("min_count")).isEqualTo("2");
+            assertThat(query.get("max_bytes")).isEqualTo("4096");
+            assertThat(Long.parseLong(query.get("request_timeout_us")))
+                    .isGreaterThan(Long.parseLong(query.get("wait_us")) + 9_000_000L);
+        }
+    }
+
+    @Test
+    void shouldRefuseAnAutomaticCommitOfOnePollWithoutReachingTheServer() throws Exception {
+        var peer = new FakePeer(exchange -> respond(exchange, 200, EMPTY_POLL));
+        try (var server = peer.start();
+                var client = client(peer)) {
+            var options = PollOptions.defaults().withCommit(CommitPolicy.auto());
+
+            assertThatThrownBy(() -> client.messages()
+                            .pollMessages(
+                                    StreamId.of(1L),
+                                    TopicId.of(1L),
+                                    0L,
+                                    Consumer.of(3L),
+                                    PollingStrategy.next(),
+                                    options))
+                    .isInstanceOf(IggyServerException.class)
+                    .extracting(error -> ((IggyServerException) error).getErrorCode())
+                    .isEqualTo(IggyErrorCode.INVALID_CONFIGURATION);
+            assertThat(peer.paths).isEmpty();
+        }
+    }
+
     @ParameterizedTest
     @ValueSource(strings = {NOT_ACCEPTED, CAPACITY_EXCEEDED})
     void shouldPreserveNonAdmissionWhenRetryBackoffIsInterrupted(String refusal) throws Exception {
@@ -437,18 +644,11 @@
         }
     }
 
+    /** A first message larger than the byte limit comes alone, so the bound holds one of the largest size. */
     @Test
     void shouldRefuseABodyLargerThanTheDeclaredBound() throws Exception {
-        var peer = new FakePeer(exchange -> {
-            // Chunked, so the bound has to hold while the body is read.
-            exchange.sendResponseHeaders(200, 0);
-            try (OutputStream body = exchange.getResponseBody()) {
-                byte[] chunk = new byte[8 * 1024];
-                for (int written = 0; written < 1024 * 1024; written += chunk.length) {
-                    body.write(chunk);
-                }
-            }
-        });
+        long beyondBound = Math.multiplyExact(DeferredPollOptions.MAX_RECORD_POLL_REPLY_SIZE, 16L) + 1;
+        var peer = new FakePeer(exchange -> exchange.sendResponseHeaders(200, beyondBound));
         try (var server = peer.start();
                 var client = client(peer)) {
             var options = DeferredPollOptions.defaults().withMaxBytes(1024);
@@ -459,6 +659,27 @@
         }
     }
 
+    private static PolledMessages committingPoll(IggyHttpClient client) {
+        return client.messages()
+                .pollMessagesDeferred(
+                        StreamId.of(1L),
+                        TopicId.of(1L),
+                        Optional.of(0L),
+                        Consumer.of(3L),
+                        PollingStrategy.next(),
+                        10L,
+                        true,
+                        DeferredPollOptions.defaults());
+    }
+
+    private static void assertBlockedBy(Throwable failure, long operation) {
+        assertThat(failure).isInstanceOf(IggyOperationException.class);
+        var blocked = (IggyOperationException) failure;
+        assertThat(blocked.getErrorCode()).isEqualTo(IggyErrorCode.PARTITION_LANE_BLOCKED);
+        assertThat(blocked.getOperationId()).isEqualTo(operation);
+        assertThat(blocked.getPartitionId()).hasValue(0);
+    }
+
     private static IggyHttpClient client(FakePeer peer) {
         return new IggyHttpClient("http://" + InetAddress.getLoopbackAddress().getHostAddress() + ":" + peer.port);
     }
@@ -496,6 +717,7 @@
 
     private static final class FakePeer implements HttpHandler {
         private final List<String> paths = new CopyOnWriteArrayList<>();
+        private final List<String> methods = new CopyOnWriteArrayList<>();
         private final List<Map<String, String>> queries = new CopyOnWriteArrayList<>();
         private final HttpHandler handler;
         private int port;
@@ -520,6 +742,7 @@
         @Override
         public void handle(HttpExchange exchange) throws IOException {
             paths.add(exchange.getRequestURI().getPath());
+            methods.add(exchange.getRequestMethod());
             queries.add(parseQuery(exchange.getRequestURI().getRawQuery()));
             try {
                 handler.handle(exchange);
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/reader/PartitionReaderTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/reader/PartitionReaderTest.java
new file mode 100644
index 0000000..c70dda8
--- /dev/null
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/reader/PartitionReaderTest.java
@@ -0,0 +1,77 @@
+/*
+ * 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.
+ */
+
+package org.apache.iggy.client.blocking.reader;
+
+import org.apache.iggy.client.async.reader.PartitionReaderTestAccess;
+import org.apache.iggy.message.PollOptions;
+import org.apache.iggy.message.PolledMessages;
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** The blocking reader keeps a batch that its caller stopped waiting for. */
+class PartitionReaderTest {
+    private static final Duration TIMEOUT = Duration.ofSeconds(5);
+
+    @Test
+    void shouldReturnNothingAtTheTimeoutAndKeepTheBatchForTheNextCall() throws Exception {
+        var access = PartitionReaderTestAccess.standalone(PollOptions.waitFor(Duration.ofSeconds(1)), 1L);
+        try (PartitionReader reader = PartitionReader.of(access.reader())) {
+            var read = access.nextRead();
+
+            assertThat(reader.next(Duration.ofMillis(100))).isEmpty();
+            access.answer(read, 1L, 4L);
+
+            Optional<PolledMessages> polled = reader.next(TIMEOUT);
+            assertThat(polled).isPresent();
+            assertThat(polled.get().messages()).hasSize(1);
+        }
+    }
+
+    @Test
+    void shouldKeepTheBatchOfACallThatItsThreadInterrupted() throws Exception {
+        var access = PartitionReaderTestAccess.standalone(PollOptions.waitFor(Duration.ofSeconds(1)), 1L);
+        try (PartitionReader reader = PartitionReader.of(access.reader())) {
+            var read = access.nextRead();
+            CompletableFuture<Boolean> interrupted = new CompletableFuture<>();
+            Thread caller = new Thread(() -> {
+                try {
+                    reader.next();
+                    interrupted.complete(false);
+                } catch (RuntimeException error) {
+                    interrupted.complete(Thread.currentThread().isInterrupted());
+                }
+            });
+            caller.start();
+            caller.interrupt();
+            assertThat(interrupted.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS))
+                    .isTrue();
+
+            access.answer(read, 1L, 4L);
+
+            assertThat(reader.next().messages()).hasSize(1);
+        }
+    }
+}
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/tcp/ConsumerGroupsTcpClientTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/tcp/ConsumerGroupsTcpClientTest.java
index d916260..70edae1 100644
--- a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/tcp/ConsumerGroupsTcpClientTest.java
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/tcp/ConsumerGroupsTcpClientTest.java
@@ -84,7 +84,7 @@
 
         // when
         consumerGroupsClient.joinConsumerGroup(STREAM_NAME, TOPIC_NAME, groupId);
-        var assignment = awaitPartitionAssignment(groupId);
+        var assignment = awaitPartitionAssignment(groupId, 1);
 
         assertThat(assignment.partitions()).hasSize(1);
     }
@@ -110,7 +110,7 @@
         var group = consumerGroupsClient.createConsumerGroup(STREAM_NAME, TOPIC_NAME, "consumer-group-42");
         ConsumerId groupId = ConsumerId.of(group.id());
         consumerGroupsClient.joinConsumerGroup(STREAM_NAME, TOPIC_NAME, groupId);
-        awaitPartitionAssignment(groupId);
+        awaitPartitionAssignment(groupId, 1);
         client.messages()
                 .sendMessages(
                         STREAM_NAME, TOPIC_NAME, Partitioning.partitionId(0L), List.of(Message.of("group message")));
@@ -150,12 +150,13 @@
                 .isInstanceOf(IggyResourceNotFoundException.class);
     }
 
-    private ConsumerGroupAssignment awaitPartitionAssignment(ConsumerId groupId) throws InterruptedException {
+    private ConsumerGroupAssignment awaitPartitionAssignment(ConsumerId groupId, int partitions)
+            throws InterruptedException {
         long deadline = System.nanoTime() + ASSIGNMENT_TIMEOUT.toNanos();
         Optional<ConsumerGroupAssignment> assignment;
         do {
             assignment = consumerGroupsClient.syncConsumerGroup(STREAM_NAME, TOPIC_NAME, groupId);
-            if (assignment.isPresent() && !assignment.get().partitions().isEmpty()) {
+            if (assignment.isPresent() && assignment.get().partitions().size() >= partitions) {
                 return assignment.get();
             }
             Thread.sleep(ASSIGNMENT_POLL_INTERVAL.toMillis());
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/tcp/MessagesTcpClientFetchTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/tcp/MessagesTcpClientFetchTest.java
new file mode 100644
index 0000000..4545c53
--- /dev/null
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/tcp/MessagesTcpClientFetchTest.java
@@ -0,0 +1,153 @@
+/*
+ * 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.
+ */
+
+package org.apache.iggy.client.blocking.tcp;
+
+import org.apache.iggy.consumergroup.Consumer;
+import org.apache.iggy.exception.IggyClientException;
+import org.apache.iggy.identifier.StreamId;
+import org.apache.iggy.identifier.TopicId;
+import org.apache.iggy.message.DeferredPollOptions;
+import org.apache.iggy.message.Message;
+import org.apache.iggy.message.Partitioning;
+import org.apache.iggy.message.PolledMessages;
+import org.apache.iggy.message.PollingStrategy;
+import org.apache.iggy.message.SendMessagesResponse;
+import org.apache.iggy.partition.PartitionContext;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigInteger;
+import java.time.Duration;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.function.Supplier;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** What an interrupt does to a blocking poll, which a cancel can make lose the messages that it committed. */
+class MessagesTcpClientFetchTest {
+    private static final Duration TIMEOUT = Duration.ofSeconds(5);
+    private static final Duration INTERRUPT_GRACE = Duration.ofMillis(300);
+
+    @Test
+    void shouldWaitThroughAnInterruptForTheReplyOfACommittingPoll() throws Exception {
+        HeldPoll delegate = new HeldPoll();
+        Caller caller = Caller.poll(new MessagesTcpClient(delegate), true);
+        delegate.called.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+
+        caller.thread.interrupt();
+        // An interrupt that ended the wait would complete the call at once.
+        assertThatThrownBy(() -> caller.result.get(INTERRUPT_GRACE.toMillis(), TimeUnit.MILLISECONDS))
+                .isInstanceOf(TimeoutException.class);
+        PolledMessages polled = new PolledMessages(1L, BigInteger.ZERO, 0L, List.of(), PartitionContext.EMPTY);
+        delegate.polled.complete(polled);
+
+        assertThat(caller.result.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)).isSameAs(polled);
+        assertThat(caller.interruptedAfter.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS))
+                .isTrue();
+    }
+
+    @Test
+    void shouldCancelAPollThatDoesNotCommitWhenTheCallerIsInterrupted() throws Exception {
+        HeldPoll delegate = new HeldPoll();
+        Caller caller = Caller.poll(new MessagesTcpClient(delegate), false);
+        delegate.called.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+
+        caller.thread.interrupt();
+
+        assertThatThrownBy(() -> caller.result.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS))
+                .hasCauseInstanceOf(IggyClientException.class);
+        assertThat(delegate.polled).isCancelled();
+    }
+
+    /** A thread that makes one blocking poll and reports its outcome and its interrupt flag after it. */
+    private record Caller(
+            Thread thread, CompletableFuture<Object> result, CompletableFuture<Boolean> interruptedAfter) {
+        static Caller poll(MessagesTcpClient client, boolean autoCommit) {
+            return start(() -> client.pollMessagesDeferred(
+                    StreamId.of(1L),
+                    TopicId.of(1L),
+                    Optional.of(1L),
+                    Consumer.of(1L),
+                    PollingStrategy.next(),
+                    1L,
+                    autoCommit,
+                    DeferredPollOptions.defaults()));
+        }
+
+        private static Caller start(Supplier<Object> call) {
+            CompletableFuture<Object> result = new CompletableFuture<>();
+            CompletableFuture<Boolean> interruptedAfter = new CompletableFuture<>();
+            Thread thread = new Thread(() -> {
+                try {
+                    result.complete(call.get());
+                } catch (RuntimeException error) {
+                    result.completeExceptionally(error);
+                }
+                interruptedAfter.complete(Thread.currentThread().isInterrupted());
+            });
+            thread.setDaemon(true);
+            thread.start();
+            return new Caller(thread, result, interruptedAfter);
+        }
+    }
+
+    /** An async client whose deferred poll replies only when the test completes {@link #polled}. */
+    private static final class HeldPoll implements org.apache.iggy.client.async.MessagesClient {
+        private final CompletableFuture<Void> called = new CompletableFuture<>();
+        private final CompletableFuture<PolledMessages> polled = new CompletableFuture<>();
+
+        @Override
+        @SuppressWarnings("checkstyle:ParameterNumber")
+        public CompletableFuture<PolledMessages> pollMessagesDeferred(
+                StreamId streamId,
+                TopicId topicId,
+                Optional<Long> partitionId,
+                Consumer consumer,
+                PollingStrategy strategy,
+                Long count,
+                boolean autoCommit,
+                DeferredPollOptions options) {
+            called.complete(null);
+            return polled;
+        }
+
+        @Override
+        public CompletableFuture<PolledMessages> pollMessages(
+                StreamId streamId,
+                TopicId topicId,
+                Optional<Long> partitionId,
+                Consumer consumer,
+                PollingStrategy strategy,
+                Long count,
+                boolean autoCommit) {
+            throw new UnsupportedOperationException("The test makes deferred polls only");
+        }
+
+        @Override
+        public CompletableFuture<SendMessagesResponse> sendMessages(
+                StreamId streamId, TopicId topicId, Partitioning partitioning, List<Message> messages) {
+            throw new UnsupportedOperationException("The test makes deferred polls only");
+        }
+    }
+}
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/tcp/MessagesTcpClientTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/tcp/MessagesTcpClientTest.java
index d9a1525..c106896 100644
--- a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/tcp/MessagesTcpClientTest.java
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/tcp/MessagesTcpClientTest.java
@@ -19,22 +19,38 @@
 
 package org.apache.iggy.client.blocking.tcp;
 
+import org.apache.iggy.Iggy;
 import org.apache.iggy.client.async.tcp.AsyncIggyTcpClient;
 import org.apache.iggy.client.blocking.IggyBaseClient;
 import org.apache.iggy.client.blocking.MessagesClientBaseTest;
+import org.apache.iggy.client.blocking.reader.PartitionReader;
 import org.apache.iggy.consumergroup.Consumer;
 import org.apache.iggy.identifier.StreamId;
 import org.apache.iggy.identifier.TopicId;
 import org.apache.iggy.message.DeferredPollOptions;
 import org.apache.iggy.message.Message;
 import org.apache.iggy.message.Partitioning;
+import org.apache.iggy.message.PollOptions;
+import org.apache.iggy.message.PolledMessages;
 import org.apache.iggy.message.PollingStrategy;
 import org.junit.jupiter.api.Test;
 
+import java.io.IOException;
+import java.math.BigInteger;
+import java.net.HttpURLConnection;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
 import java.nio.charset.StandardCharsets;
 import java.time.Duration;
+import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
 
 import static org.apache.iggy.TestConstants.PERSISTED_TOPIC_OPTIONS;
 import static org.apache.iggy.TestConstants.STREAM_NAME;
@@ -43,6 +59,21 @@
 
 class MessagesTcpClientTest extends MessagesClientBaseTest {
 
+    private static final Duration SHORT_WAIT = Duration.ofMillis(200);
+    private static final Duration LONG_WAIT = Duration.ofSeconds(10);
+    // Long enough for a wait to reach the server and park there.
+    private static final Duration PARK_DELAY = Duration.ofMillis(300);
+    // Far below the long wait, so a wait that a message or a cancel did not end fails it.
+    private static final Duration WAKE_BOUND = Duration.ofSeconds(2);
+    // The deferred polls and fetches that one client keeps in flight.
+    private static final int CLIENT_DEFERRED_SLOTS = 64;
+    // Server metrics with one series per shard.
+    private static final String PARKED_DEFERRED_REQUESTS = "deferred_poll_pending";
+    private static final String CANCELLED_DEFERRED_REQUESTS = "deferred_poll_cancelled_total";
+    private static final String METRICS_URL = "http://%s:%d/metrics";
+    private static final Duration METRICS_POLL_INTERVAL = Duration.ofMillis(50);
+    private static final HttpClient METRICS_CLIENT = HttpClient.newHttpClient();
+
     @Override
     protected IggyBaseClient getClient() {
         return TcpClientFactory.create(serverHost(), serverTcpPort());
@@ -98,6 +129,137 @@
     }
 
     @Test
+    void shouldReadEveryPartitionWithABlockingReader() {
+        setUpStream();
+        client.topics().createTopic(STREAM_NAME, TOPIC_NAME.getName(), 2L, PERSISTED_TOPIC_OPTIONS);
+        List<Long> partitions = List.of(1L, 0L);
+        for (long partition : partitions) {
+            messagesClient.sendMessages(
+                    STREAM_NAME,
+                    TOPIC_NAME,
+                    Partitioning.partitionId(partition),
+                    List.of(Message.of("partition " + partition)));
+        }
+        var asyncClient = AsyncIggyTcpClient.builder()
+                .host(serverHost())
+                .port(serverTcpPort())
+                .build();
+        try {
+            asyncClient.connect().join();
+            asyncClient.users().login("iggy", "iggy").join();
+            Map<Long, List<String>> read = new HashMap<>();
+            try (PartitionReader reader = PartitionReader.build(asyncClient
+                    .reader(STREAM_NAME, TOPIC_NAME, Consumer.of(0L))
+                    .partitions(partitions)
+                    .start(PollingStrategy.first())
+                    .options(PollOptions.waitFor(SHORT_WAIT)))) {
+                while (read.size() < partitions.size()) {
+                    PolledMessages batch = reader.next();
+                    read.put(
+                            batch.partitionId(),
+                            batch.messages().stream()
+                                    .map(message -> new String(message.payload(), StandardCharsets.UTF_8))
+                                    .toList());
+                }
+            }
+
+            assertThat(read).isEqualTo(Map.of(0L, List.of("partition 0"), 1L, List.of("partition 1")));
+        } finally {
+            asyncClient.close().join();
+        }
+    }
+
+    @Test
+    void shouldSendWhileAProgressPollIsParkedOnThePartition() throws Exception {
+        setUpStreamAndTopic();
+        var asyncClient = AsyncIggyTcpClient.builder()
+                .host(serverHost())
+                .port(serverTcpPort())
+                .build();
+        try {
+            asyncClient.connect().join();
+            asyncClient.users().login("iggy", "iggy").join();
+            var messages = asyncClient.messages();
+            var parked = messages.pollMessagesDeferred(
+                    STREAM_NAME,
+                    TOPIC_NAME,
+                    Optional.of(0L),
+                    Consumer.of(0L),
+                    PollingStrategy.next(),
+                    1L,
+                    true,
+                    DeferredPollOptions.defaults().withMaxWait(LONG_WAIT));
+            Thread.sleep(PARK_DELAY.toMillis());
+            assertThat(parked).isNotDone();
+
+            // A send that waits behind the parked poll of the same client ends only after the long wait.
+            messages.sendMessages(STREAM_NAME, TOPIC_NAME, Partitioning.partitionId(0L), List.of(Message.of("m")))
+                    .get(WAKE_BOUND.toMillis(), TimeUnit.MILLISECONDS);
+
+            assertThat(parked.get(WAKE_BOUND.toMillis(), TimeUnit.MILLISECONDS).messages())
+                    .extracting(message -> new String(message.payload(), StandardCharsets.UTF_8))
+                    .containsExactly("m");
+        } finally {
+            asyncClient.close().join();
+        }
+    }
+
+    @Test
+    void shouldFreeTheClientAndServerSlotsOfCancelledDeferredPolls() throws Exception {
+        setUpStreamAndTopic();
+        String token = accessToken();
+        var asyncClient = AsyncIggyTcpClient.builder()
+                .host(serverHost())
+                .port(serverTcpPort())
+                .build();
+        try {
+            asyncClient.connect().join();
+            asyncClient.users().login("iggy", "iggy").join();
+            var messages = asyncClient.messages();
+            List<CompletableFuture<PolledMessages>> parked = new ArrayList<>();
+            for (int wait = 0; wait < CLIENT_DEFERRED_SLOTS; wait++) {
+                parked.add(messages.pollMessages(
+                        STREAM_NAME,
+                        TOPIC_NAME,
+                        0L,
+                        Consumer.of(0L),
+                        PollingStrategy.offset(BigInteger.ZERO),
+                        PollOptions.waitFor(LONG_WAIT).withCount(1)));
+            }
+            assertThat(awaitServerMetric(token, PARKED_DEFERRED_REQUESTS, CLIENT_DEFERRED_SLOTS))
+                    .as("deferred requests that the server parked")
+                    .isGreaterThanOrEqualTo(CLIENT_DEFERRED_SLOTS);
+            long cancelledBefore = serverMetric(token, CANCELLED_DEFERRED_REQUESTS);
+
+            assertThat(parked).allSatisfy(wait -> assertThat(wait.cancel(true)).isTrue());
+
+            // The server sends nothing back for a cancelled poll that stores nothing. It answers a poll whose time is
+            // up
+            // without a slot, and the client retries a refusal. So only the server metrics show that the cancel frames
+            // freed the server slots.
+            long cancelled =
+                    awaitServerMetric(token, CANCELLED_DEFERRED_REQUESTS, cancelledBefore + CLIENT_DEFERRED_SLOTS);
+            assertThat(cancelled - cancelledBefore)
+                    .as("deferred requests that the server cancelled")
+                    .isGreaterThanOrEqualTo(CLIENT_DEFERRED_SLOTS);
+            // The client holds at most this many deferred requests, so this poll starts in time only if every cancel
+            // freed its client slot.
+            assertThat(messages.pollMessages(
+                                    STREAM_NAME,
+                                    TOPIC_NAME,
+                                    0L,
+                                    Consumer.of(0L),
+                                    PollingStrategy.offset(BigInteger.ZERO),
+                                    PollOptions.waitFor(SHORT_WAIT).withCount(1))
+                            .get(WAKE_BOUND.toMillis(), TimeUnit.MILLISECONDS)
+                            .messages())
+                    .isEmpty();
+        } finally {
+            asyncClient.close().join();
+        }
+    }
+
+    @Test
     void shouldRouteSameMessageKeyToSamePartition() {
         setUpStreamAndTopic();
 
@@ -136,4 +298,43 @@
         assertThat(new String(polledMessages.messages().get(0).payload(), StandardCharsets.UTF_8))
                 .isEqualTo(text);
     }
+
+    private static String accessToken() throws IOException {
+        try (var http = Iggy.httpClientBuilder()
+                .blocking()
+                .host(serverHost())
+                .port(serverHttpPort())
+                .build()) {
+            return http.users()
+                    .login("iggy", "iggy")
+                    .accessToken()
+                    .orElseThrow()
+                    .token();
+        }
+    }
+
+    private static long awaitServerMetric(String token, String name, long minimum)
+            throws IOException, InterruptedException {
+        long deadline = System.nanoTime() + WAKE_BOUND.toNanos();
+        long sum = serverMetric(token, name);
+        while (sum < minimum && System.nanoTime() < deadline) {
+            Thread.sleep(METRICS_POLL_INTERVAL.toMillis());
+            sum = serverMetric(token, name);
+        }
+        return sum;
+    }
+
+    /** The sum of a server metric over its series. */
+    private static long serverMetric(String token, String name) throws IOException, InterruptedException {
+        var request = HttpRequest.newBuilder(URI.create(METRICS_URL.formatted(serverHost(), serverHttpPort())))
+                .header("Authorization", "Bearer " + token)
+                .build();
+        var response = METRICS_CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
+        assertThat(response.statusCode()).as("metrics status").isEqualTo(HttpURLConnection.HTTP_OK);
+        return response.body()
+                .lines()
+                .filter(line -> line.startsWith(name + "{"))
+                .mapToLong(line -> Long.parseLong(line.substring(line.lastIndexOf(' ') + 1)))
+                .sum();
+    }
 }
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/exception/IggyErrorCodeTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/exception/IggyErrorCodeTest.java
index ca29e47..f1a322a 100644
--- a/foreign/java/java-sdk/src/test/java/org/apache/iggy/exception/IggyErrorCodeTest.java
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/exception/IggyErrorCodeTest.java
@@ -247,6 +247,7 @@
         // VSR protocol errors
         "14003, INCOMPATIBLE_PROTOCOL_VERSION",
         "14004, SESSION_MISMATCH",
+        "14009, PARTITION_LANE_BLOCKED",
     })
     void fromCodeReturnsExpectedIggyErrorCodeWhenCodeIsValid(int code, IggyErrorCode expected) {
         var iggyErrorCode = IggyErrorCode.fromCode(code);
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/exception/IggyValidationExceptionTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/exception/IggyValidationExceptionTest.java
index 676fc03..06d184f 100644
--- a/foreign/java/java-sdk/src/test/java/org/apache/iggy/exception/IggyValidationExceptionTest.java
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/exception/IggyValidationExceptionTest.java
@@ -46,6 +46,7 @@
     @EnumSource(
             value = IggyErrorCode.class,
             names = {
+                "INVALID_CONFIGURATION",
                 "INVALID_COMMAND",
                 "INVALID_FORMAT",
                 "FEATURE_UNAVAILABLE",
@@ -81,6 +82,7 @@
     @EnumSource(
             value = IggyErrorCode.class,
             names = {
+                "INVALID_CONFIGURATION",
                 "INVALID_COMMAND",
                 "INVALID_FORMAT",
                 "FEATURE_UNAVAILABLE",
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/message/PollOptionsTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/message/PollOptionsTest.java
new file mode 100644
index 0000000..25bd63b
--- /dev/null
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/message/PollOptionsTest.java
@@ -0,0 +1,147 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iggy.message;
+
+import org.apache.iggy.exception.IggyErrorCode;
+import org.apache.iggy.exception.IggyInvalidArgumentException;
+import org.apache.iggy.exception.IggyValidationException;
+import org.apache.iggy.partition.PartitionContext;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigInteger;
+import java.time.Duration;
+import java.util.List;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class PollOptionsTest {
+    private static final PartitionContext CONTEXT =
+            new PartitionContext(BigInteger.valueOf(3), BigInteger.valueOf(4), BigInteger.valueOf(5));
+
+    @Test
+    void shouldAnswerAtOnceAndStoreNothingByDefault() {
+        assertThat(PollOptions.defaults())
+                .isEqualTo(new PollOptions(1000, Duration.ZERO, 1, 1024 * 1024, CommitPolicy.manual()));
+        assertThat(PollOptions.defaults().requestTimeout()).isEqualTo(Duration.ofSeconds(10));
+        assertThatCode(() -> PollOptions.defaults().validate()).doesNotThrowAnyException();
+    }
+
+    @Test
+    void shouldStoreEvery10000MessagesOr5SecondsAfterProcessingByDefault() {
+        assertThat(AutoCommit.defaults())
+                .isEqualTo(new AutoCommit(
+                        Optional.of(Duration.ofSeconds(5)),
+                        Optional.of(CommitEvery.messages(10_000)),
+                        CommitAfter.PROCESSING));
+        assertThat(CommitPolicy.auto()).isEqualTo(CommitPolicy.auto(AutoCommit.defaults()));
+    }
+
+    @Test
+    void shouldRefuseAnAutomaticCommitThatWouldStoreOnlyOnClose() {
+        var never = PollOptions.defaults()
+                .withCommit(
+                        CommitPolicy.auto(new AutoCommit(Optional.empty(), Optional.empty(), CommitAfter.DELIVERY)));
+
+        assertThatThrownBy(never::validate)
+                .isInstanceOfSatisfying(
+                        IggyValidationException.class,
+                        error -> assertThat(error.getErrorCode()).isEqualTo(IggyErrorCode.INVALID_CONFIGURATION));
+        assertThatCode(() -> PollOptions.defaults()
+                        .withCommit(CommitPolicy.auto(AutoCommit.defaults().withoutInterval()))
+                        .validate())
+                .doesNotThrowAnyException();
+        assertThatCode(() -> PollOptions.defaults()
+                        .withCommit(CommitPolicy.auto(AutoCommit.defaults().withoutEvery()))
+                        .validate())
+                .doesNotThrowAnyException();
+    }
+
+    @Test
+    void shouldRefuseLimitsThatTheServerRefuses() {
+        assertThatThrownBy(() -> PollOptions.defaults().withCount(0).validate())
+                .isInstanceOf(IggyInvalidArgumentException.class);
+        assertThatThrownBy(() -> PollOptions.defaults().withMinCount(1001).validate())
+                .isInstanceOf(IggyInvalidArgumentException.class);
+        assertThatThrownBy(() -> PollOptions.waitFor(Duration.ofSeconds(601)).validate())
+                .isInstanceOf(IggyInvalidArgumentException.class);
+        assertThatThrownBy(() -> PollOptions.defaults().withMaxBytes(1).validate())
+                .isInstanceOf(IggyInvalidArgumentException.class);
+        assertThatThrownBy(() -> CommitEvery.messages(0)).isInstanceOf(IggyInvalidArgumentException.class);
+    }
+
+    @Test
+    void shouldWaitTheRequestTimeoutTenSecondsLongerThanTheWait() {
+        var options = PollOptions.waitFor(Duration.ofSeconds(5));
+
+        assertThat(options.requestTimeout()).isEqualTo(Duration.ofSeconds(15));
+        assertThat(options.deferredOptions())
+                .isEqualTo(new DeferredPollOptions(Duration.ofSeconds(5), 1, 1024 * 1024, Duration.ofSeconds(15)));
+    }
+
+    @Test
+    void shouldBindTheCheckpointAndTheContinuationOfABatchToItsPartition() {
+        var batch = new PolledMessages(2L, BigInteger.TEN, 2L, List.of(message(7), message(8)), CONTEXT);
+
+        Checkpoint last = batch.checkpoint().orElseThrow();
+
+        assertThat(List.of(last.partitionId(), last.offset(), last.context()))
+                .containsExactly(2L, BigInteger.valueOf(8), CONTEXT);
+        assertThat(batch.checkpointAt(0).map(Checkpoint::offset)).contains(BigInteger.valueOf(7));
+        assertThat(batch.checkpointAt(2)).isEmpty();
+        assertThat(batch.continuation())
+                .contains(new PollingStrategy(
+                        PollingKind.Offset, BigInteger.valueOf(9), Optional.of(CONTEXT), Optional.of(2L)));
+        assertThatCode(() -> batch.continuation().orElseThrow().requirePartition(2))
+                .doesNotThrowAnyException();
+        assertThatThrownBy(() -> batch.continuation().orElseThrow().requirePartition(3))
+                .isInstanceOfSatisfying(
+                        IggyValidationException.class,
+                        error -> assertThat(error.getErrorCode()).isEqualTo(IggyErrorCode.INVALID_COMMAND));
+        assertThat(new PolledMessages(2L, BigInteger.TEN, 0L, List.of(), CONTEXT).continuation())
+                .isEmpty();
+    }
+
+    @Test
+    void shouldKeepThePartitionOfAContinuationWhenItsContextChanges() {
+        var continuation = new PolledMessages(2L, BigInteger.TEN, 1L, List.of(message(7)), CONTEXT)
+                .continuation()
+                .orElseThrow()
+                .withContext(PartitionContext.EMPTY);
+
+        assertThat(continuation.partitionId()).contains(2L);
+    }
+
+    private static Message message(long offset) {
+        var plain = Message.of("payload");
+        var header = new MessageHeader(
+                BigInteger.ZERO,
+                plain.header().id(),
+                BigInteger.valueOf(offset),
+                BigInteger.ZERO,
+                BigInteger.ZERO,
+                0L,
+                (long) plain.payload().length,
+                BigInteger.ZERO);
+        return new Message(header, plain.payload(), plain.userHeaders());
+    }
+}
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/serde/BytesDeserializerTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/serde/BytesDeserializerTest.java
index 9f10f17..cf4074f 100644
--- a/foreign/java/java-sdk/src/test/java/org/apache/iggy/serde/BytesDeserializerTest.java
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/serde/BytesDeserializerTest.java
@@ -26,9 +26,13 @@
 import org.apache.iggy.cluster.ClusterNodeStatus;
 import org.apache.iggy.cluster.TransportEndpoints;
 import org.apache.iggy.exception.IggyMalformedResponseException;
+import org.apache.iggy.exception.IggyServerException;
+import org.apache.iggy.message.FetchedMessages;
 import org.apache.iggy.message.HeaderKey;
 import org.apache.iggy.message.HeaderKind;
 import org.apache.iggy.message.HeaderValue;
+import org.apache.iggy.message.PolledMessages;
+import org.apache.iggy.partition.PartitionContext;
 import org.apache.iggy.stream.EncryptionKeySource;
 import org.apache.iggy.system.CacheMetricsKey;
 import org.apache.iggy.user.UserStatus;
@@ -38,6 +42,7 @@
 import java.math.BigInteger;
 import java.nio.charset.StandardCharsets;
 import java.util.HexFormat;
+import java.util.List;
 import java.util.Map;
 
 import static org.apache.iggy.serde.BytesDeserializer.readClientInfo;
@@ -49,6 +54,7 @@
 import static org.apache.iggy.serde.BytesDeserializer.readConsumerGroupInfo;
 import static org.apache.iggy.serde.BytesDeserializer.readConsumerGroupMember;
 import static org.apache.iggy.serde.BytesDeserializer.readConsumerOffsetInfo;
+import static org.apache.iggy.serde.BytesDeserializer.readFetchedMessages;
 import static org.apache.iggy.serde.BytesDeserializer.readGlobalPermissions;
 import static org.apache.iggy.serde.BytesDeserializer.readPartition;
 import static org.apache.iggy.serde.BytesDeserializer.readPermissions;
@@ -422,6 +428,137 @@
     }
 
     @Nested
+    class FetchedMessagesDeserialization {
+        // The golden reply of the contract, made by the Rust encoder: partition 1 serves the message "hello" at offset
+        // 41 under the context (3, 9, 12), and partition 2 answers NOT_PRIMARY.
+        private static final String GOLDEN_REPLY =
+                "020000000100000000000000290000000000000001000000030000000000000009000000000000000c00000000000000"
+                        + "2a0000000000000035010000020000005900000000000000000000000000000000000000000000000000000000000000"
+                        + "00000000000000000000000000000000000000000100000000000000290000000000000064401e18240a060000401e18"
+                        + "240a06003501000000000000bd2d787d3cdb745c01000000000000000000000000000000000000000000000000000000"
+                        + "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
+                        + "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
+                        + "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
+                        + "0000000000000000000000000000000000000000000000000000000000000000000000008e5ca59edbbdbfb901000000"
+                        + "00000000000000000000000000000000000000000000000005000000000000000000000068656c6c6f";
+        private static final int GOLDEN_RECORDS_BYTES = 309;
+        private static final int NOT_PRIMARY = 89;
+        private static final int MAX_SECTIONS = 1000;
+
+        @Test
+        void shouldDeserializeTheGoldenFetchReply() {
+            ByteBuf reply = Unpooled.wrappedBuffer(HexFormat.of().parseHex(GOLDEN_REPLY));
+
+            List<FetchedMessages> sections = readFetchedMessages(reply);
+
+            assertThat(reply.isReadable()).isFalse();
+            assertThat(sections).hasSize(2);
+            FetchedMessages served = sections.get(0);
+            assertThat(List.of(served.partitionId(), served.status(), served.count()))
+                    .containsExactly(1L, FetchedMessages.STATUS_OK, 1L);
+            assertThat(List.of(served.currentOffset(), served.nextOffset()))
+                    .containsExactly(BigInteger.valueOf(41), BigInteger.valueOf(42));
+            assertThat(served.context())
+                    .isEqualTo(
+                            new PartitionContext(BigInteger.valueOf(3), BigInteger.valueOf(9), BigInteger.valueOf(12)));
+            assertThat(served.records()).hasSize(GOLDEN_RECORDS_BYTES);
+            PolledMessages polled = served.decode();
+            assertThat(List.of(polled.partitionId(), polled.count())).containsExactly(1L, 1L);
+            assertThat(polled.context()).isEqualTo(served.context());
+            assertThat(polled.messages()).singleElement().satisfies(message -> {
+                assertThat(message.header().offset()).isEqualTo(BigInteger.valueOf(41));
+                assertThat(message.payload()).isEqualTo("hello".getBytes(StandardCharsets.UTF_8));
+            });
+            FetchedMessages refused = sections.get(1);
+            assertThat(List.of(refused.partitionId(), refused.status(), refused.count()))
+                    .containsExactly(2L, (long) NOT_PRIMARY, 0L);
+            assertThat(List.of(refused.currentOffset(), refused.nextOffset())).containsOnly(BigInteger.ZERO);
+            assertThat(refused.context()).isEqualTo(PartitionContext.EMPTY);
+            assertThat(refused.records()).isEmpty();
+            assertThatThrownBy(refused::decode)
+                    .isInstanceOf(IggyServerException.class)
+                    .extracting(error -> ((IggyServerException) error).getRawErrorCode())
+                    .isEqualTo(NOT_PRIMARY);
+        }
+
+        @Test
+        void shouldDeserializeAFetchThatServedNoTarget() {
+            assertThat(readFetchedMessages(Unpooled.buffer().writeIntLE(0))).isEmpty();
+        }
+
+        @Test
+        void shouldRejectAReplyWithoutItsCount() {
+            assertThatThrownBy(() -> readFetchedMessages(Unpooled.buffer().writeShortLE(0)))
+                    .isInstanceOf(IggyMalformedResponseException.class)
+                    .hasMessageContaining("too few for its count");
+        }
+
+        @Test
+        void shouldRejectMoreSectionsThanARequestHasTargets() {
+            ByteBuf reply = Unpooled.buffer().writeIntLE(MAX_SECTIONS + 1);
+            reply.writeZero((MAX_SECTIONS + 1) * BytesDeserializer.FETCH_SECTION_BYTES);
+
+            assertThatThrownBy(() -> readFetchedMessages(reply))
+                    .isInstanceOf(IggyMalformedResponseException.class)
+                    .hasMessageContaining("more than a request has targets");
+        }
+
+        @Test
+        void shouldRejectASectionTableThatDoesNotFitTheReply() {
+            byte[] golden = HexFormat.of().parseHex(GOLDEN_REPLY);
+            ByteBuf reply = Unpooled.wrappedBuffer(
+                    golden,
+                    0,
+                    BytesDeserializer.FETCH_SECTIONS_COUNT_BYTES + 2 * BytesDeserializer.FETCH_SECTION_BYTES - 1);
+
+            assertThatThrownBy(() -> readFetchedMessages(reply))
+                    .isInstanceOf(IggyMalformedResponseException.class)
+                    .hasMessageContaining("ends inside its section table");
+        }
+
+        @Test
+        void shouldRejectAnErrorSectionWithRecords() {
+            ByteBuf reply = Unpooled.buffer().writeIntLE(1);
+            section(reply, NOT_PRIMARY, 1);
+            reply.writeByte(0);
+
+            assertThatThrownBy(() -> readFetchedMessages(reply))
+                    .isInstanceOf(IggyMalformedResponseException.class)
+                    .hasMessageContaining("has status " + NOT_PRIMARY + " and records");
+        }
+
+        @Test
+        void shouldRejectRecordsThatRunPastTheReply() {
+            byte[] golden = HexFormat.of().parseHex(GOLDEN_REPLY);
+
+            assertThatThrownBy(() -> readFetchedMessages(Unpooled.wrappedBuffer(golden, 0, golden.length - 1)))
+                    .isInstanceOf(IggyMalformedResponseException.class)
+                    .hasMessageContaining("run past the reply");
+        }
+
+        @Test
+        void shouldRejectBytesPastTheLastSection() {
+            ByteBuf reply = Unpooled.wrappedBuffer(
+                    Unpooled.wrappedBuffer(HexFormat.of().parseHex(GOLDEN_REPLY)),
+                    Unpooled.buffer().writeByte(0));
+
+            assertThatThrownBy(() -> readFetchedMessages(reply))
+                    .isInstanceOf(IggyMalformedResponseException.class)
+                    .hasMessageContaining("1 bytes past its sections");
+        }
+
+        private static void section(ByteBuf reply, int status, int recordsLength) {
+            reply.writeIntLE(1)
+                    .writeIntLE(status)
+                    .writeLongLE(0)
+                    .writeIntLE(0)
+                    .writeZero(PartitionContext.ENCODED_SIZE)
+                    .writeLongLE(0)
+                    .writeIntLE(recordsLength);
+        }
+    }
+
+    @Nested
     class ConsumerOffsetDeserialization {
 
         @Test
diff --git a/foreign/java/java-sdk/src/test/resources/deferred_polling.json b/foreign/java/java-sdk/src/test/resources/deferred_polling.json
new file mode 100644
index 0000000..452b461
--- /dev/null
+++ b/foreign/java/java-sdk/src/test/resources/deferred_polling.json
@@ -0,0 +1,610 @@
+{
+  "schema_version": 1,
+  "generated_by": "core/common/examples/deferred_polling_fixtures.rs",
+  "conventions": {
+    "body_hex": "Every *_hex field is lowercase hex without separators. body_hex is the command payload without framing.",
+    "header_hex": "header_hex is the whole request header of a cancellation, which has no body, before the transport stamps its checksum and checksum_body.",
+    "oversized": "A reply may carry a first record above max_bytes, up to max_record_size, so a client reply cap is at least max_record_reply_size.",
+    "refused": "Every refused fetch fails to decode or to validate. Every refused fetch reply fails to decode.",
+    "status": "A section status of status_ok was read. Any other status is the error code of that partition alone, and its section holds no records.",
+    "strategy": "strategy.kind is the wire kind: 1 offset, 2 timestamp, 3 first, 4 last, 5 next."
+  },
+  "constants": {
+    "codes": {
+      "cancel_deferred_request": 108,
+      "fetch_messages": 109,
+      "poll_messages": 100,
+      "poll_messages_deferred": 106
+    },
+    "status_ok": 0,
+    "errors": {
+      "deferred_poll_capacity_exceeded": 90,
+      "fetch_progress_uncertain": 14010,
+      "history_unavailable": 87,
+      "invalid_size_bytes": 80,
+      "not_primary": 89,
+      "partition_lane_blocked": 14009,
+      "too_many_consumer_offsets": 3024,
+      "transient_not_accepted": 58,
+      "transient_not_committed": 57
+    },
+    "min_deferred_poll_max_bytes": 344,
+    "max_deferred_poll_wait_us": 600000000,
+    "max_deferred_poll_timeout_us": 630000000,
+    "min_deferred_poll_reply_margin_us": 1000000,
+    "fetch_reply_margin_us": 200000,
+    "max_partitions_per_request": 1000,
+    "max_record_size": 64100304,
+    "max_record_reply_size": 64156564,
+    "request_header_size": 256,
+    "fetch_section_header_size": 56
+  },
+  "deferred_polls": [
+    {
+      "case": "consumer_offset_without_commit",
+      "request": {
+        "consumer": {
+          "kind": 1,
+          "id": {
+            "kind": "numeric",
+            "value": 1
+          }
+        },
+        "stream_id": {
+          "kind": "numeric",
+          "value": 1
+        },
+        "topic_id": {
+          "kind": "named",
+          "value": "orders"
+        },
+        "partition_id": 2,
+        "strategy": {
+          "kind": 1,
+          "value": 41
+        },
+        "count": 100,
+        "auto_commit": false,
+        "wait_us": 5000000,
+        "min_count": 1,
+        "max_bytes": 1048576,
+        "request_timeout_us": 6000000
+      },
+      "body_hex": "0101040100000001040100000002066f726465727301020000000129000000000000006400000000404b4c00000000000100000000001000808d5b0000000000"
+    },
+    {
+      "case": "group_next_with_commit",
+      "request": {
+        "consumer": {
+          "kind": 2,
+          "id": {
+            "kind": "named",
+            "value": "orders-group"
+          }
+        },
+        "stream_id": {
+          "kind": "numeric",
+          "value": 1
+        },
+        "topic_id": {
+          "kind": "named",
+          "value": "orders"
+        },
+        "partition_id": 2,
+        "strategy": {
+          "kind": 5,
+          "value": 0
+        },
+        "count": 100,
+        "auto_commit": true,
+        "wait_us": 5000000,
+        "min_count": 1,
+        "max_bytes": 1048576,
+        "request_timeout_us": 6000000
+      },
+      "body_hex": "02020c6f72646572732d67726f757001040100000002066f726465727301020000000500000000000000006400000001404b4c00000000000100000000001000808d5b0000000000"
+    }
+  ],
+  "fetches": [
+    {
+      "case": "consumer_two_targets_without_commit",
+      "request": {
+        "consumer": {
+          "kind": 1,
+          "id": {
+            "kind": "numeric",
+            "value": 1
+          }
+        },
+        "stream_id": {
+          "kind": "numeric",
+          "value": 1
+        },
+        "topic_id": {
+          "kind": "numeric",
+          "value": 2
+        },
+        "wait_us": 5000000,
+        "request_timeout_us": 6000000,
+        "min_count": 1,
+        "count": 1000,
+        "max_bytes": 1048576,
+        "auto_commit": false,
+        "targets": [
+          {
+            "partition_id": 1,
+            "strategy": {
+              "kind": 1,
+              "value": 41
+            },
+            "max_bytes": 524288,
+            "incarnation": 11,
+            "owner_generation": 0
+          },
+          {
+            "partition_id": 3,
+            "strategy": {
+              "kind": 5,
+              "value": 0
+            },
+            "max_bytes": 524288,
+            "incarnation": 11,
+            "owner_generation": 0
+          }
+        ]
+      },
+      "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b000000000001000000e803000000001000000200000001000000012900000000000000000008000b00000000000000000000000000000003000000050000000000000000000008000b000000000000000000000000000000"
+    },
+    {
+      "case": "group_three_targets_with_commit",
+      "request": {
+        "consumer": {
+          "kind": 2,
+          "id": {
+            "kind": "numeric",
+            "value": 9
+          }
+        },
+        "stream_id": {
+          "kind": "numeric",
+          "value": 1
+        },
+        "topic_id": {
+          "kind": "numeric",
+          "value": 2
+        },
+        "wait_us": 5000000,
+        "request_timeout_us": 6000000,
+        "min_count": 1,
+        "count": 1000,
+        "max_bytes": 1048576,
+        "auto_commit": true,
+        "targets": [
+          {
+            "partition_id": 1,
+            "strategy": {
+              "kind": 5,
+              "value": 0
+            },
+            "max_bytes": 524288,
+            "incarnation": 11,
+            "owner_generation": 4
+          },
+          {
+            "partition_id": 2,
+            "strategy": {
+              "kind": 5,
+              "value": 0
+            },
+            "max_bytes": 524288,
+            "incarnation": 11,
+            "owner_generation": 4
+          },
+          {
+            "partition_id": 4,
+            "strategy": {
+              "kind": 1,
+              "value": 7
+            },
+            "max_bytes": 524288,
+            "incarnation": 11,
+            "owner_generation": 4
+          }
+        ]
+      },
+      "body_hex": "02010409000000010401000000010402000000404b4c0000000000808d5b000000000001000000e803000000001000010300000001000000050000000000000000000008000b00000000000000040000000000000002000000050000000000000000000008000b00000000000000040000000000000004000000010700000000000000000008000b000000000000000400000000000000"
+    }
+  ],
+  "cancellations": [
+    {
+      "case": "cancel_deferred_poll",
+      "cancels_code": 106,
+      "client": 7,
+      "request": 21,
+      "session": 3,
+      "header_hex": "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000050000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007000000000000000000000000000000000000000000000000000000000000000000000000000000150000000000000002000000000000000300000000000000000000006c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
+    },
+    {
+      "case": "cancel_fetch",
+      "cancels_code": 109,
+      "client": 7,
+      "request": 22,
+      "session": 3,
+      "header_hex": "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000050000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007000000000000000000000000000000000000000000000000000000000000000000000000000000160000000000000002000000000000000300000000000000000000006c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
+    }
+  ],
+  "fetch_replies": [
+    {
+      "case": "ok_section_and_empty_start_section",
+      "sections": [
+        {
+          "partition_id": 1,
+          "status": 0,
+          "current_offset": 44,
+          "messages_count": 3,
+          "context": {
+            "incarnation": 11,
+            "owner_generation": 4,
+            "metadata_op": 42
+          },
+          "next_offset": 44,
+          "records_len": 416,
+          "messages": [
+            {
+              "offset": 41,
+              "id": "258",
+              "timestamp": 1700000000000500,
+              "origin_timestamp": 1700000000000000,
+              "payload": "first"
+            },
+            {
+              "offset": 42,
+              "id": "259",
+              "timestamp": 1700000000000500,
+              "origin_timestamp": 1700000000000000,
+              "payload": "second"
+            },
+            {
+              "offset": 43,
+              "id": "260",
+              "timestamp": 1700000000000500,
+              "origin_timestamp": 1700000000000000,
+              "payload": "third"
+            }
+          ]
+        },
+        {
+          "partition_id": 3,
+          "status": 0,
+          "current_offset": 9,
+          "messages_count": 0,
+          "context": {
+            "incarnation": 11,
+            "owner_generation": 4,
+            "metadata_op": 42
+          },
+          "next_offset": 10,
+          "records_len": 0,
+          "messages": []
+        }
+      ],
+      "body_hex": "0200000001000000000000002c00000000000000030000000b0000000000000004000000000000002a000000000000002c00000000000000a001000003000000000000000900000000000000000000000b0000000000000004000000000000002a000000000000000a000000000000000000000001000000000000002900000000000000f4411e18240a060000401e18240a0600a00100000000000088fc3268f2c0e02103000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000bb5b1cd43b71761a02010000000000000000000000000000000000000000000000000000050000000000000000000000666972737453c25c376f98c265030100000000000000000000000000000100000000000000000000000600000000000000000000007365636f6e64a761c18f632fb022040100000000000000000000000000000200000000000000000000000500000000000000000000007468697264"
+    },
+    {
+      "case": "ok_section_beside_error_sections",
+      "sections": [
+        {
+          "partition_id": 1,
+          "status": 89,
+          "current_offset": 0,
+          "messages_count": 0,
+          "context": {
+            "incarnation": 0,
+            "owner_generation": 0,
+            "metadata_op": 0
+          },
+          "next_offset": 0,
+          "records_len": 0,
+          "messages": []
+        },
+        {
+          "partition_id": 2,
+          "status": 0,
+          "current_offset": 7,
+          "messages_count": 1,
+          "context": {
+            "incarnation": 11,
+            "owner_generation": 4,
+            "metadata_op": 42
+          },
+          "next_offset": 7,
+          "records_len": 308,
+          "messages": [
+            {
+              "offset": 6,
+              "id": "340282366920938463463374607431768211455",
+              "timestamp": 1700000000000500,
+              "origin_timestamp": 1700000000000000,
+              "payload": "only"
+            }
+          ]
+        },
+        {
+          "partition_id": 4,
+          "status": 58,
+          "current_offset": 0,
+          "messages_count": 0,
+          "context": {
+            "incarnation": 0,
+            "owner_generation": 0,
+            "metadata_op": 0
+          },
+          "next_offset": 0,
+          "records_len": 0,
+          "messages": []
+        }
+      ],
+      "body_hex": "03000000010000005900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000700000000000000010000000b0000000000000004000000000000002a00000000000000070000000000000034010000040000003a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000600000000000000f4411e18240a060000401e18240a060034010000000000003e89bfefc17b805301000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000fbb4aeb35a85bdaeffffffffffffffffffffffffffffffff0000000000000000000000000400000000000000000000006f6e6c79"
+    },
+    {
+      "case": "no_sections",
+      "sections": [],
+      "body_hex": "00000000"
+    }
+  ],
+  "refused": {
+    "fetches": [
+      {
+        "case": "no_targets",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b000000000001000000e8030000000010000000000000"
+      },
+      {
+        "case": "duplicate_partition",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b000000000001000000e803000000001000000200000001000000010000000000000000000008000b00000000000000000000000000000001000000050000000000000000000008000b000000000000000000000000000000"
+      },
+      {
+        "case": "first_start_point",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b000000000001000000e803000000001000000100000001000000030000000000000000000008000b000000000000000000000000000000"
+      },
+      {
+        "case": "min_count_above_count",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b0000000000e9030000e803000000001000000100000001000000010000000000000000000008000b000000000000000000000000000000"
+      },
+      {
+        "case": "target_bytes_above_request_bytes",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b000000000001000000e803000000001000000100000001000000010000000000000000010010000b000000000000000000000000000000"
+      },
+      {
+        "case": "timeout_without_reply_margin",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000404b4c000000000001000000e803000000001000000100000001000000010000000000000000000008000b000000000000000000000000000000"
+      }
+    ],
+    "fetch_replies": [
+      {
+        "case": "error_section_with_records",
+        "body_hex": "01000000010000005900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000"
+      },
+      {
+        "case": "truncated_table",
+        "body_hex": "02000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
+      }
+    ]
+  },
+  "commit_outcomes": {
+    "rule": "A committing deferred poll (106) or fetch (109) that fails with a certain status stored nothing. Every other status, a status not listed here, a lost reply and a transport failure after the send leave the outcome unknown.",
+    "request": [
+      {
+        "status": 5,
+        "error": "feature_unavailable",
+        "certain": true
+      },
+      {
+        "status": 6,
+        "error": "invalid_identifier",
+        "certain": true
+      },
+      {
+        "status": 30,
+        "error": "stale_client",
+        "certain": false
+      },
+      {
+        "status": 40,
+        "error": "unauthenticated",
+        "certain": false
+      },
+      {
+        "status": 41,
+        "error": "unauthorized",
+        "certain": false
+      },
+      {
+        "status": 57,
+        "error": "transient_not_committed",
+        "certain": false
+      },
+      {
+        "status": 58,
+        "error": "transient_not_accepted",
+        "certain": true
+      },
+      {
+        "status": 72,
+        "error": "cannot_decrypt_data",
+        "certain": false
+      },
+      {
+        "status": 80,
+        "error": "invalid_size_bytes",
+        "certain": true
+      },
+      {
+        "status": 87,
+        "error": "history_unavailable",
+        "certain": true
+      },
+      {
+        "status": 89,
+        "error": "not_primary",
+        "certain": true
+      },
+      {
+        "status": 90,
+        "error": "deferred_poll_capacity_exceeded",
+        "certain": true
+      },
+      {
+        "status": 1009,
+        "error": "stream_id_not_found",
+        "certain": true
+      },
+      {
+        "status": 1010,
+        "error": "stream_name_not_found",
+        "certain": true
+      },
+      {
+        "status": 2010,
+        "error": "topic_id_not_found",
+        "certain": true
+      },
+      {
+        "status": 2011,
+        "error": "topic_name_not_found",
+        "certain": true
+      },
+      {
+        "status": 3007,
+        "error": "partition_not_found",
+        "certain": true
+      },
+      {
+        "status": 3024,
+        "error": "too_many_consumer_offsets",
+        "certain": true
+      },
+      {
+        "status": 4100,
+        "error": "invalid_offset",
+        "certain": true
+      },
+      {
+        "status": 5009,
+        "error": "consumer_group_partition_not_owned",
+        "certain": true
+      },
+      {
+        "status": 11001,
+        "error": "shard_communication_error",
+        "certain": false
+      }
+    ],
+    "section": [
+      {
+        "status": 5,
+        "error": "feature_unavailable",
+        "certain": false
+      },
+      {
+        "status": 6,
+        "error": "invalid_identifier",
+        "certain": false
+      },
+      {
+        "status": 30,
+        "error": "stale_client",
+        "certain": false
+      },
+      {
+        "status": 40,
+        "error": "unauthenticated",
+        "certain": false
+      },
+      {
+        "status": 41,
+        "error": "unauthorized",
+        "certain": false
+      },
+      {
+        "status": 57,
+        "error": "transient_not_committed",
+        "certain": false
+      },
+      {
+        "status": 58,
+        "error": "transient_not_accepted",
+        "certain": true
+      },
+      {
+        "status": 72,
+        "error": "cannot_decrypt_data",
+        "certain": false
+      },
+      {
+        "status": 80,
+        "error": "invalid_size_bytes",
+        "certain": true
+      },
+      {
+        "status": 87,
+        "error": "history_unavailable",
+        "certain": true
+      },
+      {
+        "status": 89,
+        "error": "not_primary",
+        "certain": true
+      },
+      {
+        "status": 90,
+        "error": "deferred_poll_capacity_exceeded",
+        "certain": false
+      },
+      {
+        "status": 1009,
+        "error": "stream_id_not_found",
+        "certain": false
+      },
+      {
+        "status": 1010,
+        "error": "stream_name_not_found",
+        "certain": false
+      },
+      {
+        "status": 2010,
+        "error": "topic_id_not_found",
+        "certain": false
+      },
+      {
+        "status": 2011,
+        "error": "topic_name_not_found",
+        "certain": false
+      },
+      {
+        "status": 3007,
+        "error": "partition_not_found",
+        "certain": true
+      },
+      {
+        "status": 3024,
+        "error": "too_many_consumer_offsets",
+        "certain": true
+      },
+      {
+        "status": 4100,
+        "error": "invalid_offset",
+        "certain": false
+      },
+      {
+        "status": 5009,
+        "error": "consumer_group_partition_not_owned",
+        "certain": true
+      },
+      {
+        "status": 11001,
+        "error": "shard_communication_error",
+        "certain": false
+      }
+    ]
+  }
+}
diff --git a/foreign/java/java-sdk/src/test/resources/resource_options.json b/foreign/java/java-sdk/src/test/resources/resource_options.json
index c5cc2ac..202888f 100644
--- a/foreign/java/java-sdk/src/test/resources/resource_options.json
+++ b/foreign/java/java-sdk/src/test/resources/resource_options.json
@@ -16,18 +16,18 @@
   },
   "constants": {
     "protocol_version": {
-      "packed": 12288,
-      "text": "0.12.0",
+      "packed": 12289,
+      "text": "0.12.1",
       "major": 0,
       "minor": 12,
-      "patch": 0
+      "patch": 1
     },
     "protocol_version_min": {
-      "packed": 12288,
-      "text": "0.12.0",
+      "packed": 12289,
+      "text": "0.12.1",
       "major": 0,
       "minor": 12,
-      "patch": 0
+      "patch": 1
     },
     "command_codes": {
       "create_stream": {
diff --git a/foreign/node/README.md b/foreign/node/README.md
index 0516415..283abea 100644
--- a/foreign/node/README.md
+++ b/foreign/node/README.md
@@ -22,7 +22,7 @@
 npm i --save apache-iggy@edge
 ```
 
-Use matching SDK and server builds from the coordinated VSR release. Older builds are incompatible. This release requires fresh server storage (`IGGY-METADATA-CHANGE-LOG-1`). Existing incompatible data is refused unchanged. For a local server with the example credentials, follow the [example setup](../../examples/node/README.md#running-examples).
+Use matching SDK and server builds from the coordinated VSR release, with a minimum binary protocol version of 0.12.1. Older builds are incompatible. This release requires fresh server storage (`IGGY-METADATA-CHANGE-LOG-1`). Existing incompatible data is refused unchanged. For a local server with the example credentials, follow the [example setup](../../examples/node/README.md#running-examples).
 
 ## basic usage
 
@@ -61,7 +61,7 @@
 
 `sendBinaryRequestToReplica(code, payload, replica)` sends a raw command to one node of a cluster, named by the replica id that cluster metadata returns, and `sendCommand` takes the same id as the `replica` option.
 
-Each node answers non-replicated commands such as `GetStats` from its own state, so this reads the state of that node. Replicated commands, polls, consumer-offset reads and session-control codes fail with error code 3 before anything is sent. An id that cluster metadata does not name fails with error code 6. An unreachable node is retried until the request deadline, and a `NotPrimary` (89) reply is returned rather than sent to another node.
+Each node answers non-replicated commands such as `GetStats` from its own state, so this reads the state of that node. Replicated commands, polls, fetches, consumer-offset reads and session-control codes fail with error code 3 before anything is sent. An id that cluster metadata does not name fails with error code 6. An unreachable node is retried until the request deadline, and a `NotPrimary` (89) reply is returned rather than sent to another node.
 
 Partition commands carry a context containing the partition incarnation, owner
 generation and metadata operation. Pass the context of an earlier poll reply to
@@ -70,19 +70,19 @@
 preserve the first captured context, request identity and payload. Route refreshes
 change the destination. Lifecycle error 88 retries with a fresh ID and the same
 context. A send without caller-supplied context can refresh history once after
-error 87; explicit contexts return that error. A new call can capture newer
+error 87. Explicit contexts return that error. A new call can capture newer
 metadata progress.
 
 Route discovery uses command 105. Sends, polls and offset reads/writes use the
-partition primary; other commands use the metadata primary. One logical client
+partition primary. Other commands use the metadata primary. One logical client
 owns a shared session, a monotonic request counter and one multiplexed link per
 replica. Healthy links survive primary changes. Each link dials the address
 that its node advertises in the cluster roster. With a one-node roster, the
 link dials the configured endpoint instead. A single node behind a port
 mapping or a port-forward thus stays reachable. The legacy `poolSize` option
-accepts only one logical client; it does not limit replica links or concurrency.
+accepts only one logical client. It does not limit replica links or concurrency.
 The public `Client._pool` property has been removed. Applications should use the
-client methods directly; the client owns its replica connections.
+client methods directly. The client owns its replica connections.
 
 A backup replica can miss a stream, topic or partition that the metadata primary
 already created. When such a replica replies "not found" (error 1009, 1010,
@@ -97,7 +97,7 @@
 
 VSR authentication translates password and personal-access-token login into the
 Register handshake. Replacement links bind the existing session. Confirmed
-session expiry requires a new Register; an uncertain mutation is never replayed
+session expiry requires a new Register. An uncertain mutation is never replayed
 under that new session. An eviction that ends the session does not prove the
 outcome of a mutation that the client already wrote. That call ends with
 `OperationUncertainError`. A stale client eviction drops only its link, because
@@ -111,7 +111,7 @@
 a call fails with status 40 until the caller signs in again. A sign-in or
 logout of the caller ends each earlier call that has to wait for a new session:
 with status 40 if it sent nothing, or with `OperationUncertainError` if its
-outcome is unknown. An auto-commit or Next poll, which has no operation ID, ends with status 57 instead. A typed send and a group poll can send more than one
+outcome is unknown. A Next poll that commits nothing, which has no operation ID, ends with status 57 instead, and an auto-commit poll ends with `FetchProgressUncertainError` (see below). A typed send and a group poll can send more than one
 command. If the caller signs in or logs out before the next command of such a
 call, the call fails with status 40. Only a call that waits for the client's own sign-in goes on
 under the new session. If the server refuses that sign-in, the call fails with
@@ -132,6 +132,209 @@
 It is a proved outcome, like any other committed rejection, and the client
 drops the session at once and signs in again.
 
+### Deferred polls
+
+`message.poll` takes `deferred` options. With them, the client sends command
+106, and the server waits for messages before it replies. Options that you do
+not give take the values of `DEFAULT_DEFERRED_POLL_OPTIONS`, which are the
+defaults of the Rust SDK. Durations are in milliseconds.
+
+| option | default | limits |
+| --- | --- | --- |
+| `maxWait` | `1000` | up to 600 s. When `minCount` messages are there, or when `maxWait` ends, the server replies |
+| `minCount` | `1` | from 1 to the `count` of the poll |
+| `maxBytes` | `1048576` | at least 344 bytes, the size of a reply with one message header |
+| `requestTimeout` | `11000` | from 1 ms to 630 s. If `maxWait` is above 0, at least `maxWait` plus 1 s |
+
+An option outside its limits fails before anything is sent: `minCount` with
+error 4009, `maxWait` and `requestTimeout` with error 3, and `maxBytes` with
+error 80. The call gives up at `requestTimeout`, not at the 30 s operation
+budget. A link fails on a reply larger than `maxResponseFrameSize`, so the
+client lowers `maxBytes` to that size minus the 256-byte header.
+
+A client parks at most 64 deferred polls and fetches at once. Another
+one waits for a free slot within its own deadline. If the server refuses one
+with 90, because the session parks too many requests, the client pauses and
+sends it again within the deadline.
+
+The `signal` option takes an `AbortSignal` that cancels the call. If the
+request already waits on the server, the client sends command 108 on the same
+link. The server then drops the request without a reply. The client ignores a
+reply that comes later. A raw 106 gets the same routing, limits and
+cancellation as the typed calls. A raw 108 fails with error 3 before anything
+is sent.
+
+A consumer-group poll with deferred options needs a partition. Without one, it
+fails with error 3 before anything is sent. A member reads all its partitions
+with `message.reader`.
+
+An auto-commit or Next deferred poll holds the progress lane of its partition
+while it waits on the server, up to `maxWait`. Offset reads, stores and deletes
+of the same client on that partition wait behind it. Sends do not take the
+progress lane, so a parked poll does not delay them.
+
+### Polling one partition
+
+`message.poll` with a `strategy` reads one batch of one partition with
+`PollOptions`. It waits on the server until `minCount` messages are ready, the
+reply reaches `maxBytes`, or `maxWait` ends, and returns what is ready, which
+can be nothing.
+
+```typescript
+import { CommitPolicy, Consumer, PollingStrategy } from "apache-iggy";
+
+const consumer = Consumer.Single;
+const options = { count: 100, maxWait: 5_000 };
+const polled = await client.message.poll({
+  streamId, topicId, partitionId: 1, consumer, strategy: PollingStrategy.Next, options
+});
+for (const message of polled.messages) console.log(message.payload.toString());
+
+const checkpoint = polled.checkpoint();
+if (checkpoint) await client.offset.storePosition({ streamId, topicId, consumer, checkpoint });
+
+const next = await client.message.poll({
+  streamId, topicId, partitionId: 1, consumer, strategy: polled.continuation(), options
+});
+```
+
+| option | default | limits |
+| --- | --- | --- |
+| `count` | `1000` | the most messages of one batch |
+| `maxWait` | `0` | up to 600 s. Zero replies at once. The call gives up 10 s after `maxWait` |
+| `minCount` | `1` | from 1 to `count`, else error 4009 |
+| `maxBytes` | `1048576` | at least 344 bytes, else error 80 |
+| `commit` | `CommitPolicy.Manual` | who stores the offset, see below |
+
+- `CommitPolicy.Manual` stores nothing. Store `checkpoint()`, or
+  `checkpointAt(index)` for a part of the batch, with `offset.storePosition`,
+  and remove it with `offset.deletePosition`. A checkpoint keeps the partition
+  history that delivered it, so a store after the partition was deleted and
+  created again fails with error 87.
+- `CommitPolicy.OnRead` makes the server store the offset of the last message
+  of each read as part of the read, in one round trip. While the primary stays
+  up a message is delivered at most once. A failover before the store
+  replicates can deliver the last batch again. A one-shot poll refuses
+  `CommitPolicy.Auto()` with error 2 (InvalidConfiguration), since it has
+  nothing to count.
+- `continuation()` continues the partition after the batch, under its context.
+  An empty batch keeps the start of its read, so `Next` stays `Next`. A poll of
+  another partition refuses the continuation with error 3.
+- A first message larger than `maxBytes` comes alone in its read.
+- If you abort a waiting poll, the client cancels it on the server. A poll that
+  stores nothing rejects with the abort reason. A poll that commits keeps its
+  reply: the server answers it once, refused when the cancel won and nothing was
+  stored, so the call rejects with the abort reason, or with the messages that
+  it stored before the cancel came, which the call returns.
+
+### Partition reader
+
+`message.reader` opens a `PartitionReader`, which reads many partitions in
+batches and keeps the position of each. It reads the partitions of one primary
+in one request, finds a moved primary by itself and gives each partition its
+turn. A partition whose last read ended before the end of the partition is read
+in a fetch of its own while the read ahead has room for another full read, up to
+16 reads in flight, so a backlog of many partitions is read as fast as one poll
+of each partition would read it. Under `CommitPolicy.OnRead` the reader starts
+those fetches only while `next()` waits, and each partition is in one committing
+read at a time.
+
+```typescript
+import { Consumer, ReadError } from "apache-iggy";
+
+const reader = await client.message.reader({
+  streamId, topicId, consumer: Consumer.Single, partitions: [1, 2, 3], options: { maxWait: 5_000 }
+});
+try {
+  while (true) {
+    try {
+      const batch = await reader.next();
+      process(batch);
+      await reader.commit(batch.checkpoint()!);
+    } catch (error) {
+      // A message that does not decode. The reader reads it again until a seek passes it.
+      if (error instanceof ReadError && error.skip) await reader.seek(error.partitionId!, error.skip);
+      else if (!(error instanceof ReadError)) throw error;
+    }
+  }
+} finally {
+  await reader.close();
+}
+```
+
+- A consumer group reads the assignment of its member:
+  `{ consumer: Consumer.Group('g') }` without `partitions` joins the group,
+  follows rebalances and leaves the group in `close()`.
+- `next()` returns one batch of one partition, never empty. A `ReadError`
+  names the partition of an error, or none for an error of the whole reader,
+  and the reader retries by itself. Error 87 stops a partition until `seek`
+  when its position came from an earlier read, and so do error 80 and a refusal
+  that six retries did not change.
+- For a message that does not decode, `ReadError` carries `skip`, `offset` and
+  `record`, the record as the reply carried it: its 48-byte frame header,
+  payload and user headers, so the message can be kept or inspected. When the
+  framing of the reply is malformed, `offset` is undefined and `record` holds
+  the remaining reply bytes from where decoding stopped.
+- With `CommitPolicy.Manual`, `commit` stores a checkpoint. With
+  `CommitPolicy.OnRead` the server stores the offset of each read, the reader
+  reads only while `next()` waits, and `close()` moves the offset back to the
+  last batch that the application took. A read whose reply was lost is
+  repaired: the next read finds what it stored, and the reader reads those
+  messages again by offset without a commit. Only a lost read makes the reader
+  read skipped offsets again: when the stored offset moved ahead because the
+  application or another session stored it, the reader delivers from there. A
+  batch that the server stored is handed over even when its partition left the
+  assignment of the member. A `seek` to an offset above 0 first stores the
+  offset before it, so a restart resumes at the sought offset, and a refused
+  store fails the seek and moves nothing. Every other `seek` stores nothing.
+- A refusal 5009 of a group partition drops its position, so the next read
+  starts again from `start` under the new owner of the partition. A 5009 that
+  six retries did not change is reported and stops the partition until `seek`.
+- With `CommitPolicy.Auto({ interval, every, after })` the reader counts each
+  batch when `next()` returns it (`CommitAfter.Delivery`), at the next `next()`
+  or `close()` (`CommitAfter.Processing`), or on `mark` (`CommitAfter.Marked`),
+  and stores the latest counted position at the interval, on the event
+  (`CommitEvery.Message`, `CommitEvery.Messages(n)` or `CommitEvery.Batch`),
+  and in `close()`. `DEFAULT_AUTO_COMMIT` stores every 10,000 messages or 5 s
+  after processing.
+- The reader reads ahead up to 16 MiB and 16,000 messages (`prefetchBytes`,
+  `prefetchMessages`). Without options a read waits up to 5 s. After a refused
+  read, a lost read or a failed refresh it waits `retryInterval`, 1 s by
+  default.
+
+### Consumers
+
+`client.consumer` and `client.consumerGroup` hand over one message at a time on
+a partition reader, as an async iterable, with a handler loop, or as a
+`Readable`.
+
+```typescript
+import { CommitAfter, CommitPolicy } from "apache-iggy";
+
+const consumer = await client.consumerGroup({
+  name: 'orders', streamId, topicId,
+  options: { commit: CommitPolicy.Auto({ after: CommitAfter.Processing }) },
+  onError: (error) => console.warn(error)
+});
+await consumer.consume(async ({ message }) => handle(message));
+await consumer.shutdown();
+```
+
+- `client.consumerGroup` creates the group when it is missing and joins it.
+  `client.consumer` reads the given `partitions` under the consumer `name`.
+- Under `CommitAfter.Processing` a message counts once the iterator is read
+  again or its handler returns. A handler that throws ends `consume` with its
+  error and leaves its message uncounted, and so does a shutdown while the
+  handler runs. `shutdown()` counts the last message of the iterator, stores or
+  moves back the offsets and leaves the group. A later `shutdown()` waits for
+  the first and ends as it does. `seek` drops the rest of the batch of its
+  partition.
+- A read error that the reader retries by itself goes to `onError` and the
+  consumer goes on. A message that does not decode ends the iteration with its
+  `ReadError`, whose `skip` passes it with `seek`.
+- `toReadable()` takes the next message only when its own reader asks for it,
+  and destroying it shuts the consumer down.
+
 ### Mutation recovery and limits
 
 Receipt access follows the server's [session and revocation policy](../../core/server/README.md#sessions-and-retry-receipts).
@@ -146,13 +349,26 @@
 See [metadata freshness and counters](../../core/server/README.md#metadata-freshness-and-counters).
 
 Mutations share one FIFO lane per partition history, with a separate metadata
-lane. An uncertain mutation remains retained and blocks later mutations in its
-lane. `OperationUncertainError.operationId` and `LaneBlockedError.operationId`
-are exact `bigint` values. Use `resolveOperation(operationId, { deadline })` to
+lane. Progress polls (`Next` or auto-commit), offset reads, stores and deletes
+share a separate progress lane per partition. Sends use only the mutation lane,
+so a parked poll does not delay a producer that shares the client. Offset
+stores and deletes hold both lanes. They take the progress lane first, so a
+send can pass a store that waits behind a parked poll.
+
+An uncertain mutation remains retained and blocks later mutations in its lane.
+An uncertain offset store or delete also blocks the progress lane until it is
+resolved or abandoned. `OperationUncertainError.operationId` and
+`LaneBlockedError.operationId` are exact `bigint` values. Use `resolveOperation(operationId, { deadline })` to
 replay the retained frame, where `deadline` is an absolute `Date.now()` timestamp
 in milliseconds. The default operation budget is 30 seconds, including admission,
 routing, link waits and retries.
 
+For a blocked fetch target, `LaneBlockedError.partitionId` identifies the partition
+whose retained operation needs repair. A block from an uncertain committing fetch
+also sets `requiresExplicitRecovery`, including on stores and deletes, and has
+`errorCode` 14009 (`PARTITION_LANE_BLOCKED`). Other blocks have `errorCode` 14007
+(`LANE_BLOCKED`). Other single-lane errors leave `partitionId` undefined.
+
 Resolution requires correlated committed-result proof. A proved business
 rejection returns the original error and releases the lane. An unproved denial
 keeps an uncertain operation retained. The client keeps the proved outcomes of
@@ -173,17 +389,39 @@
 discards retained operations. It does not wait for outstanding calls to finish.
 Await those calls before destroying the client when their results are needed.
 
-Auto-commit and Next polls hold their partition lane only during the active call.
+Next polls that commit nothing (100/106), and non-committing fetches, hold their progress lane only during the active call.
 Error 57, a lost reply or another unknown outcome ends the call immediately with status 57 and no
 retained operation ID. These polls never replay after uncertainty. Known
-non-admission errors 58/89 remain retryable; pure reads may retry lost replies.
+non-admission errors 58/89 remain retryable. Pure reads may retry lost replies.
 Before uncertainty, lifecycle error 88 retries with a fresh ID within the same
 deadline. After uncertainty, only committed-result proof can release the lane.
 
+An auto-commit poll (100/106) or a committing fetch (109) with an unknown outcome throws `FetchProgressUncertainError` (local code 14010) and retains progress blocks for every affected partition history. Its `operationId` is a client-lifetime recovery key, not a server replay receipt. A lost reply, a transport failure after the send, a disconnect or a logout while the call waits leave the outcome unknown.
+
+So does every refusal except those that prove the server stored nothing: 5, 6, 58, 80, 87, 89, 90, 1009, 1010, 2010, 2011, 3007, 3024, 4100 and 5009 for a whole reply, and 58, 80, 87, 89, 3007, 3024 and 5009 for a fetch section.
+
+Later progress polls, fetches, stores and deletes fail with `LaneBlockedError` carrying that ID, the blocked partition and `errorCode` 14009. Stateless reads, `GetOffset` inspection and sends remain available. Reconnect, sign-in and late replies do not clear these blocks.
+
+`resolveOperation` returns the same uncertainty without I/O. `abandonOperation` explicitly releases the blocks while accepting that the old server request may still finish. An inspected offset is not a completion barrier.
+
+Valid partial replies preserve healthy sections. An uncertain section carries
+`status: 14010` and `progressError`. Record decode failures keep their existing
+rewind behavior. Raw replies keep their wire bytes, so raw callers inspect section
+statuses and obtain the recovery ID from a later blocked-target call. A partition
+reader delivers healthy sections and abandons the lost reads of its own committing
+fetches, so the next read finds what they stored and its streams and consumers go
+on. Creating a new independent client does not inherit that client's blocks.
+
+Committing fetches reserve a separate bounded recovery pool using
+`maxPendingOperations` and `maxPendingOperationBytes` before sending. The byte charge
+is the request size, but retained state holds only target identities and the local
+ID. Capacity refusal occurs before send. A proved result or explicit abandonment
+releases the reservation. Session reset does not release retained fetch blocks.
+
 Configure `maxPendingOperations` (default 256) and `maxPendingOperationBytes`
 (default 64 MiB) on the client. Admission waits before allocating an ID or freezing
 a mutation frame. Both limits must be positive. The byte budget includes the
-256-byte header and body; smaller positive budgets reject mutations that cannot
+256-byte header and body. Smaller positive budgets reject mutations that cannot
 fit while reads and login remain available. Oversized frames fail locally.
 Completion, abandonment and reset release the operation slot. Writer aliases keep bytes charged until they release
 the frame. These limits cover admitted active/retained frames, not caller buffers,
@@ -195,7 +433,7 @@
 cancellation, withdraws credentials from automatic sign-in until another explicit
 sign-in succeeds. This also discards a session whose Register succeeded when a
 later step of the explicit sign-in fails. Earlier calls keep their original
-lineage; only SDK recovery continues them under a replacement session.
+lineage. Only SDK recovery continues them under a replacement session.
 
 An established session has its own lifetime. Password or PAT changes and PAT
 expiry do not end it. Logout, session lease expiry, or user deactivation end the
@@ -230,28 +468,38 @@
 reconnection or heartbeats are disabled, nothing restarts a link that used its
 budget, so the request fails with a plain `Error` instead.
 
-When a poll fails with status 30, 40, 57, 58 or 89, `singleConsumerStream` and
-`groupConsumerStream` poll again after `interval`. They also poll again after a
-poll deadline, after a poll with unknown progress, after `NotConnectedError` and
-after `ConnectionClosedError`. That error reports a lost connected socket. The
-client binds the session again, signs in again,
-reconnects or routes again after these on its own. Any other error ends the
-stream. The server can also refuse a sign-in with status 40, for example for an
-inactive user. So after a 40 the stream waits for the next sign-in, and a
-refused sign-in ends the stream. If the reply of an auto-commit poll is lost,
-the stream can skip that batch.
+**Breaking change:** `singleConsumerStream` and `groupConsumerStream` are
+adapters over a partition reader, each on a client of its own, and hand over
+batches with messages only, never an empty one. `pollOptions` sets `maxWait`,
+`minCount` and `maxBytes` of each read, and a read waits up to 5 s by default.
+`requestTimeout` is not used: a read gives up 10 s after its `maxWait`. With
+`autocommit`, which is the default, the reader commits on read, and the stream
+reads only when its consumer asks for another batch, so the server never stores
+a batch that waits in the stream buffer. Without `autocommit` the stream reads
+ahead. `endOnLastOffset` ends the stream once the last read of every partition
+found no message.
+
+When a read fails with status 30, 40, 57, 58, 89 or 90, after a deadline, after
+unknown progress, after `NotConnectedError` and after `ConnectionClosedError`,
+the stream reads again after `pollingRetryInterval`, 1000 ms by default, the
+same as `polling_retry_interval` of the Rust consumer. A lost reply of a read
+that commits is repaired by the reader. The client binds the session again,
+signs in again, reconnects or routes again after these errors on its own. Any
+other error ends the stream, and so does a message that does not decode. The
+server can also refuse a sign-in with status 40, for example for an inactive
+user. So after a 40 the stream signs in again, and a refused sign-in ends the
+stream.
 
 `singleConsumerStream` and `groupConsumerStream` sign in before they return the
-stream, and `groupConsumerStream` also joins its group. If the sign-in or the
-join fails, the call destroys its client and rejects with that error. If no
-endpoint accepts a dial before the retry budget or the 30 second sign-in
-deadline ends, the error is `NotConnectedError`. When a stream closes, it
-destroys its client. A group stream that is still signed in first leaves its
-group, so that the server gives its partitions to the other members at once. If
-the leave fails or takes more than 5 seconds, the membership ends when the
-session lease expires. If the client of a destroyed stream has no session, or
-loses it, the stream destroys the client at once. It does not wait for a poll
-or a sign-in that still runs.
+stream, and `groupConsumerStream` also creates and joins its group. If the
+sign-in or the join fails, the call destroys its client and rejects with that
+error. If no endpoint accepts a dial before the retry budget or the 30 second
+sign-in deadline ends, the error is `NotConnectedError`. When a stream closes,
+it closes its reader, which waits for a read that commits, moves the offsets of
+the batches that nobody read back and leaves the group, then destroys its
+client. A batch still in the stream buffer counts as unread. If the client of
+a destroyed stream has no session, the stream destroys the client at once, and
+it never keeps the client longer than 5 seconds for the close.
 
 `sendBinaryRequest(code, payload)` sends an arbitrary command code. Known replicated commands use their registered operation, while unknown codes reach the server as non-replicated requests and are rejected by servers that do not register them.
 
@@ -313,13 +561,13 @@
 
 | option | limit |
 | --- | --- |
-| `reconnection_retries` | integer up to `4294967295` (u32 max); larger values are rejected like Rust's u32 overflow, and `unlimited` maps to this ceiling. Defaults to unlimited |
-| `heartbeat_interval` | duration up to `2147483647ms` (Node's largest timer delay); `0` disables heartbeats |
-| `reconnection_interval` | positive duration (`ms`, `s`, `m`, `h`) up to `2147483647ms` (Node's largest timer delay); zero spellings are rejected. Defaults to `1s` |
+| `reconnection_retries` | integer up to `4294967295` (u32 max). Larger values are rejected like Rust's u32 overflow, and `unlimited` maps to this ceiling. Defaults to unlimited |
+| `heartbeat_interval` | duration up to `2147483647ms` (Node's largest timer delay). `0` disables heartbeats |
+| `reconnection_interval` | positive duration (`ms`, `s`, `m`, `h`) up to `2147483647ms` (Node's largest timer delay). Zero spellings are rejected. Defaults to `1s` |
 | port in the authority | decimal up to `65535` |
 
 Durations accept the same expressions as the Rust SDK, for example `500ms`,
-`10s`, `1h 30m`, `5d`, `2w`, `1y`; matching is case-insensitive and
+`10s`, `1h 30m`, `5d`, `2w`, `1y`. Matching is case-insensitive and
 `0`, `unlimited`, `disabled` and `none` map to zero. Unit-less numbers such as
 `5` are rejected.
 
@@ -432,7 +680,9 @@
 ## use sources
 
 Cluster polling uses command 15 (`BindSession`), route discovery 105 and poll
-100. Commands 14, 103, 104 and 123 are retired. Pause consumers for the coordinated
+100. Deferred polls use 106, fetches 109 and their
+cancellation 108.
+Commands 14, 103, 104 and 123 are retired. Pause consumers for the coordinated
 server/SDK upgrade and restart them to rejoin their groups. There is no fallback
 to retired polling commands.
 
diff --git a/foreign/node/cucumber.json b/foreign/node/cucumber.json
index 04c4b8c..af63657 100644
--- a/foreign/node/cucumber.json
+++ b/foreign/node/cucumber.json
@@ -7,7 +7,7 @@
       "src/bdd/**/*.ts"
     ],
     "paths": [
-      "../../bdd/**/*.feature",
+      "../../bdd/scenarios/**/*.feature",
       "./bdd/**/*.feature"
     ]
   }
diff --git a/foreign/node/package-lock.json b/foreign/node/package-lock.json
index 6e2b6c0..f7e4b88 100644
--- a/foreign/node/package-lock.json
+++ b/foreign/node/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "apache-iggy",
-  "version": "0.10.1-edge.5",
+  "version": "0.10.1-edge.6",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "apache-iggy",
-      "version": "0.10.1-edge.5",
+      "version": "0.10.1-edge.6",
       "license": "Apache-2.0",
       "dependencies": {
         "@node-rs/xxhash": "1.7.8",
diff --git a/foreign/node/package.json b/foreign/node/package.json
index 7db3e46..1e95ad0 100644
--- a/foreign/node/package.json
+++ b/foreign/node/package.json
@@ -1,7 +1,7 @@
 {
   "name": "apache-iggy",
   "type": "module",
-  "version": "0.10.1-edge.5",
+  "version": "0.10.1-edge.6",
   "description": "Official Apache Iggy NodeJS SDK",
   "keywords": [
     "iggy",
diff --git a/foreign/node/src/bdd/README.md b/foreign/node/src/bdd/README.md
index 1e1e24e..a491038 100644
--- a/foreign/node/src/bdd/README.md
+++ b/foreign/node/src/bdd/README.md
@@ -33,6 +33,8 @@
 
 The metadata changes feature needs the same key and `IGGY_METADATA_CHANGE_LOG_RESOURCE_CAPACITY=64`, so its expiry scenario can push a held cursor out of the change log.
 
+The deferred polling feature needs none of these server variables. To run only that feature, add `-- --tags @deferred-polling` to the command below.
+
 from [/foreign/node](../../) run
 
 ```bash
diff --git a/foreign/node/src/bdd/deferred-polling.ts b/foreign/node/src/bdd/deferred-polling.ts
new file mode 100644
index 0000000..a3f09d6
--- /dev/null
+++ b/foreign/node/src/bdd/deferred-polling.ts
@@ -0,0 +1,329 @@
+// 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.
+
+import assert from 'node:assert/strict';
+import type { Socket } from 'node:net';
+import { setTimeout as sleep } from 'node:timers/promises';
+import { After, Given, When, Then } from '@cucumber/cucumber';
+import {
+  FETCH_PROGRESS_UNCERTAIN, FetchProgressUncertainError, LaneBlockedError, OPERATION_TIMEOUT_MS,
+  PARTITION_LANE_BLOCKED, VsrResponseTimeoutError, waitWithin
+} from '../client/client.operation.js';
+import { ConsumerKind } from '../wire/offset/offset.utils.js';
+import { DEFERRED_POLL_OPTIONS_SIZE } from '../wire/message/deferred-poll.utils.js';
+import { serializePollMessages } from '../wire/message/poll.utils.js';
+import { HEADER_SIZE } from '../wire/vsr/header.js';
+import {
+  CommitAfter, CommitPolicy, Consumer, Partitioning, PollingStrategy,
+  type PollMessagesResponse, type PollOptions, type PollPartition
+} from '../wire/index.js';
+import { getRootCredentials } from './env.js';
+import type { BackgroundCall, Outcome, TestWorld } from './world.js';
+
+const MS_PER_SECOND = 1_000;
+const POLL_COUNT = 10;
+/** A cancelled poll that commits ends once the server answers its cancel. */
+const CANCELLED_END_MS = 2_000;
+const ABORT_ERROR = 'AbortError';
+const COMMIT_INTERVAL_MS = 5_000;
+/** How long a started poll may take to reach its link, and how often the step looks. */
+const SENT_WITHIN_MS = 5_000;
+const SENT_CHECK_MS = 5;
+/** A poll whose link drops ends at once. */
+const UNCERTAIN_WITHIN_MS = 5_000;
+
+const waitOptions = (maxWaitSeconds: number): Partial<PollOptions> =>
+  ({ count: POLL_COUNT, maxWait: maxWaitSeconds * MS_PER_SECOND });
+
+const namedConsumer = (name: string): Consumer => ({ kind: ConsumerKind.Single, id: name });
+
+const poll = (world: TestWorld, request: Omit<PollPartition, 'streamId' | 'topicId'>) =>
+  world.client.message.poll({ streamId: world.stream.id, topicId: world.topic.id, ...request });
+
+const startInBackground = <T>(call: (signal: AbortSignal) => Promise<T>): BackgroundCall<T> => {
+  const cancel = new AbortController();
+  const started = Date.now();
+  const outcome = call(cancel.signal).then(
+    (value): Outcome<T> => ({ ended: Date.now(), ok: true, value }),
+    (error: unknown): Outcome<T> => ({ ended: Date.now(), ok: false, error })
+  );
+  return { started, cancel, outcome };
+};
+
+const requireStarted = <T>(call: BackgroundCall<T> | undefined, what: string): BackgroundCall<T> => {
+  assert.ok(call, `no ${what} was started`);
+  return call;
+};
+
+/** The outcome of `call` by `deadline`. A call still running then is cancelled and fails the step. */
+const endBy = async <T>(call: BackgroundCall<T>, deadline: number, what: string): Promise<Outcome<T>> => {
+  try {
+    return await waitWithin(call.outcome, deadline);
+  } catch (error) {
+    if (!(error instanceof VsrResponseTimeoutError)) throw error;
+    call.cancel.abort();
+    assert.fail(`${what} did not end in time`);
+  }
+};
+
+const within = async <T>(promise: Promise<T>, seconds: number, what: string): Promise<T> => {
+  try {
+    return await waitWithin(promise, Date.now() + seconds * MS_PER_SECOND);
+  } catch (error) {
+    if (error instanceof VsrResponseTimeoutError) assert.fail(`${what} did not return in time`);
+    throw error;
+  }
+};
+
+const valueOrThrow = <T>(outcome: Outcome<T>): T => {
+  if (!outcome.ok) throw outcome.error;
+  return outcome.value;
+};
+
+const assertOnlyPayload = (response: PollMessagesResponse, payload: string) =>
+  assert.deepEqual(response.messages.map((message) => message.payload.toString()), [payload]);
+
+const closeConsumer = async (world: TestWorld): Promise<void> => {
+  const consumer = world.consumer;
+  world.consumer = undefined;
+  world.consumerMessages = undefined;
+  await consumer?.shutdown();
+};
+
+const openGroupConsumer = async (world: TestWorld, name: string, options: Partial<PollOptions>): Promise<void> => {
+  world.consumer = await world.client.consumerGroup({
+    name, streamId: world.stream.id, topicId: world.topic.id, start: PollingStrategy.Next, options
+  });
+  world.consumerMessages = world.consumer[Symbol.asyncIterator]();
+};
+
+After(async function (this: TestWorld) {
+  await closeConsumer(this);
+  await this.reader?.close();
+  this.reader = undefined;
+});
+
+Given(
+  'a stream named {string} with a topic named {string} with {int} partitions exists',
+  async function (this: TestWorld, streamName: string, topicName: string, partitionCount: number) {
+    const existing = await this.client.stream.get({ streamId: streamName });
+    if (existing) assert.ok(await this.client.stream.delete({ streamId: existing.id }));
+    this.stream = await this.client.stream.create({ name: streamName });
+    this.topic = await this.client.topic.create({ streamId: this.stream.id, name: topicName, partitionCount });
+  }
+);
+
+When(
+  'I start a deferred poll of partition {int} from offset {int} with a max wait of {int} second(s)',
+  function (this: TestWorld, partitionId: number, offset: number, maxWait: number) {
+    this.deferredPoll = startInBackground((signal) => poll(this, {
+      consumer: Consumer.Single, partitionId, strategy: PollingStrategy.Offset(BigInt(offset)),
+      options: waitOptions(maxWait), signal
+    }));
+  }
+);
+
+When(
+  'I send a message with payload {string} to partition {int}',
+  async function (this: TestWorld, payload: string, partitionId: number) {
+    assert.ok(await this.client.message.send({
+      streamId: this.stream.id, topicId: this.topic.id,
+      partition: Partitioning.PartitionId(partitionId), messages: [{ payload }]
+    }));
+    this.lastSentPartition = partitionId;
+  }
+);
+
+Then(
+  'the deferred poll should return the payload {string} within {int} second(s)',
+  async function (this: TestWorld, payload: string, seconds: number) {
+    const call = requireStarted(this.deferredPoll, 'deferred poll');
+    const outcome = await endBy(call, Date.now() + seconds * MS_PER_SECOND, 'the deferred poll');
+    assertOnlyPayload(valueOrThrow(outcome), payload);
+  }
+);
+
+Then(
+  'the deferred poll should return no messages between {int} and {int} seconds after it started',
+  async function (this: TestWorld, from: number, to: number) {
+    const call = requireStarted(this.deferredPoll, 'deferred poll');
+    const outcome = await endBy(call, call.started + to * MS_PER_SECOND, 'the deferred poll');
+    assert.equal(valueOrThrow(outcome).messages.length, 0);
+    const elapsed = outcome.ended - call.started;
+    assert.ok(elapsed >= from * MS_PER_SECOND, `the poll ended after ${elapsed} ms`);
+  }
+);
+
+Then(
+  'a poll of partition {int} for consumer {string} with the next strategy and the on read commit policy should return the payload {string}',
+  async function (this: TestWorld, partitionId: number, consumer: string, payload: string) {
+    const polled = await poll(this, {
+      consumer: namedConsumer(consumer), partitionId, strategy: PollingStrategy.Next,
+      options: { count: 1, commit: CommitPolicy.OnRead }
+    });
+    assertOnlyPayload(polled, payload);
+  }
+);
+
+When(
+  'I start a deferred poll of partition {int} for consumer {string} with the on read commit policy, a min count of {int} and a max wait of {int} second(s)',
+  function (this: TestWorld, partitionId: number, consumer: string, minCount: number, maxWait: number) {
+    this.deferredPoll = startInBackground((signal) => poll(this, {
+      consumer: namedConsumer(consumer), partitionId, strategy: PollingStrategy.Next,
+      options: { ...waitOptions(maxWait), minCount, commit: CommitPolicy.OnRead }, signal
+    }));
+  }
+);
+
+// The server answers a cancelled poll that commits once, refused when the cancel won, so the poll ends as aborted.
+When('I cancel the deferred poll after {int} milliseconds', async function (this: TestWorld, delay: number) {
+  const call = requireStarted(this.deferredPoll, 'deferred poll');
+  await sleep(delay);
+  call.cancel.abort();
+  const outcome = await endBy(call, Date.now() + CANCELLED_END_MS, 'the cancelled poll');
+  assert.ok(!outcome.ok, 'the cancelled poll returned a result');
+  assert.ok(outcome.error instanceof Error && outcome.error.name === ABORT_ERROR,
+    `the cancelled poll failed with ${String(outcome.error)}`);
+});
+
+When('I wait {int} second(s)', (seconds: number) => sleep(seconds * MS_PER_SECOND));
+
+Then(
+  'a poll of partition {int} for consumer {string} with the next strategy should return the payload {string}',
+  async function (this: TestWorld, partitionId: number, consumer: string, payload: string) {
+    const { messages } = await poll(this, {
+      consumer: namedConsumer(consumer), partitionId, strategy: PollingStrategy.Next
+    });
+    assert.ok(messages.length > 0, 'the poll returned no message');
+    assert.equal(messages[0].payload.toString(), payload);
+  }
+);
+
+When(
+  'I open a partition reader on partitions {int}, {int} and {int} from offset {int} with a max wait of {int} second(s)',
+  async function (this: TestWorld, first: number, second: number, third: number, offset: number, maxWait: number) {
+    this.reader = await this.client.message.reader({
+      streamId: this.stream.id, topicId: this.topic.id, consumer: Consumer.Single,
+      partitions: [first, second, third], start: PollingStrategy.Offset(BigInt(offset)), options: waitOptions(maxWait)
+    });
+  }
+);
+
+Then(
+  'the partition reader should return the payload {string} from partition {int} within {int} second(s)',
+  async function (this: TestWorld, payload: string, partitionId: number, seconds: number) {
+    assert.ok(this.reader, 'no partition reader was opened');
+    const batch = await within(this.reader.next(), seconds, 'the partition reader');
+    assert.equal(batch.partitionId, partitionId);
+    assertOnlyPayload(batch, payload);
+  }
+);
+
+When(
+  'I open a consumer in group {string} on the topic with a max wait of {int} second(s)',
+  function (this: TestWorld, name: string, maxWait: number) {
+    return openGroupConsumer(this, name, waitOptions(maxWait));
+  }
+);
+
+When(
+  'I open a consumer in group {string} on the topic that commits after processing',
+  function (this: TestWorld, name: string) {
+    return openGroupConsumer(this, name, {
+      maxWait: 5 * MS_PER_SECOND,
+      commit: CommitPolicy.Auto({ interval: COMMIT_INTERVAL_MS, after: CommitAfter.Processing })
+    });
+  }
+);
+
+Then(
+  'the consumer should return the payload {string} within {int} second(s)',
+  async function (this: TestWorld, payload: string, seconds: number) {
+    assert.ok(this.consumerMessages, 'no consumer was opened');
+    const next = await within(this.consumerMessages.next(), seconds, 'the consumer');
+    assert.ok(!next.done, 'the consumer ended');
+    assert.equal(next.value.message.payload.toString(), payload);
+  }
+);
+
+When('I close the consumer', function (this: TestWorld) {
+  return closeConsumer(this);
+});
+
+When(
+  'I start a deferred poll of partition {int} for consumer {string} with the on read commit policy and a max wait of {int} second(s)',
+  async function (this: TestWorld, partitionId: number, consumer: string, maxWait: number) {
+    // A poll that stores nothing caches the route, so the deferred poll is the next frame on its link.
+    await poll(this, {
+      consumer: namedConsumer(consumer), partitionId, strategy: PollingStrategy.Offset(0n), options: { count: 1 }
+    });
+    const socket = (await this.client.clientProvider()).getReadStream() as Socket;
+    this.deferredPollLink = {
+      socket, written: socket.bytesWritten,
+      frameBytes: HEADER_SIZE + DEFERRED_POLL_OPTIONS_SIZE + serializePollMessages(this.stream.id, this.topic.id,
+        namedConsumer(consumer), partitionId, PollingStrategy.Next, POLL_COUNT, true).length
+    };
+    this.deferredPoll = startInBackground((signal) => poll(this, {
+      consumer: namedConsumer(consumer), partitionId, strategy: PollingStrategy.Next,
+      options: { ...waitOptions(maxWait), commit: CommitPolicy.OnRead }, signal
+    }));
+  }
+);
+
+// The client has no disconnect that keeps it usable, so the step drops the link once the poll is on it.
+When('the client disconnects before the deferred poll answers', async function (this: TestWorld) {
+  const link = this.deferredPollLink;
+  assert.ok(link, 'no deferred poll was started');
+  const deadline = Date.now() + SENT_WITHIN_MS;
+  while (link.socket.bytesWritten < link.written + link.frameBytes) {
+    assert.ok(Date.now() < deadline, 'the deferred poll did not reach its link');
+    await sleep(SENT_CHECK_MS);
+  }
+  link.socket.destroy();
+});
+
+Then('the deferred poll should fail as uncertain', async function (this: TestWorld) {
+  const call = requireStarted(this.deferredPoll, 'deferred poll');
+  const outcome = await endBy(call, Date.now() + UNCERTAIN_WITHIN_MS, 'the deferred poll');
+  assert.ok(!outcome.ok, 'the deferred poll returned a result');
+  assert.ok(outcome.error instanceof FetchProgressUncertainError,
+    `the deferred poll failed with ${String(outcome.error)}`);
+  assert.equal(outcome.error.errorCode, FETCH_PROGRESS_UNCERTAIN);
+  this.uncertainOperation = outcome.error.operationId;
+});
+
+// The client dials its link again by itself, which can take its reconnect interval.
+When('the client connects again', { timeout: 2 * OPERATION_TIMEOUT_MS }, async function (this: TestWorld) {
+  assert.deepEqual(await this.client.session.login(getRootCredentials()), { userId: 0 });
+});
+
+Then(
+  'a poll of partition {int} for consumer {string} with the next strategy and the on read commit policy should fail as blocked by the deferred poll',
+  async function (this: TestWorld, partitionId: number, consumer: string) {
+    assert.ok(this.uncertainOperation !== undefined, 'no deferred poll ended uncertain');
+    await assert.rejects(poll(this, {
+      consumer: namedConsumer(consumer), partitionId, strategy: PollingStrategy.Next,
+      options: { count: 1, commit: CommitPolicy.OnRead }
+    }), (error: unknown) => error instanceof LaneBlockedError && error.errorCode === PARTITION_LANE_BLOCKED &&
+      error.operationId === this.uncertainOperation);
+  }
+);
+
+When('I abandon the operation of the deferred poll', async function (this: TestWorld) {
+  assert.ok(this.uncertainOperation !== undefined, 'no deferred poll ended uncertain');
+  await this.client.abandonOperation(this.uncertainOperation);
+});
diff --git a/foreign/node/src/bdd/world.ts b/foreign/node/src/bdd/world.ts
index 54c4b61..0eb9c84 100644
--- a/foreign/node/src/bdd/world.ts
+++ b/foreign/node/src/bdd/world.ts
@@ -15,14 +15,23 @@
 // specific language governing permissions and limitations
 // under the License.
 
+import type { Socket } from 'node:net';
+import type { ConsumerMessage, IggyConsumer } from '../stream/iggy-consumer.js';
+import type { PartitionReader } from '../stream/partition-reader.js';
 import type { Stream } from '../wire/stream/stream.utils.js';
 import type { Topic } from '../wire/topic/topic.utils.js';
 import type {
   CreateMessage, Message, MetadataChangeCursor, MetadataChangeGroup, MetadataChangeRecord,
-  MetadataCurrentItem, OptionSpec
+  MetadataCurrentItem, OptionSpec, PollMessagesResponse
 } from 'src/wire/index.js';
 import type { Client } from '../client/index.js';
 
+/** How a call that a step started in the background ended, and when. */
+export type Outcome<T> = { ended: number } & ({ ok: true, value: T } | { ok: false, error: unknown });
+
+/** A call that a step started in the background, with the controller that cancels it. */
+export type BackgroundCall<T> = { started: number, cancel: AbortController, outcome: Promise<Outcome<T>> };
+
 export interface TestWorld {
   client: Client
   stream: Stream,
@@ -44,5 +53,19 @@
   /** Records of the last stream that the follow step returned */
   followedChanges?: MetadataChangeRecord[],
   /** Items of the last current state scan */
-  currentItems?: MetadataCurrentItem[]
+  currentItems?: MetadataCurrentItem[],
+  /** Consumer group that this client joined on the topic */
+  groupName?: string,
+  lastSentPartition?: number,
+  /** Deferred poll that a step started in the background */
+  deferredPoll?: BackgroundCall<PollMessagesResponse>,
+  /** The link that the deferred poll goes out on, its bytes written before the poll and the size of the poll frame */
+  deferredPollLink?: { socket: Socket, written: number, frameBytes: number },
+  /** Operation of a deferred poll whose outcome stayed unknown */
+  uncertainOperation?: bigint,
+  /** Partition reader that a step opened */
+  reader?: PartitionReader,
+  /** Consumer that a step opened, with its message iterator */
+  consumer?: IggyConsumer,
+  consumerMessages?: AsyncIterator<ConsumerMessage>
 };
diff --git a/foreign/node/src/client/client.connection.ts b/foreign/node/src/client/client.connection.ts
index de51b27..6e2ed25 100644
--- a/foreign/node/src/client/client.connection.ts
+++ b/foreign/node/src/client/client.connection.ts
@@ -28,7 +28,8 @@
   ResponseFrameDecoder
 } from './client.frame.js';
 import {
-  Command, EvictionReason, peekCommand, readEviction, readStatus, REQUEST_OFFSET, REPLY_OFFSET
+  Command, EvictionReason, deferredCancellation, peekCommand, readEviction, readStatus, REQUEST_OFFSET,
+  REPLY_OFFSET
 } from '../wire/vsr/header.js';
 import {
   ChangeSignal, PermitPool, type FrozenFrame, OPERATION_TIMEOUT_MS, VsrResponseTimeoutError, waitWithin
@@ -97,7 +98,7 @@
 export const MAX_PENDING_PER_LINK = 256;
 const STALE_CLIENT = 30;
 
-/** A connected socket was lost; a later bootstrap can bind it again. */
+/** A connected socket was lost. A later bootstrap can bind it again. */
 export class ConnectionClosedError extends Error {
   constructor(cause?: Error) {
     super(cause?.message ?? 'replica connection closed', { cause });
@@ -183,7 +184,8 @@
     }
   }
 
-  async exchange(frame: () => Buffer, deadline: number, signal: AbortSignal, owner?: FrozenFrame): Promise<LinkAttempt> {
+  async exchange(frame: () => Buffer, deadline: number, signal: AbortSignal, owner?: FrozenFrame,
+    afterSend?: AbortSignal): Promise<LinkAttempt> {
     let release: (() => void) | undefined;
     let pending: Pending | undefined;
     try {
@@ -210,10 +212,33 @@
         this.writes.push(pending);
         void this.writeFrames();
       }
-      return await waitWithin(response, deadline, signal);
+      try {
+        return await waitWithin(response, deadline, signal);
+      } catch (error) {
+        if (afterSend && pending.started && signal.aborted) {
+          // The server answers a cancelled read that commits once: refused when the cancel won, or with what it
+          // stored, so the request keeps its reply slot.
+          const cancellation = pending.done ? undefined : deferredCancellation(pending.frame);
+          if (cancellation && !this.failed) {
+            this.writes.push({ id: pending.id, frame: cancellation, resolve: () => undefined, done: false, started: false });
+            void this.writeFrames();
+          }
+          return await waitWithin(response, deadline, afterSend);
+        }
+        throw error;
+      }
     } catch (error) {
-      if (pending)
+      if (pending) {
+        // A deferred request that reached the socket stays parked on the
+        // server until its wait ends, so the server is told to drop it. A
+        // late reply then finds no pending entry.
+        const cancellation = pending.started && !pending.done ? deferredCancellation(pending.frame) : undefined;
         this.finish(pending, { error, started: pending.started });
+        if (cancellation && !this.failed) {
+          this.writes.push({ id: pending.id, frame: cancellation, resolve: () => undefined, done: false, started: false });
+          void this.writeFrames();
+        }
+      }
       return { error, started: pending?.started ?? false };
     } finally {
       release?.();
diff --git a/foreign/node/src/client/client.operation.test.ts b/foreign/node/src/client/client.operation.test.ts
index e6a0756..b1308a9 100644
--- a/foreign/node/src/client/client.operation.test.ts
+++ b/foreign/node/src/client/client.operation.test.ts
@@ -20,7 +20,9 @@
 import { COMMAND_CODE } from '../wire/command.code.js';
 import { VsrSession } from '../wire/vsr/index.js';
 import { HEADER_SIZE, REQUEST_OFFSET, EMPTY_PARTITION_CONTEXT } from '../wire/vsr/header.js';
-import { MutationLanes, LaneBlockedError, OperationNotRetainedError } from './client.operation.js';
+import {
+  MutationLanes, LaneBlockedError, OperationNotRetainedError, type ClientOperation
+} from './client.operation.js';
 import { ResponseError } from '../wire/error.utils.js';
 
 const body = Buffer.from('frozen body');
@@ -52,7 +54,8 @@
     const first = await enter(lanes, state, 'first');
     first.uncertain = true;
     lanes.retain(first);
-    await assert.rejects(enter(lanes, state, 'first'), (error: unknown) => error instanceof LaneBlockedError && error.operationId === first.id);
+    await assert.rejects(enter(lanes, state, 'first'), (error: unknown) => error instanceof LaneBlockedError &&
+      error.operationId === first.id && !error.requiresExplicitRecovery && error.errorCode === 14007);
     const other = await enter(lanes, state, 'other');
     let admitted = false;
     const next = enter(lanes, state, 'other').then((operation) => { admitted = true; return operation; });
@@ -129,6 +132,133 @@
     });
   }
 
+  describe('progress lane', () => {
+    const settle = () => new Promise<void>((resolve) => setImmediate(resolve));
+    const enterPoll = (lanes: MutationLanes, state: VsrSession) => lanes.enter('progress', COMMAND_CODE.PollMessages,
+      body, EMPTY_PARTITION_CONTEXT, false, state, Date.now() + 1_000, new AbortController().signal);
+    const enterStore = (lanes: MutationLanes, state: VsrSession, signal = new AbortController().signal) =>
+      lanes.enter('partition', COMMAND_CODE.StoreOffset, body, EMPTY_PARTITION_CONTEXT, true, state,
+        Date.now() + 1_000, signal, ['progress']);
+    const isBlockedBy = (operation: ClientOperation) => (error: unknown) =>
+      error instanceof LaneBlockedError && error.operationId === operation.id;
+
+    it('keeps the progress lane of a store that waits for a send when a store queued behind it gives up', async () => {
+      const lanes = new MutationLanes(3, 3 * frameSize);
+      const state = session();
+      const admitted: string[] = [];
+      const track = (name: string, pending: Promise<ClientOperation>) =>
+        pending.then((operation) => { admitted.push(name); return operation; });
+      const send = await enter(lanes, state, 'partition');
+      const store = track('store', enterStore(lanes, state));
+      const cancel = new AbortController();
+      const queuedStore = enterStore(lanes, state, cancel.signal);
+      await settle();
+      cancel.abort();
+      await assert.rejects(queuedStore, (error: unknown) => error instanceof Error && error.name === 'AbortError');
+      const poll = track('poll', enterPoll(lanes, state));
+      await settle();
+      assert.deepEqual(admitted, [], 'the poll waits for the store that holds the progress lane');
+      lanes.complete(send);
+      const storeOperation = await store;
+      await settle();
+      assert.deepEqual(admitted, ['store'], 'the store holds the progress lane until it completes');
+      lanes.complete(storeOperation);
+      lanes.complete(await poll);
+      assert.deepEqual(admitted, ['store', 'poll']);
+    });
+
+    it('lets a send pass an offset store that waits for a poll, and then holds both lanes for the store', async () => {
+      const lanes = new MutationLanes(3, 3 * frameSize);
+      const state = session();
+      const admitted: string[] = [];
+      const track = (name: string, pending: Promise<ClientOperation>) =>
+        pending.then((operation) => { admitted.push(name); return operation; });
+      const poll = await enterPoll(lanes, state);
+      const store = track('store', enterStore(lanes, state));
+      const send = await enter(lanes, state, 'partition');
+      const nextPoll = track('poll', enterPoll(lanes, state));
+      lanes.complete(poll);
+      await settle();
+      assert.deepEqual(admitted, [], 'the store holds the progress lane while it waits for the send');
+      lanes.complete(send);
+      const storeOperation = await store;
+      assert.ok(storeOperation.id > send.id, 'the store takes its request id after the send that it waited for');
+      const nextSend = track('send', enter(lanes, state, 'partition'));
+      await settle();
+      assert.deepEqual(admitted, ['store']);
+      lanes.complete(storeOperation);
+      lanes.complete(await nextSend);
+      lanes.complete(await nextPoll);
+    });
+
+    it('is blocked by a retained offset store but not by a retained send', async () => {
+      const lanes = new MutationLanes(2, 2 * frameSize);
+      const state = session();
+      const send = await enter(lanes, state, 'partition');
+      send.uncertain = true;
+      lanes.retain(send);
+      lanes.complete(await enterPoll(lanes, state));
+      await assert.rejects(enterStore(lanes, state), isBlockedBy(send));
+      lanes.complete(await enterPoll(lanes, state));
+      lanes.abandon(send.id);
+
+      const store = await enterStore(lanes, state);
+      const waitingPoll = enterPoll(lanes, state);
+      store.uncertain = true;
+      lanes.retain(store);
+      await assert.rejects(waitingPoll, isBlockedBy(store));
+      await assert.rejects(enter(lanes, state, 'partition'), isBlockedBy(store));
+      lanes.abandon(store.id);
+      lanes.complete(await enterPoll(lanes, state));
+    });
+
+    const enterOn = (lanes: MutationLanes, state: VsrSession, command: number, progressLanes: string[],
+      signal = new AbortController().signal) =>
+      lanes.enter(progressLanes[progressLanes.length - 1], command, body, EMPTY_PARTITION_CONTEXT, false, state,
+        Date.now() + 1_000, signal, progressLanes.slice(0, -1));
+
+    it('holds every progress lane of a fetch, and a fetch that waits holds none of its later lanes', async () => {
+      const lanes = new MutationLanes(3, 3 * frameSize);
+      const state = session();
+      const admitted: string[] = [];
+      const track = (name: string, pending: Promise<ClientOperation>) =>
+        pending.then((operation) => { admitted.push(name); return operation; });
+      const fetch = await enterOn(lanes, state, COMMAND_CODE.FetchMessages, ['progress:1', 'progress:2']);
+      assert.deepEqual([fetch.lane, fetch.heldLanes], ['progress:2', ['progress:1']]);
+      const store = track('store', lanes.enter('partition:2', COMMAND_CODE.StoreOffset, body, EMPTY_PARTITION_CONTEXT,
+        true, state, Date.now() + 1_000, new AbortController().signal, ['progress:2']));
+      const nextFetch = track('fetch', enterOn(lanes, state, COMMAND_CODE.FetchMessages, ['progress:2', 'progress:3']));
+      const poll = track('poll', enterOn(lanes, state, COMMAND_CODE.PollMessages, ['progress:3']));
+      await settle();
+      assert.deepEqual(admitted, ['poll'], 'the waiting fetch leaves its later lane free');
+      lanes.complete(await poll);
+      lanes.complete(fetch);
+      const storeOperation = await store;
+      await settle();
+      assert.deepEqual(admitted, ['poll', 'store'], 'the fetch that waits first follows the store');
+      lanes.complete(storeOperation);
+      lanes.complete(await nextFetch);
+      assert.deepEqual(admitted, ['poll', 'store', 'fetch']);
+    });
+
+    it('frees the lanes that a fetch took when it gives up waiting for the next one', async () => {
+      const lanes = new MutationLanes(3, 3 * frameSize);
+      const state = session();
+      const blocker = await enterOn(lanes, state, COMMAND_CODE.FetchMessages, ['progress:2']);
+      const cancel = new AbortController();
+      const fetch = enterOn(lanes, state, COMMAND_CODE.FetchMessages, ['progress:1', 'progress:2'], cancel.signal);
+      let pollAdmitted = false;
+      const poll = enterOn(lanes, state, COMMAND_CODE.PollMessages, ['progress:1'])
+        .then((operation) => { pollAdmitted = true; return operation; });
+      await settle();
+      assert.equal(pollAdmitted, false, 'the fetch holds the lane that it took while it waits');
+      cancel.abort();
+      await assert.rejects(fetch, (error: unknown) => error instanceof Error && error.name === 'AbortError');
+      lanes.complete(await poll);
+      lanes.complete(blocker);
+    });
+  });
+
   it('admits an independent lane when only a later same-lane waiter fits the free bytes', async () => {
     const sizes = { retained: 900, active: 300, large: 700, small: 500, independent: 500 };
     const lanes = new MutationLanes(2, 1_500);
diff --git a/foreign/node/src/client/client.operation.ts b/foreign/node/src/client/client.operation.ts
index 4b7a4cf..8383ec3 100644
--- a/foreign/node/src/client/client.operation.ts
+++ b/foreign/node/src/client/client.operation.ts
@@ -18,10 +18,14 @@
 import { HEADER_SIZE, REQUEST_OFFSET, type PartitionContext } from '../wire/vsr/header.js';
 import type { VsrSession } from '../wire/vsr/index.js';
 import { responseError } from '../wire/error.utils.js';
+import { COMMAND_CODE } from '../wire/command.code.js';
 
 const INVALID_MESSAGE_LENGTH = 80;
 const TRANSIENT_NOT_ACCEPTED = 58;
 export const OPERATION_TIMEOUT_MS = 30_000;
+export const LANE_BLOCKED = 14007;
+export const PARTITION_LANE_BLOCKED = 14009;
+export const FETCH_PROGRESS_UNCERTAIN = 14010;
 
 export class OperationUncertainError extends Error {
   constructor(readonly operationId: bigint | undefined, cause: unknown) {
@@ -30,10 +34,25 @@
   }
 }
 
+/** A local recovery key, never a replayable server receipt. */
+export class FetchProgressUncertainError extends OperationUncertainError {
+  readonly errorCode = FETCH_PROGRESS_UNCERTAIN;
+  constructor(operationId: bigint, cause?: unknown) {
+    super(operationId, cause);
+    this.name = 'FetchProgressUncertainError';
+    this.message = `Read ${operationId} has unknown progress and requires explicit abandonment.`;
+  }
+}
+
 export class LaneBlockedError extends Error {
-  constructor(readonly operationId: bigint) {
-    super('A retained operation blocks this mutation lane.');
+  /** `PARTITION_LANE_BLOCKED` for a partition that an uncertain committing fetch blocks, else `LANE_BLOCKED`. */
+  readonly errorCode: number;
+  constructor(readonly operationId: bigint, readonly partitionId?: number, readonly requiresExplicitRecovery = false) {
+    super(requiresExplicitRecovery
+      ? `Lane of partition ${partitionId} blocked by operation ${operationId} with an unknown outcome.`
+      : `Lane blocked by operation ${operationId} with an unknown outcome.`);
     this.name = 'LaneBlockedError';
+    this.errorCode = requiresExplicitRecovery ? PARTITION_LANE_BLOCKED : LANE_BLOCKED;
   }
 }
 
@@ -189,6 +208,8 @@
 
 export type ClientOperation = {
   readonly lane: string,
+  /** The progress lanes that an offset store or delete, or a progress fetch, holds as well. */
+  readonly heldLanes: readonly string[],
   readonly command: number,
   context: PartitionContext,
   frame: FrozenFrame,
@@ -199,7 +220,16 @@
   retained: boolean,
   uncertain: boolean,
   /** An abandon frees the slot of a running replay before the replay ends. */
-  holdsSlot: boolean
+  holdsSlot: boolean,
+  readonly fetchProgress?: FetchProgress
+};
+
+/** Only identities and budget remain after an uncertain fetch. No transport or frame survives with them. */
+type FetchProgress = {
+  id: bigint,
+  readonly targets: ReadonlyMap<string, number>,
+  readonly size: number,
+  retained: boolean
 };
 
 type AdmissionWaiter = {
@@ -208,9 +238,16 @@
 };
 type LaneWaiters = { head?: AdmissionWaiter, tail?: AdmissionWaiter };
 
+const lanesOf = (operation: ClientOperation): string[] => [operation.lane, ...operation.heldLanes];
+
 export class MutationLanes {
   private readonly lanes = new Map<string, ClientOperation>();
+  /** The progress lanes that calls hold while they wait for their next lane. */
+  private readonly held = new Set<string>();
   private readonly operations = new Map<bigint, ClientOperation>();
+  private readonly fetchProgress = new Map<bigint, FetchProgress>();
+  private readonly blockedProgress = new Map<string, FetchProgress>();
+  private fetchBytes = 0;
   /**
    * The outcomes that replays proved, oldest first: the committed rejection,
    * or `undefined` for a committed success. A reset keeps them, because
@@ -225,31 +262,54 @@
 
   constructor(private readonly maxOperations: number, private readonly maxBytes: number) {}
 
+  /**
+   * Admits a call to `lane` in arrival order. It takes `heldLanes` first, in
+   * their order, and keeps each one while it waits for the next. An offset
+   * store or delete holds its progress lane, so a send can pass it while it
+   * waits behind a parked poll. A progress fetch holds the progress lane of
+   * each target, in ascending partition order, so two fetches never wait for
+   * each other.
+   */
   async enter(lane: string, command: number, body: Buffer, context: PartitionContext,
     retainable: boolean, session: VsrSession, deadline: number,
-    signal: AbortSignal): Promise<ClientOperation> {
+    signal: AbortSignal, heldLanes: readonly string[] = [], partitions?: ReadonlyMap<string, number>,
+    commits = false): Promise<ClientOperation> {
     const size = retainable ? HEADER_SIZE + body.length : 0;
-    if (!Number.isSafeInteger(size) || size > this.maxBytes)
+    const fetchSize = commits ? HEADER_SIZE + body.length : 0;
+    if (!Number.isSafeInteger(size) || size > this.maxBytes || fetchSize > this.maxBytes)
       throw responseError(command, INVALID_MESSAGE_LENGTH, 'request exceeds the mutation byte limit');
-    const waiting: AdmissionWaiter = { size, retainable, changed: new ChangeSignal() };
-    let queue = this.waiting.get(lane);
-    if (!queue) {
-      queue = {};
-      this.waiting.set(lane, queue);
-    }
-    waiting.previous = queue.tail;
-    if (queue.tail) queue.tail.next = waiting;
-    else queue.head = waiting;
-    queue.tail = waiting;
+    const admission: AdmissionWaiter = { size, retainable, changed: new ChangeSignal() };
+    // A held lane needs no capacity, so its waiter asks for none and wakes when the lane frees.
+    const waiterFor = (step: number): AdmissionWaiter =>
+      step < heldLanes.length ? { size: 0, retainable: false, changed: new ChangeSignal() } : admission;
+    let step = 0;
+    let waiting = waiterFor(step);
+    let current = heldLanes[step] ?? lane;
+    let queue = this.join(current, waiting);
+    // Only the call that took a held lane frees it. A call that waits for a
+    // lane holds nothing, so its end must not free the lane.
+    const taken: string[] = [];
     try {
       for (;;) {
         signal.throwIfAborted();
         if (Date.now() >= deadline)
           throw responseError(command, TRANSIENT_NOT_ACCEPTED);
-        this.throwIfRetained(lane);
-        const head = !this.lanes.has(lane) && queue.head === waiting;
+        this.throwIfRetained(current, partitions?.get(current), command === COMMAND_CODE.GetOffset);
+        const head = !this.busy(current) && queue.head === waiting;
+        if (head && step < heldLanes.length) {
+          this.held.add(current);
+          taken.push(current);
+          this.leave(current, queue, waiting);
+          step += 1;
+          current = heldLanes[step] ?? lane;
+          waiting = waiterFor(step);
+          queue = this.join(current, waiting);
+          continue;
+        }
         if (head &&
             (!retainable || this.count < this.maxOperations) && size <= this.maxBytes - this.bytes) {
+          if (commits && (this.fetchProgress.size >= this.maxOperations || fetchSize > this.maxBytes - this.fetchBytes))
+            throw responseError(command, TRANSIENT_NOT_ACCEPTED, 'committing fetch recovery capacity is full');
           if (retainable) this.count += 1;
           this.bytes += size;
           let bytes: Buffer;
@@ -261,13 +321,24 @@
             throw error;
           }
           const operation: ClientOperation = {
-            lane, command, context, retainable,
+            lane, heldLanes: [...taken], command, context, retainable,
             id: bytes.readBigUInt64LE(REQUEST_OFFSET.request),
             frame: this.freeze(bytes, size),
             lifetime: new AbortController(), resolving: false, retained: false, uncertain: false,
-            holdsSlot: retainable
+            holdsSlot: retainable,
+            fetchProgress: commits ? {
+              id: bytes.readBigUInt64LE(REQUEST_OFFSET.request), targets: new Map(partitions), size: fetchSize, retained: false
+            } : undefined
           };
+          if (operation.fetchProgress) {
+            this.fetchProgress.set(operation.id, operation.fetchProgress);
+            this.fetchBytes += fetchSize;
+          }
           this.lanes.set(lane, operation);
+          for (const held of taken.splice(0)) {
+            this.held.delete(held);
+            this.lanes.set(held, operation);
+          }
           this.operations.set(operation.id, operation);
           return operation;
         }
@@ -279,14 +350,11 @@
         throw responseError(command, TRANSIENT_NOT_ACCEPTED);
       throw error;
     } finally {
-      if (waiting.previous) waiting.previous.next = waiting.next;
-      else queue.head = waiting.next;
-      if (waiting.next) waiting.next.previous = waiting.previous;
-      else queue.tail = waiting.previous;
-      this.capacityWaiting.delete(waiting);
-      if (!queue.head) this.waiting.delete(lane);
-      this.wakeLane(lane);
-      this.wakeWaiting();
+      for (const held of taken) {
+        this.held.delete(held);
+        this.wakeLane(held);
+      }
+      this.leave(current, queue, waiting);
     }
   }
 
@@ -304,6 +372,12 @@
     const bytes = session.encode(operation.command, body, context);
     this.bytes += size;
     this.operations.delete(operation.id);
+    if (operation.fetchProgress) {
+      this.releaseFetch(operation.fetchProgress);
+      operation.fetchProgress.id = bytes.readBigUInt64LE(REQUEST_OFFSET.request);
+      this.fetchProgress.set(operation.fetchProgress.id, operation.fetchProgress);
+      this.fetchBytes += operation.fetchProgress.size;
+    }
     operation.id = bytes.readBigUInt64LE(REQUEST_OFFSET.request);
     operation.context = context;
     operation.frame = this.freeze(bytes, size);
@@ -318,13 +392,16 @@
     });
   }
 
-  throwIfRetained(lane: string): void {
+  throwIfRetained(lane: string, partitionId?: number, inspection = false): void {
+    const fetch = inspection ? undefined : this.blockedProgress.get(lane);
+    if (fetch) throw new LaneBlockedError(fetch.id, fetch.targets.get(lane), true);
     const operation = this.lanes.get(lane);
     if (operation?.retained)
-      throw new LaneBlockedError(operation.id);
+      throw new LaneBlockedError(operation.id, partitionId);
   }
 
   retained(id: bigint): ClientOperation {
+    if (this.fetchProgress.get(id)?.retained) throw new FetchProgressUncertainError(id);
     const operation = this.operations.get(id);
     if (!operation?.retained)
       throw new OperationNotRetainedError(id);
@@ -339,12 +416,24 @@
     // A replay keeps the state it has, which an abandon can have dropped.
     if (operation.retainable && this.owns(operation) && !operation.resolving) {
       operation.retained = true;
-      for (let waiter = this.waiting.get(operation.lane)?.head; waiter; waiter = waiter.next)
-        waiter.changed.pulse();
+      for (const lane of lanesOf(operation))
+        for (let waiter = this.waiting.get(lane)?.head; waiter; waiter = waiter.next)
+          waiter.changed.pulse();
       this.changed.pulse();
     }
   }
 
+  retainFetch(operation: ClientOperation, partitions?: ReadonlySet<number>): void {
+    const fetch = operation.fetchProgress;
+    if (!fetch || this.fetchProgress.get(fetch.id) !== fetch) return;
+    fetch.retained = true;
+    for (const [lane, partition] of fetch.targets)
+      if (!partitions || partitions.has(partition)) {
+        this.blockedProgress.set(lane, fetch);
+        for (let waiter = this.waiting.get(lane)?.head; waiter; waiter = waiter.next) waiter.changed.pulse();
+      }
+  }
+
   /** Completes a call that proved its outcome. A replay keeps the outcome for a later resolve. */
   prove(operation: ClientOperation, rejection?: unknown): void {
     if (operation.resolving) {
@@ -370,6 +459,11 @@
    * FIFO.
    */
   abandon(id: bigint): void {
+    const fetch = this.fetchProgress.get(id);
+    if (fetch?.retained) {
+      this.releaseFetch(fetch);
+      return;
+    }
     const operation = this.retained(id);
     operation.retained = false;
     if (!operation.resolving) {
@@ -384,11 +478,12 @@
     if (!this.owns(operation))
       return;
     this.operations.delete(operation.id);
-    this.lanes.delete(operation.lane);
+    for (const lane of lanesOf(operation)) this.lanes.delete(lane);
     this.releaseSlot(operation);
+    if (operation.fetchProgress && !operation.fetchProgress.retained) this.releaseFetch(operation.fetchProgress);
     operation.lifetime.abort(new OperationNotRetainedError(operation.id));
     operation.frame.releaseOwner();
-    this.wakeLane(operation.lane);
+    for (const lane of lanesOf(operation)) this.wakeLane(lane);
     this.wakeWaiting();
     this.changed.pulse();
   }
@@ -403,6 +498,13 @@
       if (operation.retained || operation.resolving) this.complete(operation);
   }
 
+  private releaseFetch(fetch: FetchProgress): void {
+    if (!this.fetchProgress.delete(fetch.id)) return;
+    this.fetchBytes -= fetch.size;
+    for (const lane of fetch.targets.keys())
+      if (this.blockedProgress.get(lane) === fetch) this.blockedProgress.delete(lane);
+  }
+
   private releaseSlot(operation: ClientOperation): void {
     if (!operation.holdsSlot)
       return;
@@ -410,9 +512,37 @@
     this.count -= 1;
   }
 
+  private join(lane: string, waiting: AdmissionWaiter): LaneWaiters {
+    let queue = this.waiting.get(lane);
+    if (!queue) {
+      queue = {};
+      this.waiting.set(lane, queue);
+    }
+    waiting.previous = queue.tail;
+    if (queue.tail) queue.tail.next = waiting;
+    else queue.head = waiting;
+    queue.tail = waiting;
+    return queue;
+  }
+
+  private leave(lane: string, queue: LaneWaiters, waiting: AdmissionWaiter): void {
+    if (waiting.previous) waiting.previous.next = waiting.next;
+    else queue.head = waiting.next;
+    if (waiting.next) waiting.next.previous = waiting.previous;
+    else queue.tail = waiting.previous;
+    this.capacityWaiting.delete(waiting);
+    if (!queue.head) this.waiting.delete(lane);
+    this.wakeLane(lane);
+    this.wakeWaiting();
+  }
+
+  private busy(lane: string): boolean {
+    return this.lanes.has(lane) || this.held.has(lane);
+  }
+
   private wakeLane(lane: string): void {
     const head = this.waiting.get(lane)?.head;
-    if (head && !this.lanes.has(lane)) this.capacityWaiting.add(head);
+    if (head && !this.busy(lane)) this.capacityWaiting.add(head);
   }
 
   private wakeWaiting(): void {
diff --git a/foreign/node/src/client/client.public-api.test.ts b/foreign/node/src/client/client.public-api.test.ts
new file mode 100644
index 0000000..7608a95
--- /dev/null
+++ b/foreign/node/src/client/client.public-api.test.ts
@@ -0,0 +1,45 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+import * as publicApi from '../index.js';
+import {
+  FetchProgressUncertainError,
+  LaneBlockedError,
+  NotConnectedError,
+  OperationNotRetainedError,
+  OperationUncertainError,
+  VsrResponseTimeoutError
+} from './client.operation.js';
+
+const errors = {
+  FetchProgressUncertainError,
+  LaneBlockedError,
+  NotConnectedError,
+  OperationNotRetainedError,
+  OperationUncertainError,
+  VsrResponseTimeoutError
+};
+
+describe('public transport errors', () => {
+  for (const [name, constructor] of Object.entries(errors)) {
+    it(`exports ${name} with the identity used by the transport`, () => {
+      assert.equal(publicApi[name as keyof typeof publicApi], constructor);
+    });
+  }
+});
diff --git a/foreign/node/src/client/client.socket.test.ts b/foreign/node/src/client/client.socket.test.ts
index 963a451..e27c58d 100644
--- a/foreign/node/src/client/client.socket.test.ts
+++ b/foreign/node/src/client/client.socket.test.ts
@@ -23,11 +23,12 @@
 import { createServer, type Server } from 'node:net';
 import type { Readable } from 'node:stream';
 import { describe, it, type TestContext } from 'node:test';
+import { setTimeout as sleep } from 'node:timers/promises';
 import { createServer as createTlsServer } from 'node:tls';
-import { groupConsumerStream, singleConsumerStream } from '../stream/consumer-stream.js';
 import { COMMAND_CODE } from '../wire/command.code.js';
-import { LEAVE_GROUP } from '../wire/consumer-group/leave-group.command.js';
+import { groupConsumerStream, singleConsumerStream, type ConsumerStreamRequest } from '../stream/consumer-stream.js';
 import { LOGIN, LOGIN_WITH_TOKEN, LOGOUT } from '../wire/index.js';
+import { LEAVE_GROUP } from '../wire/consumer-group/leave-group.command.js';
 import { UPDATE_USER } from '../wire/user/update-user.command.js';
 import { UPDATE_TOPIC } from '../wire/topic/update-topic.command.js';
 import { UPDATE_STREAM } from '../wire/stream/update-stream.command.js';
@@ -35,40 +36,59 @@
 import { MetadataChangeStart, getMetadataChanges } from '../wire/system/get-metadata-changes.command.js';
 import { CHANGE_PASSWORD } from '../wire/user/change-password.command.js';
 import { POLL_MESSAGES, pollMessages } from '../wire/message/poll-messages.command.js';
+import {
+  DEFERRED_POLL_OFFSET, DEFERRED_POLL_OPTIONS_SIZE, MAX_PARTITIONS_PER_REQUEST,
+  type DeferredPollOptions
+} from '../wire/message/deferred-poll.utils.js';
+import {
+  FETCH_MESSAGES, FETCH_MESSAGES_FIELD, FETCH_MESSAGES_FIELDS_SIZE, FETCH_SECTION_FIELD, FETCH_TARGET_FIELD,
+  FETCH_TARGET_SIZE, decodeFetchSection, fetchReplyHeadSize, type FetchMessages, type FetchTarget
+} from '../wire/message/fetch-messages.command.js';
+import { encodeMessagesBatch } from '../wire/message/message.utils.js';
+import { u128ToBuf } from '../wire/number.utils.js';
 import { SEND_MESSAGES, sendMessages } from '../wire/message/send-messages.command.js';
 import { GET_OFFSET } from '../wire/offset/get-offset.command.js';
 import { STORE_OFFSET } from '../wire/offset/store-offset.command.js';
 import { DELETE_OFFSET } from '../wire/offset/delete-offset.command.js';
 import { serializePartitionRoute } from '../wire/partition/partition-route.js';
 import { Partitioning } from '../wire/message/partitioning.utils.js';
-import { PollingStrategy } from '../wire/message/poll.utils.js';
+import { PollingStrategy, PollingStrategyKind, type PollMessagesResponse } from '../wire/message/poll.utils.js';
 import { ConsumerKind } from '../wire/offset/offset.utils.js';
 import { readWireName } from '../wire/vsr/index.js';
 import { BIND_SECRET_BYTES } from '../wire/vsr/register.js';
 import { ResponseError } from '../wire/error.utils.js';
 import {
   Command,
+  EMPTY_PARTITION_CONTEXT,
   EVICTION_OFFSET,
   EvictionReason,
   HEADER_SIZE,
   REPLY_OFFSET,
-  REQUEST_OFFSET
+  REQUEST_OFFSET,
+  type PartitionContext
 } from '../wire/vsr/header.js';
 import { Operation, operationForCode } from '../wire/vsr/operation.js';
 import { VsrEvictionError } from '../wire/vsr/reply.js';
 import { IggyConnection, type LinkAttempt } from './client.connection.js';
-import { OPERATION_TIMEOUT_MS } from './client.operation.js';
+import { FETCH_PROGRESS_UNCERTAIN, OPERATION_TIMEOUT_MS, PARTITION_LANE_BLOCKED } from './client.operation.js';
 import {
-  CommandResponseStream, LaneBlockedError, NotConnectedError, OperationNotRetainedError, OperationUncertainError
+  CommandResponseStream, FetchProgressUncertainError, LaneBlockedError, NotConnectedError, OperationNotRetainedError,
+  OperationUncertainError
 } from './client.socket.js';
 import type { ClientConfig } from './client.type.js';
 import { SimpleClient } from './client.js';
 
 const TEST_SESSION = 42n;
+const PARTITION_ID_SIZE = 4;
+const DEFERRED_POLL_CAPACITY_EXCEEDED = 90;
+const INVALID_SIZE_BYTES = 80;
 const HISTORY_UNAVAILABLE = 87;
 const LIFECYCLE_BUSY = 88;
+const NOT_PRIMARY = 89;
 const TRANSIENT_NOT_COMMITTED = 57;
 const TRANSIENT_NOT_ACCEPTED = 58;
+const REQUEST_ALREADY_APPLIED = 59;
+const INVALID_COMMAND = 3;
 const INVALID_CREDENTIALS = 42;
 const STALE_CLIENT = 30;
 const UNAUTHENTICATED = 40;
@@ -1122,16 +1142,22 @@
           assert.equal(countCommand(cluster.primary, COMMAND_CODE.BindSession), 2);
           return;
         }
-        await assert.rejects(first, (error: unknown) =>
-          failure === 'stale session'
-            ? error instanceof ResponseError && error.errorCode === STALE_CLIENT
-            : error instanceof ResponseError && error.errorCode === TRANSIENT_NOT_COMMITTED);
+        let operationId!: bigint;
+        await assert.rejects(first, (error: unknown) => {
+          assert.ok(error instanceof FetchProgressUncertainError, String(error));
+          operationId = error.operationId!;
+          return true;
+        });
         assert.equal(polls, 1);
         assert.equal(resets, 0);
         if (failure === 'partial reply') {
           await cluster.client.sendCommand(COMMAND_CODE.GetStats, Buffer.alloc(0));
           assert.equal(polls, 1);
         } else {
+          await assert.rejects(cluster.client.sendCommand(POLL_MESSAGES.code, groupPollPayload()), (error: unknown) =>
+            error instanceof LaneBlockedError && error.errorCode === PARTITION_LANE_BLOCKED &&
+            error.operationId === operationId);
+          cluster.client.abandonOperation(operationId);
           await cluster.client.sendCommand(POLL_MESSAGES.code, groupPollPayload());
           assert.equal(polls, 2);
           assert.equal(countCommand(cluster.primary, COMMAND_CODE.BindSession),
@@ -1397,8 +1423,7 @@
     });
     try {
       const polling = cluster.client.sendCommand(POLL_MESSAGES.code, groupPollPayload());
-      const failed = assert.rejects(polling, (error: unknown) =>
-        error instanceof ResponseError && error.errorCode === TRANSIENT_NOT_COMMITTED);
+      const failed = assert.rejects(polling, FetchProgressUncertainError);
       await pendingAdmission;
       await cluster.client.sendCommand(LOGOUT.code, LOGOUT.serialize());
       await failed;
@@ -1643,7 +1668,7 @@
     }
   });
 
-  // The node a client authenticated on dies; its next command has to complete
+  // The node a client authenticated on dies. Its next command has to complete
   // on a survivor the roster named, under a session established there.
   // Mirrors `core/integration/tests/cluster/failover_client_continuity.rs`.
   it('resumes on a survivor after the node it authenticated on dies',
@@ -1714,7 +1739,7 @@
           socket.destroy();
         await primary.close();
 
-        // The attempt in flight when the socket died is allowed to fail; the
+        // The attempt in flight when the socket died is allowed to fail. The
         // one after it has to land on the survivor. Two attempts, not a
         // polling loop: the comment above promises at most one failed
         // submission, and a loop of twenty would pass with nineteen failures.
@@ -3772,7 +3797,8 @@
 
   for (const strategy of ['auto-commit', 'Next'] as const) {
     for (const failure of ['not committed', 'cancel'] as const) {
-      it(`ends uncertain ${strategy} polls with 57 after ${failure} and releases the transient lane`, async () => {
+      const code = strategy === 'auto-commit' ? FETCH_PROGRESS_UNCERTAIN : TRANSIENT_NOT_COMMITTED;
+      it(`ends uncertain ${strategy} polls with ${code} after ${failure} and releases the transient lane`, async () => {
         const cancel = new AbortController();
         let polls = 0;
         const cluster = await startPollCluster((frame, socket) => {
@@ -3788,8 +3814,7 @@
           autocommit: strategy === 'auto-commit' });
         try {
           await assert.rejects(cluster.client.sendCommand(COMMAND_CODE.PollMessages, body,
-            { signal: cancel.signal }), (error: unknown) => error instanceof ResponseError &&
-            error.errorCode === TRANSIENT_NOT_COMMITTED);
+            { signal: cancel.signal }), (error: unknown) => (error as { errorCode?: number }).errorCode === code);
           assert.equal(polls, 1);
           await cluster.client.sendCommand(COMMAND_CODE.SendMessages, explicitSend);
         } finally { await cluster.close(); }
@@ -5074,7 +5099,7 @@
     let view = 0;
     const polls: { frame: Buffer, socket: Socket }[] = [];
     const cluster = await startPollCluster((frame, socket) => {
-      if (frame.readUInt32LE(REQUEST_OFFSET.reserved) !== COMMAND_CODE.GetOffset) return false;
+      if (frame.readUInt32LE(REQUEST_OFFSET.reserved) !== COMMAND_CODE.PollMessages) return false;
       polls.push({ frame, socket });
       if (polls.length > 2) socket.write(replyFrame(frame, Operation.NonReplicated));
       return true;
@@ -5087,11 +5112,13 @@
     });
     try {
       await cluster.client.sendCommand(COMMAND_CODE.SendMessages, explicitSend);
-      const body = GET_OFFSET.serialize({ streamId: 1, topicId: 2, partitionId: 3,
-        consumer: { kind: ConsumerKind.Single, id: 1 } });
+      // Polls without progress go out at once. Offset reads of one partition would wait in its progress lane.
+      const body = POLL_MESSAGES.serialize({ streamId: 1, topicId: 2, partitionId: 3,
+        consumer: { kind: ConsumerKind.Single, id: 1 }, pollingStrategy: PollingStrategy.Offset(0n),
+        count: 1, autocommit: false });
       const deadline = Date.now() + 1_000;
-      const first = cluster.client.sendCommand(COMMAND_CODE.GetOffset, body, { deadline });
-      const second = cluster.client.sendCommand(COMMAND_CODE.GetOffset, body, { deadline });
+      const first = cluster.client.sendCommand(COMMAND_CODE.PollMessages, body, { deadline });
+      const second = cluster.client.sendCommand(COMMAND_CODE.PollMessages, body, { deadline });
       void first.catch(() => undefined);
       void second.catch(() => undefined);
       await waitUntil(() => polls.length === 2);
@@ -5561,13 +5588,81 @@
   });
 });
 
+/** The base offset follows the partition id in a batch header. */
+const BATCH_BASE_OFFSET_AT = 8;
+
+/** Where the fields of a fetch body start, after the consumer kind and the consumer, stream and topic ids. */
+const fetchFieldsAt = (body: Buffer): number => {
+  let at = 1;
+  // An identifier holds its kind, its length and its value.
+  for (let identifier = 0; identifier < 3; identifier += 1) at += 2 + body[at + 1];
+  return at;
+};
+
+/** The byte limit and the targets of a fetch frame. */
+const fetchOf = (frame: Buffer) => {
+  const body = frame.subarray(HEADER_SIZE);
+  const fieldsAt = fetchFieldsAt(body);
+  const targetsAt = fieldsAt + FETCH_MESSAGES_FIELDS_SIZE;
+  return {
+    maxBytes: body.readUInt32LE(fieldsAt + FETCH_MESSAGES_FIELD.maxBytes),
+    targets: Array.from({ length: body.readUInt32LE(fieldsAt + FETCH_MESSAGES_FIELD.targetsCount) }, (_, index) => {
+      const target = body.subarray(targetsAt + index * FETCH_TARGET_SIZE);
+      return {
+        partitionId: target.readUInt32LE(FETCH_TARGET_FIELD.partitionId),
+        kind: target[FETCH_TARGET_FIELD.kind],
+        value: target.readBigUInt64LE(FETCH_TARGET_FIELD.value),
+        maxBytes: target.readUInt32LE(FETCH_TARGET_FIELD.maxBytes),
+        incarnation: target.readBigUInt64LE(FETCH_TARGET_FIELD.incarnation),
+        ownerGeneration: target.readBigUInt64LE(FETCH_TARGET_FIELD.ownerGeneration)
+      };
+    })
+  };
+};
+
+/** A section of a fetch reply: `count` messages from `offset` on, or a refusal with `status`. */
+type ReplySection = {
+  partitionId: number, status?: number, offset?: bigint, count?: number, context?: PartitionContext,
+  /** The messages in the records, when they do not match the count of the table. */
+  recorded?: number
+};
+
+/** The body of a fetch reply with `sections`. */
+const fetchReplyBody = (...sections: ReplySection[]): Buffer => {
+  const records = sections.map(({ offset = 0n, count = 0, recorded = count }) => {
+    if (recorded === 0) return Buffer.alloc(0);
+    const batch = encodeMessagesBatch(Array.from({ length: recorded }, (_, index) => ({
+      id: u128ToBuf(BigInt(index + 1)), payload: Buffer.from('fetched'), userHeaders: Buffer.alloc(0), originTimestamp: 0n
+    })));
+    batch.writeBigUInt64LE(offset, BATCH_BASE_OFFSET_AT);
+    return batch;
+  });
+  const head = Buffer.alloc(fetchReplyHeadSize(sections.length));
+  head.writeUInt32LE(sections.length, 0);
+  sections.forEach(({ partitionId, status = 0, offset = 0n, count = 0, context }, index) => {
+    const at = fetchReplyHeadSize(index);
+    head.writeUInt32LE(partitionId, at + FETCH_SECTION_FIELD.partitionId);
+    head.writeUInt32LE(status, at + FETCH_SECTION_FIELD.status);
+    head.writeUInt32LE(count, at + FETCH_SECTION_FIELD.messagesCount);
+    if (context) {
+      head.writeBigUInt64LE(context.incarnation, at + FETCH_SECTION_FIELD.context);
+      head.writeBigUInt64LE(context.ownerGeneration, at + FETCH_SECTION_FIELD.context + 8);
+      head.writeBigUInt64LE(context.metadataOp, at + FETCH_SECTION_FIELD.context + 16);
+    }
+    head.writeBigUInt64LE(offset + BigInt(count), at + FETCH_SECTION_FIELD.nextOffset);
+    head.writeUInt32LE(records[index].length, at + FETCH_SECTION_FIELD.recordsLength);
+  });
+  return Buffer.concat([head, ...records]);
+};
+
 describe('consumer stream', () => {
   const POLLED_PARTITION = 3;
   const streamConfig = (port: number): ClientConfig => ({
     ...vsrConfig(port), heartbeatInterval: 20, reconnect: { enabled: true, interval: 10, maxRetries: 2 }
   });
   const pollRequest = {
-    streamId: 1, topicId: 2, pollingStrategy: PollingStrategy.Next, count: 1, interval: 10, endOnLastOffset: true
+    streamId: 1, topicId: 2, pollingStrategy: PollingStrategy.Next, count: 1, pollingRetryInterval: 10,
+    endOnLastOffset: true
   };
 
   const emptyPoll = (frame: Buffer): Buffer => {
@@ -5576,8 +5671,18 @@
     return replyFrame(frame, Operation.NonReplicated, body);
   };
 
-  /** Group 1, whose only member owns `POLLED_PARTITION`. */
-  const groupHandler = (frame: Buffer, socket: Socket): boolean => {
+  /** The reads of a stream: the polls of a single stream and the fetches of a group stream. */
+  const READ_CODES = new Set<number>([COMMAND_CODE.PollMessagesDeferred, COMMAND_CODE.FetchMessages]);
+  const isRead = (frame: Buffer): boolean => READ_CODES.has(frame.readUInt32LE(REQUEST_OFFSET.reserved));
+  // A group stream hands out only the sections with messages, so an empty
+  // fetch gives it nothing to hand out.
+  const emptyRead = (frame: Buffer): Buffer =>
+    frame.readUInt32LE(REQUEST_OFFSET.reserved) === COMMAND_CODE.FetchMessages
+      ? replyFrame(frame, Operation.NonReplicated, fetchReplyBody({ partitionId: POLLED_PARTITION }))
+      : emptyPoll(frame);
+
+  /** Group 1, whose only member owns the partitions that `assigned` returns at each sync. */
+  const groupHandlerOf = (assigned: () => number[]) => (frame: Buffer, socket: Socket): boolean => {
     const code = frame.readUInt32LE(REQUEST_OFFSET.reserved);
     if (frame[REQUEST_OFFSET.operation] === Operation.JoinConsumerGroup) {
       socket.write(replyFrame(frame, Operation.JoinConsumerGroup, metadataResult(0)));
@@ -5586,16 +5691,18 @@
       group.writeUInt32LE(1, 0);
       socket.write(replyFrame(frame, Operation.NonReplicated, group));
     } else if (code === COMMAND_CODE.SyncGroup) {
-      const assignment = Buffer.alloc(16);
+      const partitions = assigned();
+      const assignment = Buffer.alloc(12 + 4 * partitions.length);
       assignment.writeBigUInt64LE(1n, 0);
-      assignment.writeUInt32LE(1, 8);
-      assignment.writeUInt32LE(POLLED_PARTITION, 12);
+      assignment.writeUInt32LE(partitions.length, 8);
+      partitions.forEach((partition, index) => assignment.writeUInt32LE(partition, 12 + 4 * index));
       socket.write(replyFrame(frame, Operation.NonReplicated, assignment));
     } else return false;
     return true;
   };
+  const groupHandler = groupHandlerOf(() => [POLLED_PARTITION]);
 
-  /** Collects what a stream yields; the returned wait fails on the error that ended it. */
+  /** Collects what a stream yields. The returned wait fails on the error that ended it. */
   const consume = (stream: Readable): () => Promise<unknown[]> => {
     const polled: unknown[] = [];
     let ended = false;
@@ -5628,19 +5735,19 @@
       if (code === COMMAND_CODE.GetClusterMetadata) {
         socket.write(replyFrame(frame, Operation.NonReplicated, metadataBody([{ port: ++rosters === 1 ? 0 : server.port, replicaId: 0, role: 0 }])));
       } else if (code === COMMAND_CODE.BindSession && ++binds === 1) socket.destroy();
-      else if (code === COMMAND_CODE.PollMessages) {
+      else if (isRead(frame)) {
         polls += 1;
-        socket.write(emptyPoll(frame));
+        socket.write(emptyRead(frame));
       } else singleNodeHandler(server.port)(frame, socket);
     });
     let stream: Readable | undefined;
     try {
       stream = await singleConsumerStream({ ...streamConfig(server.port), reconnect: { enabled: true, interval: 1, maxRetries: 0 } })(
         { ...pollRequest, partitionId: POLLED_PARTITION });
-      // Empty roster retains only bootstrap; the next poll must dial and bind it.
+      // Empty roster retains only bootstrap. The next poll must dial and bind it.
       for (const socket of server.sockets) socket.destroy();
       await waitUntil(() => server.sockets.size === 0);
-      assert.equal((await consume(stream)()).length, 1);
+      assert.equal((await consume(stream)()).length, 0);
       assert.equal(polls, 1);
       assert.equal(binds, 2);
       assert.ok(refusals.some((error) => error instanceof Error && error.message === CLOSED_LINK));
@@ -5654,16 +5761,23 @@
     ...streamConfig(port), reconnect: { enabled: true, interval: 1, maxRetries: 1 }
   });
 
-  /** Records each error that a poll returns to the stream. */
+  /** Records each error that a read, or the grouping of a fetch, returns to the stream. */
   const pollRefusals = (context: TestContext): unknown[] => {
     const refusals: unknown[] = [];
+    const record = <T>(pending: Promise<T>): Promise<T> => {
+      void pending.catch((error: unknown) => { refusals.push(error); });
+      return pending;
+    };
     const sendCommand = CommandResponseStream.prototype.sendCommand;
     context.mock.method(CommandResponseStream.prototype, 'sendCommand',
       function (this: CommandResponseStream, ...args: Parameters<typeof sendCommand>) {
         const pending = sendCommand.apply(this, args);
-        if (args[0] === COMMAND_CODE.PollMessages)
-          void pending.catch((error: unknown) => { refusals.push(error); });
-        return pending;
+        return READ_CODES.has(args[0]) ? record(pending) : pending;
+      });
+    const readinessGroups = CommandResponseStream.prototype.readinessGroups;
+    context.mock.method(CommandResponseStream.prototype, 'readinessGroups',
+      function (this: CommandResponseStream, ...args: Parameters<typeof readinessGroups>) {
+        return record(readinessGroups.apply(this, args));
       });
     return refusals;
   };
@@ -5672,8 +5786,8 @@
   const startOutageCluster = async () => {
     let outage = false;
     const cluster = await startPollCluster((frame, socket) => {
-      if (frame.readUInt32LE(REQUEST_OFFSET.reserved) !== COMMAND_CODE.PollMessages) return false;
-      socket.write(emptyPoll(frame));
+      if (!isRead(frame)) return false;
+      socket.write(emptyRead(frame));
       return true;
     }, (frame, socket) => {
       if (outage || frame.readUInt32LE(REQUEST_OFFSET.reserved) !== COMMAND_CODE.GetPartitionRoute)
@@ -5691,7 +5805,7 @@
 
   for (const group of [false, true]) {
     for (const failure of ['a stale client refusal', 'a lost poll reply', 'a primary link lost past the poll deadline'] as const) {
-      it(`keeps a ${group ? 'group' : 'single'} consumer polling on the same session after ${failure}`, async () => {
+      it(`keeps a ${group ? 'group' : 'single'} consumer polling after ${failure}`, async () => {
         const lostPrimary = failure === 'a primary link lost past the poll deadline';
         let primaryDown = lostPrimary;
         let polls = 0;
@@ -5702,9 +5816,9 @@
             socket.destroy();
             return true;
           }
-          if (code !== COMMAND_CODE.PollMessages) return false;
+          if (!isRead(frame)) return false;
           polls += 1;
-          if (polls > 1 || lostPrimary) socket.write(emptyPoll(frame));
+          if (polls > 1 || lostPrimary) socket.write(emptyRead(frame));
           else if (failure === 'a lost poll reply') socket.destroy();
           else socket.write(replyFrame(frame, Operation.NonReplicated, Buffer.alloc(0), STALE_CLIENT));
           return true;
@@ -5731,7 +5845,7 @@
             clockOffset = OPERATION_TIMEOUT_MS;
             primaryDown = false;
           }
-          assert.equal((await ended()).length, 1);
+          assert.equal((await ended()).length, 0);
           assert.equal(polls, lostPrimary ? 1 : 2);
           const registers = framesOf(cluster.coordinator, Operation.Register);
           const binds = cluster.primary.frames.filter((frame) =>
@@ -5752,16 +5866,16 @@
   it('keeps a single consumer polling after its session ends and the client signs in again', async () => {
     let polls = 0;
     const cluster = await startPollCluster((frame, socket) => {
-      if (frame.readUInt32LE(REQUEST_OFFSET.reserved) !== COMMAND_CODE.PollMessages) return false;
+      if (!isRead(frame)) return false;
       polls += 1;
-      socket.write(polls > 1 ? emptyPoll(frame) : evictionFrame(EvictionReason.SessionError));
+      socket.write(polls > 1 ? emptyRead(frame) : evictionFrame(EvictionReason.SessionError));
       return true;
     });
     let stream: Readable | undefined;
     try {
       stream = await singleConsumerStream(streamConfig(cluster.coordinator.port))(
         { ...pollRequest, partitionId: POLLED_PARTITION });
-      assert.equal((await consume(stream)()).length, 1);
+      assert.equal((await consume(stream)()).length, 0);
       assert.equal(polls, 2);
       assert.equal(framesOf(cluster.coordinator, Operation.Register).length, 2);
     } finally {
@@ -5786,7 +5900,7 @@
         // The coordinator comes back first, so that it answers the route lookup.
         await waitUntil(() => stream!.destroyed || countCommand(cluster.coordinator, COMMAND_CODE.GetPartitionRoute) >= 2);
         await cluster.primary.reopen();
-        assert.equal((await ended()).length, 1);
+        assert.equal((await ended()).length, 0);
         assert.equal(framesOf(cluster.coordinator, Operation.Register).length, 1);
         for (const refusal of refusals) assert.ok(refusal instanceof NotConnectedError, String(refusal));
       } finally {
@@ -5821,10 +5935,10 @@
     const refusals = pollRefusals(context);
     let polls = 0;
     const cluster = await startPollCluster((frame, socket) => {
-      if (frame.readUInt32LE(REQUEST_OFFSET.reserved) !== COMMAND_CODE.PollMessages) return false;
+      if (!isRead(frame)) return false;
       polls += 1;
       if (polls > 1) {
-        socket.write(emptyPoll(frame));
+        socket.write(emptyRead(frame));
         return true;
       }
       // The client drops every link when its session ends, so the new sign-in has to dial.
@@ -5843,11 +5957,10 @@
       // The coordinator comes back first, so that it answers the sign-in.
       await waitUntil(() => stream!.destroyed || framesOf(cluster.coordinator, Operation.Register).length === 2);
       await cluster.primary.reopen();
-      assert.equal((await ended()).length, 1);
+      assert.equal((await ended()).length, 0);
       assert.equal(polls, 2);
-      assert.ok(refusals[0] instanceof ResponseError && refusals[0].errorCode === TRANSIENT_NOT_COMMITTED &&
-        refusals[0].cause instanceof VsrEvictionError && refusals[0].cause.errorCode === UNAUTHENTICATED,
-      String(refusals[0]));
+      assert.ok(refusals[0] instanceof ResponseError && refusals[0].errorCode === TRANSIENT_NOT_COMMITTED,
+        String(refusals[0]));
       for (const refusal of refusals.slice(1)) assert.ok(refusal instanceof NotConnectedError, String(refusal));
     } finally {
       cluster.client.destroy();
@@ -5912,7 +6025,7 @@
     it(`ends a ${kind} consumer stream at its first retry when the server refuses its next sign-in with 40`, async () => {
       let registers = 0;
       const cluster = await startPollCluster((frame, socket) => {
-        if (frame.readUInt32LE(REQUEST_OFFSET.reserved) !== COMMAND_CODE.PollMessages) return false;
+        if (!isRead(frame)) return false;
         socket.write(evictionFrame(EvictionReason.SessionError));
         return true;
       }, (frame, socket) => {
@@ -5940,10 +6053,12 @@
 
     it(`releases the client of a ${kind} consumer stream destroyed while its retry waits for a sign-in`, async (context) => {
       const calls = context.mock.method(CommandResponseStream.prototype, 'sendCommand');
+      // The retry of a reader starts with the route lookup of its next read.
+      const lookups = context.mock.method(CommandResponseStream.prototype, 'readinessGroups');
       let callsBeforeRetry = Number.POSITIVE_INFINITY;
       let registers = 0;
       const cluster = await startPollCluster((frame, socket) => {
-        if (frame.readUInt32LE(REQUEST_OFFSET.reserved) !== COMMAND_CODE.PollMessages) return false;
+        if (!isRead(frame)) return false;
         callsBeforeRetry = calls.mock.callCount();
         socket.write(evictionFrame(EvictionReason.SessionError));
         return true;
@@ -5956,7 +6071,7 @@
       try {
         stream = await openStream(streamConfig(cluster.coordinator.port));
         stream.on('error', () => undefined).resume();
-        await waitUntil(() => calls.mock.callCount() > callsBeforeRetry);
+        await waitUntil(() => calls.mock.callCount() > callsBeforeRetry || lookups.mock.callCount() >= 2);
         stream.destroy();
         await waitUntil(() => cluster.coordinator.sockets.size === 0 && cluster.primary.sockets.size === 0);
       } finally {
@@ -5984,7 +6099,7 @@
 
   it('stops polling and releases its client when a retrying stream is destroyed', async () => {
     const cluster = await startPollCluster((frame, socket) => {
-      if (frame.readUInt32LE(REQUEST_OFFSET.reserved) !== COMMAND_CODE.PollMessages) return false;
+      if (!isRead(frame)) return false;
       socket.write(replyFrame(frame, Operation.NonReplicated, Buffer.alloc(0), STALE_CLIENT));
       return true;
     });
@@ -5994,7 +6109,7 @@
     stream.once('error', (error) => { failed = error; });
     try {
       stream.resume();
-      await waitUntil(() => failed !== undefined || countCommand(cluster.primary, COMMAND_CODE.PollMessages) >= 2);
+      await waitUntil(() => failed !== undefined || cluster.primary.frames.filter(isRead).length >= 2);
       assert.ifError(failed);
       stream.destroy();
       await waitUntil(() => cluster.coordinator.sockets.size === 0 && cluster.primary.sockets.size === 0);
@@ -6008,7 +6123,7 @@
     let polled: Socket | undefined;
     let registers = 0;
     const cluster = await startPollCluster((frame, socket) => {
-      if (frame.readUInt32LE(REQUEST_OFFSET.reserved) !== COMMAND_CODE.PollMessages) return false;
+      if (!isRead(frame)) return false;
       polled = socket;
       return true;
     }, (frame) => {
@@ -6031,11 +6146,60 @@
     }
   });
 
+  it('reads again after a read that the server refuses for its deferred capacity',
+    async () => {
+      const requests: bigint[] = [];
+      const server = await startVsrServer((frame, socket) => {
+        if (!isRead(frame)) {
+          singleNodeHandler(server.port)(frame, socket);
+          return;
+        }
+        requests.push(frame.readBigUInt64LE(REQUEST_OFFSET.request));
+        socket.write(requests.length === 1
+          ? replyFrame(frame, Operation.NonReplicated, Buffer.alloc(0), DEFERRED_POLL_CAPACITY_EXCEEDED)
+          : emptyRead(frame));
+      });
+      let stream: Readable | undefined;
+      try {
+        stream = await singleConsumerStream(streamConfig(server.port))({
+          ...pollRequest, partitionId: POLLED_PARTITION, pollOptions: { maxWait: 0 }
+        });
+        assert.equal((await consume(stream)()).length, 0);
+        assert.equal(requests.length, 2);
+      } finally { await closeAll([server], stream); }
+    });
+
+  it('cancels its parked read on the server when the stream is destroyed', async () => {
+    let parked: Buffer | undefined;
+    const server = await startVsrServer((frame, socket) => {
+      const code = frame.readUInt32LE(REQUEST_OFFSET.reserved);
+      if (isRead(frame)) parked = frame;
+      // A cancelled committing read answers once, here with nothing stored.
+      else if (code === COMMAND_CODE.CancelDeferredRequest && parked)
+        socket.write(replyFrame(parked, Operation.NonReplicated, Buffer.alloc(0), TRANSIENT_NOT_ACCEPTED));
+      else if (code !== COMMAND_CODE.CancelDeferredRequest) singleNodeHandler(server.port)(frame, socket);
+    });
+    let stream: Readable | undefined;
+    try {
+      stream = await singleConsumerStream(streamConfig(server.port))({
+        ...pollRequest, partitionId: POLLED_PARTITION, pollOptions: { maxWait: 10_000, requestTimeout: 11_000 }
+      });
+      stream.resume();
+      await waitUntil(() => parked !== undefined);
+      stream.destroy();
+      await waitUntil(() => countCommand(server, COMMAND_CODE.CancelDeferredRequest) === 1);
+      const cancellation = server.frames.find((frame) =>
+        frame.readUInt32LE(REQUEST_OFFSET.reserved) === COMMAND_CODE.CancelDeferredRequest)!;
+      assert.equal(cancellation.readBigUInt64LE(REQUEST_OFFSET.request), parked!.readBigUInt64LE(REQUEST_OFFSET.request));
+      await waitUntil(() => server.sockets.size === 0);
+    } finally { await closeAll([server], stream); }
+  });
+
   for (const [leave, status] of [['an accepted', 0], ['a refused', UNAUTHORIZED]] as const) {
     it(`leaves its group and releases its client when a group stream with ${leave} leave is destroyed`, async () => {
       const cluster = await startPollCluster((frame, socket) => {
-        if (frame.readUInt32LE(REQUEST_OFFSET.reserved) !== COMMAND_CODE.PollMessages) return false;
-        socket.write(emptyPoll(frame));
+        if (!isRead(frame)) return false;
+        socket.write(emptyRead(frame));
         return true;
       }, (frame, socket) => {
         if (frame[REQUEST_OFFSET.operation] !== Operation.LeaveConsumerGroup) return groupHandler(frame, socket);
@@ -6047,7 +6211,7 @@
         stream = await groupConsumerStream(streamConfig(cluster.coordinator.port))(
           { ...pollRequest, groupName: 'group', endOnLastOffset: false });
         stream.resume();
-        await waitUntil(() => countCommand(cluster.primary, COMMAND_CODE.PollMessages) > 0);
+        await waitUntil(() => cluster.primary.frames.some(isRead));
         stream.destroy();
         await waitUntil(() => cluster.coordinator.sockets.size === 0 && cluster.primary.sockets.size === 0);
         const leaves = framesOf(cluster.coordinator, Operation.LeaveConsumerGroup);
@@ -6060,4 +6224,1160 @@
       }
     });
   }
+
+  /**
+   * A cluster whose group member owns the partitions that `assigned`
+   * returns. Its primary hands each fetch to `onFetch`, takes every offset
+   * store and delete, and leaves cancellations without a reply.
+   */
+  const startGroupCluster = (onFetch: (frame: Buffer, socket: Socket) => void,
+    assigned: () => number[] = () => [POLLED_PARTITION],
+    startOffset: (partition: number) => bigint = () => 0n) =>
+    startPollCluster((frame, socket) => {
+      const operation = frame[REQUEST_OFFSET.operation];
+      if (operation === Operation.StoreConsumerOffset || operation === Operation.DeleteConsumerOffset) {
+        socket.write(replyFrame(frame, operation, metadataResult(0)));
+        return true;
+      }
+      const code = frame.readUInt32LE(REQUEST_OFFSET.reserved);
+      if (code === COMMAND_CODE.PollMessagesDeferred) {
+        const suffix = 1 + 8 + 4 + 1;
+        const partition = frame.readUInt32LE(frame.length - DEFERRED_POLL_OPTIONS_SIZE - suffix - 4);
+        const offset = startOffset(partition);
+        const records = fetchReplyBody({ partitionId: partition, offset, count: 1, context: SECTION_CONTEXT })
+          .subarray(fetchReplyHeadSize(1));
+        const head = Buffer.alloc(40);
+        head.writeUInt32LE(partition, 0);
+        head.writeBigUInt64LE(offset, 4);
+        head.writeUInt32LE(1, 12);
+        head.writeBigUInt64LE(SECTION_CONTEXT.incarnation, 16);
+        head.writeBigUInt64LE(SECTION_CONTEXT.ownerGeneration, 24);
+        head.writeBigUInt64LE(SECTION_CONTEXT.metadataOp, 32);
+        socket.write(replyFrame(frame, Operation.NonReplicated, Buffer.concat([head, records])));
+        return true;
+      }
+      if (code === COMMAND_CODE.FetchMessages) onFetch(frame, socket);
+      return code === COMMAND_CODE.FetchMessages || code === COMMAND_CODE.CancelDeferredRequest;
+    }, groupHandlerOf(assigned));
+  const openGroupStream = (port: number, request: Partial<ConsumerStreamRequest> = {}): Promise<Readable> =>
+    groupConsumerStream(streamConfig(port))({ ...pollRequest, groupName: 'group', ...request });
+  const fetchFrames = (server: VsrTestServer): Buffer[] =>
+    server.frames.filter((frame) => frame.readUInt32LE(REQUEST_OFFSET.reserved) === COMMAND_CODE.FetchMessages);
+  /** The partition, start point and fence of each target of each fetch that `server` got. */
+  const fetchStarts = (server: VsrTestServer) => fetchFrames(server).map((frame) => fetchOf(frame).targets
+    .map(({ partitionId, kind, value, incarnation, ownerGeneration }) => ({ partitionId, kind, value, incarnation, ownerGeneration })));
+  const nextStart = (partitionId: number, value = 0n) =>
+    ({ partitionId, kind: PollingStrategyKind.Next, value,
+      incarnation: SECTION_CONTEXT.incarnation, ownerGeneration: SECTION_CONTEXT.ownerGeneration });
+  const offsetStart = (partitionId: number, offset: bigint, { incarnation, ownerGeneration }: PartitionContext) =>
+    ({ partitionId, kind: PollingStrategyKind.Offset, value: offset, incarnation, ownerGeneration });
+  const offsetsOf = (polled: unknown[]) => (polled as PollMessagesResponse[])
+    .map(({ partitionId, messages }) => [partitionId, messages.map(({ headers }) => headers.offset)]);
+  const cancellationsOf = (server: VsrTestServer): Buffer[] => server.frames.filter((frame) =>
+    frame.readUInt32LE(REQUEST_OFFSET.reserved) === COMMAND_CODE.CancelDeferredRequest);
+  const offsetWritesOf = (server: VsrTestServer): Buffer[] =>
+    [...framesOf(server, Operation.StoreConsumerOffset), ...framesOf(server, Operation.DeleteConsumerOffset)];
+  /** Long enough for a stream to send a read or to close when it must not. */
+  const QUIET_MS = 50;
+  const SECTION_CONTEXT = { incarnation: 3n, ownerGeneration: 9n, metadataOp: 12n };
+  const GROUP_MEMBER = {
+    streamId: 1, topicId: 2, consumer: { kind: ConsumerKind.Group, id: 'group' }, partitionId: POLLED_PARTITION
+  };
+
+  it('fetches the partitions of a group stream from their next offsets, and moves the served ones to the end of the next fetch',
+    async () => {
+      let served = false;
+      const cluster = await startGroupCluster((frame, socket) => {
+        const sections = fetchOf(frame).targets.map(({ partitionId }) => {
+          if (partitionId === 1 && !served) {
+            served = true;
+            return { partitionId, offset: 41n, count: 2, context: SECTION_CONTEXT };
+          }
+          return { partitionId };
+        });
+        socket.write(replyFrame(frame, Operation.NonReplicated, fetchReplyBody(...sections)));
+      }, () => [1, 2, 3], (partition) => partition === 1 ? 41n : 0n);
+      let stream: Readable | undefined;
+      try {
+        stream = await openGroupStream(cluster.coordinator.port, { count: 10 });
+        assert.deepEqual(offsetsOf(await consume(stream)()), [[1, [41n, 42n]]]);
+        const starts = fetchStarts(cluster.primary);
+        assert.deepEqual([...new Set(starts.flat().map(({ partitionId }) => partitionId))].sort(), [1, 2, 3]);
+        const first = starts.flat().find(({ partitionId }) => partitionId === 1);
+        assert.deepEqual(first, nextStart(1, 41n));
+        const continued = starts.filter((targets) => targets.some(({ partitionId, value }) =>
+          partitionId === 1 && value === 43n));
+        assert.ok(continued.length > 0);
+        for (const targets of continued) assert.equal(targets.at(-1)!.partitionId, 1);
+        for (const target of starts.flat().filter(({ partitionId }) => partitionId !== 1))
+          assert.deepEqual(target, nextStart(target.partitionId));
+        for (const cancellation of cancellationsOf(cluster.primary)) {
+          assert.ok(fetchFrames(cluster.primary).some((frame) =>
+            frame.readBigUInt64LE(REQUEST_OFFSET.request) === cancellation.readBigUInt64LE(REQUEST_OFFSET.request)));
+        }
+      } finally {
+        cluster.client.destroy();
+        await closeAll([cluster.coordinator, cluster.primary], stream);
+      }
+    });
+
+  it('reads ahead of the reader of a group stream that commits nothing',
+    async () => {
+      const cluster = await startGroupCluster((frame, socket) => socket.write(replyFrame(frame, Operation.NonReplicated,
+        fetchReplyBody({ partitionId: POLLED_PARTITION, count: 1, context: SECTION_CONTEXT }))));
+      let stream: Readable | undefined;
+      try {
+        stream = await openGroupStream(cluster.coordinator.port, { autocommit: false, endOnLastOffset: false });
+        stream.read();
+        await waitUntil(() => fetchFrames(cluster.primary).length >= 2);
+        assert.equal(stream.readableLength, 1, 'the reply of the first fetch is handed out already');
+      } finally {
+        cluster.client.destroy();
+        await closeAll([cluster.coordinator, cluster.primary], stream);
+      }
+    });
+
+  it('sends the next fetch of a committing group stream only when the reader requests another reply',
+    async () => {
+      const cluster = await startGroupCluster((frame, socket) => socket.write(replyFrame(frame, Operation.NonReplicated,
+        fetchReplyBody({ partitionId: POLLED_PARTITION, count: 1, context: SECTION_CONTEXT }))));
+      let stream: Readable | undefined;
+      try {
+        stream = await openGroupStream(cluster.coordinator.port, { endOnLastOffset: false });
+        stream.read();
+        await waitUntil(() => stream!.readableLength === 1);
+        await sleep(QUIET_MS);
+        assert.equal(fetchFrames(cluster.primary).length, 1, 'the stream fetched before the reader took the reply');
+        stream.read();
+        await sleep(QUIET_MS);
+        assert.equal(fetchFrames(cluster.primary).length, 1, 'the stream fetched before the reader requested another reply');
+        stream.read();
+        await waitUntil(() => fetchFrames(cluster.primary).length === 2);
+      } finally {
+        cluster.client.destroy();
+        await closeAll([cluster.coordinator, cluster.primary], stream);
+      }
+    });
+
+  it('does not fetch ahead while an async iterator handles the last committing batch', async () => {
+    const cluster = await startGroupCluster((frame, socket) => socket.write(replyFrame(frame, Operation.NonReplicated,
+      fetchReplyBody({ partitionId: POLLED_PARTITION, count: 1, context: SECTION_CONTEXT }))));
+    let stream: Readable | undefined;
+    let requestNext!: () => void;
+    const handled = new Promise<void>((resolve) => { requestNext = resolve; });
+    const delivered: PollMessagesResponse[] = [];
+    let consuming: Promise<void> | undefined;
+    try {
+      stream = await openGroupStream(cluster.coordinator.port, { endOnLastOffset: false });
+      consuming = (async () => {
+        for await (const reply of stream!) {
+          delivered.push(reply);
+          if (delivered.length === 2) break;
+          await handled;
+        }
+      })();
+      await waitUntil(() => delivered.length === 1);
+      await sleep(QUIET_MS);
+      assert.equal(fetchFrames(cluster.primary).length, 1, 'another batch committed while the reader handled its first batch');
+      assert.equal(stream.readableLength, 0);
+      requestNext();
+      await consuming;
+      assert.equal(fetchFrames(cluster.primary).length, 2);
+    } finally {
+      requestNext();
+      await consuming?.catch(() => undefined);
+      cluster.client.destroy();
+      await closeAll([cluster.coordinator, cluster.primary], stream);
+    }
+  });
+
+  for (const [rewind, parkedOffset, handsOut, stored] of [
+    ['stores the offset of the last message that it handed out', 12n, true, 11n]
+  ] as const) {
+    it(`lets the committing fetch of a destroyed group stream end, then ${rewind}`, async () => {
+      const served = handsOut
+        ? [fetchReplyBody({ partitionId: POLLED_PARTITION, offset: 10n, count: 2, context: SECTION_CONTEXT })] : [];
+      let parked: { frame: Buffer, socket: Socket } | undefined;
+      const cluster = await startGroupCluster((frame, socket) => {
+        const reply = served.shift();
+        if (reply) socket.write(replyFrame(frame, Operation.NonReplicated, reply));
+        else parked = { frame, socket };
+      });
+      let stream: Readable | undefined;
+      try {
+        stream = await openGroupStream(cluster.coordinator.port,
+          { count: 10, endOnLastOffset: false, pollingStrategy: PollingStrategy.Offset(0n) });
+        stream.resume();
+        await waitUntil(() => parked !== undefined);
+        stream.destroy();
+        await sleep(QUIET_MS);
+        assert.equal(stream.closed, false, 'the stream closed before its committing fetch ended');
+        parked!.socket.write(replyFrame(parked!.frame, Operation.NonReplicated,
+          fetchReplyBody({ partitionId: POLLED_PARTITION, offset: parkedOffset, count: 1, context: SECTION_CONTEXT })));
+
+        const operation = stored === undefined ? Operation.DeleteConsumerOffset : Operation.StoreConsumerOffset;
+        await waitUntil(() => framesOf(cluster.primary, operation).length > 0);
+        const [write] = framesOf(cluster.primary, operation);
+        assert.deepEqual(write.subarray(HEADER_SIZE), stored === undefined
+          ? DELETE_OFFSET.serialize(GROUP_MEMBER) : STORE_OFFSET.serialize({ ...GROUP_MEMBER, offset: stored }));
+        assert.deepEqual(stampedContext(write), SECTION_CONTEXT);
+        await waitUntil(() => cluster.primary.sockets.size === 0);
+        assert.equal(offsetWritesOf(cluster.primary).length, 1);
+        assert.equal(cancellationsOf(cluster.primary).length, 1);
+      } finally {
+        cluster.client.destroy();
+        await closeAll([cluster.coordinator, cluster.primary], stream);
+      }
+    });
+  }
+
+  it('stores the offset before the reply that waits unread in the buffer of a destroyed committing group stream',
+    async () => {
+      const cluster = await startGroupCluster((frame, socket) => socket.write(replyFrame(frame, Operation.NonReplicated,
+        fetchReplyBody({ partitionId: POLLED_PARTITION, offset: 10n, count: 2, context: SECTION_CONTEXT }))), undefined, () => 10n);
+      let stream: Readable | undefined;
+      try {
+        stream = await openGroupStream(cluster.coordinator.port, { count: 10, endOnLastOffset: false });
+        stream.read();
+        await waitUntil(() => stream!.readableLength === 1);
+        stream.destroy();
+        await waitUntil(() => cluster.primary.sockets.size === 0);
+        assert.deepEqual(offsetWritesOf(cluster.primary).map((write) => write.subarray(HEADER_SIZE)),
+          [STORE_OFFSET.serialize({ ...GROUP_MEMBER, offset: 9n })]);
+        assert.equal(fetchFrames(cluster.primary).length, 1);
+      } finally {
+        cluster.client.destroy();
+        await closeAll([cluster.coordinator, cluster.primary], stream);
+      }
+    });
+
+  it('cancels the parked fetch of a group stream that commits nothing when the stream is destroyed', async () => {
+    let parked: Buffer | undefined;
+    const cluster = await startGroupCluster((frame) => { parked = frame; });
+    let stream: Readable | undefined;
+    try {
+      stream = await openGroupStream(cluster.coordinator.port, { autocommit: false });
+      stream.resume();
+      await waitUntil(() => parked !== undefined);
+      stream.destroy();
+      await waitUntil(() => cancellationsOf(cluster.primary).length === 1);
+      assert.equal(cancellationsOf(cluster.primary)[0].readBigUInt64LE(REQUEST_OFFSET.request),
+        parked!.readBigUInt64LE(REQUEST_OFFSET.request));
+      await waitUntil(() => cluster.primary.sockets.size === 0);
+      assert.equal(framesOf(cluster.primary, Operation.StoreConsumerOffset).length, 0);
+    } finally {
+      cluster.client.destroy();
+      await closeAll([cluster.coordinator, cluster.primary], stream);
+    }
+  });
+
+  for (const [status, reason, restarts] of [
+    [NOT_PRIMARY, 'it moved', false]
+  ] as const) {
+    it(`fetches a partition on a new route after a section refuses it because ${reason}, ${restarts ? 'from the start strategy' : 'at the same offset'}`,
+      async () => {
+        const replies = [
+          fetchReplyBody({ partitionId: POLLED_PARTITION, offset: 5n, count: 1, context: SECTION_CONTEXT }),
+          fetchReplyBody({ partitionId: POLLED_PARTITION, status }),
+          fetchReplyBody({ partitionId: POLLED_PARTITION })
+        ];
+        const cluster = await startGroupCluster((frame, socket) =>
+          socket.write(replyFrame(frame, Operation.NonReplicated, replies.shift())), undefined, () => 5n);
+        let stream: Readable | undefined;
+        try {
+          stream = await openGroupStream(cluster.coordinator.port, { count: 10 });
+          assert.deepEqual(offsetsOf(await consume(stream)()), [[POLLED_PARTITION, [5n]]]);
+          const fenced = { ...offsetStart(POLLED_PARTITION, 6n, SECTION_CONTEXT), kind: PollingStrategyKind.Next };
+          assert.deepEqual(fetchStarts(cluster.primary),
+            [[nextStart(POLLED_PARTITION, 5n)], [fenced], [restarts ? nextStart(POLLED_PARTITION) : fenced]]);
+          assert.equal(countCommand(cluster.coordinator, COMMAND_CODE.GetPartitionRoute), 2);
+        } finally {
+          cluster.client.destroy();
+          await closeAll([cluster.coordinator, cluster.primary], stream);
+        }
+      });
+  }
+
+});
+
+describe('deferred requests', () => {
+  const MAX_DEFERRED_REQUESTS = 64;
+
+  const deferredPoll = (deferred: Partial<DeferredPollOptions> = {}): Buffer => POLL_MESSAGES.serialize({
+    streamId: 1, topicId: 2, consumer: { kind: ConsumerKind.Single, id: 1 }, partitionId: 3,
+    pollingStrategy: PollingStrategy.Offset(0n), count: 1, autocommit: false, deferred
+  });
+  const codeOf = (frame: Buffer): number => frame.readUInt32LE(REQUEST_OFFSET.reserved);
+  const framesWith = (server: VsrTestServer, code: number): Buffer[] => server.frames.filter((frame) =>
+    frame[REQUEST_OFFSET.operation] === Operation.NonReplicated && codeOf(frame) === code);
+  const pollBudget = (frame: Buffer) => {
+    const at = frame.length - DEFERRED_POLL_OPTIONS_SIZE;
+    return {
+      wait: frame.readBigUInt64LE(at + DEFERRED_POLL_OFFSET.wait),
+      maxBytes: frame.readUInt32LE(at + DEFERRED_POLL_OFFSET.maxBytes),
+      timeout: frame.readBigUInt64LE(at + DEFERRED_POLL_OFFSET.requestTimeout)
+    };
+  };
+  const identity = (frame: Buffer) => ({
+    client: frame.subarray(REQUEST_OFFSET.client, REQUEST_OFFSET.client + 16).toString('hex'),
+    request: frame.readBigUInt64LE(REQUEST_OFFSET.request),
+    session: frame.readBigUInt64LE(REQUEST_OFFSET.session)
+  });
+  const isAbort = (error: unknown): boolean => error instanceof Error && error.name === 'AbortError';
+  const isRefusal = (code: number) => (error: unknown): boolean =>
+    error instanceof ResponseError && error.errorCode === code;
+  const fetchBody = (targets: FetchTarget[], request: Partial<FetchMessages> = {}): Buffer => FETCH_MESSAGES.serialize({
+    streamId: 1, topicId: 2, consumer: { kind: ConsumerKind.Single, id: 1 }, targets, count: 1, autocommit: false,
+    ...request
+  });
+  const offsetTargets = (...partitions: number[]): FetchTarget[] =>
+    partitions.map((partitionId) => ({ partitionId, pollingStrategy: PollingStrategy.Offset(0n) }));
+  const emptyFetch = (frame: Buffer): Buffer => replyFrame(frame, Operation.NonReplicated, fetchReplyBody());
+
+  for (const timeout of [999_999n, 1_000_000n]) {
+    it(`rejects a raw fetch timeout of ${timeout} us before connecting or routing`, async () => {
+      const cluster = await startPollCluster();
+      const body = fetchBody(offsetTargets(3), { options: { maxWait: 0, requestTimeout: 2_000 } });
+      body.writeBigUInt64LE(timeout, fetchFieldsAt(body) + FETCH_MESSAGES_FIELD.requestTimeout);
+      try {
+        await assert.rejects(cluster.client.sendCommand(COMMAND_CODE.FetchMessages, body), isRefusal(3));
+        assert.equal(cluster.coordinator.frames.length, 0);
+        assert.equal(cluster.primary.frames.length, 0);
+      } finally { await cluster.close(); }
+    });
+  }
+
+  for (const routingMs of [500, 1_000, 1_500]) {
+    it(`keeps the caller deadline after ${routingMs} ms of routing a zero-wait fetch`, async (context) => {
+      let now = Date.now();
+      context.mock.method(Date, 'now', () => now);
+      const cluster = await startPollCluster((frame, socket) => {
+        if (codeOf(frame) !== COMMAND_CODE.FetchMessages) return false;
+        socket.write(emptyFetch(frame));
+        return true;
+      }, (frame) => {
+        if (codeOf(frame) === COMMAND_CODE.GetPartitionRoute) now += routingMs;
+        return false;
+      });
+      try {
+        const request = cluster.client.sendCommand(COMMAND_CODE.FetchMessages,
+          fetchBody(offsetTargets(3), { options: { maxWait: 0, requestTimeout: 2_000 } }));
+        if (routingMs > 1_000) {
+          await assert.rejects(request, isRefusal(TRANSIENT_NOT_ACCEPTED));
+          assert.equal(framesWith(cluster.primary, COMMAND_CODE.FetchMessages).length, 0);
+        } else {
+          assert.deepEqual(FETCH_MESSAGES.deserialize(await request), []);
+          const [frame] = framesWith(cluster.primary, COMMAND_CODE.FetchMessages);
+          const fieldsAt = HEADER_SIZE + fetchFieldsAt(frame.subarray(HEADER_SIZE));
+          assert.equal(frame.readBigUInt64LE(fieldsAt + FETCH_MESSAGES_FIELD.wait), 0n);
+          assert.equal(frame.readBigUInt64LE(fieldsAt + FETCH_MESSAGES_FIELD.requestTimeout), BigInt(2_000 - routingMs) * 1_000n);
+        }
+      } finally { await cluster.close(); }
+    });
+  }
+
+  it('cancels a parked deferred poll on its link when the caller aborts, and ignores its late reply', async () => {
+    let parked: { frame: Buffer, socket: Socket } | undefined;
+    let cancelledOn: Socket | undefined;
+    const cluster = await startPollCluster((frame, socket) => {
+      if (codeOf(frame) === COMMAND_CODE.CancelDeferredRequest) {
+        cancelledOn = socket;
+        return true;
+      }
+      if (codeOf(frame) !== COMMAND_CODE.PollMessagesDeferred) return false;
+      if (parked) socket.write(replyFrame(frame, Operation.NonReplicated, Buffer.from('next')));
+      else parked = { frame, socket };
+      return true;
+    });
+    try {
+      const controller = new AbortController();
+      const polling = cluster.client.sendCommand(COMMAND_CODE.PollMessagesDeferred, deferredPoll(),
+        { signal: controller.signal });
+      await waitUntil(() => parked !== undefined);
+      controller.abort();
+      await assert.rejects(polling, isAbort);
+      await waitUntil(() => cancelledOn !== undefined);
+      const [cancellation] = framesWith(cluster.primary, COMMAND_CODE.CancelDeferredRequest);
+      assert.equal(cancellation.length, HEADER_SIZE);
+      assert.deepEqual(identity(cancellation), identity(parked!.frame));
+      assert.equal(cancelledOn, parked!.socket);
+
+      parked!.socket.write(replyFrame(parked!.frame, Operation.NonReplicated, Buffer.from('late')));
+      const next = await cluster.client.sendCommand(COMMAND_CODE.PollMessagesDeferred, deferredPoll());
+      assert.equal(next.data.toString(), 'next');
+      assert.equal(cluster.primary.sockets.size, 1);
+    } finally {
+      await cluster.close();
+    }
+  });
+
+  for (const code of [COMMAND_CODE.PollMessagesDeferred, COMMAND_CODE.FetchMessages]) {
+    for (const cancelWins of [true, false]) {
+      it(`cancels a parked committing ${code === COMMAND_CODE.FetchMessages ? 'fetch' : 'poll'} on the server and ${cancelWins ? 'ends as aborted when the cancel won' : 'delivers the reply that stored first'}`, async () => {
+        let parked: { frame: Buffer, socket: Socket } | undefined;
+        const cluster = await startPollCluster((frame, socket) => {
+          if (frame[REQUEST_OFFSET.operation] === Operation.StoreConsumerOffset) {
+            socket.write(replyFrame(frame, Operation.StoreConsumerOffset, metadataResult(0)));
+            return true;
+          }
+          if (codeOf(frame) === COMMAND_CODE.CancelDeferredRequest) return true;
+          if (codeOf(frame) !== code) return false;
+          parked = { frame, socket };
+          return true;
+        });
+        try {
+          const controller = new AbortController();
+          const body = code === COMMAND_CODE.FetchMessages
+            ? fetchBody(offsetTargets(3), { autocommit: true })
+            : POLL_MESSAGES.serialize({
+              streamId: 1, topicId: 2, consumer: { kind: ConsumerKind.Single, id: 1 }, partitionId: 3,
+              pollingStrategy: PollingStrategy.Offset(0n), count: 1, autocommit: true, deferred: {}
+            });
+          let settled = false;
+          const pending = cluster.client.sendCommand(code, body, { signal: controller.signal });
+          void pending.finally(() => { settled = true; }).catch(() => undefined);
+          await waitUntil(() => parked !== undefined);
+
+          controller.abort();
+
+          await waitUntil(() => framesWith(cluster.primary, COMMAND_CODE.CancelDeferredRequest).length === 1);
+          await cluster.client.sendCommand(COMMAND_CODE.GetStats, Buffer.alloc(0));
+          assert.equal(settled, false, 'a committing request keeps its reply slot after the cancel');
+          const storing = cluster.client.sendCommand(COMMAND_CODE.StoreOffset, storeOffsetPayload);
+          void storing.catch(() => undefined);
+          await cluster.client.sendCommand(COMMAND_CODE.GetStats, Buffer.alloc(0));
+          assert.equal(framesOf(cluster.primary, Operation.StoreConsumerOffset).length, 0,
+            'the partition takes no store until the cancelled request has its reply');
+          if (cancelWins) {
+            parked!.socket.write(replyFrame(parked!.frame, Operation.NonReplicated, Buffer.alloc(0),
+              TRANSIENT_NOT_ACCEPTED));
+            await assert.rejects(pending, isAbort);
+          } else {
+            const reply = code === COMMAND_CODE.FetchMessages ? Buffer.alloc(4) : Buffer.from('stored');
+            parked!.socket.write(replyFrame(parked!.frame, Operation.NonReplicated, reply));
+            assert.deepEqual((await pending).data, reply);
+          }
+          await storing;
+          await cluster.client.sendCommand(COMMAND_CODE.StoreOffset, storeOffsetPayload);
+        } finally {
+          await cluster.close();
+        }
+      });
+    }
+  }
+
+  it('sends to the partition of a parked progress poll at once, while an offset store waits for the poll', async () => {
+    const SEND_DEADLINE_MS = 2_000;
+    const progressPoll = POLL_MESSAGES.serialize({
+      streamId: 1, topicId: 2, consumer: { kind: ConsumerKind.Single, id: 1 }, partitionId: 3,
+      pollingStrategy: PollingStrategy.Next, count: 1, autocommit: false, deferred: {}
+    });
+    let parked: { frame: Buffer, socket: Socket } | undefined;
+    const cluster = await startPollCluster((frame, socket) => {
+      if (frame[REQUEST_OFFSET.operation] === Operation.StoreConsumerOffset) {
+        socket.write(replyFrame(frame, Operation.StoreConsumerOffset, metadataResult(0)));
+        return true;
+      }
+      if (codeOf(frame) !== COMMAND_CODE.PollMessagesDeferred) return false;
+      parked = { frame, socket };
+      return true;
+    });
+    try {
+      const polling = cluster.client.sendCommand(COMMAND_CODE.PollMessagesDeferred, progressPoll);
+      void polling.catch(() => undefined);
+      await waitUntil(() => parked !== undefined);
+      const storing = cluster.client.sendCommand(COMMAND_CODE.StoreOffset, storeOffsetPayload);
+      void storing.catch(() => undefined);
+      // One lane for polls and sends would hold the send until the poll ends.
+      await cluster.client.sendCommand(COMMAND_CODE.SendMessages, explicitSend,
+        { deadline: Date.now() + SEND_DEADLINE_MS });
+      assert.equal(framesOf(cluster.primary, Operation.StoreConsumerOffset).length, 0,
+        'the store waits for the parked poll');
+
+      parked!.socket.write(replyFrame(parked!.frame, Operation.NonReplicated, Buffer.from('polled')));
+      assert.equal((await polling).data.toString(), 'polled');
+      await storing;
+      const [send] = framesOf(cluster.primary, Operation.SendMessages);
+      const [store] = framesOf(cluster.primary, Operation.StoreConsumerOffset);
+      assert.ok(store.readBigUInt64LE(REQUEST_OFFSET.request) > send.readBigUInt64LE(REQUEST_OFFSET.request),
+        'the partition sees the request ids of its mutations in order');
+    } finally {
+      await cluster.close();
+    }
+  });
+
+  it('gives up at its request timeout and cancels the parked request', async () => {
+    const REQUEST_TIMEOUT_MS = 200;
+    const cluster = await startPollCluster((frame) =>
+      codeOf(frame) === COMMAND_CODE.PollMessagesDeferred || codeOf(frame) === COMMAND_CODE.CancelDeferredRequest);
+    try {
+      const started = Date.now();
+      await assert.rejects(cluster.client.sendCommand(COMMAND_CODE.PollMessagesDeferred,
+        deferredPoll({ maxWait: 0, requestTimeout: REQUEST_TIMEOUT_MS })));
+      assert.ok(Date.now() - started < OPERATION_TIMEOUT_MS / 10);
+      await waitUntil(() => framesWith(cluster.primary, COMMAND_CODE.CancelDeferredRequest).length === 1);
+      assert.deepEqual(identity(framesWith(cluster.primary, COMMAND_CODE.CancelDeferredRequest)[0]),
+        identity(framesWith(cluster.primary, COMMAND_CODE.PollMessagesDeferred)[0]));
+    } finally {
+      await cluster.close();
+    }
+  });
+
+  it(`parks at most ${MAX_DEFERRED_REQUESTS} deferred requests and sends the next one when a parked one ends`, async () => {
+    const parked: { frame: Buffer, socket: Socket }[] = [];
+    const cluster = await startPollCluster((frame, socket) => {
+      if (codeOf(frame) === COMMAND_CODE.FetchMessages) {
+        socket.write(emptyFetch(frame));
+        return true;
+      }
+      if (codeOf(frame) !== COMMAND_CODE.PollMessagesDeferred) return false;
+      parked.push({ frame, socket });
+      return true;
+    });
+    try {
+      const polls = Array.from({ length: MAX_DEFERRED_REQUESTS }, () =>
+        cluster.client.sendCommand(COMMAND_CODE.PollMessagesDeferred, deferredPoll()));
+      await waitUntil(() => parked.length === MAX_DEFERRED_REQUESTS);
+      // A request that finds no free slot within its deadline was never sent.
+      await assert.rejects(cluster.client.sendCommand(COMMAND_CODE.PollMessagesDeferred,
+        deferredPoll({ maxWait: 0, requestTimeout: 100 })), isRefusal(TRANSIENT_NOT_ACCEPTED));
+      // Fetches take slots of the same pool.
+      const waiting = cluster.client.sendCommand(COMMAND_CODE.FetchMessages, fetchBody(offsetTargets(3)));
+      await new Promise((resolve) => setTimeout(resolve, 50));
+      assert.equal(parked.length, MAX_DEFERRED_REQUESTS);
+      assert.equal(framesWith(cluster.primary, COMMAND_CODE.FetchMessages).length, 0);
+      parked[0].socket.write(replyFrame(parked[0].frame, Operation.NonReplicated, Buffer.from('first')));
+      assert.deepEqual(FETCH_MESSAGES.deserialize(await waiting), []);
+      for (const { frame, socket } of parked.slice(1))
+        socket.write(replyFrame(frame, Operation.NonReplicated, Buffer.from('rest')));
+      await Promise.all(polls);
+      assert.equal(framesWith(cluster.primary, COMMAND_CODE.PollMessagesDeferred).length, MAX_DEFERRED_REQUESTS);
+    } finally {
+      await cluster.close();
+    }
+  });
+
+  it('sends a deferred poll again on its route after a capacity refusal, with what remains of its budget', async () => {
+    const REFUSAL_DELAY_MS = 30;
+    let polls = 0;
+    const cluster = await startPollCluster((frame, socket) => {
+      if (codeOf(frame) !== COMMAND_CODE.PollMessagesDeferred) return false;
+      polls += 1;
+      if (polls === 1)
+        setTimeout(() => socket.write(replyFrame(frame, Operation.NonReplicated, Buffer.alloc(0),
+          DEFERRED_POLL_CAPACITY_EXCEEDED)), REFUSAL_DELAY_MS);
+      else socket.write(replyFrame(frame, Operation.NonReplicated, Buffer.from('primary')));
+      return true;
+    });
+    try {
+      await cluster.client.sendCommand(COMMAND_CODE.PollMessagesDeferred,
+        deferredPoll({ maxWait: 5_000, requestTimeout: 8_000 }));
+      const [first, second] = framesWith(cluster.primary, COMMAND_CODE.PollMessagesDeferred).map(pollBudget);
+      const spent = first.wait - second.wait;
+      assert.ok(spent >= BigInt(REFUSAL_DELAY_MS - 10) * 1_000n, `the second attempt waits ${spent} us less`);
+      assert.equal(first.timeout - second.timeout, spent);
+      assert.equal(countCommand(cluster.coordinator, COMMAND_CODE.GetPartitionRoute), 1);
+    } finally {
+      await cluster.close();
+    }
+  });
+
+  it('lowers the byte limit of a deferred poll to the largest reply that its link reads', async () => {
+    const MAX_RESPONSE_FRAME_SIZE = 4_096;
+    const cluster = await startPollCluster(undefined, undefined, 'TCP', { maxResponseFrameSize: MAX_RESPONSE_FRAME_SIZE });
+    try {
+      await cluster.client.sendCommand(COMMAND_CODE.PollMessagesDeferred, deferredPoll());
+      const [frame] = framesWith(cluster.primary, COMMAND_CODE.PollMessagesDeferred);
+      assert.equal(pollBudget(frame).maxBytes, MAX_RESPONSE_FRAME_SIZE - HEADER_SIZE);
+    } finally {
+      await cluster.close();
+    }
+  });
+
+  it('refuses a raw cancellation, a deferred request to a chosen replica and a malformed deferred body', async () => {
+    const server = await startVsrServer((frame, socket) => singleNodeHandler(server.port)(frame, socket));
+    const client = new CommandResponseStream(vsrConfig(server.port));
+    const malformed = deferredPoll();
+    malformed.writeBigUInt64LE(0n, malformed.length - DEFERRED_POLL_OPTIONS_SIZE + DEFERRED_POLL_OFFSET.requestTimeout);
+    const duplicatedFetch = fetchBody(offsetTargets(3, 4));
+    duplicatedFetch.writeUInt32LE(3, fetchFieldsAt(duplicatedFetch) + FETCH_MESSAGES_FIELDS_SIZE + FETCH_TARGET_SIZE);
+    try {
+      for (const [command, payload, options] of [
+        [COMMAND_CODE.CancelDeferredRequest, Buffer.alloc(0), {}],
+        [COMMAND_CODE.PollMessagesDeferred, deferredPoll(), { replica: 0 }],
+        [COMMAND_CODE.FetchMessages, fetchBody(offsetTargets(3)), { replica: 0 }],
+        [COMMAND_CODE.PollMessagesDeferred, malformed, {}],
+        [COMMAND_CODE.FetchMessages, duplicatedFetch, {}]
+      ] as const)
+        await assert.rejects(client.sendCommand(command, payload, options), (error: unknown) =>
+          error instanceof ResponseError && error.commandCode === command && error.errorCode === 3);
+      assert.equal(server.frames.length, 0);
+    } finally { client.destroy(); await server.close(); }
+  });
+
+  /**
+   * A cluster whose metadata primary is replica 0, with partition primaries 7
+   * and 9. `primaryOf` routes each partition, given its lookup count, unless
+   * `answerLookup` answers that lookup.
+   */
+  const startReadinessCluster = async (
+    waitOn7: FrameOverride, waitOn9: FrameOverride, primaryOf: (partition: number, lookup: number) => number,
+    answerLookup: (frame: Buffer, socket: Socket, partition: number, lookup: number) => boolean = () => false
+  ) => {
+    const lookups = new Map<number, number>();
+    const ninth = await startVsrServer((frame, socket) => {
+      if (!waitOn9(frame, socket)) singleNodeHandler(ninth.port)(frame, socket);
+    });
+    const cluster = await startPollCluster(waitOn7, (frame, socket) => {
+      const code = codeOf(frame);
+      if (code === COMMAND_CODE.GetClusterMetadata) {
+        socket.write(replyFrame(frame, Operation.NonReplicated,
+          threeNodeMetadataBody(cluster.coordinator.port, cluster.primary.port, ninth.port)));
+        return true;
+      }
+      if (code !== COMMAND_CODE.GetPartitionRoute) return false;
+      const partition = frame.readUInt32LE(frame.length - PARTITION_ID_SIZE);
+      const lookup = (lookups.get(partition) ?? 0) + 1;
+      lookups.set(partition, lookup);
+      if (answerLookup(frame, socket, partition, lookup)) return true;
+      socket.write(replyFrame(frame, Operation.NonReplicated,
+        routingBody(BigInt(lookup), 0n, primaryOf(partition, lookup))));
+      return true;
+    });
+    return {
+      ...cluster, ninth, lookups,
+      close: async () => {
+        await cluster.close();
+        await ninth.close();
+      }
+    };
+  };
+
+  it('sends a fetch to the primary of its partitions with context zero, and fences a target without a fence from its route',
+    async () => {
+      const ROUTE_INCARNATION = 5n;
+      const fence = { incarnation: 3n, ownerGeneration: 9n, metadataOp: 0n };
+      const cluster = await startPollCluster((frame, socket) => {
+        if (codeOf(frame) !== COMMAND_CODE.FetchMessages) return false;
+        socket.write(emptyFetch(frame));
+        return true;
+      }, (frame, socket) => {
+        if (codeOf(frame) !== COMMAND_CODE.GetPartitionRoute) return false;
+        socket.write(replyFrame(frame, Operation.NonReplicated,
+          routingBody(frame.readBigUInt64LE(REQUEST_OFFSET.minimumMetadataOp) || 1n, ROUTE_INCARNATION)));
+        return true;
+      });
+      try {
+        await cluster.client.sendCommand(COMMAND_CODE.FetchMessages, fetchBody([
+          { partitionId: 1, pollingStrategy: PollingStrategy.Offset(0n) },
+          { partitionId: 2, pollingStrategy: PollingStrategy.Offset(4n), context: fence }
+        ]));
+        const [frame] = framesWith(cluster.primary, COMMAND_CODE.FetchMessages);
+        assert.deepEqual(stampedContext(frame), EMPTY_PARTITION_CONTEXT);
+        assert.deepEqual(fetchOf(frame).targets.map(({ partitionId, incarnation, ownerGeneration }) =>
+          ({ partitionId, incarnation, ownerGeneration })), [
+          { partitionId: 1, incarnation: ROUTE_INCARNATION, ownerGeneration: 0n },
+          { partitionId: 2, incarnation: fence.incarnation, ownerGeneration: fence.ownerGeneration }
+        ]);
+        assert.equal(framesWith(cluster.coordinator, COMMAND_CODE.FetchMessages).length, 0);
+      } finally {
+        await cluster.close();
+      }
+    });
+
+  it('gives a fetch and each target the room that its link reads after the section table, and refuses one without room',
+    async () => {
+      const MAX_RESPONSE_FRAME_SIZE = 4_096;
+      // The table of 63 sections leaves 308 of the 3840 body bytes, less than
+      // the 344 bytes of a reply with one message.
+      const ROOMLESS_TARGETS = 63;
+      const cluster = await startPollCluster((frame, socket) => {
+        if (codeOf(frame) !== COMMAND_CODE.FetchMessages) return false;
+        socket.write(emptyFetch(frame));
+        return true;
+      }, undefined, 'TCP', { maxResponseFrameSize: MAX_RESPONSE_FRAME_SIZE });
+      try {
+        await cluster.client.sendCommand(COMMAND_CODE.FetchMessages, fetchBody(offsetTargets(1, 2)));
+        const { maxBytes, targets } = fetchOf(framesWith(cluster.primary, COMMAND_CODE.FetchMessages)[0]);
+        const room = MAX_RESPONSE_FRAME_SIZE - HEADER_SIZE - fetchReplyHeadSize(2);
+        assert.deepEqual([maxBytes, ...targets.map((target) => target.maxBytes)], [room, room, room]);
+        const partitions = Array.from({ length: ROOMLESS_TARGETS }, (_, index) => index + 1);
+        await assert.rejects(cluster.client.sendCommand(COMMAND_CODE.FetchMessages, fetchBody(offsetTargets(...partitions))),
+          isRefusal(INVALID_SIZE_BYTES));
+        assert.equal(framesWith(cluster.primary, COMMAND_CODE.FetchMessages).length, 1);
+        assert.equal(countCommand(cluster.coordinator, COMMAND_CODE.GetPartitionRoute), 2);
+      } finally {
+        await cluster.close();
+      }
+    });
+
+  it('parks a fetch of many partitions in one deferred slot', async () => {
+    const parked: { frame: Buffer, socket: Socket }[] = [];
+    const cluster = await startPollCluster((frame, socket) => {
+      if (codeOf(frame) !== COMMAND_CODE.PollMessagesDeferred && codeOf(frame) !== COMMAND_CODE.FetchMessages)
+        return false;
+      parked.push({ frame, socket });
+      return true;
+    });
+    try {
+      const requests = [
+        cluster.client.sendCommand(COMMAND_CODE.FetchMessages, fetchBody(offsetTargets(1, 2, 3))),
+        ...Array.from({ length: MAX_DEFERRED_REQUESTS - 1 }, () =>
+          cluster.client.sendCommand(COMMAND_CODE.PollMessagesDeferred, deferredPoll()))
+      ];
+      await waitUntil(() => parked.length === MAX_DEFERRED_REQUESTS);
+      await assert.rejects(cluster.client.sendCommand(COMMAND_CODE.PollMessagesDeferred,
+        deferredPoll({ maxWait: 0, requestTimeout: 100 })), isRefusal(TRANSIENT_NOT_ACCEPTED));
+      for (const { frame, socket } of parked)
+        socket.write(codeOf(frame) === COMMAND_CODE.FetchMessages
+          ? emptyFetch(frame) : replyFrame(frame, Operation.NonReplicated, Buffer.from('polled')));
+      await Promise.all(requests);
+    } finally {
+      await cluster.close();
+    }
+  });
+
+  it('blocks every target of a still-live committing fetch after its deadline until explicit abandonment', async () => {
+    let parked: { frame: Buffer, socket: Socket } | undefined;
+    const answer: FrameOverride = (frame, socket) => {
+      if (frame[REQUEST_OFFSET.operation] === Operation.StoreConsumerOffset) {
+        socket.write(provedReply(frame));
+        return true;
+      }
+      if (codeOf(frame) !== COMMAND_CODE.FetchMessages) return false;
+      if (!parked) parked = { frame, socket };
+      else socket.write(emptyFetch(frame));
+      return true;
+    };
+    const cluster = await startReadinessCluster(answer, answer, (_partition, lookup) => lookup === 1 ? 7 : 9);
+    try {
+      await assert.rejects(cluster.client.sendCommand(COMMAND_CODE.FetchMessages,
+        fetchBody(offsetTargets(5, 3), { autocommit: true, options: { maxWait: 0, requestTimeout: 1_200 } })));
+      assert.ok(parked && !parked.socket.destroyed, 'the old request remains live on its original connection');
+      await cluster.client.sendCommand(COMMAND_CODE.GetPartitionRoute, serializePartitionRoute(1, 2, 3));
+      await cluster.client.sendCommand(COMMAND_CODE.GetPartitionRoute, serializePartitionRoute(1, 2, 5));
+      let operationId!: bigint;
+      await assert.rejects(cluster.client.sendCommand(COMMAND_CODE.StoreOffset, storeOffsetPayload), (error: unknown) => {
+        assert.ok(error instanceof LaneBlockedError);
+        assert.equal(error.partitionId, 3);
+        operationId = error.operationId;
+        return true;
+      });
+      assert.equal(framesOf(cluster.primary, Operation.StoreConsumerOffset).length, 0);
+      assert.equal(framesOf(cluster.ninth, Operation.StoreConsumerOffset).length, 0);
+      await assert.rejects(cluster.client.sendCommand(COMMAND_CODE.FetchMessages,
+        fetchBody(offsetTargets(1, 5), { autocommit: true })), (error: unknown) =>
+        error instanceof LaneBlockedError && error.operationId === operationId && error.partitionId === 5);
+      await cluster.client.sendCommand(COMMAND_CODE.FetchMessages, fetchBody(offsetTargets(1), { autocommit: true }));
+      await cluster.client.sendCommand(COMMAND_CODE.FetchMessages, fetchBody(offsetTargets(3)));
+      await cluster.client.sendCommand(COMMAND_CODE.GetOffset, GET_OFFSET.serialize({
+        streamId: 1, topicId: 2, consumer: { kind: ConsumerKind.Single, id: 1 }, partitionId: 3
+      }));
+      await assert.rejects(cluster.client.resolveOperation(operationId), (error: unknown) =>
+        error instanceof OperationUncertainError && error.operationId === operationId);
+      parked.socket.write(emptyFetch(parked.frame));
+      await cluster.client.sendCommand(COMMAND_CODE.GetStats, Buffer.alloc(0));
+      await assert.rejects(cluster.client.sendCommand(COMMAND_CODE.StoreOffset, storeOffsetPayload), (error: unknown) =>
+        error instanceof LaneBlockedError && error.operationId === operationId);
+      const fetches = framesWith(cluster.primary, COMMAND_CODE.FetchMessages).length;
+      cluster.client.abandonOperation(operationId);
+      await cluster.client.sendCommand(COMMAND_CODE.StoreOffset, storeOffsetPayload);
+      assert.equal(framesWith(cluster.primary, COMMAND_CODE.FetchMessages).length, fetches, 'recovery never replays109');
+      assert.equal(framesOf(cluster.ninth, Operation.StoreConsumerOffset).length, 1);
+    } finally { await cluster.close(); }
+  });
+
+  for (const status of [TRANSIENT_NOT_COMMITTED, REQUEST_ALREADY_APPLIED, INVALID_COMMAND, 11001]) {
+    it(`preserves healthy committing sections and retains only the section with unknown status ${status}`, async () => {
+      const cluster = await startPollCluster((frame, socket) => {
+        if (frame[REQUEST_OFFSET.operation] === Operation.StoreConsumerOffset) socket.write(provedReply(frame));
+        else if (codeOf(frame) === COMMAND_CODE.FetchMessages)
+          socket.write(replyFrame(frame, Operation.NonReplicated, fetchReplyBody(
+            { partitionId: 1, count: 1, context: EMPTY_PARTITION_CONTEXT }, { partitionId: 3, status })));
+        else return false;
+        return true;
+      });
+      try {
+        const response = await cluster.client.sendCommand(COMMAND_CODE.FetchMessages,
+          fetchBody(offsetTargets(1, 3), { autocommit: true }));
+        assert.equal(response.data.readUInt32LE(fetchReplyHeadSize(1) + FETCH_SECTION_FIELD.status), status, 'raw bytes stay unchanged');
+        const [healthy, uncertain] = FETCH_MESSAGES.deserialize(response);
+        assert.equal(decodeFetchSection(healthy).count, 1);
+        assert.ok(uncertain.progressError instanceof FetchProgressUncertainError);
+        assert.equal(uncertain.status, uncertain.progressError.errorCode);
+        const operationId = uncertain.progressError.operationId!;
+        await cluster.client.sendCommand(COMMAND_CODE.StoreOffset, STORE_OFFSET.serialize({
+          streamId: 1, topicId: 2, consumer: { kind: ConsumerKind.Single, id: 1 }, partitionId: 1, offset: 0n
+        }));
+        await assert.rejects(cluster.client.sendCommand(COMMAND_CODE.StoreOffset, storeOffsetPayload), (error: unknown) =>
+          error instanceof LaneBlockedError && error.partitionId === 3 && error.operationId === operationId);
+        cluster.client.abandonOperation(operationId);
+        await cluster.client.sendCommand(COMMAND_CODE.StoreOffset, storeOffsetPayload);
+      } finally { await cluster.close(); }
+    });
+  }
+
+  for (const status of [TRANSIENT_NOT_ACCEPTED, 3024]) {
+    it(`keeps a committing section refused with status ${status} a plain refusal that blocks nothing`, async () => {
+      const cluster = await startPollCluster((frame, socket) => {
+        if (frame[REQUEST_OFFSET.operation] === Operation.StoreConsumerOffset) socket.write(provedReply(frame));
+        else if (codeOf(frame) === COMMAND_CODE.FetchMessages)
+          socket.write(replyFrame(frame, Operation.NonReplicated, fetchReplyBody({ partitionId: 3, status })));
+        else return false;
+        return true;
+      });
+      try {
+        const [refused] = FETCH_MESSAGES.deserialize(await cluster.client.sendCommand(COMMAND_CODE.FetchMessages,
+          fetchBody(offsetTargets(3), { autocommit: true })));
+        assert.deepEqual([refused.status, refused.progressError], [status, undefined]);
+        await cluster.client.sendCommand(COMMAND_CODE.StoreOffset, storeOffsetPayload);
+      } finally { await cluster.close(); }
+    });
+  }
+
+  for (const malformed of ['missing table', 'duplicate target', 'foreign target'] as const) {
+    it(`retains every committing target for a malformed reply with ${malformed}`, async () => {
+      const cluster = await startPollCluster((frame, socket) => {
+        if (codeOf(frame) !== COMMAND_CODE.FetchMessages) return false;
+        const body = malformed === 'missing table' ? Buffer.alloc(0)
+          : malformed === 'duplicate target' ? fetchReplyBody({ partitionId: 3 }, { partitionId: 3 })
+            : fetchReplyBody({ partitionId: 9 });
+        socket.write(replyFrame(frame, Operation.NonReplicated, body));
+        return true;
+      });
+      try {
+        await assert.rejects(cluster.client.sendCommand(COMMAND_CODE.FetchMessages,
+          fetchBody(offsetTargets(1, 3), { autocommit: true })), FetchProgressUncertainError);
+        await assert.rejects(cluster.client.sendCommand(COMMAND_CODE.StoreOffset, storeOffsetPayload), (error: unknown) =>
+          error instanceof LaneBlockedError && error.partitionId === 3 && error.requiresExplicitRecovery &&
+          error.errorCode === 14009);
+      } finally { await cluster.close(); }
+    });
+  }
+
+  for (const loss of ['link loss', 'not committed'] as const) {
+    it(`ends a committing poll with unknown outcome after ${loss} with 14010 and blocks its partition until abandoned`,
+      async () => {
+        let polls = 0;
+        const cluster = await startPollCluster((frame, socket) => {
+          if (frame[REQUEST_OFFSET.operation] === Operation.StoreConsumerOffset) socket.write(provedReply(frame));
+          else if (codeOf(frame) !== COMMAND_CODE.PollMessagesDeferred) return false;
+          else if (++polls > 1) socket.write(replyFrame(frame, Operation.NonReplicated, Buffer.from('polled')));
+          else if (loss === 'link loss') socket.destroy();
+          else socket.write(replyFrame(frame, Operation.NonReplicated, Buffer.alloc(0), TRANSIENT_NOT_COMMITTED));
+          return true;
+        }, undefined, 'TCP', { reconnect: { enabled: true, interval: 10, maxRetries: 5 } });
+        const committing = POLL_MESSAGES.serialize({
+          streamId: 1, topicId: 2, consumer: { kind: ConsumerKind.Single, id: 1 }, partitionId: 3,
+          pollingStrategy: PollingStrategy.Next, count: 1, autocommit: true, deferred: {}
+        });
+        try {
+          let operationId!: bigint;
+          await assert.rejects(cluster.client.sendCommand(COMMAND_CODE.PollMessagesDeferred, committing),
+            (error: unknown) => {
+              assert.ok(error instanceof FetchProgressUncertainError, String(error));
+              operationId = error.operationId!;
+              return true;
+            });
+          await cluster.client.authenticate({ username: 'iggy', password: 'iggy' });
+          for (const [code, body] of [[COMMAND_CODE.PollMessagesDeferred, committing],
+            [COMMAND_CODE.StoreOffset, storeOffsetPayload], [COMMAND_CODE.DeleteConsumerOffset, deleteOffsetPayload]] as const)
+            await assert.rejects(cluster.client.sendCommand(code, body), (error: unknown) =>
+              error instanceof LaneBlockedError && error.errorCode === PARTITION_LANE_BLOCKED &&
+              error.operationId === operationId && error.partitionId === 3);
+          assert.equal(polls, 1);
+          cluster.client.abandonOperation(operationId);
+          await cluster.client.sendCommand(COMMAND_CODE.StoreOffset, storeOffsetPayload);
+          await cluster.client.sendCommand(COMMAND_CODE.PollMessagesDeferred, committing);
+          assert.equal(polls, 2);
+        } finally { await cluster.close(); }
+      });
+  }
+
+  it('preserves committing recovery across sign-in, resolves aliases, and separates new incarnations', async () => {
+    let incarnation = 7n;
+    const cluster = await startPollCluster((frame, socket) => {
+      if (frame[REQUEST_OFFSET.operation] === Operation.StoreConsumerOffset) socket.write(stampedContext(frame).incarnation === 0n
+        ? replyFrame(frame, Operation.StoreConsumerOffset, Buffer.alloc(0), HISTORY_UNAVAILABLE) : provedReply(frame));
+      else if (codeOf(frame) === COMMAND_CODE.FetchMessages)
+        socket.write(replyFrame(frame, Operation.NonReplicated, Buffer.alloc(0), TRANSIENT_NOT_COMMITTED));
+      else return false;
+      return true;
+    }, (frame, socket) => {
+      if (codeOf(frame) !== COMMAND_CODE.GetPartitionRoute) return false;
+      socket.write(replyFrame(frame, Operation.NonReplicated, routingBody(1n, incarnation)));
+      return true;
+    });
+    try {
+      let operationId!: bigint;
+      await assert.rejects(cluster.client.sendCommand(COMMAND_CODE.FetchMessages,
+        fetchBody(offsetTargets(3), { autocommit: true })), (error: unknown) => {
+        assert.ok(error instanceof FetchProgressUncertainError);
+        operationId = error.operationId!;
+        return true;
+      });
+      await cluster.client.authenticate({ username: 'iggy', password: 'iggy' });
+      await assert.rejects(cluster.client.sendCommand(COMMAND_CODE.StoreOffset, STORE_OFFSET.serialize({
+        streamId: 'stream', topicId: 'topic', consumer: { kind: ConsumerKind.Single, id: 1 }, partitionId: 3, offset: 0n
+      })), (error: unknown) => error instanceof LaneBlockedError && error.operationId === operationId);
+      await assert.rejects(cluster.client.sendCommand(COMMAND_CODE.StoreOffset, storeOffsetPayload,
+        { context: EMPTY_PARTITION_CONTEXT }), (error: unknown) =>
+        error instanceof LaneBlockedError && error.operationId === operationId);
+      incarnation = 8n;
+      await cluster.client.sendCommand(COMMAND_CODE.GetPartitionRoute, serializePartitionRoute(1, 2, 3));
+      await cluster.client.sendCommand(COMMAND_CODE.StoreOffset, storeOffsetPayload);
+      await assert.rejects(cluster.client.resolveOperation(operationId), FetchProgressUncertainError);
+      cluster.client.abandonOperation(operationId);
+      await assert.rejects(cluster.client.resolveOperation(operationId), OperationNotRetainedError);
+      await assert.rejects(cluster.client.sendCommand(COMMAND_CODE.StoreOffset, storeOffsetPayload,
+        { context: EMPTY_PARTITION_CONTEXT }), isRefusal(HISTORY_UNAVAILABLE));
+      assert.equal(stampedContext(framesOf(cluster.primary, Operation.StoreConsumerOffset).at(-1)!).incarnation, 0n);
+    } finally { await cluster.close(); }
+  });
+
+  for (const [limit, configuration] of [
+    ['count', { maxPendingOperations: 1 }],
+    ['bytes', { maxPendingOperationBytes: HEADER_SIZE + fetchBody(offsetTargets(3)).length }]
+  ] as const) it(`reserves committing recovery ${limit} before send and releases it on explicit abandonment`, async () => {
+    let fetches = 0;
+    const cluster = await startPollCluster((frame, socket) => {
+      if (codeOf(frame) !== COMMAND_CODE.FetchMessages) return false;
+      socket.write(++fetches === 1
+        ? replyFrame(frame, Operation.NonReplicated, Buffer.alloc(0), TRANSIENT_NOT_COMMITTED) : emptyFetch(frame));
+      return true;
+    }, undefined, 'TCP', configuration);
+    try {
+      let operationId!: bigint;
+      await assert.rejects(cluster.client.sendCommand(COMMAND_CODE.FetchMessages,
+        fetchBody(offsetTargets(3), { autocommit: true })), (error: unknown) => {
+        assert.ok(error instanceof FetchProgressUncertainError);
+        operationId = error.operationId!;
+        return true;
+      });
+      await assert.rejects(cluster.client.sendCommand(COMMAND_CODE.FetchMessages,
+        fetchBody(offsetTargets(1), { autocommit: true })), isRefusal(TRANSIENT_NOT_ACCEPTED));
+      assert.equal(fetches, 1);
+      await cluster.client.sendCommand(COMMAND_CODE.FetchMessages, fetchBody(offsetTargets(1)));
+      cluster.client.abandonOperation(operationId);
+      await cluster.client.sendCommand(COMMAND_CODE.FetchMessages, fetchBody(offsetTargets(1), { autocommit: true }));
+      await cluster.client.sendCommand(COMMAND_CODE.FetchMessages, fetchBody(offsetTargets(1), { autocommit: true }));
+      assert.equal(fetches, 4);
+    } finally { await cluster.close(); }
+  });
+
+  it('cancels a sent committing fetch on the server, keeps its reply slot and retains its later unknown reply', async () => {
+    let parked: { frame: Buffer, socket: Socket } | undefined;
+    const cluster = await startPollCluster((frame, socket) => {
+      if (codeOf(frame) === COMMAND_CODE.CancelDeferredRequest) return true;
+      if (codeOf(frame) !== COMMAND_CODE.FetchMessages) return false;
+      parked = { frame, socket };
+      return true;
+    });
+    try {
+      const cancel = new AbortController();
+      let settled = false;
+      const pending = cluster.client.sendCommand(COMMAND_CODE.FetchMessages,
+        fetchBody(offsetTargets(3), { autocommit: true }), { signal: cancel.signal });
+      void pending.finally(() => { settled = true; }).catch(() => undefined);
+      await waitUntil(() => parked !== undefined);
+      cancel.abort();
+      await cluster.client.sendCommand(COMMAND_CODE.GetStats, Buffer.alloc(0));
+      await waitUntil(() => framesWith(cluster.primary, COMMAND_CODE.CancelDeferredRequest).length === 1);
+      assert.equal(settled, false);
+      parked!.socket.write(replyFrame(parked!.frame, Operation.NonReplicated, Buffer.alloc(0), TRANSIENT_NOT_COMMITTED));
+      await assert.rejects(pending, FetchProgressUncertainError);
+      await assert.rejects(cluster.client.sendCommand(COMMAND_CODE.StoreOffset, storeOffsetPayload), LaneBlockedError);
+    } finally { await cluster.close(); }
+  });
+
+  it('releases a committing fetch canceled behind an active progress lane before any send', async () => {
+    let parked: { frame: Buffer, socket: Socket } | undefined;
+    const cluster = await startPollCluster((frame, socket) => {
+      if (codeOf(frame) !== COMMAND_CODE.FetchMessages) return false;
+      if (parked) socket.write(emptyFetch(frame));
+      else parked = { frame, socket };
+      return true;
+    }, undefined, 'TCP', { maxPendingOperations: 1 });
+    try {
+      const active = cluster.client.sendCommand(COMMAND_CODE.FetchMessages,
+        fetchBody([{ partitionId: 3, pollingStrategy: PollingStrategy.Next }]));
+      await waitUntil(() => parked !== undefined);
+      const cancel = new AbortController();
+      const waiting = cluster.client.sendCommand(COMMAND_CODE.FetchMessages,
+        fetchBody(offsetTargets(3), { autocommit: true }), { signal: cancel.signal });
+      await cluster.client.sendCommand(COMMAND_CODE.GetStats, Buffer.alloc(0));
+      cancel.abort();
+      await assert.rejects(waiting, (error: unknown) => error instanceof Error && error.name === 'AbortError');
+      assert.equal(framesWith(cluster.primary, COMMAND_CODE.FetchMessages).length, 1);
+      parked!.socket.write(emptyFetch(parked!.frame));
+      await active;
+      for (let index = 0; index < 2; index++)
+        await cluster.client.sendCommand(COMMAND_CODE.FetchMessages, fetchBody(offsetTargets(3), { autocommit: true }));
+      assert.equal(framesWith(cluster.primary, COMMAND_CODE.FetchMessages).length, 3);
+      assert.equal(framesWith(cluster.primary, COMMAND_CODE.CancelDeferredRequest).length, 0);
+    } finally { await cluster.close(); }
+  });
+
+  for (const replacement of ['automatic', 'explicit'] as const)
+    it(`settles a correlated healthy committing reply before ${replacement} session replacement without false retention`, async () => {
+    let first = true;
+    const cluster = await startPollCluster((frame, socket) => {
+      if (codeOf(frame) === COMMAND_CODE.FetchMessages) {
+        socket.write(first ? Buffer.concat([emptyFetch(frame), evictionFrame(EvictionReason.SessionError)]) : emptyFetch(frame));
+        first = false;
+      } else if (frame[REQUEST_OFFSET.operation] === Operation.StoreConsumerOffset) socket.write(provedReply(frame));
+      else return false;
+      return true;
+    }, undefined, 'TCP', { reconnect: { enabled: true, interval: 10, maxRetries: 2 }, maxPendingOperations: 1 });
+    try {
+      let signedOut: Promise<unknown> | undefined;
+      if (replacement === 'explicit') cluster.client.once('eviction', () => {
+        signedOut = cluster.client.sendCommand(LOGOUT.code, LOGOUT.serialize()).catch(() => undefined);
+      });
+      const fetching = cluster.client.sendCommand(COMMAND_CODE.FetchMessages,
+        fetchBody(offsetTargets(3), { autocommit: true }));
+      if (replacement === 'explicit') {
+        await assert.rejects(fetching, isRefusal(UNAUTHENTICATED));
+        await signedOut;
+        await cluster.client.authenticate({ username: 'iggy', password: 'iggy' });
+      } else await fetching;
+      await cluster.client.sendCommand(COMMAND_CODE.StoreOffset, storeOffsetPayload);
+      await cluster.client.sendCommand(COMMAND_CODE.FetchMessages, fetchBody(offsetTargets(3), { autocommit: true }));
+      assert.equal(framesWith(cluster.primary, COMMAND_CODE.FetchMessages).length, 2);
+    } finally { await cluster.close(); }
+  });
+
+  const linkLosses: [string, Partial<FetchMessages>, boolean][] = [
+    ['retains an uncertain auto-commit fetch and never sends it again', { autocommit: true }, true],
+    ['fails a fetch with a Next target with 57 and never sends it again',
+      { targets: [{ partitionId: 1, pollingStrategy: PollingStrategy.Next }] }, true],
+    ['sends a fetch that commits nothing again', {}, false]
+  ];
+  for (const [name, request, progress] of linkLosses)
+    it(`${name} when its link closes while it waits`, async () => {
+      let fetches = 0;
+      const cluster = await startPollCluster((frame, socket) => {
+        if (codeOf(frame) !== COMMAND_CODE.FetchMessages) return false;
+        if (++fetches === 1) socket.destroy();
+        else socket.write(emptyFetch(frame));
+        return true;
+      }, undefined, 'TCP', { reconnect: { enabled: true, interval: 10, maxRetries: 2 } });
+      try {
+        const fetching = cluster.client.sendCommand(COMMAND_CODE.FetchMessages, fetchBody(offsetTargets(1, 2), request));
+        if (request.autocommit) await assert.rejects(fetching, FetchProgressUncertainError);
+        else if (progress) await assert.rejects(fetching, isRefusal(TRANSIENT_NOT_COMMITTED));
+        else await fetching;
+        assert.equal(fetches, progress ? 1 : 2);
+      } finally {
+        await cluster.close();
+      }
+    });
+
+  it('holds the progress lane of each partition of an auto-commit fetch, while a fetch that commits nothing holds none',
+    async () => {
+      let parked: { frame: Buffer, socket: Socket } | undefined;
+      const cluster = await startPollCluster((frame, socket) => {
+        if (frame[REQUEST_OFFSET.operation] === Operation.StoreConsumerOffset) {
+          socket.write(replyFrame(frame, Operation.StoreConsumerOffset, metadataResult(0)));
+          return true;
+        }
+        if (codeOf(frame) !== COMMAND_CODE.FetchMessages) return false;
+        if (parked) socket.write(emptyFetch(frame));
+        else parked = { frame, socket };
+        return true;
+      });
+      try {
+        const fetching = cluster.client.sendCommand(COMMAND_CODE.FetchMessages,
+          fetchBody(offsetTargets(3, 1), { autocommit: true }));
+        void fetching.catch(() => undefined);
+        await waitUntil(() => parked !== undefined);
+        const storing = cluster.client.sendCommand(COMMAND_CODE.StoreOffset, storeOffsetPayload);
+        void storing.catch(() => undefined);
+        await cluster.client.sendCommand(COMMAND_CODE.FetchMessages, fetchBody(offsetTargets(3)));
+        assert.equal(framesOf(cluster.primary, Operation.StoreConsumerOffset).length, 0,
+          'the store waits for the auto-commit fetch');
+
+        parked!.socket.write(emptyFetch(parked!.frame));
+        await fetching;
+        await storing;
+        assert.equal(framesOf(cluster.primary, Operation.StoreConsumerOffset).length, 1);
+      } finally {
+        await cluster.close();
+      }
+    });
+
+  it('names a blocked fetch target and its retained operation, frees earlier lanes, and retries after repair', async () => {
+    const cancel = new AbortController();
+    let stores = 0;
+    const cluster = await startPollCluster((frame, socket) => {
+      if (frame[REQUEST_OFFSET.operation] === Operation.StoreConsumerOffset) {
+        if (++stores === 1) cancel.abort();
+        else socket.write(provedReply(frame));
+      } else if (codeOf(frame) === COMMAND_CODE.FetchMessages) socket.write(emptyFetch(frame));
+      else return false;
+      return true;
+    });
+    try {
+      let operationId!: bigint;
+      await assert.rejects(cluster.client.sendCommand(COMMAND_CODE.StoreOffset, storeOffsetPayload, { signal: cancel.signal }),
+        (error: unknown) => {
+          assert.ok(error instanceof OperationUncertainError && error.operationId !== undefined);
+          operationId = error.operationId;
+          return true;
+        });
+      await assert.rejects(cluster.client.sendCommand(COMMAND_CODE.StoreOffset, storeOffsetPayload),
+        (error: unknown) => error instanceof LaneBlockedError && error.operationId === operationId && error.partitionId === undefined);
+      const request = fetchBody(offsetTargets(5, 1, 3), { autocommit: true });
+      await assert.rejects(cluster.client.sendCommand(COMMAND_CODE.FetchMessages, request), (error: unknown) => {
+        assert.ok(error instanceof LaneBlockedError);
+        assert.equal(error.operationId, operationId);
+        assert.equal(error.partitionId, 3);
+        return true;
+      });
+      assert.equal(framesWith(cluster.primary, COMMAND_CODE.FetchMessages).length, 0);
+      await cluster.client.sendCommand(COMMAND_CODE.FetchMessages, fetchBody(offsetTargets(1), { autocommit: true }));
+      await cluster.client.resolveOperation(operationId);
+      await cluster.client.sendCommand(COMMAND_CODE.FetchMessages, request);
+      assert.equal(framesWith(cluster.primary, COMMAND_CODE.FetchMessages).length, 2);
+    } finally { await cluster.close(); }
+  });
+
+  it('looks up the routes of every target of a fetch refused as not the primary, and only the route of a refused section',
+    async () => {
+      let fetches = 0;
+      const cluster = await startReadinessCluster((frame, socket) => {
+        if (codeOf(frame) !== COMMAND_CODE.FetchMessages) return false;
+        fetches += 1;
+        socket.write(fetches === 1
+          ? replyFrame(frame, Operation.NonReplicated, Buffer.alloc(0), NOT_PRIMARY)
+          : replyFrame(frame, Operation.NonReplicated, fetchReplyBody({ partitionId: 2, status: NOT_PRIMARY })));
+        return true;
+      }, () => false, (partition, lookup) => partition === 1 && lookup > 1 ? 9 : 7);
+      try {
+        const groups = () => cluster.client.readinessGroups(1, 2, [1, 2]);
+        assert.deepEqual(await groups(), [[1, 2]]);
+        await assert.rejects(cluster.client.sendCommand(COMMAND_CODE.FetchMessages, fetchBody(offsetTargets(1, 2))),
+          isRefusal(NOT_PRIMARY));
+        assert.deepEqual(await groups(), [[2], [1]]);
+        await cluster.client.sendCommand(COMMAND_CODE.FetchMessages, fetchBody(offsetTargets(2)));
+        assert.deepEqual(await groups(), [[2], [1]]);
+        assert.deepEqual([...cluster.lookups], [[1, 2], [2, 3]]);
+      } finally {
+        await cluster.close();
+      }
+    });
+
+  it('groups partitions by primary in replica id order, each group in the order asked, and splits a group to fit a fetch',
+    async () => {
+      const FIRST_OF_MANY = 4;
+      const cluster = await startReadinessCluster(() => false, () => false, (partition) => partition === 3 ? 7 : 9);
+      try {
+        assert.deepEqual(await cluster.client.readinessGroups(1, 2, [2, 3, 1]), [[3], [2, 1]]);
+        const many = Array.from({ length: MAX_PARTITIONS_PER_REQUEST + 1 }, (_, index) => index + FIRST_OF_MANY);
+        assert.deepEqual(await cluster.client.readinessGroups(1, 2, many),
+          [many.slice(0, MAX_PARTITIONS_PER_REQUEST), many.slice(MAX_PARTITIONS_PER_REQUEST)]);
+      } finally {
+        await cluster.close();
+      }
+    });
 });
diff --git a/foreign/node/src/client/client.socket.ts b/foreign/node/src/client/client.socket.ts
index f9b8947..c227f29 100644
--- a/foreign/node/src/client/client.socket.ts
+++ b/foreign/node/src/client/client.socket.ts
@@ -22,21 +22,31 @@
 } from './client.type.js';
 import { DeserializeError, ResponseError, responseError } from '../wire/error.utils.js';
 import { IggyConnection, endpointKey, type Endpoint } from './client.connection.js';
-import { normalizeClientConfig } from './client.config.js';
+import { DEFAULT_MAX_RESPONSE_FRAME_SIZE, normalizeClientConfig } from './client.config.js';
 import {
-  ChangeSignal, MutationLanes, NotConnectedError, OperationUncertainError, OperationNotRetainedError,
-  OPERATION_TIMEOUT_MS, VsrResponseTimeoutError, pause, waitWithin, type ClientOperation
+  ChangeSignal, FetchProgressUncertainError, MutationLanes, NotConnectedError, OperationUncertainError, OperationNotRetainedError,
+  OPERATION_TIMEOUT_MS, PermitPool, VsrResponseTimeoutError, pause, waitWithin, type ClientOperation
 } from './client.operation.js';
 import { LOGIN, LOGIN_WITH_TOKEN, LOGOUT, PING } from '../wire/index.js';
 import { GET_CLUSTER_METADATA } from '../wire/cluster/get-cluster-metadata.command.js';
 import { deserializePartitionRoute, type PartitionRoute } from '../wire/partition/partition-route.js';
 import { ConsumerKind } from '../wire/offset/offset.utils.js';
 import { COMMAND_CODE } from '../wire/command.code.js';
-import { serializeIdentifier } from '../wire/identifier.utils.js';
+import { serializeIdentifier, type Id } from '../wire/identifier.utils.js';
+import { uint32ToBuf } from '../wire/number.utils.js';
 import { deserializeOptions } from '../wire/options.utils.js';
 import { OptionsScope } from '../wire/system/describe-options.command.js';
 import { PartitionKind } from '../wire/message/partitioning.utils.js';
 import { PollingStrategyKind } from '../wire/message/poll.utils.js';
+import { CommitErrorScope, provesNothingStored } from '../wire/message/commit-outcome.js';
+import {
+  DEFERRED_POLL_OFFSET, DEFERRED_POLL_OPTIONS_SIZE, MAX_PARTITIONS_PER_REQUEST, MIN_DEFERRED_POLL_MAX_BYTES,
+  MIN_DEFERRED_POLL_REPLY_MARGIN_US, US_PER_MS, isDeferredBudget, isFetchBudget
+} from '../wire/message/deferred-poll.utils.js';
+import {
+  FETCH_MESSAGES, FETCH_MESSAGES_FIELD, FETCH_MESSAGES_FIELDS_SIZE, FETCH_SECTION_FIELD, FETCH_SECTION_SIZE, FETCH_TARGET_FIELD,
+  FETCH_TARGET_SIZE, fetchReplyHeadSize
+} from '../wire/message/fetch-messages.command.js';
 import {
   EvictionReason, HEADER_SIZE, REPLY_OFFSET, hasCommittedResult, type PartitionContext,
   EMPTY_PARTITION_CONTEXT, readReplyOperation
@@ -50,8 +60,8 @@
 import { debug } from './client.debug.js';
 
 export {
-  LaneBlockedError, NotConnectedError, OperationNotRetainedError, OperationUncertainError,
-  VsrResponseTimeoutError
+  FETCH_PROGRESS_UNCERTAIN, FetchProgressUncertainError, LANE_BLOCKED, LaneBlockedError, NotConnectedError,
+  OperationNotRetainedError, OperationUncertainError, PARTITION_LANE_BLOCKED, VsrResponseTimeoutError
 } from './client.operation.js';
 
 const RETRY_INTERVAL_MS = 50;
@@ -69,9 +79,14 @@
 const HISTORY_UNAVAILABLE = 87;
 const LIFECYCLE_BUSY = 88;
 const NOT_PRIMARY = 89;
+const DEFERRED_POLL_CAPACITY_EXCEEDED = 90;
+const GROUP_PARTITION_NOT_OWNED = 5009;
+/** The refusals of a partition after which its route can be stale: it moved, or its history or owner changed. */
+const STALE_ROUTE_CODES = new Set([NOT_PRIMARY, HISTORY_UNAVAILABLE, GROUP_PARTITION_NOT_OWNED]);
 const INVALID_COMMAND = 3;
 const FEATURE_UNAVAILABLE = 5;
 const INVALID_IDENTIFIER = 6;
+const INVALID_SIZE_BYTES = 80;
 // Session control, and the partition reads that need the primary and its incarnation.
 const REPLICA_REFUSED_CODES = new Set<number>([
   COMMAND_CODE.LoginUser,
@@ -80,11 +95,19 @@
   COMMAND_CODE.LoginWithAccessToken,
   COMMAND_CODE.LoginRegisterWithAccessToken,
   COMMAND_CODE.PollMessages,
+  COMMAND_CODE.PollMessagesDeferred,
+  COMMAND_CODE.FetchMessages,
   COMMAND_CODE.GetOffset,
 ]);
 const INVALID_SESSION_BINDING = 14004;
 const ROUTE_NAMESPACE_MISSING = new Set([1009, 1010, 2010, 2011, 3007]);
 const POLL_OPTIONS_SIZE = 9 + 4 + 1;
+const POLL_COUNT_SIZE = 4;
+const POLL_AUTOCOMMIT_SIZE = 1;
+// Deferred polls and fetches together, as MAX_DEFERRED_REQUESTS
+// in core/sdk/src/driver.rs. The server parks as many per session and shard.
+const MAX_DEFERRED_REQUESTS = 64;
+const MAX_ROUTE_LOOKUPS_IN_FLIGHT = 16;
 const IDENTIFIER_PREFIX_SIZE = 2;
 const SEND_METADATA_LENGTH_SIZE = 4;
 const PARTITION_ID_SIZE = 4;
@@ -103,7 +126,10 @@
   COMMAND_CODE.GetClient, COMMAND_CODE.GetClients, COMMAND_CODE.GetUser, COMMAND_CODE.GetUsers,
   COMMAND_CODE.GetAccessTokens, COMMAND_CODE.GetPartitionRoute, COMMAND_CODE.GetOffset,
   COMMAND_CODE.GetStream, COMMAND_CODE.GetStreams, COMMAND_CODE.GetTopic,
-  COMMAND_CODE.GetTopics, COMMAND_CODE.GetGroup, COMMAND_CODE.GetGroups, COMMAND_CODE.SyncGroup
+  COMMAND_CODE.GetTopics, COMMAND_CODE.GetGroup, COMMAND_CODE.GetGroups, COMMAND_CODE.SyncGroup,
+]);
+const OFFSET_COMMANDS = new Set<number>([
+  COMMAND_CODE.GetOffset, COMMAND_CODE.StoreOffset, COMMAND_CODE.DeleteConsumerOffset
 ]);
 
 type ReplicaSlot = {
@@ -133,6 +159,18 @@
   committed: boolean,
   started: boolean
 };
+/**
+ * The wait and the request timeout of a deferred poll or a
+ * fetch, in microseconds, and where its body carries them.
+ */
+type DeferredBudget = {
+  readonly started: number,
+  readonly wait: bigint,
+  readonly timeout: bigint,
+  readonly waitOffset: number,
+  readonly timeoutOffset: number
+};
+type DriveOptions = SendCommandOptions & { budget?: DeferredBudget };
 
 /** A replica link used its retry budget, and nothing restarts it. */
 class ReconnectExhaustedError extends Error {
@@ -148,6 +186,7 @@
   private readonly lanes: MutationLanes;
   private readonly replicas = new Map<number, ReplicaSlot>();
   private readonly partitionRoutes = new Map<string, CachedRoute>();
+  private readonly deferredRequests = new PermitPool(MAX_DEFERRED_REQUESTS);
   private readonly changed = new ChangeSignal();
   private readonly lifetime = new AbortController();
   private sessionLifetime = new AbortController();
@@ -198,6 +237,9 @@
   get busy(): boolean { return this.pendingSubmissions > 0; }
 
   async sendCommand(command: number, payload: Buffer, options: SendCommandOptions = {}): Promise<CommandResponse> {
+    // Only a link cancels a deferred request, the one that it sent.
+    if (command === COMMAND_CODE.CancelDeferredRequest)
+      throw responseError(command, INVALID_COMMAND);
     if (options.replica !== undefined && !isReplicaCommand(command))
       throw responseError(command, INVALID_COMMAND);
     if (options.replica !== undefined && !(Number.isInteger(options.replica) && options.replica >= 0 && options.replica < MAX_REPLICAS))
@@ -205,8 +247,13 @@
     if (isLoginCommand(command)) return this.loginByCaller(command, () => Buffer.from(payload), options);
     const lineage = options.lineage ?? this.lineage;
     payload = Buffer.from(payload);
+    const budget = deferredBudget(command, payload,
+      (this.options.maxResponseFrameSize ?? DEFAULT_MAX_RESPONSE_FRAME_SIZE) - HEADER_SIZE);
     const release = this.hold();
-    const deadline = options.deadline ?? Date.now() + OPERATION_TIMEOUT_MS;
+    // A deferred request gives up at its own request timeout.
+    const deadline = budget
+      ? Math.min(options.deadline ?? Number.POSITIVE_INFINITY, budget.started + Number(budget.timeout / US_PER_MS))
+      : options.deadline ?? Date.now() + OPERATION_TIMEOUT_MS;
     const signal = AbortSignal.any([this.lifetime.signal, ...(options.signal ? [options.signal] : [])]);
     try {
       if (command === LOGOUT.code) {
@@ -226,26 +273,66 @@
           }
         });
       }
-      if (lineage !== this.lineage) throw responseError(command, UNAUTHENTICATED);
-      if (!this.isAuthenticated && command !== PING.code) {
-        void this.signIn();
-        await this.nextSession(command, lineage, deadline, signal);
+      await this.ready(command, lineage, deadline, signal);
+      if (!budget) return await this.drive(command, payload, options, deadline, signal, lineage);
+      const lease = await this.deferredLease(command, deadline, signal);
+      try {
+        if (command === COMMAND_CODE.FetchMessages)
+          return await this.fetchMessages(payload, { ...options, budget }, deadline, signal, lineage);
+        return await this.drive(command, payload, { ...options, budget }, deadline, signal, lineage);
+      } finally {
+        lease();
       }
-      if (this.isAuthenticated && this.replicas.size === 0) {
-        const generation = this.routingGeneration;
-        // The call waits for a session lost meanwhile itself.
-        await this.refreshRoster(deadline, signal).catch((error: unknown) => {
-          if (generation !== this.routingGeneration) return;
-          // The call sent nothing yet, so a deadline leaves it not accepted.
-          throw error instanceof VsrResponseTimeoutError ? responseError(command, TRANSIENT_NOT_ACCEPTED) : error;
-        });
-      }
-      return await this.drive(command, payload, options, deadline, signal, lineage);
     } finally {
       release();
     }
   }
 
+  /**
+   * The partitions of a topic grouped by their primary, in replica id order,
+   * and split to fit one fetch each. Each group keeps the order of
+   * `partitions`. The routes come from the cache, so only the partitions
+   * without a fresh route are looked up.
+   */
+  async readinessGroups(streamId: Id, topicId: Id, partitions: number[],
+    options: SendCommandOptions = {}): Promise<number[][]> {
+    const command = COMMAND_CODE.GetPartitionRoute;
+    const lineage = options.lineage ?? this.lineage;
+    const release = this.hold();
+    const deadline = options.deadline ?? Date.now() + OPERATION_TIMEOUT_MS;
+    const signal = AbortSignal.any([this.lifetime.signal, ...(options.signal ? [options.signal] : [])]);
+    try {
+      await this.ready(command, lineage, deadline, signal);
+      const topic = Buffer.concat([serializeIdentifier(streamId), serializeIdentifier(topicId)]);
+      const groups = await this.primaryGroups(command, topic, partitions.map(uint32ToBuf), deadline, signal, lineage);
+      return groups.flatMap((group) => Array.from(
+        { length: Math.ceil(group.length / MAX_PARTITIONS_PER_REQUEST) },
+        (_, index) => group.slice(index * MAX_PARTITIONS_PER_REQUEST, (index + 1) * MAX_PARTITIONS_PER_REQUEST)
+          .map((target) => target.readUInt32LE(0))
+      ));
+    } finally {
+      release();
+    }
+  }
+
+  /** Waits for a session of `lineage` and for the roster that routes a call of it. */
+  private async ready(command: number, lineage: number, deadline: number, signal: AbortSignal): Promise<void> {
+    if (lineage !== this.lineage) throw responseError(command, UNAUTHENTICATED);
+    if (!this.isAuthenticated && command !== PING.code) {
+      void this.signIn();
+      await this.nextSession(command, lineage, deadline, signal);
+    }
+    if (this.isAuthenticated && this.replicas.size === 0) {
+      const generation = this.routingGeneration;
+      // The call waits for a session lost meanwhile itself.
+      await this.refreshRoster(deadline, signal).catch((error: unknown) => {
+        if (generation !== this.routingGeneration) return;
+        // The call sent nothing yet, so a deadline leaves it not accepted.
+        throw error instanceof VsrResponseTimeoutError ? responseError(command, TRANSIENT_NOT_ACCEPTED) : error;
+      });
+    }
+  }
+
   async resolveOperation(operationId: bigint, options: SendCommandOptions = {}): Promise<void> {
     try {
       await this.replay(this.lanes.retained(operationId), options);
@@ -279,15 +366,30 @@
     }
   }
 
-  private async drive(command: number, body: Buffer, options: SendCommandOptions,
+  private async drive(command: number, body: Buffer, options: DriveOptions,
     deadline: number, signal: AbortSignal, lineage: number, retained?: ClientOperation): Promise<CommandResponse> {
     /** The session that the frame of the call belongs to. */
     let generation = this.routingGeneration;
     const target = partitionRoute(command, body);
+    const fetch = command === COMMAND_CODE.FetchMessages ? readFetch(body) : undefined;
     const replicated = command !== LOGOUT.code && operationForCode(command) !== Operation.NonReplicated;
-    const progress = command === COMMAND_CODE.PollMessages &&
-      (body[body.length - 1] !== 0 || body[body.length - POLL_OPTIONS_SIZE] === PollingStrategyKind.Next);
-    const safeRead = READ_COMMANDS.has(command) || (command === COMMAND_CODE.PollMessages && !progress);
+    const pollEnd = command === COMMAND_CODE.PollMessagesDeferred ? body.length - DEFERRED_POLL_OPTIONS_SIZE : body.length;
+    const progress = fetch ? fetch.progress : isPoll(command) &&
+      (body[pollEnd - POLL_AUTOCOMMIT_SIZE] !== 0 || body[pollEnd - POLL_OPTIONS_SIZE] === PollingStrategyKind.Next);
+    const safeRead = READ_COMMANDS.has(command) || ((isPoll(command) || fetch !== undefined) && !progress);
+    // A read that stores its offset can store it before a cancel arrives, so once it is sent the caller's abort only
+    // cancels it on the server and the call waits for its one reply.
+    const commits = fetch ? fetch.commits : isPoll(command) && body[pollEnd - POLL_AUTOCOMMIT_SIZE] !== 0;
+    // A progress fetch holds the progress lane of each target, in ascending
+    // partition order, as offset stores take them.
+    const fetchPartitions = fetch?.progress
+      ? new Map(fetch.targets
+        .map((fetchTarget) => [fetchTarget.readUInt32LE(FETCH_TARGET_FIELD.partitionId),
+          fetchTarget.readBigUInt64LE(FETCH_TARGET_FIELD.incarnation)] as const)
+        .sort(([left], [right]) => left - right)
+        .map(([partition, incarnation]) => [progressLaneOf(laneKey(incarnation, partition)), partition]))
+      : undefined;
+    const fetchLanes = fetchPartitions ? [...fetchPartitions.keys()] : [];
     // A replay and a logout belong to their session. Any other call that
     // needs a session waits for the one that replaces a lost one.
     const restartable = !retained && command !== LOGOUT.code && (command !== PING.code || this.isAuthenticated);
@@ -306,6 +408,7 @@
     let lastRefusal: ResponseError | undefined;
     let previousLink: IggyConnection | undefined;
     let sent = false;
+    let mayHaveCommitted = false;
     try {
       for (;;) {
         this.ensureLineage(lineage, command);
@@ -347,7 +450,15 @@
         }
         context ??= { ...EMPTY_PARTITION_CONTEXT, metadataOp: this.metadataWatermark };
         this.ensureLineage(lineage, command);
-        const lane = target ? groupKey(context, target) : 'metadata';
+        // A caller's zero wire fence must not bypass the resolved history's local recovery block.
+        const laneContext = context.incarnation === 0n && selected ? selected.route.context : context;
+        const mutationLane = target ? groupKey(laneContext, target) : 'metadata';
+        // Offset calls and progress polls keep their order in a progress lane
+        // apart from sends, so a parked poll does not delay a send. An offset
+        // store or delete holds both lanes, as in core/sdk/src/session_owner/lanes.rs.
+        const progressLanes = fetch ? fetchLanes
+          : target && (progress || OFFSET_COMMANDS.has(command)) ? [progressLaneOf(mutationLane)] : [];
+        const lane = replicated ? mutationLane : progressLanes.at(-1);
         if (operation && staleFrame && operation.lane !== lane) {
           // A definitive refusal released the old history. The replacement
           // must enter the lane that orders writes to its current incarnation.
@@ -364,10 +475,14 @@
             operation = undefined;
             continue;
           }
-        } else if (!operation && (replicated || progress)) {
+        } else if (!operation && lane) {
           try {
+            // A committing poll keeps a recovery record for its partition, as a committing fetch does for each target.
+            const recovered = fetchPartitions ?? (commits && target
+              ? new Map([[lane, target.query.readUInt32LE(target.query.length - PARTITION_ID_SIZE)]]) : undefined);
             operation = await this.lanes.enter(lane, command, body, context, replicated,
-              this.vsrSession, deadline, signal);
+              this.vsrSession, deadline, signal, replicated ? progressLanes : progressLanes.slice(0, -1), recovered,
+              commits);
             staleFrame = false;
           } catch (error) {
             // A lane can free while no session is bound to encode the frame.
@@ -399,14 +514,46 @@
           attempt = await this.exchange(link, command, () => {
             this.ensureGeneration(generation, command);
             this.ensureLineage(lineage, command);
-            return operation?.frame.buffer ?? (frame ??= this.vsrSession.encode(command, body, context));
-          }, deadline, attemptSignal, operation);
+            const bytes = operation?.frame.buffer ?? (frame ??= this.vsrSession.encode(command, body, context));
+            return options.budget ? deferredFrame(command, bytes, options.budget, deadline) : bytes;
+          }, deadline, attemptSignal, operation, commits
+            ? AbortSignal.any([this.lifetime.signal, this.sessionLifetime.signal,
+              ...(operation ? [operation.lifetime.signal] : [])]) : undefined);
           exchanged = true;
         } catch (error) {
           attempt = { error, committed: false, started: false };
         }
         sent ||= attempt.started;
-        if (generation !== this.routingGeneration && attempt.started && (replicated || progress))
+        // The server refused the read because the cancel won, so it stored nothing.
+        if (commits && attempt.started && options.signal?.aborted && attempt.error instanceof ResponseError &&
+            attempt.error.fromServer && attempt.error.errorCode === TRANSIENT_NOT_ACCEPTED)
+          throw options.signal.reason;
+        if (commits && attempt.started)
+          mayHaveCommitted = !(attempt.error instanceof ResponseError && attempt.error.fromServer &&
+            provesNothingStored(CommitErrorScope.Request, attempt.error.errorCode));
+        if (fetch?.commits && operation && !attempt.error) {
+          const sections = FETCH_MESSAGES.deserialize(attempt.response!);
+          const requested = new Set(fetch.targets.map((target) => target.readUInt32LE(FETCH_TARGET_FIELD.partitionId)));
+          const seen = new Set<number>();
+          for (const section of sections) {
+            if (!requested.has(section.partitionId) || seen.has(section.partitionId))
+              throw new DeserializeError('fetch reply names a duplicate or unrequested partition');
+            seen.add(section.partitionId);
+          }
+          const uncertain = new Set(sections.filter(({ status }) =>
+            status !== 0 && !provesNothingStored(CommitErrorScope.Section, status))
+            .map(({ partitionId }) => partitionId));
+          if (uncertain.size > 0) {
+            this.lanes.retainFetch(operation, uncertain);
+            attempt.response!.fetchProgress = { operationId: operation.id, partitions: uncertain };
+          }
+          mayHaveCommitted = false;
+          this.ensureLineage(lineage, command);
+        } else if (commits && !attempt.error) {
+          mayHaveCommitted = false;
+        }
+        const hasCommittingReply = commits && !attempt.error;
+        if (!hasCommittingReply && generation !== this.routingGeneration && attempt.started && (replicated || progress))
           throw uncertainOutcome(command, operation, attempt.error ?? responseError(command, TRANSIENT_NOT_ACCEPTED));
         // A committed rejection proves the outcome even when it also ends the session.
         if (operation?.uncertain && attempt.committed && attempt.error) {
@@ -427,10 +574,12 @@
           if (restartable && exchanged && !attempt.started && generation !== this.routingGeneration) continue;
           throw attempt.error;
         }
-        if (generation !== this.routingGeneration) {
+        if (!hasCommittingReply && generation !== this.routingGeneration) {
           if (restartable && (!attempt.started || safeRead)) continue;
           throw responseError(command, TRANSIENT_NOT_ACCEPTED);
         }
+        if (mayHaveCommitted && attempt.error)
+          throw new FetchProgressUncertainError(operation!.id, attempt.error);
         if (!attempt.error) {
           if (operation?.uncertain && !attempt.committed)
             throw new OperationUncertainError(operation.id, new Error('reply does not prove the retained result'));
@@ -461,8 +610,11 @@
         } else if (code === OPERATION_NOT_RETAINED) {
           if (operation?.retainable) operation.uncertain = true;
           throw uncertainOutcome(command, operation, error);
-        } else if (code === NOT_PRIMARY && options.replica !== undefined) {
-          // The caller chose the replica, so its refusal is the answer.
+        } else if (code === NOT_PRIMARY &&
+            (options.replica !== undefined || fetch)) {
+          // The caller chose the replica, so its refusal is the answer. A
+          // fetch does not say which of its partitions moved, so its caller
+          // looks up the route of each one again.
           throw error;
         } else if (code === NOT_PRIMARY || code === TRANSIENT_NOT_ACCEPTED) {
           if (selected) this.invalidateRoute(selected, code === NOT_PRIMARY);
@@ -515,6 +667,9 @@
           }
           lastRefusal = responseError(command, TRANSIENT_NOT_ACCEPTED);
           lastRefusal.fromServer = true;
+        } else if (code === DEFERRED_POLL_CAPACITY_EXCEEDED) {
+          // The primary parks no more deferred requests of this session for
+          // now. It still leads the partition, so the call keeps its route.
         } else if (code !== undefined) {
           if (operation?.uncertain)
             throw new OperationUncertainError(operation.id, error);
@@ -535,6 +690,10 @@
         retryDelay = Math.min(retryDelay * 2, MAX_RETRY_INTERVAL_MS);
       }
     } catch (error) {
+      if (operation?.fetchProgress && mayHaveCommitted) {
+        this.lanes.retainFetch(operation);
+        throw error instanceof FetchProgressUncertainError ? error : new FetchProgressUncertainError(operation.id, error);
+      }
       // A call that sent nothing and saw no refusal was not accepted either.
       if (error instanceof VsrResponseTimeoutError && (lastRefusal || !sent))
         error = lastRefusal ?? responseError(command, TRANSIENT_NOT_ACCEPTED);
@@ -545,24 +704,125 @@
       }
       if (retained && !this.lanes.owns(retained))
         throw error instanceof ResponseError ? error : new OperationNotRetainedError(retained.id);
-      if (error instanceof ResponseError && !(error instanceof VsrEvictionError) && error.commandCode !== command) {
-        const remapped = responseError(command, error.errorCode);
-        remapped.fromServer = error.fromServer;
-        remapped.committed = error.committed;
-        throw remapped;
-      }
-      throw error;
+      throw refusalOf(command, error);
     } finally {
       if (operation && !operation.retained) this.lanes.complete(operation);
       if (operation && frameLease) operation.frame.release();
     }
   }
 
+  /**
+   * A fetch goes to the primary of its targets.
+   * Each target carries its own fence, so the header carries context zero,
+   * and a target without a fence takes the incarnation of its route. A
+   * refusal of the fetch as not the primary, or for a history or an owner
+   * that changed, does not say which partition it is about, so the routes of
+   * all targets go stale. A section refused so names its partition, so only
+   * that route goes stale. The next `readinessGroups` then looks them up.
+   */
+  private async fetchMessages(body: Buffer, options: DriveOptions, deadline: number,
+    signal: AbortSignal, lineage: number): Promise<CommandResponse> {
+    const command = COMMAND_CODE.FetchMessages;
+    const { topic, targets } = readFetch(body);
+    const unfenced = targets.filter((target) => target.readBigUInt64LE(FETCH_TARGET_FIELD.incarnation) === 0n);
+    const routes = await this.routesOf(command, topic, unfenced, deadline, signal, lineage);
+    unfenced.forEach((target, index) => {
+      target.writeBigUInt64LE(routes[index].route.context.incarnation, FETCH_TARGET_FIELD.incarnation);
+      target.writeBigUInt64LE(routes[index].route.context.ownerGeneration, FETCH_TARGET_FIELD.ownerGeneration);
+    });
+    let response: CommandResponse;
+    try {
+      response = await this.drive(command, body, { ...options, context: EMPTY_PARTITION_CONTEXT },
+        deadline, signal, lineage);
+    } catch (error) {
+      if (error instanceof ResponseError && error.fromServer && STALE_ROUTE_CODES.has(error.errorCode))
+        this.staleRoutes(topic, targets);
+      throw error;
+    }
+    const data = options.handleResponse === false
+      ? (response as unknown as Buffer).subarray(HEADER_SIZE) : response.data;
+    this.staleRoutes(topic, fetchSections(data)
+      .filter((section) => STALE_ROUTE_CODES.has(section.readUInt32LE(FETCH_SECTION_FIELD.status))));
+    return response;
+  }
+
+  /** Marks the routes of the partitions that lead `targets` stale. */
+  private staleRoutes(topic: Buffer, targets: Buffer[]): void {
+    for (const target of targets) {
+      const route = this.partitionRoutes.get(readinessTarget(topic, target).key);
+      if (route) this.invalidateRoute(route, false);
+    }
+  }
+
+  /** `targets` grouped by the primary of their partition, in replica id order, each group in the order of `targets`. */
+  private async primaryGroups(command: number, topic: Buffer, targets: Buffer[], deadline: number,
+    signal: AbortSignal, lineage: number): Promise<Buffer[][]> {
+    const routes = await this.routesOf(command, topic, targets, deadline, signal, lineage);
+    const groups = new Map<number, Buffer[]>();
+    targets.forEach((target, index) => {
+      const primary = routes[index].route.primaryReplica;
+      const group = groups.get(primary);
+      if (group) group.push(target);
+      else groups.set(primary, [target]);
+    });
+    return [...groups].sort(([left], [right]) => left - right).map(([, group]) => group);
+  }
+
+  /**
+   * The routes of the partitions that lead `targets`, in their order. At most
+   * MAX_ROUTE_LOOKUPS_IN_FLIGHT routes are looked up at once, as
+   * `readiness_groups` in core/sdk/src/driver.rs does.
+   */
+  private async routesOf(command: number, topic: Buffer, targets: Buffer[], deadline: number,
+    signal: AbortSignal, lineage: number): Promise<CachedRoute[]> {
+    let generation = this.routingGeneration;
+    for (;;) {
+      this.ensureLineage(lineage, command);
+      // As in `drive`, a lookup goes out only under a live session.
+      if (generation !== this.routingGeneration || !this.isAuthenticated) {
+        await this.nextSession(command, lineage, deadline, signal);
+        generation = this.routingGeneration;
+      }
+      const sessionSignal = AbortSignal.any([signal, this.sessionLifetime.signal]);
+      const routes: CachedRoute[] = [];
+      let next = 0;
+      let failed = false;
+      try {
+        await Promise.all(Array.from({ length: Math.min(MAX_ROUTE_LOOKUPS_IN_FLIGHT, targets.length) }, async () => {
+          while (next < targets.length && !failed) {
+            const index = next++;
+            try {
+              routes[index] = await this.routeFor(readinessTarget(topic, targets[index]), deadline, sessionSignal, generation);
+            } catch (error) {
+              failed = true;
+              throw error;
+            }
+          }
+        }));
+      } catch (error) {
+        // A session failure answered a lookup of the call itself.
+        if (generation !== this.routingGeneration && !isSessionFailure(error)) continue;
+        // No request of these targets went out.
+        throw error instanceof VsrResponseTimeoutError ? responseError(command, TRANSIENT_NOT_ACCEPTED) : error;
+      }
+      return routes;
+    }
+  }
+
+  /** A deferred request waits for one of the MAX_DEFERRED_REQUESTS leases within its deadline. */
+  private async deferredLease(command: number, deadline: number, signal: AbortSignal): Promise<() => void> {
+    try {
+      return await this.deferredRequests.acquire(deadline, signal);
+    } catch (error) {
+      throw error instanceof VsrResponseTimeoutError ? responseError(command, TRANSIENT_NOT_ACCEPTED) : error;
+    }
+  }
+
   private async exchange(link: IggyConnection, command: number, frame: () => Buffer,
-    deadline: number, signal: AbortSignal, operation?: ClientOperation): Promise<Attempt> {
+    deadline: number, signal: AbortSignal, operation?: ClientOperation, afterSend?: AbortSignal): Promise<Attempt> {
     const generation = this.routingGeneration;
     let request: Buffer | undefined;
-    const attempt = await link.exchange(() => request = frame(), deadline, signal, operation?.frame);
+    const attempt = await link.exchange(() => request = frame(), deadline, signal, operation?.frame, afterSend);
     if (attempt.error || !attempt.response) {
       const error = this.isAuthenticated && command !== COMMAND_CODE.BindSession &&
         isDetachedFailure(attempt.error)
@@ -1224,7 +1484,7 @@
     } finally { release(); }
   }
 
-  /** Only SDK recovery shares a sign-in; callers always own a fresh attempt. */
+  /** Only SDK recovery shares a sign-in. Callers always own a fresh attempt. */
   private signIn(): Promise<boolean> {
     if (this.isAuthenticated) return Promise.resolve(true);
     if (!this.authenticationPromise) {
@@ -1338,8 +1598,22 @@
   (error instanceof ResponseError && error.fromServer &&
     ((error.errorCode === UNAUTHENTICATED && (binding || error.committed)) ||
       (error.errorCode === STALE_CLIENT && error.committed)));
+const laneKey = (incarnation: bigint, partitionId: number): string => `${incarnation}:${partitionId}`;
 const groupKey = (context: PartitionContext, target: PartitionTarget): string =>
-  `${context.incarnation}:${target.query.readUInt32LE(target.query.length - PARTITION_ID_SIZE)}`;
+  laneKey(context.incarnation, target.query.readUInt32LE(target.query.length - PARTITION_ID_SIZE));
+const progressLaneOf = (lane: string): string => `progress:${lane}`;
+const isPoll = (command: number): boolean =>
+  command === COMMAND_CODE.PollMessages || command === COMMAND_CODE.PollMessagesDeferred;
+
+/** `error` as the refusal of `command`, also when a step for it failed, such as a route lookup. */
+const refusalOf = (command: number, error: unknown): unknown => {
+  if (!(error instanceof ResponseError) || error instanceof VsrEvictionError || error.commandCode === command)
+    return error;
+  const remapped = responseError(command, error.errorCode);
+  remapped.fromServer = error.fromServer;
+  remapped.committed = error.committed;
+  return remapped;
+};
 
 type PartitionTarget = {
   query: Buffer,
@@ -1351,21 +1625,25 @@
     const { query } = sendTarget(payload);
     return { query, key: query.toString('hex') };
   }
-  if (command !== COMMAND_CODE.PollMessages && command !== COMMAND_CODE.GetOffset &&
-      command !== COMMAND_CODE.StoreOffset && command !== COMMAND_CODE.DeleteConsumerOffset)
+  if (command === COMMAND_CODE.FetchMessages) {
+    const { topic, targets } = readFetch(payload);
+    return readinessTarget(topic, targets[0]);
+  }
+  if (!isPoll(command) && !OFFSET_COMMANDS.has(command))
     return undefined;
   let position = 1;
   const consumerEnd = identifierEnd(payload, position, command);
   const streamEnd = identifierEnd(payload, consumerEnd, command);
   position = identifierEnd(payload, streamEnd, command);
   const suffix = command === COMMAND_CODE.PollMessages ? POLL_OPTIONS_SIZE
-    : command === COMMAND_CODE.StoreOffset ? STORE_OFFSET_SUFFIX_SIZE
-      : command === COMMAND_CODE.DeleteConsumerOffset ? DELETE_OFFSET_SUFFIX_SIZE : 0;
+    : command === COMMAND_CODE.PollMessagesDeferred ? POLL_OPTIONS_SIZE + DEFERRED_POLL_OPTIONS_SIZE
+      : command === COMMAND_CODE.StoreOffset ? STORE_OFFSET_SUFFIX_SIZE
+        : command === COMMAND_CODE.DeleteConsumerOffset ? DELETE_OFFSET_SUFFIX_SIZE : 0;
   if (payload.length !== position + 1 + PARTITION_ID_SIZE + suffix || payload[position] > 1)
     throw responseError(command, INVALID_COMMAND);
   if (payload[position] === 0) {
     if (command === COMMAND_CODE.StoreOffset || command === COMMAND_CODE.DeleteConsumerOffset ||
-        (command === COMMAND_CODE.PollMessages && payload[0] === ConsumerKind.Group))
+        (isPoll(command) && payload[0] === ConsumerKind.Group))
       throw responseError(command, INVALID_COMMAND);
   }
   const query = Buffer.concat([payload.subarray(consumerEnd, position),
@@ -1374,6 +1652,134 @@
   return { query, key: query.toString('hex') };
 };
 
+/** The parts of a fetch body that the client reads. */
+type Fetch = {
+  /** Where the fields after the topic start. */
+  fieldsAt: number,
+  /** The stream and topic identifiers, which lead each route lookup of the fetch. */
+  topic: Buffer,
+  /** One view into the body per target, each led by its partition id. */
+  targets: Buffer[],
+  /** The fetch commits on read, or a target starts at the stored offset. */
+  progress: boolean,
+  commits: boolean
+};
+
+/** Reads a fetch body, after the checks that the server's decoder makes. */
+const readFetch = (payload: Buffer): Fetch => {
+  const command = COMMAND_CODE.FetchMessages;
+  const consumerEnd = identifierEnd(payload, 1, command);
+  const topicEnd = identifierEnd(payload, identifierEnd(payload, consumerEnd, command), command);
+  const targetsAt = topicEnd + FETCH_MESSAGES_FIELDS_SIZE;
+  if (payload.length < targetsAt)
+    throw responseError(command, INVALID_COMMAND);
+  const count = payload.readUInt32LE(topicEnd + FETCH_MESSAGES_FIELD.targetsCount);
+  const minCount = payload.readUInt32LE(topicEnd + FETCH_MESSAGES_FIELD.minCount);
+  const maxBytes = payload.readUInt32LE(topicEnd + FETCH_MESSAGES_FIELD.maxBytes);
+  if (count === 0 || count > MAX_PARTITIONS_PER_REQUEST || payload.length !== targetsAt + count * FETCH_TARGET_SIZE ||
+      !isFetchBudget(payload.readBigUInt64LE(topicEnd + FETCH_MESSAGES_FIELD.wait),
+        payload.readBigUInt64LE(topicEnd + FETCH_MESSAGES_FIELD.requestTimeout)) ||
+      minCount === 0 || minCount > payload.readUInt32LE(topicEnd + FETCH_MESSAGES_FIELD.count) ||
+      maxBytes < MIN_DEFERRED_POLL_MAX_BYTES)
+    throw responseError(command, INVALID_COMMAND);
+  const targets = Array.from({ length: count }, (_, index) =>
+    payload.subarray(targetsAt + index * FETCH_TARGET_SIZE, targetsAt + (index + 1) * FETCH_TARGET_SIZE));
+  const kinds = targets.map((target) => target[FETCH_TARGET_FIELD.kind]);
+  const limits = targets.map((target) => target.readUInt32LE(FETCH_TARGET_FIELD.maxBytes));
+  if (new Set(targets.map((target) => target.readUInt32LE(FETCH_TARGET_FIELD.partitionId))).size !== count ||
+      kinds.some((kind) => kind !== PollingStrategyKind.Offset && kind !== PollingStrategyKind.Next) ||
+      limits.some((limit) => limit < MIN_DEFERRED_POLL_MAX_BYTES || limit > maxBytes))
+    throw responseError(command, INVALID_COMMAND);
+  return {
+    fieldsAt: topicEnd, topic: payload.subarray(consumerEnd, topicEnd), targets,
+    progress: payload[topicEnd + FETCH_MESSAGES_FIELD.autoCommit] !== 0 || kinds.includes(PollingStrategyKind.Next),
+    commits: payload[topicEnd + FETCH_MESSAGES_FIELD.autoCommit] !== 0
+  };
+};
+
+/**
+ * The section headers of a fetch reply, each led by its partition id. A reply
+ * whose table does not fit is left to the decoder of the caller.
+ */
+const fetchSections = (data: Buffer): Buffer[] => {
+  const count = data.length >= POLL_COUNT_SIZE ? data.readUInt32LE(0) : 0;
+  if (data.length < fetchReplyHeadSize(count))
+    return [];
+  return Array.from({ length: count }, (_, index) => data.subarray(
+    POLL_COUNT_SIZE + index * FETCH_SECTION_SIZE, POLL_COUNT_SIZE + (index + 1) * FETCH_SECTION_SIZE));
+};
+
+/** The route lookup of a target partition. */
+const readinessTarget = (topic: Buffer, target: Buffer): PartitionTarget => {
+  const query = Buffer.concat([topic, target.subarray(0, PARTITION_ID_SIZE)]);
+  return { query, key: query.toString('hex') };
+};
+
+/**
+ * The budget of a deferred poll or a fetch, after the checks
+ * that the server's decoder makes, which fail with 3 before anything is sent.
+ * A deferred poll fills at most `replyBodyLimit` bytes, and a fetch that many
+ * with its section table, because a link fails on a larger reply. A fetch
+ * that cannot fit one message then fails with 80. Other commands have no
+ * budget.
+ */
+const deferredBudget = (command: number, payload: Buffer, replyBodyLimit: number): DeferredBudget | undefined => {
+  if (command === COMMAND_CODE.FetchMessages) {
+    const { fieldsAt, targets } = readFetch(payload);
+    if (payload.readBigUInt64LE(fieldsAt + FETCH_MESSAGES_FIELD.requestTimeout) <= MIN_DEFERRED_POLL_REPLY_MARGIN_US)
+      throw responseError(command, INVALID_COMMAND);
+    const maxBytes = Math.min(payload.readUInt32LE(fieldsAt + FETCH_MESSAGES_FIELD.maxBytes),
+      replyBodyLimit - fetchReplyHeadSize(targets.length));
+    if (maxBytes < MIN_DEFERRED_POLL_MAX_BYTES)
+      throw responseError(command, INVALID_SIZE_BYTES);
+    payload.writeUInt32LE(maxBytes, fieldsAt + FETCH_MESSAGES_FIELD.maxBytes);
+    for (const target of targets)
+      target.writeUInt32LE(Math.min(target.readUInt32LE(FETCH_TARGET_FIELD.maxBytes), maxBytes), FETCH_TARGET_FIELD.maxBytes);
+    const waitOffset = fieldsAt + FETCH_MESSAGES_FIELD.wait;
+    const timeoutOffset = fieldsAt + FETCH_MESSAGES_FIELD.requestTimeout;
+    return {
+      started: Date.now(), wait: payload.readBigUInt64LE(waitOffset), timeout: payload.readBigUInt64LE(timeoutOffset),
+      waitOffset, timeoutOffset
+    };
+  }
+  if (command !== COMMAND_CODE.PollMessagesDeferred)
+    return undefined;
+  partitionRoute(command, payload);
+  const pollEnd = payload.length - DEFERRED_POLL_OPTIONS_SIZE;
+  const count = payload.readUInt32LE(pollEnd - POLL_AUTOCOMMIT_SIZE - POLL_COUNT_SIZE);
+  const waitOffset = pollEnd + DEFERRED_POLL_OFFSET.wait;
+  const timeoutOffset = pollEnd + DEFERRED_POLL_OFFSET.requestTimeout;
+  const wait = payload.readBigUInt64LE(waitOffset);
+  const timeout = payload.readBigUInt64LE(timeoutOffset);
+  const minCount = payload.readUInt32LE(pollEnd + DEFERRED_POLL_OFFSET.minCount);
+  const maxBytes = payload.readUInt32LE(pollEnd + DEFERRED_POLL_OFFSET.maxBytes);
+  if (!isDeferredBudget(wait, timeout) || minCount === 0 || minCount > count || maxBytes < MIN_DEFERRED_POLL_MAX_BYTES)
+    throw responseError(command, INVALID_COMMAND);
+  payload.writeUInt32LE(Math.min(maxBytes, replyBodyLimit), pollEnd + DEFERRED_POLL_OFFSET.maxBytes);
+  return { started: Date.now(), wait, timeout, waitOffset, timeoutOffset };
+};
+
+/**
+ * A frame of a deferred request that waits only for what remains of its wait,
+ * and leaves its reply margin before the deadline of the call, as
+ * `Call::deferred_payload` in core/sdk/src/driver.rs does. The frame is a
+ * copy, because a link can still hold the bytes of an earlier attempt.
+ */
+const deferredFrame = (command: number, frame: Buffer, budget: DeferredBudget, deadline: number): Buffer => {
+  const now = Date.now();
+  const elapsed = BigInt(Math.max(0, now - budget.started)) * US_PER_MS;
+  const timeout = smaller(budget.timeout - elapsed, BigInt(Math.floor(deadline - now)) * US_PER_MS);
+  if (timeout <= 0n || (command === COMMAND_CODE.FetchMessages && timeout < MIN_DEFERRED_POLL_REPLY_MARGIN_US))
+    throw responseError(command, TRANSIENT_NOT_ACCEPTED);
+  const wait = smaller(budget.wait - elapsed, timeout - MIN_DEFERRED_POLL_REPLY_MARGIN_US);
+  const bytes = Buffer.from(frame);
+  bytes.writeBigUInt64LE(wait > 0n ? wait : 0n, HEADER_SIZE + budget.waitOffset);
+  bytes.writeBigUInt64LE(timeout, HEADER_SIZE + budget.timeoutOffset);
+  return bytes;
+};
+
+const smaller = (left: bigint, right: bigint): bigint => left < right ? left : right;
+
 /** A command the caller may send to a replica it chooses: non-replicated, not a partition read, not session control. */
 const isReplicaCommand = (command: number): boolean =>
   !REPLICA_REFUSED_CODES.has(command) && operationForCode(command) === Operation.NonReplicated;
diff --git a/foreign/node/src/client/client.type.ts b/foreign/node/src/client/client.type.ts
index b57d499..99a3caf 100644
--- a/foreign/node/src/client/client.type.ts
+++ b/foreign/node/src/client/client.type.ts
@@ -19,6 +19,7 @@
 import { type TcpNetConnectOpts } from 'node:net';
 import { type ConnectionOptions } from 'node:tls';
 import type { PartitionContext } from '../wire/vsr/header.js';
+import type { Id } from '../wire/identifier.utils.js';
 
 /**
  * TCP socket connection options.
@@ -43,7 +44,9 @@
   /** Length of the response data in bytes */
   length: number,
   /** Response payload data */
-  data: Buffer
+  data: Buffer,
+  /** Local recovery metadata for uncertain sections. The wire bytes stay unchanged. */
+  fetchProgress?: { operationId: bigint, partitions: ReadonlySet<number> }
 };
 
 export type SendCommandOptions = {
@@ -101,13 +104,13 @@
   isAuthenticated: boolean
   /** Raised by each sign-in and logout of the caller. */
   readonly lineage?: number
-  /** Uses caller credentials; any failed attempt withdraws automatic sign-in. */
+  /** Uses caller credentials. Any failed attempt withdraws automatic sign-in. */
   authenticate: (c: ClientCredentials) => Promise<boolean>
   /** Typed login delegates validation to the session owner when supported. */
   _authWithPassword?: (c: PasswordCredentials) => Promise<{ userId: number }>
   _authWithToken?: (c: TokenCredentials) => Promise<{ userId: number }>
   /**
-   * Resolves the original frozen operation; never sends it under a fresh identity.
+   * Resolves the original frozen operation. Never sends it under a fresh identity.
    * The client keeps the proved outcomes of its last `maxPendingOperations`
    * replays, also across a session reset, so a later resolve of the same
    * operation returns the same outcome without a request.
@@ -121,6 +124,12 @@
    * client keeps the outcome that it proves.
    */
   abandonOperation?: (operationId: bigint) => void,
+  /**
+   * Groups partitions of a topic by their primary, from the cached routes,
+   * so a partition reader can keep one fetch in flight per group.
+   */
+  readinessGroups?: (streamId: Id, topicId: Id, partitions: number[], options?: SendCommandOptions) =>
+    Promise<number[][]>,
   /** Destroys the client connection */
   destroy: () => void,
   /** Tracks one facade operation across multiple command submissions. */
@@ -203,9 +212,9 @@
  * Legacy logical-client count. Replica links are managed independently.
  */
 export type PoolSizeOption = {
-  /** Minimum logical clients; normalized to one */
+  /** Minimum logical clients. Normalized to one */
   min?: number,
-  /** Maximum logical clients; only one is supported */
+  /** Maximum logical clients. Only one is supported */
   max?: number
 }
 
@@ -225,7 +234,7 @@
   options: TransportOption,
   /** Authentication credentials */
   credentials: ClientCredentials,
-  /** Legacy logical-client count; does not limit replica links. */
+  /** Legacy logical-client count. Does not limit replica links. */
   poolSize?: PoolSizeOption,
   /** Automatic reconnection configuration */
   reconnect?: ReconnectOption,
diff --git a/foreign/node/src/debug-send.ts b/foreign/node/src/debug-send.ts
index ce95752..28c2e7c 100644
--- a/foreign/node/src/debug-send.ts
+++ b/foreign/node/src/debug-send.ts
@@ -89,8 +89,7 @@
     streamId: streamName,
     topicId: topicName,
     pollingStrategy: PollingStrategy.Next,
-    count: 1,
-    interval: 5000
+    count: 1
   };
 
   const cs = await groupConsumerStream(opt)(pollReq);
diff --git a/foreign/node/src/debug.ts b/foreign/node/src/debug.ts
index d45ac43..a9838cd 100644
--- a/foreign/node/src/debug.ts
+++ b/foreign/node/src/debug.ts
@@ -85,8 +85,7 @@
     streamId: streamName,
     topicId: topicName,
     pollingStrategy: PollingStrategy.Next,
-    count: 5,
-    interval: 5000
+    count: 5
   };
 
   console.log('poll req', pollReq);
diff --git a/foreign/node/src/e2e/tcp.cluster.e2e.ts b/foreign/node/src/e2e/tcp.cluster.e2e.ts
index a5912a6..5f95e65 100644
--- a/foreign/node/src/e2e/tcp.cluster.e2e.ts
+++ b/foreign/node/src/e2e/tcp.cluster.e2e.ts
@@ -17,36 +17,23 @@
 
 import { after, describe, it } from "node:test";
 import assert from "node:assert/strict";
+import { getIggyAddress } from "../tcp.sm.utils.js";
 import { getTestClient } from "./test-client.utils.js";
 
-// The suite runs against a single-node server (cluster mode off), which
-// reports itself as the sole healthy leader with endpoints derived from
-// its default config.
-const expectedMeta = {
-  name: "single-node",
-  nodes: [
-    {
-      replicaId: 0,
-      name: "iggy-node",
-      ip: "127.0.0.1",
-      endpoints: {
-        tcp: 8090,
-        quic: 8080,
-        http: 3000,
-        websocket: 8092,
-      },
-      role: "Leader",
-      status: "Healthy",
-    },
-  ],
-};
-
+// The suite runs against one node or a cluster on any ports, so it checks
+// the shape of the roster and the node that the client dialed.
 describe("e2e -> system", async () => {
   const c = getTestClient();
 
   it("e2e -> cluster::getClusterMetadata", async () => {
     const meta = await c.cluster.getClusterMetadata();
-    assert.deepEqual(meta, expectedMeta);
+    const [host, port] = getIggyAddress();
+
+    assert.deepEqual(meta.nodes.map(({ replicaId }) => replicaId), meta.nodes.map((_, index) => index));
+    assert.deepEqual(meta.nodes.filter(({ role }) => role === "Leader").length, 1);
+    assert.ok(meta.nodes.every(({ status }) => status === "Healthy"), JSON.stringify(meta.nodes));
+    assert.ok(meta.nodes.some(({ ip, endpoints }) => ip === host && endpoints.tcp === port),
+      `${host}:${port} is not in ${JSON.stringify(meta.nodes)}`);
   });
 
   after(() => {
diff --git a/foreign/node/src/e2e/tcp.consumer-stream.e2e.ts b/foreign/node/src/e2e/tcp.consumer-stream.e2e.ts
index 32bf7ed..5b660b9 100644
--- a/foreign/node/src/e2e/tcp.consumer-stream.e2e.ts
+++ b/foreign/node/src/e2e/tcp.consumer-stream.e2e.ts
@@ -17,7 +17,7 @@
 
 import { after, describe, it } from 'node:test';
 import assert from 'node:assert/strict';
-import type { Readable } from 'node:stream';
+import { once } from 'node:events';
 import { Client } from '../client/client.js';
 import { groupConsumerStream } from '../stream/consumer-stream.js';
 import {
@@ -54,6 +54,12 @@
 
   const ct = 1000;
 
+  after(async () => {
+    assert.ok(await c.stream.delete({ streamId: streamName }));
+    assert.ok(await c.session.logout());
+    c.destroy();
+  });
+
   it('e2e -> consumer-stream::send-messages', async () => {
     for (let i = 0; i < ct; i += 100) {
       await sendSomeMessages(c.clientProvider)(
@@ -71,47 +77,37 @@
       streamId: streamName,
       topicId: topicName,
       pollingStrategy: PollingStrategy.Next,
-      count: 100,
-      interval: 500
+      count: 100
     };
 
     const pollStream = groupConsumerStream(opt);
     const s = await pollStream(pollReq);
     const s2 = await pollStream(pollReq);
-    let recv = 0;
-    const dataCb = (str: Readable) => (d: PollMessagesResponse) => {
-      recv += d.messages.length;
-      assert.equal(d.messages.length, d.count);
-      if (recv === ct) {
-        str.destroy();
-      }
-    };
-
-    s.on('data', dataCb(s));
-    s2.on('data', dataCb(s2));
-
-    // promise prevent test from finishing before end is handled
-    await new Promise((resolve, reject) => {
-      s.on('error', (err) => {
-        console.error('=>>Stream ERROR::', err)
-        s.destroy(err);
-        reject(err);
+    const received = new Set<string>();
+    try {
+      await new Promise<void>((resolve, reject) => {
+        for (const stream of [s, s2]) {
+          stream.once('error', reject);
+          stream.on('data', ({ partitionId, messages, count }: PollMessagesResponse) => {
+            try {
+              assert.equal(messages.length, count);
+              for (const { headers: { offset } } of messages) {
+                const key = `${partitionId}:${offset}`;
+                assert.ok(!received.has(key), `message ${key} was delivered twice`);
+                received.add(key);
+              }
+              if (received.size === ct) resolve();
+            } catch (error) { reject(error); }
+          });
+        }
       });
-
-      s.on('end', async () => {
-        resolve(assert.equal(ct, recv));
-      });
-    });
-
-    after(async () => {
-      // The member that did not get the last batch can still be polling,
-      // and a poll that fails after the delete has no error listener.
-      s.destroy();
-      s2.destroy();
-      assert.ok(await c.stream.delete({ streamId: streamName }));
-      assert.ok(await c.session.logout());
-      await c.destroy();
-    });
-
+      assert.equal(received.size, ct);
+    } finally {
+      await Promise.all([s, s2].map(async (stream) => {
+        const closed = once(stream, 'close');
+        stream.destroy();
+        await closed;
+      }));
+    }
   });
 });
diff --git a/foreign/node/src/e2e/tcp.decode-skip.e2e.ts b/foreign/node/src/e2e/tcp.decode-skip.e2e.ts
new file mode 100644
index 0000000..1a2290c
--- /dev/null
+++ b/foreign/node/src/e2e/tcp.decode-skip.e2e.ts
@@ -0,0 +1,96 @@
+// 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.
+
+import { after, describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { getTestClient } from './test-client.utils.js';
+import { ReadError } from '../stream/partition-reader.js';
+import { COMMAND_CODE } from '../wire/command.code.js';
+import { Consumer, Partitioning, PollingStrategy } from '../wire/index.js';
+import { fetchMessages } from '../wire/message/fetch-messages.command.js';
+import { BATCH_HEADER_SIZE, FRAME_HEADER_SIZE } from '../wire/message/iggy-header.utils.js';
+import { encodeMessagesBatch, mintMessageId, serializeSendMessages } from '../wire/message/message.utils.js';
+import { generateMessages } from '../tcp.sm.utils.js';
+
+describe('e2e -> decode-skip', async () => {
+  const client = getTestClient();
+  const streamId = 'e2e-decode-skip-stream';
+  const topicId = 'e2e-decode-skip-topic';
+  const partition = Partitioning.PartitionId(0);
+  /** User headers whose first key has length zero. The server stores them, the client cannot decode them. */
+  const UNPARSABLE_HEADERS = Buffer.from([1, 0, 0, 0, 0]);
+
+  await client.stream.create({ name: streamId });
+  await client.topic.create({ streamId, name: topicId, partitionCount: 1, compressionAlgorithm: 1 });
+
+  after(async () => {
+    await client.stream.delete({ streamId });
+    await client.session.logout();
+    await client.destroy();
+  });
+
+  const send = (count: number) => client.message.send({
+    streamId, topicId, messages: generateMessages(count), partition
+  });
+
+  /** Sends one message with user headers that do not decode, through the raw command. */
+  const sendUnparsable = async () => {
+    const payload = Buffer.from('unparsable-headers');
+    const plain = serializeSendMessages(streamId, topicId, [{ payload }], partition);
+    const head = plain.subarray(0, plain.length - BATCH_HEADER_SIZE - FRAME_HEADER_SIZE - payload.length);
+    const batch = encodeMessagesBatch([{
+      id: mintMessageId(), payload, userHeaders: UNPARSABLE_HEADERS, originTimestamp: BigInt(Date.now()) * 1000n
+    }]);
+    await client.sendBinaryRequest(COMMAND_CODE.SendMessages, Buffer.concat([head, batch]));
+  };
+
+  /** The frame of the message at `offset` as a fetch serves it, undecoded. */
+  const frameAsServed = async (offset: bigint): Promise<Buffer> => {
+    const [section] = await fetchMessages(client.clientProvider)({
+      streamId, topicId, consumer: Consumer.Single, count: 1, autocommit: false,
+      targets: [{ partitionId: 0, pollingStrategy: PollingStrategy.Offset(offset) }]
+    });
+    const frame = section.records.subarray(BATCH_HEADER_SIZE);
+    const headersLength = frame.readUInt32LE(32);
+    const payloadLength = frame.readUInt32LE(36);
+    return frame.subarray(0, FRAME_HEADER_SIZE + payloadLength + headersLength);
+  };
+
+  it('e2e -> decode-skip::a message that does not decode is reported with its offset and frame, and a seek passes it',
+    async () => {
+      await send(1);
+      await sendUnparsable();
+      await send(1);
+      const reader = await client.message.reader({
+        streamId, topicId, consumer: Consumer.Single, partitions: [0], start: PollingStrategy.First,
+        options: { count: 10, maxWait: 1_000 }
+      });
+      try {
+        const prefix = await reader.next();
+        const error = await reader.next().then(() => undefined, (failure: unknown) => failure);
+        assert.ok(error instanceof ReadError, String(error));
+        assert.deepEqual(prefix.messages.map(({ headers }) => headers.offset), [0n]);
+        assert.deepEqual([error.partitionId, error.offset, error.skip?.value], [0, 1n, 2n], String(error.error));
+        assert.deepEqual(error.record, await frameAsServed(1n));
+        await reader.seek(0, error.skip!);
+        const resumed = await reader.next();
+        assert.deepEqual(resumed.messages.map(({ headers }) => headers.offset), [2n]);
+      } finally {
+        await reader.close();
+      }
+    });
+});
diff --git a/foreign/node/src/e2e/tcp.deferred-poll.e2e.ts b/foreign/node/src/e2e/tcp.deferred-poll.e2e.ts
new file mode 100644
index 0000000..da7249c
--- /dev/null
+++ b/foreign/node/src/e2e/tcp.deferred-poll.e2e.ts
@@ -0,0 +1,202 @@
+// 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.
+
+import { after, describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { setTimeout as sleep } from 'node:timers/promises';
+import { SingleClient } from '../client/client.js';
+import { singleConsumerStream } from '../stream/consumer-stream.js';
+import {
+  Consumer, PollingStrategy, Partitioning, syncGroup, type PollMessagesResponse
+} from '../wire/index.js';
+import { generateMessages, getIggyAddress } from '../tcp.sm.utils.js';
+
+describe('e2e -> deferred-poll', async () => {
+  const [host, port] = getIggyAddress();
+  const config = {
+    transport: 'TCP' as const,
+    options: { host, port },
+    credentials: { username: 'iggy', password: 'iggy' }
+  };
+  const PARTITIONS = 3;
+  const LONG_WAIT_MS = 10_000;
+  const LONG_WAIT = { maxWait: LONG_WAIT_MS, requestTimeout: LONG_WAIT_MS + 1_000 };
+  const PROMPT_MS = 2_000;
+  const ASSIGNMENT_TIMEOUT_MS = 10_000;
+  /** The server parks at most 64 deferred requests of one session on one shard. */
+  const MAX_PARKED = 64;
+
+  const streamId = 'e2e-deferred-poll-stream';
+  const topicId = 'e2e-deferred-poll-topic';
+  const backlogTopicId = 'e2e-deferred-poll-backlog-topic';
+  const idleTopicId = 'e2e-deferred-poll-idle-topic';
+  const laneTopicId = 'e2e-deferred-poll-lane-topic';
+  const groupId = 'e2e-deferred-poll-group';
+
+  const producer = new SingleClient(config);
+  const consumer = new SingleClient(config);
+  // One fetch reads the partitions of one primary, and one partition more
+  // than nodes puts two of them on the same primary.
+  const backlogPartitions = (await producer.cluster.getClusterMetadata()).nodes.length + 1;
+
+  await producer.stream.create({ name: streamId });
+  await producer.topic.create({ streamId, name: topicId, partitionCount: PARTITIONS, compressionAlgorithm: 1 });
+  await producer.topic.create({ streamId, name: idleTopicId, partitionCount: 1, compressionAlgorithm: 1 });
+  await producer.topic.create({ streamId, name: laneTopicId, partitionCount: 1, compressionAlgorithm: 1 });
+  await producer.topic.create({
+    streamId, name: backlogTopicId, partitionCount: backlogPartitions, compressionAlgorithm: 1
+  });
+  for (const topic of [topicId, backlogTopicId]) {
+    await producer.group.create({ streamId, topicId: topic, name: groupId });
+    await consumer.group.join({ streamId, topicId: topic, groupId });
+  }
+
+  after(async () => {
+    await producer.stream.delete({ streamId });
+    await producer.session.logout();
+    await consumer.session.logout();
+    producer.destroy();
+    consumer.destroy();
+  });
+
+  const send = (topic: string, partition: number, count = 1) => producer.message.send({
+    streamId, topicId: topic, messages: generateMessages(count), partition: Partitioning.PartitionId(partition)
+  });
+
+  // A member gets its partitions one by one after it joins.
+  const waitForAssignment = async (topic: string, partitions: number) => {
+    const deadline = Date.now() + ASSIGNMENT_TIMEOUT_MS;
+    while ((await syncGroup(consumer.clientProvider)({ streamId, topicId: topic, groupId }))?.partitions.length !==
+        partitions) {
+      assert.ok(Date.now() < deadline, 'the member did not get every partition');
+      await sleep(50);
+    }
+  };
+
+  it('e2e -> deferred-poll::a group reader reads the partition that holds messages', async () => {
+    await waitForAssignment(topicId, PARTITIONS);
+    const reader = await consumer.message.reader({
+      streamId, topicId, consumer: Consumer.Group(groupId), options: { count: 10, maxWait: LONG_WAIT_MS }
+    });
+    try {
+      while (reader.partitions().length < PARTITIONS) await sleep(50);
+      // The reads of every primary wait at once, so none of these waits 10 s on an idle partition.
+      for (let partition = PARTITIONS - 1; partition >= 0; partition -= 1) {
+        await send(topicId, partition);
+        const started = Date.now();
+        const batch = await reader.next();
+        const elapsed = Date.now() - started;
+        assert.equal(batch.partitionId, partition);
+        assert.equal(batch.messages.length, 1);
+        assert.ok(elapsed < PROMPT_MS, `the read of partition ${partition} took ${elapsed} ms`);
+      }
+    } finally {
+      await reader.close();
+    }
+  });
+
+  it('e2e -> deferred-poll::a backlog in one partition does not hide a message in another', async () => {
+    const BACKLOG = 1_000;
+    await waitForAssignment(backlogTopicId, backlogPartitions);
+    const assigned = Array.from({ length: backlogPartitions }, (_, partitionId) => partitionId);
+    const raw = await consumer.clientProvider();
+    const groups = await raw.readinessGroups!(streamId, backlogTopicId, assigned);
+    const shared = groups.find((group) => group.length > 1);
+    assert.ok(shared, `no primary leads two partitions in ${JSON.stringify(groups)}`);
+    const [backlogPartition, latePartition] = shared;
+    await send(backlogTopicId, backlogPartition, BACKLOG);
+    await send(backlogTopicId, latePartition);
+    const reader = await consumer.message.reader({
+      streamId, topicId: backlogTopicId, consumer: Consumer.Group(groupId), start: PollingStrategy.Offset(0n),
+      options: { count: 10, maxWait: 1_000 }
+    });
+    try {
+      while (reader.partitions().length < backlogPartitions) await sleep(50);
+      // A fetch from an offset fills its count from the backlog in request order, so the first reply may hold the
+      // backlog alone. The late partition then gets a fetch of its own beside the next backlog fetch, whose replies
+      // land in either order, so it shows by the third batch, and one more backlog reply may overtake its reply.
+      const read: number[] = [];
+      while (read.length < 4 && !read.includes(latePartition)) read.push((await reader.next()).partitionId);
+      assert.ok(read.includes(latePartition), `read ${read.join(', ')} before ${latePartition}`);
+    } finally {
+      await reader.close();
+    }
+  });
+
+  it('e2e -> deferred-poll::a client sends at once to the partition where its progress poll waits', async () => {
+    const PARK_MS = 200;
+    const polling = consumer.message.poll({
+      streamId, topicId: laneTopicId, consumer: Consumer.Single, partitionId: 0,
+      pollingStrategy: PollingStrategy.Next, count: 1, autocommit: false, deferred: LONG_WAIT
+    });
+    await sleep(PARK_MS);
+    const started = Date.now();
+    await consumer.message.send({
+      streamId, topicId: laneTopicId, messages: generateMessages(1), partition: Partitioning.PartitionId(0)
+    });
+    // One lane for polls and sends would hold the send until the poll ends after 10 s.
+    const sent = Date.now() - started;
+    assert.ok(sent < PROMPT_MS, `the send took ${sent} ms`);
+    assert.equal((await polling).count, 1);
+    assert.ok(Date.now() - started < PROMPT_MS);
+  });
+
+  it('e2e -> deferred-poll::aborted deferred polls end at once and free their server slots', async () => {
+    const poll = (deferred: { maxWait: number, requestTimeout: number }, signal?: AbortSignal) => consumer.message.poll({
+      streamId, topicId: idleTopicId, consumer: Consumer.Single, partitionId: 0,
+      pollingStrategy: PollingStrategy.Offset(1_000n), count: 1, autocommit: false, deferred, signal
+    });
+    const controller = new AbortController();
+    const parked = Array.from({ length: MAX_PARKED }, () => poll(LONG_WAIT, controller.signal));
+    await sleep(500);
+    const started = Date.now();
+    controller.abort();
+    const aborted = await Promise.allSettled(parked);
+    assert.ok(aborted.every((result) => result.status === 'rejected' && result.reason?.name === 'AbortError'));
+    assert.ok(Date.now() - started < PROMPT_MS);
+    // Without the cancellations the server would still park the aborted polls
+    // and refuse these with 90 until their deadline.
+    const SHORT_WAIT = { maxWait: 500, requestTimeout: 1_500 };
+    const next = await Promise.all(Array.from({ length: MAX_PARKED }, () => poll(SHORT_WAIT)));
+    assert.ok(next.every(({ count }) => count === 0));
+  });
+
+  it('e2e -> deferred-poll::a consumer stream delivers a message at once after an idle period', async () => {
+    const IDLE_MS = 1_500;
+    const stream = await singleConsumerStream(config)({
+      streamId, topicId: idleTopicId, partitionId: 0, pollingStrategy: PollingStrategy.Next,
+      count: 10, pollOptions: LONG_WAIT
+    });
+    try {
+      const received: { response: PollMessagesResponse, at: number }[] = [];
+      stream.on('data', (response: PollMessagesResponse) => received.push({ response, at: Date.now() }));
+      await sleep(IDLE_MS);
+      // The poll waits on the server, so an idle stream sends no poll after poll.
+      assert.equal(received.length, 0);
+      await send(idleTopicId, 0);
+      const sentAt = Date.now();
+      while (received.length === 0) {
+        assert.ok(Date.now() - sentAt < PROMPT_MS, 'the stream did not deliver the message');
+        await sleep(10);
+      }
+      assert.equal(received[0].response.count, 1);
+      assert.ok(received[0].at - sentAt < PROMPT_MS / 2, `delivered after ${received[0].at - sentAt} ms`);
+    } finally {
+      stream.destroy();
+    }
+  });
+});
diff --git a/foreign/node/src/examples/stream-topic-to-file.ts b/foreign/node/src/examples/stream-topic-to-file.ts
index b0eb9fa..ce65b46 100644
--- a/foreign/node/src/examples/stream-topic-to-file.ts
+++ b/foreign/node/src/examples/stream-topic-to-file.ts
@@ -54,7 +54,6 @@
     partitionId,
     pollingStrategy: PollingStrategy.Next,
     count: 1,
-    interval: 1000,
     endOnLastOffset: true
   });
 
diff --git a/foreign/node/src/index.ts b/foreign/node/src/index.ts
index 456e6c1..369e53a 100644
--- a/foreign/node/src/index.ts
+++ b/foreign/node/src/index.ts
@@ -18,6 +18,20 @@
 export {
   type Id,
   PollingStrategy,
+  DEFAULT_DEFERRED_POLL_OPTIONS,
+  type DeferredPollOptions,
+  DEFAULT_POLL_OPTIONS,
+  DEFAULT_AUTO_COMMIT,
+  CommitPolicy,
+  CommitEvery,
+  CommitAfter,
+  type AutoCommit,
+  type PollOptions,
+  type PollPartition,
+  Checkpoint,
+  type Continuation,
+  type PolledBatch,
+  type ConsumerPosition,
   Consumer,
   Partitioning,
   HeaderValue,
diff --git a/foreign/node/src/stream/consumer-stream.ts b/foreign/node/src/stream/consumer-stream.ts
index 9a647c9..b2502ca 100644
--- a/foreign/node/src/stream/consumer-stream.ts
+++ b/foreign/node/src/stream/consumer-stream.ts
@@ -15,166 +15,74 @@
 // specific language governing permissions and limitations
 // under the License.
 
-import { Readable } from "node:stream";
-import type { ClientConfigOrString, RawClient } from "../client/client.type.js";
+import { Readable } from 'node:stream';
+import type { ClientConfigOrString } from '../client/client.type.js';
 import type { Id } from '../wire/identifier.utils.js';
-import { SimpleClient } from "../client/client.js";
-import { normalizeClientConfig } from "../client/client.config.js";
-import { debug } from "../client/client.debug.js";
-import { getRawClient } from "../client/client.socket.js";
-import {
-  NotConnectedError, OperationUncertainError, VsrResponseTimeoutError
-} from "../client/client.operation.js";
+import { SimpleClient } from '../client/client.js';
+import { normalizeClientConfig } from '../client/client.config.js';
 import { ConnectionClosedError } from '../client/client.connection.js';
-import { ResponseError } from "../wire/error.utils.js";
-import { LEAVE_GROUP } from "../wire/consumer-group/leave-group.command.js";
-import { ConsumerKind } from "../wire/offset/offset.utils.js";
+import { debug } from '../client/client.debug.js';
 import {
-  NO_ASSIGNED_PARTITION,
-  type PollMessages
-} from "../wire/message/poll-messages.command.js";
-import {
-  type PollingStrategy,
-  type CommandAPI,
-  Consumer
-} from "../wire/index.js";
+  FetchProgressUncertainError, LaneBlockedError, NotConnectedError, OperationUncertainError, VsrResponseTimeoutError
+} from '../client/client.operation.js';
+import { getRawClient } from '../client/client.socket.js';
+import { ResponseError } from '../wire/error.utils.js';
+import { Consumer } from '../wire/offset/offset.utils.js';
+import type { PollingStrategy } from '../wire/message/poll.utils.js';
+import type { DeferredPollOptions } from '../wire/message/deferred-poll.utils.js';
+import { CommitPolicy, pollOptions, type PollOptions } from '../wire/message/poll-options.js';
+import type { PolledBatch } from '../wire/message/checkpoint.js';
+import { ReadError, type PartitionReader } from './partition-reader.js';
 
+const DEFAULT_POLLING_RETRY_INTERVAL_MS = 1_000;
+// A closing stream keeps its client this long for the rewind and the leave of
+// its reader. A membership that it could not leave ends with the session lease.
+const CLOSE_TIMEOUT_MS = 5_000;
 const STALE_CLIENT = 30;
 const UNAUTHENTICATED = 40;
 const TRANSIENT_NOT_COMMITTED = 57;
 const TRANSIENT_NOT_ACCEPTED = 58;
 const NOT_PRIMARY = 89;
+const DEFERRED_POLL_CAPACITY_EXCEEDED = 90;
 const RECOVERED_ERROR_CODES = new Set([
-  STALE_CLIENT, UNAUTHENTICATED, TRANSIENT_NOT_COMMITTED, TRANSIENT_NOT_ACCEPTED, NOT_PRIMARY
+  STALE_CLIENT, UNAUTHENTICATED, TRANSIENT_NOT_COMMITTED, TRANSIENT_NOT_ACCEPTED, NOT_PRIMARY,
+  DEFERRED_POLL_CAPACITY_EXCEEDED
 ]);
-// A closed group stream keeps its client until the leave ends. A leave that
-// misses this deadline leaves the membership to its lease.
-const LEAVE_GROUP_TIMEOUT_MS = 5_000;
 
-const wait = (interval: number, cb?: () => void): Promise<void> =>
-  new Promise((resolve) => {
-    setTimeout(() => resolve(cb ? cb() : undefined), interval)
-  });
-
-// The client binds the session again, signs in again, reconnects or routes
-// again on its own after these errors, so they must not end a stream that
-// nothing restarts.
-const pollsAgainAfter = (error: unknown): boolean =>
-  error instanceof OperationUncertainError || error instanceof VsrResponseTimeoutError ||
-  error instanceof NotConnectedError || error instanceof ConnectionClosedError ||
-  (error instanceof ResponseError && RECOVERED_ERROR_CODES.has(error.errorCode));
-
-const isUnauthenticated = (error: unknown): boolean =>
-  error instanceof ResponseError && error.errorCode === UNAUTHENTICATED;
-
-async function* genEagerUntilPoll(
-  c: CommandAPI,
-  poll: PollMessages,
-  signIn: () => Promise<unknown>,
-  stopped: () => boolean,
-  interval = 1000,
-  endOnLastOffset = false
-) {
-  const state: Map<string, number> = new Map();
-
-  while (!stopped()) {
-    const r = await c.message.poll(poll).catch(async (error: unknown) => {
-      if (!pollsAgainAfter(error)) throw error;
-      debug('consumer stream poll failed, polling again', error);
-      // A 40 also comes from a sign-in that the server refuses, as for an
-      // inactive user, and every later sign-in gets the same refusal. So after
-      // a 40 the stream waits for the next sign-in, and a refused one ends it.
-      if (isUnauthenticated(error))
-        await signIn().catch((failure: unknown) => {
-          if (isUnauthenticated(failure) || !pollsAgainAfter(failure)) throw failure;
-          debug('consumer stream sign-in failed, polling again', failure);
-        });
-      return undefined;
-    });
-    if (!r || (r.count === 0 && r.partitionId === NO_ASSIGNED_PARTITION)) {
-      await wait(interval);
-      continue;
-    }
-    yield r;
-    state.set(`${r.partitionId}`, r.count);
-
-    if (Array.from(state).every(([, last]) => last === 0)) {
-      if( endOnLastOffset )
-        return;
-      else
-        await wait(interval);
-    }
-  }
+// The client binds the session again, reconnects or routes again on its own
+// after these errors, and the reader reads again, so they do not end a stream
+// that nothing restarts. A message that does not decode and a block that
+// needs explicit recovery end it.
+const readsAgainAfter = (error: unknown): boolean => {
+  if (error instanceof ReadError && error.skip) return false;
+  const cause = error instanceof ReadError ? error.error : error;
+  if (cause instanceof FetchProgressUncertainError || (cause instanceof LaneBlockedError && cause.requiresExplicitRecovery))
+    return false;
+  return cause instanceof OperationUncertainError || cause instanceof VsrResponseTimeoutError ||
+    cause instanceof NotConnectedError || cause instanceof ConnectionClosedError ||
+    (cause instanceof ResponseError && RECOVERED_ERROR_CODES.has(cause.errorCode));
 };
 
-/** The client that a stream owns, and the sign-in that the stream waits for. */
-type StreamClient = { client: SimpleClient, raw: RawClient, signIn: () => Promise<boolean> };
-
-// A running stream polls again after NotConnectedError, so the factory signs
-// in first: a wrong address or broken TLS then rejects the factory call. The
-// stream owns this client, so a failed sign-in or setup destroys it.
-const openClient = async (
-  config: ClientConfigOrString,
-  setup?: (c: SimpleClient) => Promise<unknown>
-): Promise<StreamClient> => {
-  const options = normalizeClientConfig(config);
-  const raw = getRawClient(options);
-  const signIn = () => raw.authenticate(options.credentials);
-  try {
-    await signIn();
-    const client = new SimpleClient(raw);
-    await setup?.(client);
-    return { client, raw, signIn };
-  } catch (error) {
-    raw.destroy();
-    throw error;
-  }
+const isUnauthenticated = (error: unknown): boolean => {
+  const cause = error instanceof ReadError ? error.error : error;
+  return cause instanceof ResponseError && cause.errorCode === UNAUTHENTICATED;
 };
 
-// A client without a session would have to sign in again to leave, and that
-// new session is not the member.
-const leaveConsumerGroup = async (raw: RawClient, { streamId, topicId, consumer }: PollMessages): Promise<void> => {
-  if (consumer.kind !== ConsumerKind.Group || !raw.isAuthenticated) return;
-  await raw.sendCommand(LEAVE_GROUP.code, LEAVE_GROUP.serialize({ streamId, topicId, groupId: consumer.id }),
-    { deadline: Date.now() + LEAVE_GROUP_TIMEOUT_MS });
-};
-
-// The stream owns its client, so a closed stream must not keep the links,
-// heartbeat and group membership of that client alive.
-const pollStream = (
-  { client, raw, signIn }: StreamClient, poll: PollMessages, interval: number, endOnLastOffset: boolean
-) => {
-  const stream: Readable = Readable.from(
-    genEagerUntilPoll(client, poll, signIn, () => stream.destroyed, interval, endOnLastOffset),
-    { objectMode: true }
-  );
-  // A destroyed stream closes only after its running poll ends, and a poll
-  // can wait for a sign-in until its deadline. A client without a session has
-  // no membership to leave, so it goes at once, also when it loses its
-  // session later.
-  const closeGenerator = stream._destroy.bind(stream);
-  stream._destroy = (error, callback) => {
-    const releaseSignedOut = () => { if (!raw.isAuthenticated) raw.destroy(); };
-    releaseSignedOut();
-    raw.on('sessionReset', releaseSignedOut);
-    closeGenerator(error, callback);
-  };
-  stream.once('close', () => {
-    void leaveConsumerGroup(raw, poll)
-      .catch((error: unknown) => { debug('consumer stream group leave failed', error); })
-      .finally(() => raw.destroy());
-  });
-  return stream;
-};
-
-
 export type ConsumerStreamRequest = {
   streamId: Id,
   topicId: Id,
   pollingStrategy: PollingStrategy,
   count: number,
-  interval?: number,
+  /**
+   * How each read waits on the server for messages: `maxWait`, `minCount`
+   * and `maxBytes` of `PollOptions`. A read waits up to 5 s by default.
+   */
+  pollOptions?: Partial<DeferredPollOptions>,
+  /** The pause in milliseconds before a refused partition reads again. Defaults to 1000. */
+  pollingRetryInterval?: number,
+  /** Whether the server stores the offset of each read (`CommitPolicy.OnRead`). Defaults to true. */
   autocommit?: boolean
+  /** Ends the stream once the last read of every partition found no message. */
   endOnLastOffset?: boolean
 }
 
@@ -184,55 +92,131 @@
 
 export type GroupConsumerStreamRequest = ConsumerStreamRequest & { groupName: string };
 
-export const singleConsumerStream = (config: ClientConfigOrString) => async (
-  {
-    streamId,
-    topicId,
-    partitionId,
-    pollingStrategy,
+/** The reader options of a stream request. */
+const readerOptions = ({ count, pollOptions: wait = {}, autocommit = true }: ConsumerStreamRequest):
+  Partial<PollOptions> => {
+  const options: Partial<PollOptions> = {
     count,
-    interval = 1000,
-    autocommit = true,
-    endOnLastOffset = false
-  }: SingleConsumerStreamRequest
-): Promise<Readable> => {
-  const c = await openClient(config);
-
-  const poll = {
-    streamId,
-    topicId,
-    partitionId,
-    consumer: Consumer.Single,
-    pollingStrategy,
-    count,
-    autocommit
+    ...(wait.maxWait === undefined ? {} : { maxWait: wait.maxWait }),
+    ...(wait.minCount === undefined ? {} : { minCount: wait.minCount }),
+    ...(wait.maxBytes === undefined ? {} : { maxBytes: wait.maxBytes }),
+    commit: autocommit ? CommitPolicy.OnRead : CommitPolicy.Manual
   };
-
-  return pollStream(c, poll, interval, endOnLastOffset);
+  // Invalid options fail here, before the stream owns a client.
+  pollOptions({ maxWait: 5_000, ...options });
+  return options;
 };
 
+/** A `Readable` of the batches of a reader on a client of its own, which it closes with the stream. */
+const readerStream = async (
+  config: ClientConfigOrString,
+  open: (client: SimpleClient) => Promise<PartitionReader>,
+  endOnLastOffset: boolean,
+  committing: boolean
+): Promise<Readable> => {
+  const options = normalizeClientConfig(config);
+  const raw = getRawClient(options);
+  let reader: PartitionReader;
+  try {
+    await raw.authenticate(options.credentials);
+    reader = await open(new SimpleClient(raw));
+  } catch (error) {
+    raw.destroy();
+    throw error;
+  }
+  // A 40 also comes from a sign-in that the server refuses, as for an
+  // inactive user, so a refused sign-in ends the stream.
+  const signInAgain = async (error: unknown): Promise<void> => {
+    if (!isUnauthenticated(error)) return;
+    await raw.authenticate(options.credentials).catch((failure: unknown) => {
+      if (isUnauthenticated(failure) || !readsAgainAfter(failure)) throw failure;
+      debug('consumer stream sign-in failed, reading again', failure);
+    });
+  };
+  // The last batches pushed, so a destroy can take back those still in the buffer.
+  const pushed: PolledBatch[] = [];
+  const read = async (stream: Readable): Promise<void> => {
+    while (!stream.destroyed) {
+      try {
+        const batch = endOnLastOffset ? await reader.nextUntilEnd() : await reader.next();
+        if (stream.destroyed) {
+          if (batch) reader.returned(batch);
+          return;
+        }
+        if (batch) {
+          pushed.push(batch);
+          if (pushed.length > 2) pushed.shift();
+        }
+        stream.push(batch ?? null);
+        return;
+      } catch (error) {
+        if (stream.destroyed) return;
+        if (!readsAgainAfter(error)) throw error;
+        debug('consumer stream read failed, reading again', error);
+        await signInAgain(error);
+      }
+    }
+  };
+  // A destroy closes the reader, which ends a read that waits for messages. Each
+  // batch in the buffer of a stream that commits on read is stored already, so
+  // that stream reads only when its consumer asks for more.
+  return new Readable({
+    objectMode: true,
+    highWaterMark: committing ? 0 : 1,
+    read() {
+      read(this).catch((error: unknown) => {
+        if (!this.destroyed) this.destroy(error as Error);
+      });
+    },
+    destroy(error, callback) {
+      let released = false;
+      const release = () => {
+        if (released) return;
+        released = true;
+        raw.destroy();
+      };
+      for (const batch of pushed.slice(pushed.length - this.readableLength).reverse()) reader.returned(batch);
+      // A client without a session can neither rewind nor leave, so it goes at once.
+      if (!raw.isAuthenticated) release();
+      const timer = setTimeout(release, CLOSE_TIMEOUT_MS);
+      reader.close().catch(() => undefined).finally(() => {
+        clearTimeout(timer);
+        release();
+        callback(error);
+      });
+    }
+  });
+};
 
+/**
+ * A `Readable` of the batches of one partition, read by a `PartitionReader`
+ * of a standalone consumer on a client of its own. Prefer
+ * `client.consumer`, which hands over one message at a time on your client.
+ */
+export const singleConsumerStream = (config: ClientConfigOrString) =>
+  async (request: SingleConsumerStreamRequest): Promise<Readable> => {
+    const options = readerOptions(request);
+    return readerStream(config, (client) => client.message.reader({
+      streamId: request.streamId, topicId: request.topicId, consumer: Consumer.Single,
+      partitions: [request.partitionId], start: request.pollingStrategy, options,
+      retryInterval: request.pollingRetryInterval ?? DEFAULT_POLLING_RETRY_INTERVAL_MS
+    }), request.endOnLastOffset ?? false, options.commit === CommitPolicy.OnRead);
+  };
+
+/**
+ * A `Readable` of the batches of a consumer group member, read by a
+ * `PartitionReader` on a client of its own, which creates the group when it is
+ * missing. Prefer `client.consumerGroup`.
+ */
 export const groupConsumerStream = (config: ClientConfigOrString) =>
-  async function groupConsumerStream({
-    groupName,
-    streamId,
-    topicId,
-    pollingStrategy,
-    count,
-    interval = 1000,
-    autocommit = true,
-    endOnLastOffset = false
-  }: GroupConsumerStreamRequest): Promise<Readable> {
-    const s = await openClient(config, (client) => client.group.ensureAndJoin(streamId, topicId, groupName));
-
-    const poll = {
-      streamId,
-      topicId,
-      consumer: Consumer.Group(groupName),
-      partitionId: null,
-      pollingStrategy,
-      count,
-      autocommit
-    };
-    return pollStream(s, poll, interval, endOnLastOffset);
+  async (request: GroupConsumerStreamRequest): Promise<Readable> => {
+    const options = readerOptions(request);
+    return readerStream(config, async (client) => {
+      await client.group.ensure(request.streamId, request.topicId, request.groupName);
+      return client.message.reader({
+        streamId: request.streamId, topicId: request.topicId, consumer: Consumer.Group(request.groupName),
+        start: request.pollingStrategy, options,
+        retryInterval: request.pollingRetryInterval ?? DEFAULT_POLLING_RETRY_INTERVAL_MS
+      });
+    }, request.endOnLastOffset ?? false, options.commit === CommitPolicy.OnRead);
   };
diff --git a/foreign/node/src/stream/iggy-consumer.ts b/foreign/node/src/stream/iggy-consumer.ts
new file mode 100644
index 0000000..02cec9b
--- /dev/null
+++ b/foreign/node/src/stream/iggy-consumer.ts
@@ -0,0 +1,226 @@
+// 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.
+
+import { Readable } from 'node:stream';
+import type { ClientProvider } from '../client/client.type.js';
+import type { Id } from '../wire/identifier.utils.js';
+import { ConsumerKind } from '../wire/offset/offset.utils.js';
+import { ensureConsumerGroup } from '../wire/consumer-group/ensure-group.virtual.command.js';
+import type { Message, PollingStrategy } from '../wire/message/poll.utils.js';
+import type { PollOptions } from '../wire/message/poll-options.js';
+import type { Checkpoint, PolledBatch } from '../wire/message/checkpoint.js';
+import { PartitionReader, ReadError } from './partition-reader.js';
+
+/** One message that a consumer hands over. */
+export type ConsumerMessage = {
+  partitionId: number,
+  message: Message,
+  /** The position after this message, to commit it with `IggyConsumer.commit`. */
+  checkpoint: Checkpoint
+};
+
+/** Parameters of `client.consumer`: a standalone consumer of the given partitions. */
+export type ConsumerRequest = {
+  /** The consumer identity whose offsets the consumer reads and stores. */
+  name: Id,
+  streamId: Id,
+  topicId: Id,
+  partitions: number[],
+  /** Where a partition without a position starts, Next by default. */
+  start?: PollingStrategy,
+  /** The limits of each read and the commit policy. A read waits up to 5 s by default. */
+  options?: Partial<PollOptions>,
+  /** Gets each read error that the reader retries by itself, while the consumer goes on. */
+  onError?: (error: ReadError) => void
+};
+
+/** Parameters of `client.consumerGroup`: a member of a consumer group, created when it is missing. */
+export type ConsumerGroupRequest = Omit<ConsumerRequest, 'partitions'>;
+
+/**
+ * Hands over the messages of a topic one at a time, on a `PartitionReader`.
+ * With `CommitPolicy.Manual` the application commits checkpoints. With
+ * `CommitPolicy.OnRead` the server stores the offset of each read and
+ * `shutdown()` moves it back to the last message handed over. With
+ * `CommitPolicy.Auto` a message counts when it is handed over
+ * (`after: delivery`), once the iterator is read again or the handler of
+ * `consume()` returns with success (`after: processing`), or on `mark()`,
+ * and the latest counted position is stored at the interval or event of the
+ * policy and on shutdown. A read error that the reader retries by itself goes
+ * to `onError` and the consumer goes on. A message that does not decode ends
+ * the iteration with its `ReadError`, whose `skip` passes it with `seek`.
+ */
+export class IggyConsumer implements AsyncIterable<ConsumerMessage> {
+  private uncounted?: { checkpoint: Checkpoint, endsBatch: boolean };
+  private stopped = false;
+  /** The batch whose messages the consumer hands over, until a seek of its partition drops the rest. */
+  private current?: PolledBatch;
+
+  /** @internal */
+  constructor(
+    private readonly reader: PartitionReader,
+    private readonly after?: string,
+    private readonly onError?: (error: ReadError) => void
+  ) {
+    reader.countsMessages = true;
+  }
+
+  /** The partitions that the consumer reads now. */
+  partitions(): number[] {
+    return this.reader.partitions();
+  }
+
+  async *[Symbol.asyncIterator](): AsyncIterator<ConsumerMessage> {
+    while (!this.stopped) {
+      const batch = await this.nextBatch();
+      if (!batch) return;
+      this.current = batch;
+      for (let index = 0; index < batch.messages.length && this.current === batch; index += 1) {
+        if (this.stopped) return;
+        const message = this.hand(batch, index);
+        yield message;
+        this.countProcessed();
+      }
+    }
+  }
+
+  /**
+   * Hands each message to `handler` until the consumer shuts down or a handler
+   * fails, which ends the loop with its error and leaves its message uncounted.
+   * A message counts only once its handler returns, so a shutdown while the
+   * handler runs leaves it uncounted too.
+   */
+  async consume(handler: (message: ConsumerMessage) => Promise<void> | void): Promise<void> {
+    while (!this.stopped) {
+      const batch = await this.nextBatch();
+      if (!batch) return;
+      this.current = batch;
+      for (let index = 0; index < batch.messages.length && this.current === batch; index += 1) {
+        if (this.stopped) return;
+        const message = this.hand(batch, index);
+        // A shutdown while the handler runs must not count its message.
+        const handled = this.uncounted;
+        this.uncounted = undefined;
+        await handler(message);
+        if (handled) this.reader.countDelivered(handled.checkpoint, 1, handled.endsBatch);
+      }
+    }
+  }
+
+  /** Stores the offset of a checkpoint that this consumer handed over. */
+  commit(checkpoint: Checkpoint): Promise<void> {
+    return this.reader.commit(checkpoint);
+  }
+
+  /** Counts a checkpoint under `CommitPolicy.Auto` with `after: marked`. */
+  mark(checkpoint: Checkpoint): void {
+    this.reader.mark(checkpoint);
+  }
+
+  /**
+   * Sets the next read position of a partition, as `PartitionReader.seek`
+   * does, and drops the rest of the batch of that partition that the consumer
+   * hands over.
+   */
+  async seek(partitionId: number, start: PollingStrategy): Promise<void> {
+    await this.reader.seek(partitionId, start);
+    if (this.current?.partitionId === partitionId) this.current = undefined;
+  }
+
+  /**
+   * Stops the consumer: counts the last message under `after: processing`,
+   * closes the reader, which stores or rewinds the offsets, and leaves the group.
+   * A later call waits for the first one and ends as it does.
+   */
+  async shutdown(): Promise<void> {
+    if (!this.stopped) {
+      this.stopped = true;
+      this.countProcessed();
+    }
+    await this.reader.close();
+  }
+
+  /**
+   * The messages as an object mode `Readable`, which shuts the consumer down
+   * when it is destroyed. It takes the next message only when its own consumer
+   * asks, so a message counts as processed only after that.
+   */
+  toReadable(): Readable {
+    const messages = this[Symbol.asyncIterator]();
+    const shutdown = () => this.shutdown();
+    return new Readable({
+      objectMode: true,
+      highWaterMark: 0,
+      read() {
+        messages.next().then(({ done, value }) => {
+          if (!this.destroyed) this.push(done ? null : value);
+        }, (error: unknown) => {
+          if (!this.destroyed) this.destroy(error as Error);
+        });
+      },
+      destroy(error, callback) {
+        shutdown().catch(() => undefined).finally(() => callback(error));
+      }
+    });
+  }
+
+  private async nextBatch(): Promise<PolledBatch | undefined> {
+    while (true) {
+      try {
+        return await this.reader.next();
+      } catch (error) {
+        if (this.stopped && !(error instanceof ReadError)) return undefined;
+        if (!(error instanceof ReadError) || error.skip) throw error;
+        this.onError?.(error);
+      }
+    }
+  }
+
+  private hand(batch: PolledBatch, index: number): ConsumerMessage {
+    const checkpoint = batch.checkpointAt(index)!;
+    const endsBatch = index === batch.messages.length - 1;
+    this.reader.delivered(checkpoint);
+    if (this.after === 'delivery') this.reader.countDelivered(checkpoint, 1, endsBatch);
+    else if (this.after === 'processing') this.uncounted = { checkpoint, endsBatch };
+    return { partitionId: batch.partitionId, message: batch.messages[index], checkpoint };
+  }
+
+  private countProcessed(): void {
+    const uncounted = this.uncounted;
+    this.uncounted = undefined;
+    if (uncounted) this.reader.countDelivered(uncounted.checkpoint, 1, uncounted.endsBatch);
+  }
+}
+
+const afterOf = (options?: Partial<PollOptions>): string | undefined =>
+  options?.commit?.kind === 'auto' ? options.commit.after : undefined;
+
+/** Opens a standalone consumer of `partitions` under the consumer identity `name`. */
+export const openConsumer = (getClient: ClientProvider) =>
+  async ({ name, streamId, topicId, partitions, start, options, onError }: ConsumerRequest): Promise<IggyConsumer> =>
+    new IggyConsumer(await PartitionReader.open(getClient, {
+      streamId, topicId, consumer: { kind: ConsumerKind.Single, id: name }, partitions, start, options
+    }), afterOf(options), onError);
+
+/** Opens a member of the consumer group `name`, created when it is missing. */
+export const openConsumerGroup = (getClient: ClientProvider) =>
+  async ({ name, streamId, topicId, start, options, onError }: ConsumerGroupRequest): Promise<IggyConsumer> => {
+    await ensureConsumerGroup(getClient)(streamId, topicId, String(name));
+    return new IggyConsumer(await PartitionReader.open(getClient, {
+      streamId, topicId, consumer: { kind: ConsumerKind.Group, id: name }, start, options
+    }), afterOf(options), onError);
+  };
diff --git a/foreign/node/src/stream/index.ts b/foreign/node/src/stream/index.ts
index 8d2cc24..1cc5ff3 100644
--- a/foreign/node/src/stream/index.ts
+++ b/foreign/node/src/stream/index.ts
@@ -16,3 +16,5 @@
 // under the License.
 
 export * from './consumer-stream.js';
+export * from './partition-reader.js';
+export * from './iggy-consumer.js';
diff --git a/foreign/node/src/stream/partition-reader.test.ts b/foreign/node/src/stream/partition-reader.test.ts
new file mode 100644
index 0000000..555fc75
--- /dev/null
+++ b/foreign/node/src/stream/partition-reader.test.ts
@@ -0,0 +1,1102 @@
+// 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.
+
+import assert from 'node:assert/strict';
+import { EventEmitter, once } from 'node:events';
+import { Readable } from 'node:stream';
+import { describe, it } from 'node:test';
+import { FetchProgressUncertainError } from '../client/client.operation.js';
+import type { CommandResponse, RawClient, SendCommandOptions } from '../client/client.type.js';
+import { COMMAND_CODE } from '../wire/command.code.js';
+import { responseError } from '../wire/error.utils.js';
+import { ConsumerKind } from '../wire/offset/offset.utils.js';
+import { encodeMessagesBatch } from '../wire/message/message.utils.js';
+import { BATCH_HEADER_SIZE, FRAME_HEADER_SIZE } from '../wire/message/iggy-header.utils.js';
+import { u128ToBuf } from '../wire/number.utils.js';
+import { PollingStrategy, PollingStrategyKind } from '../wire/message/poll.utils.js';
+import {
+  FETCH_MESSAGES_FIELD, FETCH_MESSAGES_FIELDS_SIZE, FETCH_SECTION_FIELD, FETCH_TARGET_FIELD, FETCH_TARGET_SIZE,
+  fetchReplyHeadSize
+} from '../wire/message/fetch-messages.command.js';
+import { DEFERRED_POLL_OFFSET, DEFERRED_POLL_OPTIONS_SIZE } from '../wire/message/deferred-poll.utils.js';
+import { CommitAfter, CommitEvery, CommitPolicy } from '../wire/message/poll-options.js';
+import { Checkpoint } from '../wire/message/checkpoint.js';
+import { PartitionReader, ReadError } from './partition-reader.js';
+import { IggyConsumer } from './iggy-consumer.js';
+
+const SERVED = { incarnation: 11n, ownerGeneration: 4n, metadataOp: 42n };
+const BATCH_BASE_OFFSET_AT = 8;
+const HISTORY_UNAVAILABLE = 87;
+const INVALID_COMMAND = 3;
+const CONSUMER_GROUP_PARTITION_NOT_OWNED = 5009;
+/** A poll body ends with the strategy kind and value, the count and the auto-commit flag. */
+const POLL_SUFFIX_SIZE = 1 + 8 + 4 + 1;
+
+type Read = { code: number, partition: number, kind: number, value: bigint, autocommit: boolean, count: number, at: number };
+
+/** User headers whose first key has length zero, so they do not decode. */
+const UNPARSABLE_HEADERS = Buffer.from([1, 0, 0, 0, 0]);
+
+/** `count` messages from `offset` as batch records, two by default. */
+const records = (offset: bigint, unparsableHeaders = new Set<bigint>(), count = 2): Buffer => {
+  const batch = encodeMessagesBatch(Array.from({ length: count }, (_, index) => ({
+    id: u128ToBuf(BigInt(index + 1)), payload: Buffer.from(`m${offset + BigInt(index)}`),
+    userHeaders: unparsableHeaders.has(offset + BigInt(index)) ? UNPARSABLE_HEADERS : Buffer.alloc(0),
+    originTimestamp: 0n
+  })));
+  batch.writeBigUInt64LE(offset, BATCH_BASE_OFFSET_AT);
+  return batch;
+};
+
+/**
+ * A client of one topic whose partitions hold `limit` messages each, two per
+ * read, with primaries `partition % 2`. It stores and deletes offsets, and
+ * answers a read that finds nothing with an empty reply.
+ */
+class FakeTopic {
+  readonly reads: Read[] = [];
+  readonly stored = new Map<number, bigint>();
+  readonly undecodable = new Set<bigint>();
+  /** The offsets whose user headers do not decode. */
+  readonly unparsableHeaders = new Set<bigint>();
+  readonly history = new Set<number>();
+  readonly emitter = new EventEmitter();
+  /** The operations that the reader abandoned. */
+  readonly abandoned: bigint[] = [];
+  /** The partitions of the group member, and the assignment it moves to once a committing read is served. */
+  assigned: number[] = [];
+  assignedAfterRead?: number[];
+  /** Fails every route lookup, as a client without a link does. */
+  unrouted?: Error;
+  routeLookups = 0;
+  loseNextCommittingRead = false;
+  onLostCommittingRead?: () => void;
+  /** A fetch reads its targets in order until it holds its count, as the server does. */
+  countLimited = false;
+  /** A fetch replies without sections. */
+  sectionless = false;
+  /** A poll holds up to its count of the messages from its start, as the server does, instead of two. */
+  pollsHoldCount = false;
+  /** The owner generation of the member, which a fetch target with a fence must name. */
+  ownerGeneration = SERVED.ownerGeneration;
+  /** Partitions that the member does not own, whatever a fetch target names. */
+  readonly notOwned = new Set<number>();
+  /** A read by offset finds nothing, as after its messages were removed. */
+  emptyOffsetReads = false;
+  /** Every offset store fails with this error. */
+  storeFailure?: Error;
+  /**
+   * Each fetch waits until the test releases it, in the order they arrived. An offset store of a partition that a
+   * held committing fetch reads waits for that fetch, as the progress lane of the client makes it.
+   */
+  holdFetches = false;
+  readonly heldFetches: (() => void)[] = [];
+  private readonly lanes = new Map<number, Promise<void>>();
+  /** The target partitions of each fetch. */
+  readonly fetches: number[][] = [];
+  /** A fetch section reports the last offset of the topic as the current offset of its partition. */
+  reportsHead = false;
+  constructor(public limit = 2n) {}
+
+  client(): RawClient {
+    return {
+      protocol: 'vsr',
+      isAuthenticated: true,
+      sendCommand: async (code: number, payload: Buffer, options?: SendCommandOptions) => this.answer(code, payload, options),
+      readinessGroups: async (_streamId: unknown, _topicId: unknown, partitions: number[]) => {
+        this.routeLookups += 1;
+        if (this.unrouted) throw this.unrouted;
+        return [0, 1].map((primary) => partitions.filter((partition) => partition % 2 === primary))
+          .filter((group) => group.length > 0);
+      },
+      authenticate: async () => true,
+      abandonOperation: (operationId: bigint) => { this.abandoned.push(operationId); },
+      destroy: () => {},
+      on: this.emitter.on.bind(this.emitter),
+      once: this.emitter.once.bind(this.emitter),
+      getReadStream: () => Readable.from([])
+    } as unknown as RawClient;
+  }
+
+  private start(partition: number, kind: number, value: bigint): bigint {
+    return kind === PollingStrategyKind.Next ? (this.stored.has(partition) ? this.stored.get(partition)! + 1n : 0n)
+      : kind === PollingStrategyKind.Offset ? value : 0n;
+  }
+
+  private async answer(code: number, payload: Buffer, options?: SendCommandOptions): Promise<CommandResponse> {
+    const reply = (data: Buffer): CommandResponse => ({ status: 0, length: data.length, data });
+    if (code === COMMAND_CODE.StoreOffset) {
+      if (this.storeFailure) throw this.storeFailure;
+      const partition = payload.readUInt32LE(payload.length - 13);
+      await this.lanes.get(partition);
+      this.stored.set(partition, payload.readBigUInt64LE(payload.length - 9));
+      return reply(Buffer.alloc(0));
+    }
+    if (code === COMMAND_CODE.DeleteConsumerOffset) {
+      this.stored.delete(payload.readUInt32LE(payload.length - 5));
+      return reply(Buffer.alloc(0));
+    }
+    if (code === COMMAND_CODE.JoinGroup || code === COMMAND_CODE.LeaveGroup) return reply(Buffer.alloc(0));
+    if (code === COMMAND_CODE.SyncGroup) {
+      const assignment = Buffer.alloc(12 + 4 * this.assigned.length);
+      assignment.writeUInt32LE(this.assigned.length, 8);
+      this.assigned.forEach((partition, index) => assignment.writeUInt32LE(partition, 12 + 4 * index));
+      setImmediate(() => this.emitter.emit('synced'));
+      return reply(assignment);
+    }
+    if (code === COMMAND_CODE.PollMessagesDeferred) {
+      const body = payload.subarray(0, payload.length - DEFERRED_POLL_OPTIONS_SIZE);
+      const at = body.length - POLL_SUFFIX_SIZE;
+      const partition = body.readUInt32LE(at - 4);
+      const read = { code, partition, kind: body[at], value: body.readBigUInt64LE(at + 1),
+        autocommit: body[body.length - 1] !== 0, count: body.readUInt32LE(at + 9), at: Date.now() };
+      this.reads.push(read);
+      const start = this.start(partition, read.kind, read.value);
+      if (start >= this.limit || (this.emptyOffsetReads && read.kind === PollingStrategyKind.Offset))
+        return this.idle(options, reply(pollReply(partition, start, Buffer.alloc(0), 0, this.served())));
+      const held = this.pollsHoldCount ? Math.min(read.count, Number(this.limit - start)) : 2;
+      if (read.autocommit) this.stored.set(partition, start + BigInt(held) - 1n);
+      if (read.autocommit && this.loseNextCommittingRead) {
+        this.loseNextCommittingRead = false;
+        this.onLostCommittingRead?.();
+        throw new FetchProgressUncertainError(1n, new Error('reply lost after storing'));
+      }
+      const batch = records(start, this.unparsableHeaders, held);
+      return reply(pollReply(partition, start + BigInt(held) - 1n,
+        this.undecodable.has(start) ? batch.subarray(0, batch.length - 1) : batch, held, this.served()));
+    }
+    if (code === COMMAND_CODE.FetchMessages) {
+      let targets = 1;
+      while (payload.readUInt32LE(payload.length - FETCH_MESSAGES_FIELDS_SIZE - targets * FETCH_TARGET_SIZE +
+          FETCH_MESSAGES_FIELD.targetsCount) !== targets) targets += 1;
+      const fieldsAt = payload.length - FETCH_MESSAGES_FIELDS_SIZE - targets * FETCH_TARGET_SIZE;
+      const partitions = Array.from({ length: targets }, (_, index) =>
+        payload.readUInt32LE(fieldsAt + FETCH_MESSAGES_FIELDS_SIZE + index * FETCH_TARGET_SIZE));
+      this.fetches.push(partitions);
+      if (this.holdFetches) {
+        const released = new Promise<void>((release) => this.heldFetches.push(release));
+        if (payload[fieldsAt + FETCH_MESSAGES_FIELD.autoCommit] !== 0)
+          for (const partition of partitions) this.lanes.set(partition, released);
+        await released;
+        for (const partition of partitions) if (this.lanes.get(partition) === released) this.lanes.delete(partition);
+      }
+      return this.fetch(code, payload, fieldsAt, targets, options);
+    }
+    throw responseError(code, INVALID_COMMAND, 'not served by the fake topic');
+  }
+
+  private async fetch(code: number, payload: Buffer, fieldsAt: number, targets: number,
+    options?: SendCommandOptions): Promise<CommandResponse> {
+    const reply = (data: Buffer): CommandResponse => ({ status: 0, length: data.length, data });
+    const autocommit = payload[fieldsAt + FETCH_MESSAGES_FIELD.autoCommit] !== 0;
+    const count = payload.readUInt32LE(fieldsAt + FETCH_MESSAGES_FIELD.count);
+    const sections: { partition: number, status: number, start: bigint, records: Buffer }[] = [];
+    let held = 0;
+    for (let index = 0; index < targets; index += 1) {
+      const at = fieldsAt + FETCH_MESSAGES_FIELDS_SIZE + index * FETCH_TARGET_SIZE;
+      const partition = payload.readUInt32LE(at + FETCH_TARGET_FIELD.partitionId);
+      const read = { code, partition, kind: payload[at + FETCH_TARGET_FIELD.kind],
+        value: payload.readBigUInt64LE(at + FETCH_TARGET_FIELD.value), autocommit, count, at: Date.now() };
+      this.reads.push(read);
+      if (this.history.has(partition)) {
+        sections.push({ partition, status: HISTORY_UNAVAILABLE, start: 0n, records: Buffer.alloc(0) });
+        continue;
+      }
+      const fenced = payload.readBigUInt64LE(at + FETCH_TARGET_FIELD.incarnation) !== 0n;
+      if (this.notOwned.has(partition) ||
+          (fenced && payload.readBigUInt64LE(at + FETCH_TARGET_FIELD.ownerGeneration) !== this.ownerGeneration)) {
+        sections.push({ partition, status: CONSUMER_GROUP_PARTITION_NOT_OWNED, start: 0n, records: Buffer.alloc(0) });
+        continue;
+      }
+      const start = this.start(partition, read.kind, read.value);
+      if (start >= this.limit || (this.countLimited && held >= count)) {
+        sections.push({ partition, status: 0, start, records: Buffer.alloc(0) });
+        continue;
+      }
+      held += 2;
+      if (autocommit) this.stored.set(partition, start + 1n);
+      const batch = records(start, this.unparsableHeaders);
+      sections.push({ partition, status: 0, start,
+        records: this.undecodable.has(start) ? batch.subarray(0, batch.length - 1) : batch });
+    }
+    if (autocommit && this.loseNextCommittingRead) {
+      this.loseNextCommittingRead = false;
+      this.onLostCommittingRead?.();
+      throw new FetchProgressUncertainError(1n, new Error('reply lost after storing'));
+    }
+    const body = fetchReply(this.sectionless ? [] : sections, this.served(), this.reportsHead ? this.limit - 1n : 0n);
+    if (autocommit && this.assignedAfterRead) {
+      this.assigned = this.assignedAfterRead;
+      this.assignedAfterRead = undefined;
+    }
+    return sections.every(({ records: held, status }) => held.length === 0 && status === 0)
+      ? this.idle(options, reply(body)) : reply(body);
+  }
+
+  private served() {
+    return { ...SERVED, ownerGeneration: this.ownerGeneration };
+  }
+
+  /** A read that finds nothing waits until the reader stops it, as a parked read does. */
+  private idle(options: SendCommandOptions | undefined, reply: CommandResponse): Promise<CommandResponse> {
+    return new Promise((resolve, reject) => {
+      const timer = setTimeout(() => resolve(reply), 50);
+      options?.signal?.addEventListener('abort', () => {
+        clearTimeout(timer);
+        reject(options.signal!.reason);
+      }, { once: true });
+    });
+  }
+}
+
+const pollReply = (partition: number, current: bigint, held: Buffer, count: number, context = SERVED): Buffer => {
+  const head = Buffer.alloc(40);
+  head.writeUInt32LE(partition, 0);
+  head.writeBigUInt64LE(current, 4);
+  head.writeUInt32LE(count, 12);
+  head.writeBigUInt64LE(context.incarnation, 16);
+  head.writeBigUInt64LE(context.ownerGeneration, 24);
+  head.writeBigUInt64LE(context.metadataOp, 32);
+  return Buffer.concat([head, held]);
+};
+
+const fetchReply = (sections: { partition: number, status: number, start: bigint, records: Buffer }[],
+  context = SERVED, current = 0n): Buffer => {
+  const head = Buffer.alloc(fetchReplyHeadSize(sections.length));
+  head.writeUInt32LE(sections.length, 0);
+  sections.forEach(({ partition, status, start, records: held }, index) => {
+    const at = fetchReplyHeadSize(index);
+    const count = held.length === 0 ? 0 : 2;
+    head.writeUInt32LE(partition, at + FETCH_SECTION_FIELD.partitionId);
+    head.writeUInt32LE(status, at + FETCH_SECTION_FIELD.status);
+    head.writeUInt32LE(count, at + FETCH_SECTION_FIELD.messagesCount);
+    head.writeBigUInt64LE(current, at + FETCH_SECTION_FIELD.currentOffset);
+    if (status === 0) {
+      head.writeBigUInt64LE(context.incarnation, at + FETCH_SECTION_FIELD.context);
+      head.writeBigUInt64LE(context.ownerGeneration, at + FETCH_SECTION_FIELD.context + 8);
+      head.writeBigUInt64LE(context.metadataOp, at + FETCH_SECTION_FIELD.context + 16);
+    }
+    head.writeBigUInt64LE(start + BigInt(count), at + FETCH_SECTION_FIELD.nextOffset);
+    head.writeUInt32LE(held.length, at + FETCH_SECTION_FIELD.recordsLength);
+  });
+  return Buffer.concat([head, ...sections.map(({ records: held }) => held)]);
+};
+
+const consumer = { kind: ConsumerKind.Single, id: 4 };
+
+const open = (topic: FakeTopic, partitions: number[], options = {}) => {
+  const client = topic.client();
+  return PartitionReader.open(async () => client, {
+    streamId: 1, topicId: 2, consumer, partitions, options: { maxWait: 100, ...options }, retryInterval: 20
+  });
+};
+
+const offsets = (batches: { messages: { headers: { offset: bigint } }[] }[]): bigint[] =>
+  batches.flatMap(({ messages }) => messages.map(({ headers }) => headers.offset));
+
+const waitFor = async (condition: () => boolean, what: string): Promise<void> => {
+  const deadline = Date.now() + 1_000;
+  while (!condition()) {
+    assert.ok(Date.now() < deadline, what);
+    await new Promise((resolve) => setTimeout(resolve, 5));
+  }
+};
+
+describe('partition reader', () => {
+  it('reads the partitions of each primary in one fetch and hands over every partition', async () => {
+    const topic = new FakeTopic();
+    const reader = await open(topic, [0, 1, 2, 3]);
+    const partitions = [];
+    for (let read = 0; read < 4; read += 1) partitions.push((await reader.next()).partitionId);
+    await reader.close();
+
+    assert.deepEqual(partitions.sort(), [0, 1, 2, 3]);
+    const fetched = topic.reads.filter(({ code }) => code === COMMAND_CODE.FetchMessages);
+    assert.ok(fetched.length >= 4);
+  });
+
+  it('stores a checkpoint of its own and refuses one of another reader', async () => {
+    const topic = new FakeTopic();
+    const reader = await open(topic, [1]);
+    const batch = await reader.next();
+
+    await reader.commit(batch.checkpoint()!);
+    await assert.rejects(reader.commit(new Checkpoint(1, 1n, SERVED)), (error: { errorCode: number }) =>
+      error.errorCode === INVALID_COMMAND);
+    await reader.close();
+
+    assert.equal(topic.stored.get(1), 1n);
+  });
+
+  it('keeps the configured limits for its initial probe and does not retry an oversized first record', async () => {
+    const topic = new FakeTopic();
+    const client = topic.client();
+    const reads: { code: number, payload: Buffer }[] = [];
+    const record = encodeMessagesBatch([{
+      id: u128ToBuf(1n), payload: Buffer.alloc(2_000), userHeaders: Buffer.alloc(0), originTimestamp: 0n
+    }]);
+    record.writeBigUInt64LE(0n, BATCH_BASE_OFFSET_AT);
+    client.sendCommand = async (code, payload) => {
+      assert.ok(code === COMMAND_CODE.PollMessagesDeferred || code === COMMAND_CODE.FetchMessages);
+      reads.push({ code, payload });
+      const data = code === COMMAND_CODE.PollMessagesDeferred ? pollReply(1, 0n, record, 1)
+        : fetchReply([{ partition: 1, status: 0, start: 0n, records: record }]);
+      if (code === COMMAND_CODE.FetchMessages) {
+        data.writeUInt32LE(1, fetchReplyHeadSize(0) + FETCH_SECTION_FIELD.messagesCount);
+        data.writeBigUInt64LE(1n, fetchReplyHeadSize(0) + FETCH_SECTION_FIELD.nextOffset);
+      }
+      return { status: 0, length: data.length, data };
+    };
+    const reader = await PartitionReader.open(async () => client, {
+      streamId: 1, topicId: 2, consumer, partitions: [1],
+      options: { count: 8, minCount: 3, maxBytes: 512, maxWait: 100, commit: CommitPolicy.OnRead }
+    });
+    try {
+      assert.equal((await reader.next()).messages[0].payload.length, 2_000);
+      assert.equal(reads.length, 2);
+      const probe = reads[0].payload;
+      const probeOptions = probe.subarray(probe.length - DEFERRED_POLL_OPTIONS_SIZE);
+      assert.equal(probe.readUInt32LE(probe.length - DEFERRED_POLL_OPTIONS_SIZE - 5), 1);
+      assert.equal(probe[probe.length - DEFERRED_POLL_OPTIONS_SIZE - 1], 0);
+      assert.equal(probeOptions.readBigUInt64LE(DEFERRED_POLL_OFFSET.wait), 100_000n);
+      assert.equal(probeOptions.readUInt32LE(DEFERRED_POLL_OFFSET.maxBytes), 512);
+      assert.equal(probeOptions.readUInt32LE(DEFERRED_POLL_OFFSET.minCount), 1);
+      const fetch = reads[1].payload.subarray(reads[1].payload.length - FETCH_MESSAGES_FIELDS_SIZE - FETCH_TARGET_SIZE);
+      assert.equal(fetch.readBigUInt64LE(FETCH_MESSAGES_FIELD.wait), 100_000n);
+      assert.equal(fetch.readUInt32LE(FETCH_MESSAGES_FIELD.maxBytes), 512);
+      assert.equal(fetch.readUInt32LE(FETCH_MESSAGES_FIELD.count), 8);
+      assert.equal(fetch.readUInt32LE(FETCH_MESSAGES_FIELD.minCount), 3);
+    } finally {
+      await reader.close();
+    }
+  });
+
+  for (const limit of [7n, 9n]) {
+    it(`recovers a lost first committing reply before any successful delivery with tail ${limit}`, async () => {
+      const topic = new FakeTopic(limit);
+      topic.stored.set(1, 4n);
+      topic.loseNextCommittingRead = true;
+      const reader = await open(topic, [1], { commit: CommitPolicy.OnRead });
+      try {
+        const batch = await reader.next({ signal: AbortSignal.timeout(2_000) });
+        assert.deepEqual(offsets([batch]), [5n, 6n]);
+      } finally {
+        await reader.close();
+      }
+    });
+  }
+
+  for (const start of [0n, 5n]) {
+    it(`rewinds a lost first reply on close before any delivery from ${start}`, async () => {
+      const topic = new FakeTopic(start + 4n);
+      if (start > 0n) topic.stored.set(1, start - 1n);
+      topic.loseNextCommittingRead = true;
+      const cancellation = new AbortController();
+      topic.onLostCommittingRead = () => cancellation.abort(new Error('stop before delivery'));
+      const reader = await open(topic, [1], { commit: CommitPolicy.OnRead });
+      await assert.rejects(reader.next({ signal: cancellation.signal }), /stop before delivery/);
+      await reader.close();
+      assert.equal(topic.stored.get(1), start === 0n ? undefined : start - 1n);
+    });
+  }
+
+  it('reads again by offset without a commit the messages that a lost read stored', async () => {
+    const topic = new FakeTopic(8n);
+    const reader = await open(topic, [1], { commit: CommitPolicy.OnRead });
+    const batches = [await reader.next()];
+    topic.loseNextCommittingRead = true;
+    for (let read = 0; read < 3; read += 1) batches.push(await reader.next());
+    await reader.close();
+
+    assert.deepEqual(offsets(batches), [0n, 1n, 2n, 3n, 4n, 5n, 6n, 7n]);
+    const catchUp = topic.reads.filter(({ code, kind }) =>
+      code === COMMAND_CODE.PollMessagesDeferred && kind === PollingStrategyKind.Offset);
+    assert.deepEqual(catchUp.map(({ kind, value, autocommit }) => [kind, value, autocommit]),
+      [[PollingStrategyKind.Offset, 2n, false], [PollingStrategyKind.Offset, 4n, false]]);
+    assert.equal(topic.stored.get(1), 7n);
+  });
+
+  it('reads a lost range again only up to the offset the server stored, so no message arrives twice', async () => {
+    const topic = new FakeTopic(9n);
+    topic.pollsHoldCount = true;
+    topic.stored.set(1, 0n);
+    topic.loseNextCommittingRead = true;
+    const reader = await open(topic, [1], { commit: CommitPolicy.OnRead });
+    const batches = [];
+    try {
+      for (let read = 0; read < 3; read += 1) batches.push(await reader.next({ signal: AbortSignal.timeout(2_000) }));
+    } finally {
+      await reader.close();
+    }
+
+    assert.deepEqual(offsets(batches), [1n, 2n, 3n, 4n, 5n, 6n, 7n, 8n]);
+    const catchUp = topic.reads.filter(({ code, kind }) =>
+      code === COMMAND_CODE.PollMessagesDeferred && kind === PollingStrategyKind.Offset);
+    assert.deepEqual(catchUp.map(({ value, autocommit, count }) => [value, autocommit, count]), [[1n, false, 4]]);
+  });
+
+  it('delivers from where another session stored, without reading the skipped messages again', async () => {
+    const topic = new FakeTopic(8n);
+    const reader = await open(topic, [1], { commit: CommitPolicy.OnRead });
+    const batches = [await reader.next()];
+    topic.stored.set(1, 3n);
+    try {
+      while (offsets(batches).at(-1)! < 7n) batches.push(await reader.next({ signal: AbortSignal.timeout(2_000) }));
+    } finally {
+      await reader.close();
+    }
+
+    assert.deepEqual(offsets(batches), [0n, 1n, 4n, 5n, 6n, 7n]);
+    assert.ok(!topic.reads.some(({ kind }) => kind === PollingStrategyKind.Offset));
+  });
+
+  it('accepts a later jump once a read after the recovery of a lost read passed the gap check', async () => {
+    const topic = new FakeTopic(12n);
+    const reader = await open(topic, [1], { commit: CommitPolicy.OnRead });
+    const batches = [await reader.next()];
+    topic.loseNextCommittingRead = true;
+    try {
+      for (let read = 0; read < 3; read += 1) batches.push(await reader.next({ signal: AbortSignal.timeout(2_000) }));
+      topic.stored.set(1, 9n);
+      while (offsets(batches).at(-1)! < 11n) batches.push(await reader.next({ signal: AbortSignal.timeout(2_000) }));
+    } finally {
+      await reader.close();
+    }
+
+    assert.deepEqual(offsets(batches), [0n, 1n, 2n, 3n, 4n, 5n, 6n, 7n, 10n, 11n]);
+  });
+
+  it('waits its retry interval after it abandons a lost committing read, before it reads that partition again', async () => {
+    const topic = new FakeTopic(8n);
+    const client = topic.client();
+    const reader = await PartitionReader.open(async () => client, {
+      streamId: 1, topicId: 2, consumer, partitions: [1], options: { maxWait: 100, commit: CommitPolicy.OnRead },
+      retryInterval: 300
+    });
+    const first = await reader.next();
+    topic.loseNextCommittingRead = true;
+    let lostAt = 0;
+    topic.onLostCommittingRead = () => { lostAt = Date.now(); };
+    try {
+      await reader.next({ signal: AbortSignal.timeout(2_000) });
+    } finally {
+      await reader.close();
+    }
+
+    assert.deepEqual([offsets([first]), topic.abandoned], [[0n, 1n], [1n]]);
+    const after = topic.reads.find(({ at }) => at > lostAt)!;
+    assert.ok(after.at - lostAt >= 250, `read again ${after.at - lostAt} ms after the lost read`);
+  });
+
+  it('abandons a lost committing poll of its partition as it abandons a lost fetch', async () => {
+    const topic = new FakeTopic(4n);
+    topic.loseNextCommittingRead = true;
+    const reader = await open(topic, [1], { commit: CommitPolicy.OnRead }).then((opened) => opened);
+    await reader.seek(1, PollingStrategy.Timestamp(0n));
+    try {
+      await reader.next({ signal: AbortSignal.timeout(2_000) });
+    } finally {
+      await reader.close();
+    }
+
+    const lost = topic.reads.find(({ autocommit }) => autocommit)!;
+    assert.deepEqual([lost.code, lost.kind, topic.abandoned], [COMMAND_CODE.PollMessagesDeferred,
+      PollingStrategyKind.Timestamp, [1n]]);
+  });
+
+  it('ends a catch-up whose read by offset finds nothing, reports the lost range and reads on', async () => {
+    const topic = new FakeTopic(8n);
+    const reader = await open(topic, [1], { commit: CommitPolicy.OnRead });
+    const first = await reader.next();
+    topic.loseNextCommittingRead = true;
+    topic.emptyOffsetReads = true;
+    try {
+      await assert.rejects(reader.next({ signal: AbortSignal.timeout(2_000) }), (error: unknown) =>
+        error instanceof ReadError && error.partitionId === 1 &&
+        (error.error as { errorCode: number }).errorCode === 14010);
+      const held = await reader.next({ signal: AbortSignal.timeout(2_000) });
+      const after = await reader.next({ signal: AbortSignal.timeout(2_000) });
+      assert.deepEqual([offsets([first]), offsets([held]), offsets([after])], [[0n, 1n], [4n, 5n], [6n, 7n]]);
+    } finally {
+      await reader.close();
+    }
+
+    assert.equal(topic.reads.filter(({ kind }) => kind === PollingStrategyKind.Offset).length, 1);
+  });
+
+  it('keeps the read of a partition assigned again apart from a late read of its earlier assignment', async () => {
+    const topic = new FakeTopic(2n);
+    topic.assigned = [1];
+    topic.holdFetches = true;
+    const client = topic.client();
+    const reader = await PartitionReader.open(async () => client, {
+      streamId: 1, topicId: 2, consumer: { kind: ConsumerKind.Group, id: 5 }, options: { maxWait: 100 }, retryInterval: 20
+    });
+    const wakeUntil = async (condition: () => boolean) => {
+      const deadline = Date.now() + 2_000;
+      while (!condition()) {
+        assert.ok(Date.now() < deadline, 'the reader did not get there in time');
+        await reader.next({ signal: AbortSignal.timeout(10) }).then(() => assert.fail('a batch arrived'), () => undefined);
+      }
+    };
+    try {
+      await wakeUntil(() => topic.heldFetches.length === 1);
+      topic.assigned = [];
+      await wakeUntil(() => !reader.partitions().includes(1));
+      topic.assigned = [1];
+      await wakeUntil(() => topic.heldFetches.length === 2);
+      topic.heldFetches[0]();
+      await reader.next({ signal: AbortSignal.timeout(200) }).then(() => assert.fail('a batch arrived'), () => undefined);
+      assert.equal(topic.heldFetches.length, 2, 'a second read of the new assignment started');
+    } finally {
+      topic.holdFetches = false;
+      for (const release of topic.heldFetches) release();
+      await reader.close();
+    }
+  });
+
+  for (const closing of ['reader', 'consumer'] as const) {
+    it(`makes a second ${closing} close wait for the first and fail as it does`, async () => {
+      const topic = new FakeTopic(4n);
+      const reader = await open(topic, [1], {
+        commit: CommitPolicy.Auto({ interval: 3_600_000, after: CommitAfter.Delivery })
+      });
+      const consumer = closing === 'consumer' ? new IggyConsumer(reader, CommitAfter.Delivery) : undefined;
+      if (consumer) await consumer[Symbol.asyncIterator]().next();
+      else await reader.next();
+      topic.storeFailure = responseError(COMMAND_CODE.StoreOffset, 41, 'unauthorized');
+      const close = () => consumer ? consumer.shutdown() : reader.close();
+      const first = close();
+      const second = close();
+      const isFailure = (error: unknown) => error === topic.storeFailure;
+      await assert.rejects(second, isFailure);
+      await assert.rejects(first, isFailure);
+    });
+  }
+
+  it('reports the lost range and hands over the stored batch when reassignment interrupts gap recovery', async () => {
+    const topic = new FakeTopic(6n);
+    topic.assigned = [1];
+    const client = topic.client();
+    const reader = await PartitionReader.open(async () => client, {
+      streamId: 1, topicId: 2, consumer: { kind: ConsumerKind.Group, id: 5 },
+      options: { maxWait: 100, commit: CommitPolicy.OnRead }, retryInterval: 20
+    });
+    try {
+      assert.deepEqual(offsets([await reader.next()]), [0n, 1n]);
+      topic.loseNextCommittingRead = true;
+      topic.assignedAfterRead = [];
+      await assert.rejects(reader.next({ signal: AbortSignal.timeout(2_000) }), (error: unknown) =>
+        error instanceof ReadError && error.partitionId === 1 &&
+        (error.error as { errorCode: number }).errorCode === 14010);
+      assert.deepEqual(offsets([await reader.next({ signal: AbortSignal.timeout(2_000) })]), [4n, 5n]);
+    } finally {
+      await reader.close();
+    }
+  });
+
+  it('moves an on read offset back to the last batch taken when it closes', async () => {
+    const topic = new FakeTopic(2n);
+    const reader = await open(topic, [0, 1], { commit: CommitPolicy.OnRead });
+    const taken = await reader.next();
+    await new Promise((resolve) => setTimeout(resolve, 100));
+
+    await reader.close();
+
+    assert.equal(topic.stored.get(taken.partitionId), 1n);
+    assert.equal(topic.stored.has(taken.partitionId === 0 ? 1 : 0), false);
+  });
+
+  it('stores the offset before an on read seek to an offset, so a restart resumes at the sought offset', async () => {
+    const topic = new FakeTopic(10n);
+    const reader = await open(topic, [1], { commit: CommitPolicy.OnRead });
+    try {
+      assert.deepEqual(offsets([await reader.next()]), [0n, 1n]);
+      await reader.seek(1, PollingStrategy.Offset(6n));
+      assert.equal(topic.stored.get(1), 5n);
+      assert.deepEqual(offsets([await reader.next()]), [6n, 7n]);
+    } finally {
+      await reader.close();
+    }
+    assert.equal(topic.stored.get(1), 7n);
+  });
+
+  for (const [name, start, commit] of [
+    ['offset 0', PollingStrategy.Offset(0n), CommitPolicy.OnRead],
+    ['first', PollingStrategy.First, CommitPolicy.OnRead],
+    ['next', PollingStrategy.Next, CommitPolicy.OnRead],
+    ['last', PollingStrategy.Last, CommitPolicy.OnRead],
+    ['timestamp', PollingStrategy.Timestamp(0n), CommitPolicy.OnRead],
+    ['an offset under a manual policy', PollingStrategy.Offset(6n), CommitPolicy.Manual]
+  ] as const) {
+    it(`stores nothing on a seek to ${name}`, async () => {
+      const topic = new FakeTopic(10n);
+      const reader = await open(topic, [1], { commit });
+      try {
+        assert.deepEqual(offsets([await reader.next()]), [0n, 1n]);
+        await reader.seek(1, start);
+        assert.equal(topic.stored.get(1), commit.kind === 'onRead' ? 1n : undefined);
+      } finally {
+        await reader.close();
+      }
+    });
+  }
+
+  it('fails an on read seek whose store is refused, and moves nothing', async () => {
+    const topic = new FakeTopic(10n);
+    const reader = await open(topic, [1, 3], { commit: CommitPolicy.OnRead });
+    try {
+      assert.deepEqual([(await reader.next()).partitionId, topic.stored.get(3)], [1, 1n]);
+      topic.storeFailure = new Error('store refused');
+      await assert.rejects(reader.seek(3, PollingStrategy.Offset(6n)), { message: 'store refused' });
+      topic.storeFailure = undefined;
+      assert.equal(topic.stored.get(3), 1n);
+      const queued = await reader.next();
+      assert.deepEqual([queued.partitionId, offsets([queued])], [3, [0n, 1n]]);
+      let resumed = await reader.next();
+      while (resumed.partitionId !== 3) resumed = await reader.next();
+      assert.deepEqual(offsets([resumed]), [2n, 3n]);
+    } finally {
+      await reader.close();
+    }
+  });
+
+  it('stores an on read seek after the committing read in flight, and drops that read', async () => {
+    const topic = new FakeTopic(10n);
+    topic.holdFetches = true;
+    const reader = await open(topic, [1], { commit: CommitPolicy.OnRead });
+    try {
+      const waiting = new AbortController();
+      const pending = reader.next({ signal: waiting.signal }).then(() => undefined, (failure: unknown) => failure);
+      await waitFor(() => topic.heldFetches.length === 1, 'the committing read did not start');
+      let sought = false;
+      const seek = reader.seek(1, PollingStrategy.Offset(6n)).then(() => { sought = true; });
+      await new Promise((resolve) => setTimeout(resolve, 50));
+      assert.deepEqual([topic.stored.has(1), sought], [false, false]);
+      waiting.abort();
+      assert.ok((await pending) instanceof Error);
+      topic.holdFetches = false;
+      topic.heldFetches.shift()!();
+      await seek;
+      assert.deepEqual(offsets([await reader.next()]), [6n, 7n]);
+      const resumed = topic.reads.filter(({ code }) => code === COMMAND_CODE.FetchMessages).at(-1)!;
+      assert.deepEqual([topic.fetches.length, resumed.kind, resumed.value], [2, PollingStrategyKind.Offset, 6n]);
+    } finally {
+      topic.holdFetches = false;
+      for (const release of topic.heldFetches) release();
+      await reader.close();
+    }
+    assert.equal(topic.stored.get(1), 7n);
+  });
+
+  it('applies an on read seek that waits for a read when the reader closes meanwhile, and the close keeps its offset',
+    async () => {
+      const topic = new FakeTopic(10n);
+      topic.holdFetches = true;
+      const reader = await open(topic, [1], { commit: CommitPolicy.OnRead });
+      const pending = reader.next().then(() => undefined, (failure: unknown) => failure);
+      await waitFor(() => topic.heldFetches.length === 1, 'the committing read did not start');
+      const seek = reader.seek(1, PollingStrategy.Offset(6n));
+      await new Promise((resolve) => setTimeout(resolve, 50));
+      const closing = reader.close();
+      await new Promise(setImmediate);
+      topic.holdFetches = false;
+      topic.heldFetches.shift()!();
+      await seek;
+      await closing;
+
+      assert.equal(topic.stored.get(1), 5n);
+      assert.ok((await pending) instanceof Error);
+    });
+
+  it('reports a message that does not decode with the continuation that skips it', async () => {
+    const topic = new FakeTopic(5n);
+    topic.undecodable.add(2n);
+    const reader = await open(topic, [1]);
+    assert.deepEqual(offsets([await reader.next()]), [0n, 1n]);
+
+    const error = await reader.next().then(() => undefined, (failure: unknown) => failure);
+    assert.ok(error instanceof ReadError);
+    assert.deepEqual([error.partitionId, error.skip?.kind, error.skip?.value], [1, PollingStrategyKind.Offset, 3n],
+      String(error.error));
+    assert.equal(error.offset, undefined);
+    assert.deepEqual(error.record, records(2n).subarray(0, -1));
+    await reader.seek(1, error.skip!);
+    const resumed = await reader.next();
+    await reader.close();
+
+    assert.deepEqual(offsets([resumed]), [3n, 4n]);
+  });
+
+  it('reports a message whose user headers do not decode with its offset and its frame as served', async () => {
+    const topic = new FakeTopic(5n);
+    topic.unparsableHeaders.add(2n);
+    const reader = await open(topic, [1]);
+    assert.deepEqual(offsets([await reader.next()]), [0n, 1n]);
+
+    const error = await reader.next().then(() => undefined, (failure: unknown) => failure);
+    assert.ok(error instanceof ReadError);
+    const frameEnd = BATCH_HEADER_SIZE + FRAME_HEADER_SIZE + 'm2'.length + UNPARSABLE_HEADERS.length;
+    assert.deepEqual([error.partitionId, error.offset, error.skip?.kind, error.skip?.value],
+      [1, 2n, PollingStrategyKind.Offset, 3n], String(error.error));
+    assert.deepEqual(error.record, records(2n, topic.unparsableHeaders).subarray(BATCH_HEADER_SIZE, frameEnd));
+    await reader.seek(1, error.skip!);
+    const resumed = await reader.next();
+    await reader.close();
+
+    assert.deepEqual(offsets([resumed]), [3n, 4n]);
+  });
+
+  it('hands over an on read batch of a partition that left the assignment, since the server stored it', async () => {
+    const topic = new FakeTopic();
+    topic.assigned = [1, 3];
+    topic.assignedAfterRead = [1];
+    const client = topic.client();
+    const reader = await PartitionReader.open(async () => client, {
+      streamId: 1, topicId: 2, consumer: { kind: ConsumerKind.Group, id: 5 },
+      options: { maxWait: 100, commit: CommitPolicy.OnRead }, retryInterval: 20
+    });
+    let first, second;
+    try {
+      first = await reader.next();
+      await once(topic.emitter, 'synced');
+      await new Promise(setImmediate);
+
+      second = await reader.next({ signal: AbortSignal.timeout(2_000) });
+    } finally {
+      await reader.close();
+    }
+
+    assert.deepEqual([first.partitionId, second.partitionId].sort(), [1, 3]);
+    assert.deepEqual(offsets([second]), [0n, 1n]);
+  });
+
+  for (const seek of [false, true]) {
+    it(`keeps a stored batch across reassignment unless a seek supersedes it (seek=${seek})`, async () => {
+      const topic = new FakeTopic(4n);
+      topic.assigned = [1, 3];
+      topic.assignedAfterRead = [1];
+      const client = topic.client();
+      const reader = await PartitionReader.open(async () => client, {
+        streamId: 1, topicId: 2, consumer: { kind: ConsumerKind.Group, id: 5 },
+        options: { maxWait: 100, commit: CommitPolicy.OnRead }, retryInterval: 20
+      });
+      try {
+        assert.equal((await reader.next()).partitionId, 1);
+        while (reader.partitions().includes(3)) await once(topic.emitter, 'synced');
+        topic.assigned = [1, 3];
+        while (!reader.partitions().includes(3)) await once(topic.emitter, 'synced');
+        if (seek) await reader.seek(3, PollingStrategy.Offset(2n));
+        let batch = await reader.next({ signal: AbortSignal.timeout(2_000) });
+        while (batch.partitionId !== 3) batch = await reader.next({ signal: AbortSignal.timeout(2_000) });
+        assert.deepEqual(offsets([batch]), seek ? [2n, 3n] : [0n, 1n]);
+      } finally {
+        await reader.close();
+      }
+    });
+  }
+
+  for (const commit of [CommitPolicy.Manual, CommitPolicy.OnRead]) {
+    it(`reads on from the stored offset under a new owner generation after a rejoin (${commit.kind})`, async () => {
+      const topic = new FakeTopic(2n);
+      topic.assigned = [1];
+      const client = topic.client();
+      const reader = await PartitionReader.open(async () => client, {
+        streamId: 1, topicId: 2, consumer: { kind: ConsumerKind.Group, id: 5 },
+        options: { maxWait: 100, commit }, retryInterval: 20
+      });
+      try {
+        const first = await reader.next({ signal: AbortSignal.timeout(2_000) });
+        if (commit.kind === 'manual') await reader.commit(first.checkpoint()!);
+        topic.ownerGeneration += 1n;
+        topic.limit = 4n;
+        const resumed = await reader.next({ signal: AbortSignal.timeout(2_000) });
+        assert.deepEqual([offsets([first]), offsets([resumed])], [[0n, 1n], [2n, 3n]]);
+        assert.equal(resumed.context.ownerGeneration, topic.ownerGeneration);
+      } finally {
+        await reader.close();
+      }
+    });
+  }
+
+  it('reports a partition that the server keeps refusing as not owned while the assignment still names it', async () => {
+    const topic = new FakeTopic();
+    topic.assigned = [1];
+    topic.notOwned.add(1);
+    const client = topic.client();
+    const reader = await PartitionReader.open(async () => client, {
+      streamId: 1, topicId: 2, consumer: { kind: ConsumerKind.Group, id: 5 }, options: { maxWait: 100 }, retryInterval: 5
+    });
+    try {
+      await assert.rejects(reader.next({ signal: AbortSignal.timeout(2_000) }), (error: unknown) =>
+        error instanceof ReadError && error.partitionId === 1 &&
+        (error.error as { errorCode: number }).errorCode === CONSUMER_GROUP_PARTITION_NOT_OWNED);
+    } finally {
+      await reader.close();
+    }
+  });
+
+  it('waits its retry interval after a failed route lookup while the caller asks again at once', async () => {
+    const topic = new FakeTopic();
+    topic.unrouted = new Error('not connected');
+    const reader = await open(topic, [1], { commit: CommitPolicy.OnRead });
+    const until = Date.now() + 200;
+    const errors: unknown[] = [];
+    while (Date.now() < until)
+      errors.push(await reader.next({ signal: AbortSignal.timeout(50) }).then(() => undefined, (error: unknown) => error));
+    await reader.close();
+
+    assert.ok(errors.some((error) => error instanceof ReadError && error.partitionId === undefined));
+    assert.ok(topic.routeLookups <= 200 / 20 + 2, `${topic.routeLookups} route lookups in 200 ms`);
+  });
+
+  it('puts the partitions that a fetch served at the end of the next fetch, so each one gets its turn', async () => {
+    const topic = new FakeTopic(100n);
+    topic.countLimited = true;
+    const reader = await open(topic, [1, 3, 5], { count: 2 });
+    const partitions: number[] = [];
+    try {
+      for (let read = 0; read < 3; read += 1) partitions.push((await reader.next()).partitionId);
+    } finally {
+      await reader.close();
+    }
+
+    assert.deepEqual(partitions, [1, 3, 5]);
+  });
+
+  for (const [prefetchBytes, spread] of [
+    [undefined, [[1], [3], [5], [7], [9]]],
+    [3 * 1_048_576, [[1, 5, 9], [3, 7]]]
+  ] as const) {
+    it(`spreads the backlogged partitions of a primary over the free read slots (${spread.length} slots)`, async () => {
+      const topic = new FakeTopic(100n);
+      topic.reportsHead = true;
+      topic.holdFetches = true;
+      const client = topic.client();
+      const reader = await PartitionReader.open(async () => client, {
+        streamId: 1, topicId: 2, consumer, partitions: [1, 3, 5, 7, 9], options: { maxWait: 100 }, retryInterval: 20,
+        ...(prefetchBytes === undefined ? {} : { prefetchBytes })
+      });
+      try {
+        await waitFor(() => topic.fetches.length === 1, 'the first fetch did not start');
+        topic.heldFetches[0]();
+        // The fetches of one turn start together, so the first one of the next turn shows them all.
+        await waitFor(() => topic.fetches.length > 1, 'no fetch followed the backlog');
+        assert.deepEqual(topic.fetches, [[1, 3, 5, 7, 9], ...spread]);
+      } finally {
+        topic.holdFetches = false;
+        for (const release of topic.heldFetches) release();
+        await reader.close();
+      }
+    });
+  }
+
+  for (const [prefetchBytes, spread] of [
+    [undefined, [[1], [3], [5], [7], [9]]],
+    [3 * 1_048_576, [[1, 7], [3, 9], [5]]]
+  ] as const) {
+    it(`spreads the backlogged partitions of a primary over the free slots of a committing reader (${spread.length} slots)`,
+      async () => {
+        const topic = new FakeTopic(100n);
+        topic.reportsHead = true;
+        topic.holdFetches = true;
+        const client = topic.client();
+        const reader = await PartitionReader.open(async () => client, {
+          streamId: 1, topicId: 2, consumer, partitions: [1, 3, 5, 7, 9], retryInterval: 20,
+          options: { maxWait: 100, commit: CommitPolicy.OnRead }, ...(prefetchBytes === undefined ? {} : { prefetchBytes })
+        });
+        try {
+          const first = reader.next();
+          await waitFor(() => topic.heldFetches.length === 1, 'the first fetch did not start');
+          topic.heldFetches.shift()!();
+          const batches = [await first];
+          for (let read = 1; read < 5; read += 1) batches.push(await reader.next());
+          assert.deepEqual(batches.map(({ partitionId }) => partitionId), [1, 3, 5, 7, 9]);
+          const sixth = reader.next();
+          await waitFor(() => topic.fetches.length > 1, 'no fetch followed the backlog');
+          await new Promise(setImmediate);
+          assert.deepEqual(topic.fetches, [[1, 3, 5, 7, 9], ...spread]);
+          assert.ok(topic.reads.filter(({ code }) => code === COMMAND_CODE.FetchMessages).every(({ autocommit }) => autocommit));
+          topic.holdFetches = false;
+          for (const release of topic.heldFetches.splice(0)) release();
+          assert.deepEqual(offsets([await sixth]), [2n, 3n]);
+        } finally {
+          topic.holdFetches = false;
+          for (const release of topic.heldFetches) release();
+          await reader.close();
+        }
+      });
+  }
+
+  it('waits its retry interval before it fetches a target again that a reply left out, when reads do not wait', async () => {
+    const topic = new FakeTopic();
+    topic.sectionless = true;
+    const reader = await open(topic, [1], { maxWait: 0 });
+    await new Promise((resolve) => setTimeout(resolve, 200));
+    await reader.close();
+
+    const fetches = topic.reads.filter(({ code }) => code === COMMAND_CODE.FetchMessages).length;
+    assert.ok(fetches > 0 && fetches <= 200 / 20 + 2, `${fetches} fetches in 200 ms`);
+  });
+
+  it('stops only the partition whose known history is gone, until a seek', async () => {
+    const topic = new FakeTopic(1_000n);
+    const reader = await open(topic, [1, 3]);
+    await reader.next();
+    await reader.next();
+    topic.history.add(1);
+
+    let error: unknown;
+    while (!error) error = await reader.next().then(() => undefined, (failure: unknown) => failure);
+    const others = [await reader.next(), await reader.next()];
+    topic.history.clear();
+    await reader.seek(1, PollingStrategy.Offset(0n));
+    let resumed = await reader.next();
+    while (resumed.partitionId !== 1) resumed = await reader.next();
+    await reader.close();
+
+    assert.ok(error instanceof ReadError);
+    assert.equal(error.partitionId, 1);
+    assert.ok(others.every(({ partitionId }) => partitionId === 3));
+    assert.equal(resumed.messages[0].headers.offset, 0n);
+  });
+});
+
+describe('consumer', () => {
+  it('counts a message once the iterator is read again, and the last one on shutdown', async () => {
+    const topic = new FakeTopic(4n);
+    const reader = await open(topic, [1], {
+      commit: CommitPolicy.Auto({ every: CommitEvery.Message, after: CommitAfter.Processing })
+    });
+    const consumer = new IggyConsumer(reader, CommitAfter.Processing);
+    const messages = consumer[Symbol.asyncIterator]();
+
+    await messages.next();
+    await new Promise((resolve) => setTimeout(resolve, 100));
+    const before = topic.stored.get(1);
+    await messages.next();
+    while (topic.stored.get(1) !== 0n) await new Promise((resolve) => setTimeout(resolve, 5));
+    await consumer.shutdown();
+
+    assert.equal(before, undefined);
+    assert.equal(topic.stored.get(1), 1n);
+  });
+
+  it('ends the handler loop with the error of a handler and leaves its message uncounted', async () => {
+    const topic = new FakeTopic(4n);
+    const reader = await open(topic, [1], {
+      commit: CommitPolicy.Auto({ interval: 3_600_000, after: CommitAfter.Processing })
+    });
+    const consumer = new IggyConsumer(reader, CommitAfter.Processing);
+    const failure = new Error('the handler failed');
+
+    await assert.rejects(consumer.consume(({ message }) => {
+      if (message.headers.offset === 1n) throw failure;
+    }), (error: unknown) => error === failure);
+    await consumer.shutdown();
+
+    assert.equal(topic.stored.get(1), 0n);
+  });
+
+  it('stops the handler loop between messages when a handler shuts the consumer down', async () => {
+    const topic = new FakeTopic(4n);
+    const reader = await open(topic, [1], { commit: CommitPolicy.OnRead });
+    const consumer = new IggyConsumer(reader);
+    const received: bigint[] = [];
+    await consumer.consume(async ({ message }) => {
+      received.push(message.headers.offset);
+      await consumer.shutdown();
+    });
+
+    assert.deepEqual(received, [0n]);
+    assert.equal(topic.stored.get(1), 0n);
+  });
+
+  it('drops the rest of the batch of a partition that the consumer seeks', async () => {
+    const topic = new FakeTopic(6n);
+    const consumer = new IggyConsumer(await open(topic, [1]));
+    const messages = consumer[Symbol.asyncIterator]();
+    try {
+      const first = await messages.next();
+      await consumer.seek(1, PollingStrategy.Offset(4n));
+      const second = await messages.next();
+      assert.deepEqual([first.value?.message.headers.offset, second.value?.message.headers.offset], [0n, 4n]);
+    } finally {
+      await consumer.shutdown();
+    }
+  });
+
+  it('does not count the message of a handler that still runs when the consumer shuts down', async () => {
+    const topic = new FakeTopic(4n);
+    const reader = await open(topic, [1], {
+      commit: CommitPolicy.Auto({ interval: 3_600_000, after: CommitAfter.Processing })
+    });
+    const consumer = new IggyConsumer(reader, CommitAfter.Processing);
+    let entered!: () => void;
+    const handling = new Promise<void>((resolve) => { entered = resolve; });
+    let release!: () => void;
+    const released = new Promise<void>((resolve) => { release = resolve; });
+    const failure = new Error('the handler failed');
+    const consuming = consumer.consume(async ({ message }) => {
+      if (message.headers.offset !== 1n) return;
+      entered();
+      await released;
+      throw failure;
+    });
+    await handling;
+    await consumer.shutdown();
+    release();
+    await assert.rejects(consuming, (error: unknown) => error === failure);
+
+    assert.equal(topic.stored.get(1), 0n);
+  });
+
+  it('reports a read error that the reader recovers from and hands over the messages after it', async () => {
+    const topic = new FakeTopic();
+    topic.unrouted = new Error('not connected');
+    const reader = await open(topic, [1]);
+    const errors: ReadError[] = [];
+    const consumer = new IggyConsumer(reader, undefined, (error) => {
+      errors.push(error);
+      topic.unrouted = undefined;
+    });
+    let first;
+    try {
+      first = await consumer[Symbol.asyncIterator]().next();
+    } finally {
+      await consumer.shutdown();
+    }
+
+    assert.equal(errors[0]?.partitionId, undefined);
+    assert.equal(first.value?.message.headers.offset, 0n);
+  });
+
+  it('shuts down when its readable is destroyed while a read waits for messages', async () => {
+    const topic = new FakeTopic(0n);
+    const consumer = new IggyConsumer(await open(topic, [1]));
+    const readable = consumer.toReadable();
+    readable.resume();
+    await new Promise((resolve) => setTimeout(resolve, 50));
+
+    const closed = once(readable, 'close').then(() => 'closed');
+    readable.destroy();
+    const outcome = await Promise.race([closed, new Promise((resolve) => setTimeout(resolve, 1_000, 'still open'))]);
+    if (outcome !== 'closed') await consumer.shutdown();
+
+    assert.equal(outcome, 'closed');
+  });
+});
diff --git a/foreign/node/src/stream/partition-reader.ts b/foreign/node/src/stream/partition-reader.ts
new file mode 100644
index 0000000..1b12ce8
--- /dev/null
+++ b/foreign/node/src/stream/partition-reader.ts
@@ -0,0 +1,1160 @@
+// 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.
+
+import { setTimeout as sleep } from 'node:timers/promises';
+import type { ClientProvider, RawClient } from '../client/client.type.js';
+import { FetchProgressUncertainError, OperationUncertainError } from '../client/client.operation.js';
+import { COMMAND_CODE } from '../wire/command.code.js';
+import { DeserializeError, ResponseError, responseError } from '../wire/error.utils.js';
+import type { Id } from '../wire/identifier.utils.js';
+import { ConsumerKind, type Consumer } from '../wire/offset/offset.utils.js';
+import { storeOffset } from '../wire/offset/store-offset.command.js';
+import { deleteOffset } from '../wire/offset/delete-offset.command.js';
+import { storePosition } from '../wire/offset/position.command.js';
+import { joinGroup } from '../wire/consumer-group/join-group.command.js';
+import { leaveGroup } from '../wire/consumer-group/leave-group.command.js';
+import { syncGroup } from '../wire/consumer-group/sync-group.command.js';
+import {
+  decodeFetchSection, fetchMessages, fetchSectionError, type FetchMessagesSection
+} from '../wire/message/fetch-messages.command.js';
+import { pollMessages } from '../wire/message/poll-messages.command.js';
+import { PollingStrategy, PollingStrategyKind, type PollMessagesResponse } from '../wire/message/poll.utils.js';
+import { CommitPolicy, pollOptions, wireOptions, type PollOptions } from '../wire/message/poll-options.js';
+import {
+  continuation, polledBatch, validatePartition, type Checkpoint, type Continuation, type PolledBatch
+} from '../wire/message/checkpoint.js';
+import type { PartitionContext } from '../wire/vsr/header.js';
+
+const INVALID_COMMAND = 3;
+const UNAUTHORIZED = 41;
+const TRANSIENT_NOT_COMMITTED = 57;
+const TRANSIENT_NOT_ACCEPTED = 58;
+const INVALID_SIZE_BYTES = 80;
+const HISTORY_UNAVAILABLE = 87;
+const LIFECYCLE_BUSY = 88;
+const NOT_PRIMARY = 89;
+const DEFERRED_POLL_CAPACITY_EXCEEDED = 90;
+const PARTITION_NOT_FOUND = 3007;
+const CONSUMER_OFFSET_NOT_FOUND = 3021;
+const CONSUMER_GROUP_MEMBER_NOT_FOUND = 5006;
+const CONSUMER_GROUP_PARTITION_NOT_OWNED = 5009;
+const FETCH_PROGRESS_UNCERTAIN = 14010;
+const LASTING_REFUSALS = new Set([UNAUTHORIZED, 5, INVALID_COMMAND, PARTITION_NOT_FOUND, 1009, 1010, 2010, 2011,
+  CONSUMER_GROUP_PARTITION_NOT_OWNED]);
+const TRANSIENT_REFUSALS = new Set([TRANSIENT_NOT_COMMITTED, TRANSIENT_NOT_ACCEPTED, NOT_PRIMARY,
+  DEFERRED_POLL_CAPACITY_EXCEEDED, LIFECYCLE_BUSY]);
+const MAX_LASTING_REFUSALS = 6;
+/** The most reads in flight that a reader adds to spread a backlog, the deferred request limit of a Rust SDK consumer. */
+const MAX_READS_IN_FLIGHT = 16;
+const GROUP_REFRESH_MS = 1_000;
+/** The wait of a reader read when the caller sets none. */
+export const DEFAULT_READER_MAX_WAIT_MS = 5_000;
+export const DEFAULT_PREFETCH_BYTES = 16 * 1024 * 1024;
+export const DEFAULT_PREFETCH_MESSAGES = 16_000;
+
+/** Parameters of `message.reader`. */
+export type PartitionReaderRequest = {
+  streamId: Id,
+  topicId: Id,
+  /** A standalone consumer reads `partitions`. A consumer group reads the assignment of its member. */
+  consumer: Consumer,
+  /** The partitions of a standalone consumer. A consumer group refuses them. */
+  partitions?: number[],
+  /** Where a partition without a position starts, Next by default. */
+  start?: PollingStrategy,
+  /** The limits of each read and the commit policy. A read waits up to 5 s by default. */
+  options?: Partial<PollOptions>,
+  /** The most bytes the reader holds read ahead of the application, 16 MiB by default. */
+  prefetchBytes?: number,
+  /** The most messages the reader holds read ahead of the application, 16,000 by default. */
+  prefetchMessages?: number,
+  /** The wait in milliseconds before a refused partition reads again, 1 s by default. */
+  retryInterval?: number
+};
+
+/**
+ * An error of a read of a `PartitionReader`. The reader stays usable after it
+ * and retries by itself.
+ */
+export class ReadError extends Error {
+  constructor(
+    /** The partition of the error, or undefined for an error of the whole reader. */
+    readonly partitionId: number | undefined,
+    /** The error itself. */
+    readonly error: unknown,
+    /**
+     * For a message that does not decode: the continuation after it. The
+     * reader reads the message again until a `seek` to this continuation
+     * skips it. The messages before it arrive in a batch before this error.
+     */
+    readonly skip?: Continuation
+  ) {
+    super(partitionId === undefined ? `reading failed: ${String(error)}`
+      : `reading partition ${partitionId} failed: ${String(error)}`, { cause: error });
+    this.name = 'ReadError';
+    if (error instanceof DeserializeError) {
+      this.offset = error.offset;
+      this.record = error.record;
+    }
+  }
+
+  /**
+   * For a message that does not decode: its offset, known when its frame
+   * decoded and a later step failed, such as its user headers. Undefined when
+   * the framing of the reply is malformed, so no message can be told apart.
+   */
+  readonly offset?: bigint;
+
+  /**
+   * For a message that does not decode: the record as the reply carried it,
+   * its 48-byte frame header, payload and user headers. For malformed
+   * framing, the remaining bytes of the reply records from where decoding
+   * stopped. Undefined for an error that is not a decode failure.
+   */
+  readonly record?: Buffer;
+}
+
+type Target = { partition: number, sent: PollingStrategy, generation: number, state?: PartitionState };
+
+type Entry = {
+  partitionId?: number,
+  generation: number,
+  batch?: PolledBatch,
+  error?: ReadError,
+  messages: number,
+  bytes: number
+};
+
+type PartitionState = {
+  position: PollingStrategy,
+  generation: number,
+  inFlight: boolean,
+  idle?: Promise<void>,
+  settleIdle?: () => void,
+  stopped?: ReadError,
+  retryAt: number,
+  lasting: number,
+  catchUpUntil?: bigint,
+  catchUpBatch?: PollMessagesResponse,
+  /** A committing read of the partition lost its reply, so a later read that skips offsets skipped what it stored. */
+  lost?: boolean,
+  /** The start of a committing read whose store nobody took yet, so the close moves the offset back before it. */
+  pendingStart?: PollingStrategy & { context?: PartitionContext },
+  aloneUntil?: bigint,
+  delivered?: { offset: bigint, context?: PartitionContext },
+  handed?: { offset: bigint, context?: PartitionContext },
+  firstHanded?: bigint,
+  counted?: Checkpoint,
+  countedSinceStore: number,
+  stored?: Checkpoint,
+  /** The last read of the partition found no message. */
+  atEnd?: boolean,
+  /** Its last fetch section ended before the head of the partition, so its next read returns at once. */
+  backlogged?: boolean,
+  /** When a read last served messages of the partition, so served partitions go last in the next fetch. */
+  servedAt: number
+};
+
+let readers = 0;
+
+const sameHistory = (sent: PartitionContext, served: PartitionContext): boolean =>
+  sent.incarnation === served.incarnation && sent.ownerGeneration === served.ownerGeneration;
+
+/** An empty partition reports current offset 0, so only a section with messages or a later head shows a backlog. */
+const endsBeforeHead = ({ nextOffset, currentOffset, messagesCount }: FetchMessagesSection): boolean =>
+  nextOffset <= currentOffset && (messagesCount > 0 || currentOffset > 0n);
+
+const errorCode = (error: unknown): number | undefined =>
+  error instanceof FetchProgressUncertainError ? FETCH_PROGRESS_UNCERTAIN
+    : error instanceof ResponseError ? error.errorCode : undefined;
+
+const isAbort = (error: unknown): boolean => error instanceof Error && error.name === 'AbortError';
+
+/**
+ * Reads the partitions of a topic in batches, with routing, read ahead and the
+ * position of each partition inside. Open one with `client.message.reader`.
+ *
+ * - The partitions of one primary share one request, and a moved primary is
+ *   found again by itself.
+ * - `next()` returns one batch of one partition, never empty, in offset order
+ *   within one generation of the partition: from its first read, each `seek`
+ *   and each new assignment to this member. One caller at a time calls it.
+ * - With `CommitPolicy.Manual`, `commit` stores a checkpoint. With
+ *   `CommitPolicy.OnRead` the server stores the offset of each read, the reader
+ *   reads only while `next()` waits, and `close()` moves the offset back to
+ *   the last batch the application took. A read whose reply was lost is
+ *   repaired: the next read finds the messages that it stored, and the reader
+ *   reads them again by offset without a commit. With `CommitPolicy.Auto` the
+ *   reader counts each batch as its `after` says, or each `mark`, and stores
+ *   the latest counted position at its interval or event and in `close()`.
+ * - A `ReadError` leaves the reader usable. A history refusal (87) of a
+ *   position from an earlier read, an oversized `Last` start (80) and a refusal
+ *   that six retries did not change stop their partition until a `seek`.
+ */
+export class PartitionReader implements AsyncIterable<PolledBatch> {
+  private readonly id = ++readers;
+  private readonly states = new Map<number, PartitionState>();
+  private readonly sought = new Map<number, number>();
+  private readonly queue: Entry[] = [];
+  private readonly reads = new Set<Promise<void>>();
+  private readonly seeks = new Set<Promise<void>>();
+  private readonly lifetime = new AbortController();
+  private readonly closing = new Promise<void>((resolve) =>
+    this.lifetime.signal.addEventListener('abort', () => resolve(), { once: true }));
+  private readonly options: PollOptions;
+  private readonly start: PollingStrategy;
+  private readonly group: boolean;
+  private readonly prefetchBytes: number;
+  private readonly prefetchMessages: number;
+  private readonly retryInterval: number;
+  private arrival = resolvers();
+  private wakeUp = resolvers();
+  private storeDue = resolvers();
+  private demand = false;
+  private closed = false;
+  private closure?: Promise<void>;
+  private resync = false;
+  private queuedMessages = 0;
+  private queuedBytes = 0;
+  private generations = 0;
+  private serves = 0;
+  private uncounted?: Checkpoint;
+  private uncountedMessages = 0;
+  private sessionResets = 0;
+  private joinedResets = 0;
+  private readonly worker: Promise<void>;
+  private readonly committer: Promise<void>;
+  /** @internal Set by a consumer, which reports and counts each delivered message itself. */
+  countsMessages = false;
+
+  private constructor(
+    private readonly getClient: ClientProvider,
+    private readonly request: PartitionReaderRequest
+  ) {
+    this.options = pollOptions({ maxWait: DEFAULT_READER_MAX_WAIT_MS, ...request.options });
+    const start = request.start ?? PollingStrategy.Next;
+    this.start = start.kind === PollingStrategyKind.First ? PollingStrategy.Offset(0n) : start;
+    this.group = request.consumer.kind === ConsumerKind.Group;
+    this.prefetchBytes = request.prefetchBytes ?? DEFAULT_PREFETCH_BYTES;
+    this.prefetchMessages = request.prefetchMessages ?? DEFAULT_PREFETCH_MESSAGES;
+    this.retryInterval = request.retryInterval ?? 1_000;
+    for (const partition of request.partitions ?? [])
+      this.states.set(partition, this.newState());
+    this.worker = this.run();
+    this.committer = this.options.commit.kind === 'auto' ? this.commitLoop() : Promise.resolve();
+  }
+
+  /**
+   * Checks the request, joins the group of a group reader and starts reading.
+   * A standalone consumer without partitions, a group with partitions and a
+   * continuation as the start fail with 3.
+   */
+  static async open(getClient: ClientProvider, request: PartitionReaderRequest): Promise<PartitionReader> {
+    pollOptions({ maxWait: DEFAULT_READER_MAX_WAIT_MS, ...request.options });
+    const group = request.consumer.kind === ConsumerKind.Group;
+    const partitions = request.partitions ?? [];
+    if (group ? partitions.length > 0 : partitions.length === 0)
+      throw responseError(0, INVALID_COMMAND, group
+        ? 'a consumer group reads the partitions of its assignment' : 'a standalone reader needs partitions');
+    if (request.start && (request.start as Partial<Continuation>).partitionId !== undefined)
+      throw responseError(0, INVALID_COMMAND, 'a continuation belongs to one partition and cannot start a reader');
+    const client = await getClient();
+    if (group) {
+      await joinGroup(getClient)({ streamId: request.streamId, topicId: request.topicId, groupId: request.consumer.id });
+    }
+    const reader = new PartitionReader(getClient, { ...request, partitions: [...new Set(partitions)] });
+    client.on('sessionReset', () => { reader.sessionResets += 1; reader.wake(); });
+    return reader;
+  }
+
+  /**
+   * The partitions that the reader reads: those of a standalone consumer, or
+   * the current assignment of a group member.
+   */
+  partitions(): number[] {
+    return [...this.states.keys()].sort((left, right) => left - right);
+  }
+
+  /**
+   * The next batch of one partition, never empty. Rejects with a `ReadError`,
+   * after which the reader stays usable. An aborted call leaves the batches in
+   * the reader.
+   */
+  async next({ signal }: { signal?: AbortSignal } = {}): Promise<PolledBatch> {
+    return (await this.take(signal, false))!;
+  }
+
+  /** @internal As `next`, but undefined once the last read of every partition found no message. */
+  nextUntilEnd(): Promise<PolledBatch | undefined> {
+    return this.take(undefined, true);
+  }
+
+  private async take(signal: AbortSignal | undefined, untilEnd: boolean): Promise<PolledBatch | undefined> {
+    this.countUncounted();
+    while (true) {
+      if (this.closed) throw new Error('the partition reader is closed');
+      let entry: Entry | undefined;
+      while ((entry = this.queue.shift()) !== undefined) {
+        this.release(entry);
+        if (!this.isCurrent(entry)) continue;
+        this.demand = false;
+        if (entry.error) throw entry.error;
+        return this.deliver(entry.batch!);
+      }
+      if (untilEnd && this.states.size > 0 && [...this.states.values()].every((state) => state.atEnd)) {
+        this.demand = false;
+        return undefined;
+      }
+      this.demand = true;
+      const arrival = this.arrival.promise;
+      this.wake();
+      await (signal ? Promise.race([arrival, abortOf(signal)]) : arrival);
+    }
+  }
+
+  async *[Symbol.asyncIterator](): AsyncIterator<PolledBatch> {
+    while (!this.closed) yield await this.next();
+  }
+
+  /**
+   * Counts a checkpoint that this reader delivered under `CommitPolicy.Auto`.
+   * It stores nothing now. Fails with 3 without an automatic policy and for a
+   * checkpoint of another reader.
+   */
+  mark(checkpoint: Checkpoint): void {
+    if (this.options.commit.kind !== 'auto')
+      throw responseError(0, INVALID_COMMAND, 'only an automatic commit policy counts marks');
+    this.ensureOwn(checkpoint);
+    this.count(checkpoint, 1, false);
+  }
+
+  /**
+   * Stores the offset of a checkpoint that this reader delivered. A checkpoint
+   * of an older generation of its partition, or at or below the last commit,
+   * stores nothing. Fails with 3 with `CommitPolicy.OnRead` and for a
+   * checkpoint of another reader.
+   */
+  async commit(checkpoint: Checkpoint): Promise<void> {
+    if (this.options.commit.kind === 'onRead')
+      throw responseError(0, INVALID_COMMAND, 'the server stores the offsets of an on read policy');
+    this.ensureOwn(checkpoint);
+    const state = this.states.get(checkpoint.partitionId);
+    if (!state || state.generation !== checkpoint.stamp!.generation ||
+        (state.stored && state.stored.offset >= checkpoint.offset))
+      return;
+    await storePosition(this.getClient)({ ...this.target(), checkpoint });
+    this.recordStored(checkpoint);
+  }
+
+  /**
+   * Sets the next read position of a partition and starts a new generation of
+   * it: it drops the queued batches of the partition, ignores its late replies
+   * and resumes a stopped partition. With `CommitPolicy.OnRead` a seek to an
+   * offset above 0 first stores the offset before it on the server, so a
+   * restart resumes at the sought offset, a refused store fails the seek and
+   * moves nothing, and the seek then waits for the read of the partition in
+   * flight, whose batch it drops. Every other seek stores nothing. Fails with 3
+   * for a partition the reader does not read and for a continuation of another
+   * partition.
+   */
+  seek(partitionId: number, start: PollingStrategy): Promise<void> {
+    const seek = this.seekOnce(partitionId, start);
+    this.seeks.add(seek);
+    return seek.finally(() => this.seeks.delete(seek));
+  }
+
+  private async seekOnce(partitionId: number, start: PollingStrategy): Promise<void> {
+    validatePartition(start, partitionId, 0);
+    const state = this.stateOf(partitionId);
+    const onRead = this.options.commit.kind === 'onRead';
+    const position = start.kind === PollingStrategyKind.First ? PollingStrategy.Offset(0n) : start;
+    let stored: { offset: bigint, context?: PartitionContext } | undefined;
+    if (onRead && position.kind === PollingStrategyKind.Offset && position.value > 0n) {
+      stored = { offset: position.value - 1n,
+        context: (start as Partial<Continuation>).context ?? state.handed?.context };
+      await storeOffset(this.getClient)({ ...this.target(), partitionId, ...stored });
+    }
+    const raced = state.inFlight ||
+      this.queue.some((entry) => entry.partitionId === partitionId && entry.batch !== undefined);
+    if (onRead && state.inFlight) await state.idle;
+    Object.assign(state, {
+      position: continuation(partitionId, position.kind, position.value,
+        (start as Partial<Continuation>).context ?? (state.position as Partial<Continuation>).context!),
+      generation: ++this.generations,
+      stopped: undefined, lasting: 0, retryAt: 0, catchUpUntil: undefined, catchUpBatch: undefined, lost: undefined,
+      pendingStart: undefined, aloneUntil: undefined, backlogged: undefined,
+      counted: undefined, countedSinceStore: 0, stored: undefined,
+      delivered: stored, handed: stored, firstHanded: undefined
+    });
+    this.sought.set(partitionId, state.generation);
+    if ((start as Partial<Continuation>).context === undefined && (state.position as Partial<Continuation>).context === undefined)
+      state.position = position;
+    // A committing read that the server admitted after the store moved the offset on, so the close puts it back.
+    if (raced && stored) state.pendingStart = state.position;
+    for (let index = this.queue.length - 1; index >= 0; index -= 1) {
+      if (this.queue[index].partitionId === partitionId) {
+        this.release(this.queue[index]);
+        this.queue.splice(index, 1);
+      }
+    }
+    this.wake();
+  }
+
+  /**
+   * Stops the reader and waits for its reads in flight. With
+   * `CommitPolicy.Auto` it counts the last batch under `after: processing`
+   * and stores the latest counted position of each partition. With
+   * `CommitPolicy.OnRead` it moves the offset of each partition with
+   * undelivered messages back to its last delivered message, or before the
+   * first undelivered one when it delivered nothing and started at Next. A
+   * group reader then leaves its group. Rejects with the first failed store.
+   * A later call waits for the first one and ends as it does.
+   */
+  close(): Promise<void> {
+    this.closure ??= this.closeOnce();
+    return this.closure;
+  }
+
+  private async closeOnce(): Promise<void> {
+    this.countUncounted();
+    this.closed = true;
+    this.lifetime.abort();
+    this.wake();
+    this.storeDue.resolve();
+    await this.worker;
+    await Promise.allSettled([...this.reads]);
+    // A seek whose store waited for a read still applies, so the rewind sees its position and not the dropped batch.
+    await Promise.allSettled([...this.seeks]);
+    await this.committer;
+    let failure: unknown;
+    if (this.options.commit.kind === 'onRead') failure = await this.rewind();
+    else if (this.options.commit.kind === 'auto') failure = await this.flushCounted();
+    if (this.group) {
+      try {
+        await leaveGroup(this.getClient)({ streamId: this.request.streamId, topicId: this.request.topicId,
+          groupId: this.request.consumer.id });
+      } catch {
+        // The session or the member may be gone already, which ends the membership too.
+      }
+    }
+    this.queue.length = 0;
+    this.arrival.resolve();
+    if (failure !== undefined) throw failure;
+  }
+
+  /** @internal Records the last message that a consumer handed over. */
+  delivered(checkpoint: Checkpoint): void {
+    const state = this.states.get(checkpoint.partitionId);
+    if (state && state.generation === checkpoint.stamp?.generation)
+      state.delivered = { offset: checkpoint.offset, context: checkpoint.context };
+  }
+
+  /**
+   * @internal Takes back a batch that `next` returned and nobody read, so a
+   * close under `CommitPolicy.OnRead` moves the offset back before it.
+   */
+  returned(batch: PolledBatch): void {
+    const generation = batch.checkpoint()?.stamp?.generation;
+    const state = this.states.get(batch.partitionId);
+    if (!state || generation === undefined || state.generation !== generation) return;
+    const first = batch.messages[0].headers.offset;
+    if (state.firstHanded !== undefined && first > state.firstHanded) {
+      state.handed = { offset: first - 1n, context: batch.context };
+      state.delivered = state.handed;
+    } else {
+      state.handed = state.delivered = state.firstHanded = undefined;
+    }
+    this.queue.unshift({ partitionId: batch.partitionId, generation, batch, messages: batch.messages.length,
+      bytes: batch.messages.reduce((sum, message) => sum + message.payload.length, 0) });
+    this.queuedMessages += batch.messages.length;
+  }
+
+  /** @internal Counts messages that a consumer delivered or processed. */
+  countDelivered(checkpoint: Checkpoint, messages: number, endsBatch: boolean): void {
+    this.count(checkpoint, messages, endsBatch);
+  }
+
+  private newState(): PartitionState {
+    return {
+      position: this.start, generation: ++this.generations, inFlight: false, retryAt: 0, lasting: 0, countedSinceStore: 0,
+      servedAt: 0
+    };
+  }
+
+  private target() {
+    return { streamId: this.request.streamId, topicId: this.request.topicId, consumer: this.request.consumer };
+  }
+
+  private stateOf(partitionId: number): PartitionState {
+    const state = this.states.get(partitionId);
+    if (!state) throw responseError(0, INVALID_COMMAND, `the reader does not read partition ${partitionId}`);
+    return state;
+  }
+
+  private ensureOwn(checkpoint: Checkpoint): void {
+    if (checkpoint.stamp?.reader !== this.id)
+      throw responseError(0, INVALID_COMMAND, 'the checkpoint was not delivered by this reader');
+  }
+
+  private deliver(batch: PolledBatch): PolledBatch {
+    const checkpoint = batch.checkpoint()!;
+    const state = this.states.get(batch.partitionId);
+    if (state && state.generation === checkpoint.stamp?.generation) {
+      state.firstHanded ??= batch.messages[0].headers.offset;
+      state.handed = { offset: checkpoint.offset, context: batch.context };
+      if (!this.countsMessages) state.delivered = state.handed;
+    }
+    const commit = this.options.commit;
+    if (this.countsMessages || commit.kind !== 'auto') return batch;
+    if (commit.after === 'delivery') this.count(checkpoint, batch.messages.length, true);
+    else if (commit.after === 'processing') {
+      this.uncounted = checkpoint;
+      this.uncountedMessages = batch.messages.length;
+    }
+    return batch;
+  }
+
+  private countUncounted(): void {
+    const uncounted = this.uncounted;
+    this.uncounted = undefined;
+    if (uncounted) this.count(uncounted, this.uncountedMessages, true);
+  }
+
+  private count(checkpoint: Checkpoint, messages: number, endsBatch: boolean): void {
+    const commit = this.options.commit;
+    const state = this.states.get(checkpoint.partitionId);
+    if (commit.kind !== 'auto' || !state || state.generation !== checkpoint.stamp?.generation) return;
+    if (!state.counted || state.counted.offset < checkpoint.offset) state.counted = checkpoint;
+    state.countedSinceStore += messages;
+    const every = commit.every;
+    const due = every?.kind === 'message' ||
+      (every?.kind === 'messages' && state.countedSinceStore >= every.count) ||
+      (every?.kind === 'batch' && endsBatch);
+    if (due) this.storeDue.resolve();
+  }
+
+  private recordStored(checkpoint: Checkpoint): void {
+    const state = this.states.get(checkpoint.partitionId);
+    if (state && state.generation === checkpoint.stamp?.generation &&
+        (!state.stored || state.stored.offset < checkpoint.offset)) {
+      state.stored = checkpoint;
+      if (!state.counted || state.counted.offset <= checkpoint.offset) state.countedSinceStore = 0;
+    }
+  }
+
+  private async commitLoop(): Promise<void> {
+    const commit = this.options.commit;
+    const interval = commit.kind === 'auto' ? commit.interval : undefined;
+    while (!this.closed) {
+      await Promise.race([this.storeDue.promise, ...(interval === undefined ? [] : [delay(interval)])]);
+      this.storeDue = resolvers();
+      if (this.closed) return;
+      await this.flushCounted();
+    }
+  }
+
+  /** Stores the latest counted position of each partition ahead of its last store, one at a time. */
+  private async flushCounted(): Promise<unknown> {
+    const due = [...this.states.values()]
+      .filter((state) => state.counted && state.counted.stamp?.generation === state.generation &&
+        (!state.stored || state.stored.offset < state.counted.offset))
+      .map((state) => state.counted!);
+    let failure: unknown;
+    for (const checkpoint of due) {
+      try {
+        await storePosition(this.getClient)({ ...this.target(), checkpoint });
+        this.recordStored(checkpoint);
+      } catch (error) {
+        failure ??= error;
+      }
+    }
+    return failure;
+  }
+
+  /**
+   * With `CommitPolicy.OnRead` the server stored the offsets of messages that
+   * the application never took, so each such partition moves its offset back.
+   */
+  private async rewind(): Promise<unknown> {
+    const queued = new Map<number, PolledBatch>();
+    for (const entry of this.queue)
+      if (entry.batch && this.isCurrent(entry) && !queued.has(entry.batch.partitionId))
+        queued.set(entry.batch.partitionId, entry.batch);
+    let failure: unknown;
+    for (const [partitionId, state] of this.states) {
+      const handedOver = state.handed && (!state.delivered || state.delivered.offset < state.handed.offset);
+      const first = queued.get(partitionId) ?? state.catchUpBatch;
+      if (!handedOver && !first && !state.pendingStart) continue;
+      let offset: bigint | null | undefined;
+      let context: PartitionContext | undefined;
+      if (state.delivered) {
+        offset = state.delivered.offset;
+        context = state.delivered.context;
+      } else if (this.start.kind === PollingStrategyKind.Next) {
+        const firstOffset = state.firstHanded ?? first?.messages[0].headers.offset ?? state.pendingStart!.value;
+        offset = firstOffset === 0n ? null : firstOffset - 1n;
+        context = state.handed?.context ?? first?.context ?? state.pendingStart!.context;
+      }
+      if (offset === undefined) continue;
+      try {
+        if (offset === null) {
+          await deleteOffset(this.getClient)({ ...this.target(), partitionId, context });
+        } else {
+          await storeOffset(this.getClient)({ ...this.target(), partitionId, offset, context });
+        }
+      } catch (error) {
+        if (!(offset === null && errorCode(error) === CONSUMER_OFFSET_NOT_FOUND)) failure ??= error;
+      }
+    }
+    return failure;
+  }
+
+  private wake(): void {
+    this.wakeUp.resolve();
+  }
+
+  private async run(): Promise<void> {
+    while (!this.closed) {
+      let wait: number | undefined;
+      let failed = false;
+      try {
+        if (this.group) await this.refreshAssignment();
+        wait = await this.startReads();
+      } catch (error) {
+        if (this.closed) return;
+        const code = errorCode(error);
+        if (!(code !== undefined && TRANSIENT_REFUSALS.has(code)) && !(error instanceof OperationUncertainError))
+          this.report(undefined, 0, error);
+        wait = this.retryInterval;
+        failed = true;
+      }
+      // A caller that asks again after a failure does not cut its retry interval short. The pause
+      // keeps the process alive like a read in flight, until the reader closes.
+      const woken = failed ? this.closing : this.wakeUp.promise;
+      await Promise.race([woken, ...(wait === undefined ? []
+        : [sleep(wait, undefined, { signal: this.lifetime.signal }).catch(() => undefined)])]);
+      this.wakeUp = resolvers();
+    }
+  }
+
+  private async refreshAssignment(): Promise<void> {
+    const { streamId, topicId, consumer } = this.request;
+    if (this.joinedResets !== this.sessionResets) {
+      const resets = this.sessionResets;
+      await joinGroup(this.getClient)({ streamId, topicId, groupId: consumer.id });
+      this.joinedResets = resets;
+      this.resync = true;
+    }
+    let assignment;
+    try {
+      assignment = await syncGroup(this.getClient)({ streamId, topicId, groupId: consumer.id });
+    } catch (error) {
+      if (errorCode(error) === CONSUMER_GROUP_MEMBER_NOT_FOUND) {
+        await joinGroup(this.getClient)({ streamId, topicId, groupId: consumer.id });
+        assignment = await syncGroup(this.getClient)({ streamId, topicId, groupId: consumer.id });
+      } else {
+        throw error;
+      }
+    }
+    this.resync = false;
+    const owned = new Set(assignment?.partitions ?? []);
+    const leaving: Checkpoint[] = [];
+    for (const [partition, state] of this.states) {
+      if (owned.has(partition)) continue;
+      if (state.counted && (!state.stored || state.stored.offset < state.counted.offset)) leaving.push(state.counted);
+      this.giveUpGap(partition, state);
+      this.states.delete(partition);
+      state.settleIdle?.();
+    }
+    for (const partition of owned)
+      if (!this.states.has(partition)) this.states.set(partition, this.newState());
+    for (const checkpoint of leaving) {
+      try {
+        await storePosition(this.getClient)({ ...this.target(), checkpoint });
+      } catch {
+        // The partition moved to another member, which may refuse this store.
+      }
+    }
+  }
+
+  /** Starts a read for each partition that may read now, and returns how long the worker may sleep. */
+  private async startReads(): Promise<number | undefined> {
+    const now = Date.now();
+    let nextRetry = Number.POSITIVE_INFINITY;
+    const grouped: number[] = [];
+    const alone: number[] = [];
+    const reading = this.options.commit.kind === 'onRead'
+      ? this.demand && this.queue.length === 0
+      : this.queuedMessages < this.prefetchMessages && this.queuedBytes < this.prefetchBytes;
+    if (reading) {
+      for (const [partition, state] of this.states) {
+        if (state.inFlight || state.stopped) continue;
+        if (state.retryAt > now) {
+          nextRetry = Math.min(nextRetry, state.retryAt);
+          continue;
+        }
+        const fetchable = (state.position.kind === PollingStrategyKind.Offset ||
+          state.position.kind === PollingStrategyKind.Next) && state.catchUpUntil === undefined &&
+          state.aloneUntil === undefined && !this.needsStart(state.position);
+        (fetchable ? grouped : alone).push(partition);
+      }
+    }
+    for (const partition of alone) this.startPoll(partition);
+    // A fetch reads its targets in order until it holds its count, so the partitions served last go last.
+    grouped.sort((left, right) => this.states.get(left)!.servedAt - this.states.get(right)!.servedAt || left - right);
+    if (grouped.length > 0) {
+      const client: RawClient = await this.getClient();
+      const groups = client.readinessGroups
+        ? await client.readinessGroups(this.request.streamId, this.request.topicId, grouped,
+          { signal: this.lifetime.signal })
+        : grouped.map((partition) => [partition]);
+      for (const targets of this.spread(groups)) this.startFetch(targets);
+    }
+    let wait = Number.isFinite(nextRetry) ? Math.max(1, nextRetry - Date.now()) : undefined;
+    if (this.group && (wait === undefined || wait > GROUP_REFRESH_MS)) wait = GROUP_REFRESH_MS;
+    return wait;
+  }
+
+  /**
+   * The fetches of a turn. Each group reads its idle partitions in one fetch.
+   * The free read slots left then spread the backlogged partitions of the
+   * groups over more fetches, each with the full limits. Their reads return at
+   * once, so each slot keeps a full read in flight, as a poll of each partition
+   * would, and no more reads park. Under `CommitPolicy.OnRead` a turn starts
+   * only while `next()` waits, and a partition is in one committing read at a
+   * time, so the fetches of a turn commit disjoint partitions.
+   */
+  private spread(groups: number[][]): number[][] {
+    const plans = groups.map((group) => {
+      const backlogged = group.filter((partition) => this.states.get(partition)?.backlogged);
+      const quiet = group.filter((partition) => !this.states.get(partition)?.backlogged);
+      return { quiet, backlogged, extra: 0, room: backlogged.length - (quiet.length === 0 ? 1 : 0) };
+    });
+    let extra = this.freeSlots() - plans.length;
+    while (extra > 0 && plans.some(({ extra: taken, room }) => taken < room)) {
+      for (const plan of plans) {
+        if (extra === 0) break;
+        if (plan.extra < plan.room) {
+          plan.extra += 1;
+          extra -= 1;
+        }
+      }
+    }
+    return plans.flatMap(({ quiet, backlogged, extra: taken }) => {
+      const chunks = taken + (quiet.length === 0 ? 1 : 0);
+      const spread: number[][] = Array.from({ length: chunks }, () => []);
+      const waiting = [...quiet];
+      backlogged.forEach((partition, index) => (chunks > 0 ? spread[index % chunks] : waiting).push(partition));
+      return waiting.length > 0 ? [waiting, ...spread] : spread;
+    });
+  }
+
+  /** The full reads that the read ahead budget has room for besides those in flight. */
+  private freeSlots(): number {
+    const room = Math.min(Math.floor((this.prefetchMessages - this.queuedMessages) / this.options.count),
+      Math.floor((this.prefetchBytes - this.queuedBytes) / this.options.maxBytes), MAX_READS_IN_FLIGHT);
+    return room - this.reads.size;
+  }
+
+  private claim(partition: number): Target | undefined {
+    const state = this.states.get(partition);
+    if (!state || state.inFlight || state.stopped) return undefined;
+    state.inFlight = true;
+    const idle = resolvers();
+    state.idle = idle.promise;
+    state.settleIdle = idle.resolve;
+    return { partition, sent: state.position, generation: state.generation, state };
+  }
+
+  private track(read: Promise<void>): void {
+    this.reads.add(read);
+    void read.finally(() => {
+      this.reads.delete(read);
+      this.wake();
+    });
+  }
+
+  private startPoll(partition: number): void {
+    const target = this.claim(partition);
+    if (!target) return;
+    const state = this.states.get(partition)!;
+    const single = state.aloneUntil !== undefined;
+    const check = this.needsStart(target.sent);
+    const commits = this.options.commit.kind === 'onRead' && state.catchUpUntil === undefined && !single && !check;
+    if (commits && (target.sent as Partial<Continuation>).context)
+      state.pendingStart ??= target.sent as Continuation;
+    this.track(this.poll(target, commits, single, check));
+  }
+
+  private startFetch(group: number[]): void {
+    const targets = group.map((partition) => this.claim(partition)).filter((target) => target !== undefined);
+    if (this.options.commit.kind === 'onRead') {
+      for (const target of targets)
+        if ((target.sent as Partial<Continuation>).context)
+          this.states.get(target.partition)!.pendingStart ??= target.sent as Continuation;
+    }
+    if (targets.length > 0) this.track(this.fetch(targets));
+  }
+
+  private async fetch(targets: Target[]): Promise<void> {
+    let sections;
+    try {
+      sections = await fetchMessages(this.getClient)({
+        ...this.target(),
+        targets: targets.map(({ partition, sent }) => ({
+          partitionId: partition, pollingStrategy: { kind: sent.kind, value: sent.value } as PollingStrategy,
+          context: (sent as Partial<Continuation>).context
+        })),
+        count: this.options.count,
+        autocommit: this.options.commit.kind === 'onRead',
+        options: wireOptions(this.options, COMMAND_CODE.FetchMessages),
+        signal: this.lifetime.signal
+      });
+    } catch (error) {
+      await this.abandonLost(error);
+      for (const target of targets) this.refuse(target, error);
+      return;
+    }
+    for (const target of targets) {
+      const section = sections.find(({ partitionId }) => partitionId === target.partition);
+      if (!section) {
+        // A read that does not wait would ask the server again at once.
+        this.retry(target, this.options.maxWait === 0 ? this.retryInterval : 0);
+        continue;
+      }
+      if (section.status !== 0) {
+        const refusal = fetchSectionError(section);
+        await this.abandonLost(refusal);
+        this.refuse(target, refusal);
+        continue;
+      }
+      let polled: PollMessagesResponse;
+      try {
+        polled = decodeFetchSection(section);
+      } catch (error) {
+        this.readAlone(target, section.nextOffset - BigInt(section.messagesCount), section.nextOffset,
+          section.context, error);
+        continue;
+      }
+      this.complete(target, polled, section.nextOffset, endsBeforeHead(section));
+    }
+  }
+
+  private needsStart(position: PollingStrategy): boolean {
+    return this.options.commit.kind === 'onRead' && position.kind === PollingStrategyKind.Next &&
+      (position as Partial<Continuation>).context === undefined;
+  }
+
+  private async poll(target: Target, commits: boolean, single: boolean, check = false): Promise<void> {
+    const until = this.states.get(target.partition)?.catchUpUntil;
+    const catchUp = !commits && !single && !check && until !== undefined &&
+      target.sent.kind === PollingStrategyKind.Offset && target.sent.value <= until;
+    const options = { ...this.options, commit: commits ? CommitPolicy.OnRead : CommitPolicy.Manual,
+      ...(single || check ? { count: 1, minCount: 1 } : {}),
+      // The server stored these messages already, so the read takes them and no more.
+      ...(catchUp ? { count: Math.min(Number(until - target.sent.value + 1n), this.options.count), minCount: 1,
+        maxWait: 0 } : {}) };
+    let polled: PolledBatch;
+    try {
+      polled = await pollMessages(this.getClient)({
+        ...this.target(), partitionId: target.partition, strategy: target.sent, options,
+        signal: this.lifetime.signal
+      });
+    } catch (error) {
+      // A reply whose records end early fails to decode with a range error.
+      const decodeFailed = error instanceof RangeError ||
+        (error instanceof Error && error.name === 'DeserializeError');
+      if (decodeFailed && target.sent.kind === PollingStrategyKind.Offset) {
+        if (single) this.undecodable(target, error);
+        else this.readAlone(target, target.sent.value, target.sent.value + BigInt(this.options.count),
+          (target.sent as Partial<Continuation>).context, error);
+        return;
+      }
+      await this.abandonLost(error);
+      this.refuse(target, error);
+      return;
+    }
+    if (check) {
+      const state = this.settle(target);
+      if (state) {
+        state.atEnd = polled.messages.length === 0;
+        this.notify();
+        if (polled.messages.length > 0)
+          state.position = continuation(target.partition, PollingStrategyKind.Next,
+            polled.messages[0].headers.offset, polled.context);
+        else if (this.options.maxWait === 0) state.retryAt = Date.now() + this.retryInterval;
+      }
+      return;
+    }
+    this.complete(target, polled);
+  }
+
+  /** Abandons the operation of a lost committing read of this reader, so the next read finds what it stored. */
+  private async abandonLost(error: unknown): Promise<void> {
+    if (!(error instanceof FetchProgressUncertainError)) return;
+    try {
+      const client = await this.getClient();
+      if (error.operationId !== undefined) client.abandonOperation?.(error.operationId);
+    } catch {
+      // A later read reports what keeps the partition blocked.
+    }
+  }
+
+  /** Ends the read of a target. Undefined when the generation of its partition moved on. */
+  private settle(target: Target): PartitionState | undefined {
+    const state = this.states.get(target.partition);
+    // A partition assigned again has a new state, whose read this is not.
+    if (!state || state !== target.state) return undefined;
+    state.inFlight = false;
+    state.settleIdle?.();
+    return state.generation === target.generation ? state : undefined;
+  }
+
+  private complete(target: Target, polled: PollMessagesResponse, nextOffset?: bigint, backlogged?: boolean): void {
+    const state = this.settle(target);
+    const onRead = this.options.commit.kind === 'onRead';
+    if (!state) {
+      // The server stored this read, so a partition that left the assignment still hands it over.
+      if (onRead && polled.messages.length > 0 && this.keeps(target.partition, target.generation))
+        this.enqueueBatch(target, polledBatch(polled, target.sent, { reader: this.id, generation: target.generation }));
+      return;
+    }
+    if (backlogged !== undefined) state.backlogged = backlogged;
+    if (polled.messages.length > 0) state.pendingStart = undefined;
+    state.lasting = 0;
+    state.atEnd = polled.messages.length === 0;
+    if (polled.messages.length === 0) {
+      const context = (target.sent as Partial<Continuation>).context;
+      const next = nextOffset ?? polled.currentOffset + 1n;
+      if (onRead && state.lost && target.sent.kind === PollingStrategyKind.Next && context &&
+          sameHistory(context, polled.context) && next > target.sent.value) {
+        state.position = continuation(target.partition, PollingStrategyKind.Offset, target.sent.value, polled.context);
+        state.catchUpUntil = next - 1n;
+        state.atEnd = false;
+        return;
+      }
+      // A catch-up that finds nothing by offset cannot read the lost range again.
+      if (state.catchUpUntil !== undefined && target.sent.kind === PollingStrategyKind.Offset) {
+        this.giveUpGap(target.partition, state);
+        return;
+      }
+      this.notify();
+      if (target.sent.kind !== PollingStrategyKind.Next || !onRead)
+        state.position = polledBatch(polled, target.sent).continuation();
+      if (this.options.maxWait === 0) state.retryAt = Date.now() + this.retryInterval;
+      return;
+    }
+    const first = polled.messages[0].headers.offset;
+    const last = polled.messages.at(-1)!.headers.offset;
+    const sentContext = (target.sent as Partial<Continuation>).context;
+    if (onRead && state.lost && target.sent.kind === PollingStrategyKind.Next && sentContext &&
+        first > target.sent.value && sameHistory(sentContext, polled.context)) {
+      // A lost read stored these offsets without handing them over.
+      state.position = continuation(target.partition, PollingStrategyKind.Offset, target.sent.value, polled.context);
+      state.catchUpUntil = last;
+      state.catchUpBatch = polled;
+      return;
+    }
+    // This store fences out any older lost read, so a later jump is a store of the application or another session.
+    if (onRead && target.sent.kind === PollingStrategyKind.Next) state.lost = undefined;
+    const next = last + 1n;
+    const byOffset = (state.aloneUntil !== undefined && next < state.aloneUntil) ||
+      (state.catchUpUntil !== undefined && next <= state.catchUpUntil);
+    if (state.aloneUntil !== undefined && next >= state.aloneUntil) state.aloneUntil = undefined;
+    if (state.catchUpUntil !== undefined && next > state.catchUpUntil) {
+      state.catchUpUntil = undefined;
+      state.catchUpBatch = undefined;
+    }
+    state.position = continuation(target.partition,
+      onRead && !byOffset ? PollingStrategyKind.Next : PollingStrategyKind.Offset, next, polled.context);
+    state.servedAt = ++this.serves;
+    this.enqueueBatch(target, polledBatch(polled, target.sent, { reader: this.id, generation: target.generation }));
+  }
+
+  private enqueueBatch(target: Target, batch: PolledBatch): void {
+    this.enqueue({
+      partitionId: target.partition, generation: target.generation, batch,
+      messages: batch.messages.length,
+      bytes: batch.messages.reduce((sum, message) => sum + message.payload.length, 0)
+    });
+  }
+
+  /** A section or a poll that did not decode: its partition reads that range one message at a time. */
+  private readAlone(target: Target, from: bigint, until: bigint, context: PartitionContext | undefined,
+    error: unknown): void {
+    void error;
+    const state = this.settle(target);
+    if (!state) return;
+    state.position = context ? continuation(target.partition, PollingStrategyKind.Offset, from, context)
+      : PollingStrategy.Offset(from);
+    state.aloneUntil = until;
+  }
+
+  /** The message at the sent offset does not decode. The partition reads it again until a seek past it. */
+  private undecodable(target: Target, error: unknown): void {
+    const state = this.settle(target);
+    if (!state) return;
+    state.aloneUntil ??= target.sent.value + 1n;
+    state.retryAt = Date.now() + this.retryInterval;
+    const context = (target.sent as Partial<Continuation>).context;
+    const skip = continuation(target.partition, PollingStrategyKind.Offset, target.sent.value + 1n,
+      context ?? { incarnation: 0n, ownerGeneration: 0n, metadataOp: 0n });
+    this.enqueue({ partitionId: target.partition, generation: target.generation,
+      error: new ReadError(target.partition, error, skip), messages: 0, bytes: 0 });
+  }
+
+  /**
+   * A read that did not return its partition. A refusal before the read
+   * retries, and only a lasting or a history refusal stops the partition.
+   */
+  private refuse(target: Target, error: unknown): void {
+    const state = this.settle(target);
+    if (!state || this.closed) return;
+    state.backlogged = undefined;
+    const now = Date.now();
+    const code = errorCode(error);
+    const report = (stop: boolean) => {
+      const readError = new ReadError(target.partition, error);
+      if (stop) state.stopped = readError;
+      this.enqueue({ partitionId: target.partition, generation: target.generation, error: readError,
+        messages: 0, bytes: 0 });
+    };
+    if (isAbort(error) || code === NOT_PRIMARY) return;
+    if (code === FETCH_PROGRESS_UNCERTAIN || error instanceof OperationUncertainError) {
+      state.lost = true;
+      state.retryAt = now + this.retryInterval;
+      return;
+    }
+    if (code === CONSUMER_GROUP_PARTITION_NOT_OWNED) {
+      // The owner generation of the position is gone, so the next read starts again under a new route.
+      this.giveUpGap(target.partition, state);
+      Object.assign(state, { position: this.start, lost: undefined, pendingStart: undefined, aloneUntil: undefined });
+    }
+    if (code === HISTORY_UNAVAILABLE && (target.sent as Partial<Continuation>).context === undefined) {
+      state.retryAt = now + this.retryInterval;
+      return;
+    }
+    if (code === HISTORY_UNAVAILABLE || code === INVALID_SIZE_BYTES) {
+      report(true);
+      return;
+    }
+    if (code !== undefined && LASTING_REFUSALS.has(code)) {
+      state.lasting += 1;
+      if (state.lasting >= MAX_LASTING_REFUSALS) {
+        report(true);
+        return;
+      }
+      state.retryAt = now + this.retryInterval * 2 ** (state.lasting - 1);
+      return;
+    }
+    state.retryAt = now + this.retryInterval;
+    if (code !== undefined && TRANSIENT_REFUSALS.has(code)) return;
+    if (code === undefined) {
+      this.report(undefined, 0, error);
+      return;
+    }
+    report(false);
+  }
+
+  private giveUpGap(partitionId: number, state: PartitionState): void {
+    const held = state.catchUpBatch;
+    if (state.catchUpUntil === undefined) return;
+    const target = { partition: partitionId, generation: state.generation, sent: state.position };
+    const first = held?.messages[0].headers.offset ?? state.catchUpUntil + 1n;
+    if (state.position.value < first) {
+      const lost = responseError(COMMAND_CODE.PollMessagesDeferred, FETCH_PROGRESS_UNCERTAIN,
+        `a lost read stored offsets ${state.position.value} to ${first - 1n} of partition ${partitionId}, ` +
+        'which the reader could not read again');
+      this.enqueue({ partitionId, generation: state.generation,
+        error: new ReadError(partitionId, lost), messages: 0, bytes: 0 });
+    }
+    const messages = held?.messages.filter(({ headers }) => headers.offset >= state.position.value) ?? [];
+    const next = held ? held.messages.at(-1)!.headers.offset + 1n : state.catchUpUntil + 1n;
+    const context = held?.context ?? (state.position as Continuation).context;
+    state.catchUpUntil = undefined;
+    state.catchUpBatch = undefined;
+    state.position = continuation(partitionId, PollingStrategyKind.Next, next, context);
+    if (held && messages.length > 0)
+      this.enqueueBatch(target, polledBatch({ ...held, messages }, target.sent,
+        { reader: this.id, generation: target.generation }));
+  }
+
+  private retry(target: Target, after: number): void {
+    const state = this.settle(target);
+    if (state) state.retryAt = after === 0 ? 0 : Date.now() + after;
+  }
+
+  private report(partitionId: number | undefined, generation: number, error: unknown): void {
+    // One error of the whole reader at a time, so a lasting outage does not fill the queue.
+    if (partitionId === undefined && this.queue.some((entry) => entry.error && entry.partitionId === undefined)) return;
+    this.enqueue({ partitionId, generation, error: new ReadError(partitionId, error), messages: 0, bytes: 0 });
+  }
+
+  private enqueue(entry: Entry): void {
+    this.queue.push(entry);
+    this.queuedMessages += entry.messages;
+    this.queuedBytes += entry.bytes;
+    this.notify();
+  }
+
+  /** Wakes a caller that waits in `next`. */
+  private notify(): void {
+    const arrival = this.arrival;
+    this.arrival = resolvers();
+    arrival.resolve();
+  }
+
+  private release(entry: Entry): void {
+    this.queuedMessages -= entry.messages;
+    this.queuedBytes -= entry.bytes;
+  }
+
+  private isCurrent(entry: Entry): boolean {
+    return entry.partitionId === undefined || this.keeps(entry.partitionId, entry.generation);
+  }
+
+  /**
+   * Whether a read of `partition` under `generation` still counts. Only a seek
+   * drops stored reads under `CommitPolicy.OnRead`, including across reassignment.
+   */
+  private keeps(partition: number, generation: number): boolean {
+    const state = this.states.get(partition);
+    return state?.generation === generation || (this.options.commit.kind === 'onRead' &&
+      generation >= (this.sought.get(partition) ?? 0));
+  }
+}
+
+/** Opens a `PartitionReader` on the client. */
+export const openReader = (getClient: ClientProvider) =>
+  (request: PartitionReaderRequest): Promise<PartitionReader> => PartitionReader.open(getClient, request);
+
+type Resolvers = { promise: Promise<void>, resolve: () => void };
+
+const resolvers = (): Resolvers => {
+  let resolve!: () => void;
+  const promise = new Promise<void>((settle) => { resolve = settle; });
+  return { promise, resolve };
+};
+
+const delay = (milliseconds: number): Promise<void> =>
+  new Promise((resolve) => setTimeout(resolve, milliseconds).unref());
+
+const abortOf = (signal: AbortSignal): Promise<never> => new Promise((_, reject) => {
+  if (signal.aborted) reject(signal.reason);
+  else signal.addEventListener('abort', () => reject(signal.reason), { once: true });
+});
+
diff --git a/foreign/node/src/wire/command-set.test.ts b/foreign/node/src/wire/command-set.test.ts
index 786d4a3..2c327a7 100644
--- a/foreign/node/src/wire/command-set.test.ts
+++ b/foreign/node/src/wire/command-set.test.ts
@@ -56,6 +56,14 @@
     });
   });
 
+  it('rejects the cancellation of a deferred request before reaching the client provider', async () => {
+    const client = new SimpleClient(mockRawClient());
+    await assert.rejects(
+      () => client.sendBinaryRequest(COMMAND_CODE.CancelDeferredRequest, Buffer.alloc(0)),
+      /code: 3, message: Invalid command/
+    );
+  });
+
   it('forwards a custom code and opaque payload to sendCommand', async () => {
     const customCode = 60_000;
     const payload = Buffer.from([0xAA, 0xBB, 0xCC]);
diff --git a/foreign/node/src/wire/command-set.ts b/foreign/node/src/wire/command-set.ts
index e77b20a..826d2c0 100644
--- a/foreign/node/src/wire/command-set.ts
+++ b/foreign/node/src/wire/command-set.ts
@@ -48,6 +48,9 @@
 import { getOffset } from './offset/get-offset.command.js';
 import { storeOffset } from './offset/store-offset.command.js';
 import { deleteOffset } from './offset/delete-offset.command.js';
+import { storePosition, deletePosition } from './offset/position.command.js';
+import { openReader } from '../stream/partition-reader.js';
+import { openConsumer, openConsumerGroup } from '../stream/iggy-consumer.js';
 
 import { sendMessages } from './message/send-messages.command.js';
 import { pollMessages } from './message/poll-messages.command.js';
@@ -172,13 +175,16 @@
 const offsetAPI = (c: ClientProvider) => ({
   get: getOffset(c),
   store: storeOffset(c),
-  delete: deleteOffset(c)
+  delete: deleteOffset(c),
+  storePosition: storePosition(c),
+  deletePosition: deletePosition(c)
 });
 
 type OffsetAPI = ReturnType<typeof offsetAPI>;
 
 const messageAPI = (c: ClientProvider) => ({
   poll: pollMessages(c),
+  reader: openReader(c),
   send: sendMessages(c)
 });
 
@@ -231,6 +237,10 @@
   group: GroupAPI = groupAPI(this.clientProvider);
   offset: OffsetAPI = offsetAPI(this.clientProvider);
   message: MessageAPI = messageAPI(this.clientProvider);
+  /** A standalone consumer of some partitions, one message at a time, on this client. */
+  consumer = openConsumer(this.clientProvider);
+  /** A member of a consumer group, created when it is missing, one message at a time, on this client. */
+  consumerGroup = openConsumerGroup(this.clientProvider);
   system: SystemAPI = systemAPI(this.clientProvider);
   cluster: ClusterAPI = clusterAPI(this.clientProvider);
 
@@ -254,7 +264,9 @@
 
   /**
    * Sends a command code with a payload and returns the raw response payload.
-   * Session-control codes are rejected with an invalid-command error.
+   * Session-control codes and the cancellation of a deferred request (108),
+   * which only the client sends for its own requests, are rejected with an
+   * invalid-command error.
    *
    * @param code - Command code to send
    * @param payload - Raw command payload
@@ -271,8 +283,8 @@
    * Sends a command code with a payload to the replica with the given id, as
    * the cluster metadata names it, and returns the raw response payload. Every
    * node answers non-replicated commands such as GetStats from its own state.
-   * Replicated commands, polls, consumer-offset reads and session-control codes
-   * are rejected with an invalid-command error, and a replica id the cluster
+   * Replicated commands, polls, fetches, consumer-offset reads
+   * and session-control codes are rejected with an invalid-command error, and a replica id the cluster
    * metadata does not name with an invalid-identifier error. A `NotPrimary`
    * refusal is returned and not retried on another replica.
    *
@@ -294,7 +306,8 @@
     payload: Buffer,
     options?: SendCommandOptions
   ): Promise<Buffer> {
-    if (SESSION_CONTROL_CODES.has(code))
+    // Only a link cancels a deferred request, the one that it sent.
+    if (SESSION_CONTROL_CODES.has(code) || code === COMMAND_CODE.CancelDeferredRequest)
       throw responseError(code, INVALID_COMMAND_ERROR_CODE);
 
     const snapshot = Buffer.from(payload);
diff --git a/foreign/node/src/wire/command.code.ts b/foreign/node/src/wire/command.code.ts
index b0468c2..835abb4 100644
--- a/foreign/node/src/wire/command.code.ts
+++ b/foreign/node/src/wire/command.code.ts
@@ -49,8 +49,8 @@
   // 103 and 104 are retired and must never be reused.
   GetPartitionRoute: 105,
   PollMessagesDeferred: 106,
-  WaitForMessages: 107,
   CancelDeferredRequest: 108,
+  FetchMessages: 109,
   GetOffset: 120,
   StoreOffset: 121,
   DeleteConsumerOffset: 122,
diff --git a/foreign/node/src/wire/error.code.test.ts b/foreign/node/src/wire/error.code.test.ts
index a2646cb..a7f85d5 100644
--- a/foreign/node/src/wire/error.code.test.ts
+++ b/foreign/node/src/wire/error.code.test.ts
@@ -42,6 +42,10 @@
   assert.equal(translateErrorCode(88), 'A committed lifecycle operation prevents this mutation. Retry as a new request.');
 });
 
+it('translates the deferred poll capacity refusal', () => {
+  assert.equal(translateErrorCode(90), 'Deferred poll capacity exceeded; retry after backoff on the same replica');
+});
+
 it('translates the consumer-offset capacity error', () => {
   assert.equal(translateErrorCode(3024), 'Consumer offset limit reached for partition, raise [partition] consumer_offsets_max');
 });
@@ -94,3 +98,9 @@
   assert.equal(translateErrorCode(4107), 'Unsupported critical option: {0}');
   assert.equal(translateErrorCode(4108), 'Cannot modify root user');
 });
+
+it('translates the local lane and read recovery codes', () => {
+  assert.equal(translateErrorCode(14007), 'Lane blocked by operation {0} with an unknown outcome');
+  assert.equal(translateErrorCode(14009), 'Lane of partition {0} blocked by operation {1} with an unknown outcome');
+  assert.equal(translateErrorCode(14010), 'Read {0} has unknown progress and requires explicit abandonment');
+});
diff --git a/foreign/node/src/wire/error.code.ts b/foreign/node/src/wire/error.code.ts
index 1740d82..f3a0ca2 100644
--- a/foreign/node/src/wire/error.code.ts
+++ b/foreign/node/src/wire/error.code.ts
@@ -90,6 +90,7 @@
     case '87': return "The partition history is no longer available; this request cannot execute again";
     case '88': return "A committed lifecycle operation prevents this mutation. Retry as a new request.";
     case '89': return "The replica is not the partition primary.";
+    case '90': return "Deferred poll capacity exceeded; retry after backoff on the same replica";
 
     case '100': return "Client with ID: {0} was not found.";
     case '101': return "Invalid client ID";
@@ -285,6 +286,9 @@
     case '14003': return "Incompatible binary protocol version: client {}, server accepts [{}, {}]";
     case '14004': return "VSR session mismatch: requested {0}, server bound {1}";
     case '14005': return "VSR request id exhausted";
+    case '14007': return "Lane blocked by operation {0} with an unknown outcome";
+    case '14009': return "Lane of partition {0} blocked by operation {1} with an unknown outcome";
+    case '14010': return "Read {0} has unknown progress and requires explicit abandonment";
 
     default: return 'error';
   }
diff --git a/foreign/node/src/wire/error.utils.ts b/foreign/node/src/wire/error.utils.ts
index e80d897..8014c6a 100644
--- a/foreign/node/src/wire/error.utils.ts
+++ b/foreign/node/src/wire/error.utils.ts
@@ -40,10 +40,26 @@
 export const responseError = (cmdCode: number, errCode: number, detail?: string) =>
   new ResponseError(cmdCode, errCode, detail);
 
+export type DeserializeErrorOptions = {
+  cause?: unknown,
+  /** The offset of a record that does not decode, when its frame decoded and a later step failed. */
+  offset?: bigint,
+  /**
+   * The received bytes that do not decode: the frame of the record whose
+   * offset is known, otherwise the remaining bytes from where decoding stopped.
+   */
+  record?: Buffer
+};
+
 export class DeserializeError extends Error {
-  constructor(message: string, cause?: Record<string , unknown>) {
-    super(message, cause);
+  readonly offset?: bigint;
+  readonly record?: Buffer;
+
+  constructor(message: string, options?: DeserializeErrorOptions) {
+    super(message, options?.cause === undefined ? undefined : { cause: options.cause });
     this.name = "DeserializeError";
+    this.offset = options?.offset;
+    this.record = options?.record;
     Object.setPrototypeOf(this, DeserializeError.prototype);
   }
 }
diff --git a/foreign/node/src/wire/fixtures/deferred_polling.json b/foreign/node/src/wire/fixtures/deferred_polling.json
new file mode 100644
index 0000000..452b461
--- /dev/null
+++ b/foreign/node/src/wire/fixtures/deferred_polling.json
@@ -0,0 +1,610 @@
+{
+  "schema_version": 1,
+  "generated_by": "core/common/examples/deferred_polling_fixtures.rs",
+  "conventions": {
+    "body_hex": "Every *_hex field is lowercase hex without separators. body_hex is the command payload without framing.",
+    "header_hex": "header_hex is the whole request header of a cancellation, which has no body, before the transport stamps its checksum and checksum_body.",
+    "oversized": "A reply may carry a first record above max_bytes, up to max_record_size, so a client reply cap is at least max_record_reply_size.",
+    "refused": "Every refused fetch fails to decode or to validate. Every refused fetch reply fails to decode.",
+    "status": "A section status of status_ok was read. Any other status is the error code of that partition alone, and its section holds no records.",
+    "strategy": "strategy.kind is the wire kind: 1 offset, 2 timestamp, 3 first, 4 last, 5 next."
+  },
+  "constants": {
+    "codes": {
+      "cancel_deferred_request": 108,
+      "fetch_messages": 109,
+      "poll_messages": 100,
+      "poll_messages_deferred": 106
+    },
+    "status_ok": 0,
+    "errors": {
+      "deferred_poll_capacity_exceeded": 90,
+      "fetch_progress_uncertain": 14010,
+      "history_unavailable": 87,
+      "invalid_size_bytes": 80,
+      "not_primary": 89,
+      "partition_lane_blocked": 14009,
+      "too_many_consumer_offsets": 3024,
+      "transient_not_accepted": 58,
+      "transient_not_committed": 57
+    },
+    "min_deferred_poll_max_bytes": 344,
+    "max_deferred_poll_wait_us": 600000000,
+    "max_deferred_poll_timeout_us": 630000000,
+    "min_deferred_poll_reply_margin_us": 1000000,
+    "fetch_reply_margin_us": 200000,
+    "max_partitions_per_request": 1000,
+    "max_record_size": 64100304,
+    "max_record_reply_size": 64156564,
+    "request_header_size": 256,
+    "fetch_section_header_size": 56
+  },
+  "deferred_polls": [
+    {
+      "case": "consumer_offset_without_commit",
+      "request": {
+        "consumer": {
+          "kind": 1,
+          "id": {
+            "kind": "numeric",
+            "value": 1
+          }
+        },
+        "stream_id": {
+          "kind": "numeric",
+          "value": 1
+        },
+        "topic_id": {
+          "kind": "named",
+          "value": "orders"
+        },
+        "partition_id": 2,
+        "strategy": {
+          "kind": 1,
+          "value": 41
+        },
+        "count": 100,
+        "auto_commit": false,
+        "wait_us": 5000000,
+        "min_count": 1,
+        "max_bytes": 1048576,
+        "request_timeout_us": 6000000
+      },
+      "body_hex": "0101040100000001040100000002066f726465727301020000000129000000000000006400000000404b4c00000000000100000000001000808d5b0000000000"
+    },
+    {
+      "case": "group_next_with_commit",
+      "request": {
+        "consumer": {
+          "kind": 2,
+          "id": {
+            "kind": "named",
+            "value": "orders-group"
+          }
+        },
+        "stream_id": {
+          "kind": "numeric",
+          "value": 1
+        },
+        "topic_id": {
+          "kind": "named",
+          "value": "orders"
+        },
+        "partition_id": 2,
+        "strategy": {
+          "kind": 5,
+          "value": 0
+        },
+        "count": 100,
+        "auto_commit": true,
+        "wait_us": 5000000,
+        "min_count": 1,
+        "max_bytes": 1048576,
+        "request_timeout_us": 6000000
+      },
+      "body_hex": "02020c6f72646572732d67726f757001040100000002066f726465727301020000000500000000000000006400000001404b4c00000000000100000000001000808d5b0000000000"
+    }
+  ],
+  "fetches": [
+    {
+      "case": "consumer_two_targets_without_commit",
+      "request": {
+        "consumer": {
+          "kind": 1,
+          "id": {
+            "kind": "numeric",
+            "value": 1
+          }
+        },
+        "stream_id": {
+          "kind": "numeric",
+          "value": 1
+        },
+        "topic_id": {
+          "kind": "numeric",
+          "value": 2
+        },
+        "wait_us": 5000000,
+        "request_timeout_us": 6000000,
+        "min_count": 1,
+        "count": 1000,
+        "max_bytes": 1048576,
+        "auto_commit": false,
+        "targets": [
+          {
+            "partition_id": 1,
+            "strategy": {
+              "kind": 1,
+              "value": 41
+            },
+            "max_bytes": 524288,
+            "incarnation": 11,
+            "owner_generation": 0
+          },
+          {
+            "partition_id": 3,
+            "strategy": {
+              "kind": 5,
+              "value": 0
+            },
+            "max_bytes": 524288,
+            "incarnation": 11,
+            "owner_generation": 0
+          }
+        ]
+      },
+      "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b000000000001000000e803000000001000000200000001000000012900000000000000000008000b00000000000000000000000000000003000000050000000000000000000008000b000000000000000000000000000000"
+    },
+    {
+      "case": "group_three_targets_with_commit",
+      "request": {
+        "consumer": {
+          "kind": 2,
+          "id": {
+            "kind": "numeric",
+            "value": 9
+          }
+        },
+        "stream_id": {
+          "kind": "numeric",
+          "value": 1
+        },
+        "topic_id": {
+          "kind": "numeric",
+          "value": 2
+        },
+        "wait_us": 5000000,
+        "request_timeout_us": 6000000,
+        "min_count": 1,
+        "count": 1000,
+        "max_bytes": 1048576,
+        "auto_commit": true,
+        "targets": [
+          {
+            "partition_id": 1,
+            "strategy": {
+              "kind": 5,
+              "value": 0
+            },
+            "max_bytes": 524288,
+            "incarnation": 11,
+            "owner_generation": 4
+          },
+          {
+            "partition_id": 2,
+            "strategy": {
+              "kind": 5,
+              "value": 0
+            },
+            "max_bytes": 524288,
+            "incarnation": 11,
+            "owner_generation": 4
+          },
+          {
+            "partition_id": 4,
+            "strategy": {
+              "kind": 1,
+              "value": 7
+            },
+            "max_bytes": 524288,
+            "incarnation": 11,
+            "owner_generation": 4
+          }
+        ]
+      },
+      "body_hex": "02010409000000010401000000010402000000404b4c0000000000808d5b000000000001000000e803000000001000010300000001000000050000000000000000000008000b00000000000000040000000000000002000000050000000000000000000008000b00000000000000040000000000000004000000010700000000000000000008000b000000000000000400000000000000"
+    }
+  ],
+  "cancellations": [
+    {
+      "case": "cancel_deferred_poll",
+      "cancels_code": 106,
+      "client": 7,
+      "request": 21,
+      "session": 3,
+      "header_hex": "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000050000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007000000000000000000000000000000000000000000000000000000000000000000000000000000150000000000000002000000000000000300000000000000000000006c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
+    },
+    {
+      "case": "cancel_fetch",
+      "cancels_code": 109,
+      "client": 7,
+      "request": 22,
+      "session": 3,
+      "header_hex": "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000050000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007000000000000000000000000000000000000000000000000000000000000000000000000000000160000000000000002000000000000000300000000000000000000006c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
+    }
+  ],
+  "fetch_replies": [
+    {
+      "case": "ok_section_and_empty_start_section",
+      "sections": [
+        {
+          "partition_id": 1,
+          "status": 0,
+          "current_offset": 44,
+          "messages_count": 3,
+          "context": {
+            "incarnation": 11,
+            "owner_generation": 4,
+            "metadata_op": 42
+          },
+          "next_offset": 44,
+          "records_len": 416,
+          "messages": [
+            {
+              "offset": 41,
+              "id": "258",
+              "timestamp": 1700000000000500,
+              "origin_timestamp": 1700000000000000,
+              "payload": "first"
+            },
+            {
+              "offset": 42,
+              "id": "259",
+              "timestamp": 1700000000000500,
+              "origin_timestamp": 1700000000000000,
+              "payload": "second"
+            },
+            {
+              "offset": 43,
+              "id": "260",
+              "timestamp": 1700000000000500,
+              "origin_timestamp": 1700000000000000,
+              "payload": "third"
+            }
+          ]
+        },
+        {
+          "partition_id": 3,
+          "status": 0,
+          "current_offset": 9,
+          "messages_count": 0,
+          "context": {
+            "incarnation": 11,
+            "owner_generation": 4,
+            "metadata_op": 42
+          },
+          "next_offset": 10,
+          "records_len": 0,
+          "messages": []
+        }
+      ],
+      "body_hex": "0200000001000000000000002c00000000000000030000000b0000000000000004000000000000002a000000000000002c00000000000000a001000003000000000000000900000000000000000000000b0000000000000004000000000000002a000000000000000a000000000000000000000001000000000000002900000000000000f4411e18240a060000401e18240a0600a00100000000000088fc3268f2c0e02103000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000bb5b1cd43b71761a02010000000000000000000000000000000000000000000000000000050000000000000000000000666972737453c25c376f98c265030100000000000000000000000000000100000000000000000000000600000000000000000000007365636f6e64a761c18f632fb022040100000000000000000000000000000200000000000000000000000500000000000000000000007468697264"
+    },
+    {
+      "case": "ok_section_beside_error_sections",
+      "sections": [
+        {
+          "partition_id": 1,
+          "status": 89,
+          "current_offset": 0,
+          "messages_count": 0,
+          "context": {
+            "incarnation": 0,
+            "owner_generation": 0,
+            "metadata_op": 0
+          },
+          "next_offset": 0,
+          "records_len": 0,
+          "messages": []
+        },
+        {
+          "partition_id": 2,
+          "status": 0,
+          "current_offset": 7,
+          "messages_count": 1,
+          "context": {
+            "incarnation": 11,
+            "owner_generation": 4,
+            "metadata_op": 42
+          },
+          "next_offset": 7,
+          "records_len": 308,
+          "messages": [
+            {
+              "offset": 6,
+              "id": "340282366920938463463374607431768211455",
+              "timestamp": 1700000000000500,
+              "origin_timestamp": 1700000000000000,
+              "payload": "only"
+            }
+          ]
+        },
+        {
+          "partition_id": 4,
+          "status": 58,
+          "current_offset": 0,
+          "messages_count": 0,
+          "context": {
+            "incarnation": 0,
+            "owner_generation": 0,
+            "metadata_op": 0
+          },
+          "next_offset": 0,
+          "records_len": 0,
+          "messages": []
+        }
+      ],
+      "body_hex": "03000000010000005900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000700000000000000010000000b0000000000000004000000000000002a00000000000000070000000000000034010000040000003a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000600000000000000f4411e18240a060000401e18240a060034010000000000003e89bfefc17b805301000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000fbb4aeb35a85bdaeffffffffffffffffffffffffffffffff0000000000000000000000000400000000000000000000006f6e6c79"
+    },
+    {
+      "case": "no_sections",
+      "sections": [],
+      "body_hex": "00000000"
+    }
+  ],
+  "refused": {
+    "fetches": [
+      {
+        "case": "no_targets",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b000000000001000000e8030000000010000000000000"
+      },
+      {
+        "case": "duplicate_partition",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b000000000001000000e803000000001000000200000001000000010000000000000000000008000b00000000000000000000000000000001000000050000000000000000000008000b000000000000000000000000000000"
+      },
+      {
+        "case": "first_start_point",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b000000000001000000e803000000001000000100000001000000030000000000000000000008000b000000000000000000000000000000"
+      },
+      {
+        "case": "min_count_above_count",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b0000000000e9030000e803000000001000000100000001000000010000000000000000000008000b000000000000000000000000000000"
+      },
+      {
+        "case": "target_bytes_above_request_bytes",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b000000000001000000e803000000001000000100000001000000010000000000000000010010000b000000000000000000000000000000"
+      },
+      {
+        "case": "timeout_without_reply_margin",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000404b4c000000000001000000e803000000001000000100000001000000010000000000000000000008000b000000000000000000000000000000"
+      }
+    ],
+    "fetch_replies": [
+      {
+        "case": "error_section_with_records",
+        "body_hex": "01000000010000005900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000"
+      },
+      {
+        "case": "truncated_table",
+        "body_hex": "02000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
+      }
+    ]
+  },
+  "commit_outcomes": {
+    "rule": "A committing deferred poll (106) or fetch (109) that fails with a certain status stored nothing. Every other status, a status not listed here, a lost reply and a transport failure after the send leave the outcome unknown.",
+    "request": [
+      {
+        "status": 5,
+        "error": "feature_unavailable",
+        "certain": true
+      },
+      {
+        "status": 6,
+        "error": "invalid_identifier",
+        "certain": true
+      },
+      {
+        "status": 30,
+        "error": "stale_client",
+        "certain": false
+      },
+      {
+        "status": 40,
+        "error": "unauthenticated",
+        "certain": false
+      },
+      {
+        "status": 41,
+        "error": "unauthorized",
+        "certain": false
+      },
+      {
+        "status": 57,
+        "error": "transient_not_committed",
+        "certain": false
+      },
+      {
+        "status": 58,
+        "error": "transient_not_accepted",
+        "certain": true
+      },
+      {
+        "status": 72,
+        "error": "cannot_decrypt_data",
+        "certain": false
+      },
+      {
+        "status": 80,
+        "error": "invalid_size_bytes",
+        "certain": true
+      },
+      {
+        "status": 87,
+        "error": "history_unavailable",
+        "certain": true
+      },
+      {
+        "status": 89,
+        "error": "not_primary",
+        "certain": true
+      },
+      {
+        "status": 90,
+        "error": "deferred_poll_capacity_exceeded",
+        "certain": true
+      },
+      {
+        "status": 1009,
+        "error": "stream_id_not_found",
+        "certain": true
+      },
+      {
+        "status": 1010,
+        "error": "stream_name_not_found",
+        "certain": true
+      },
+      {
+        "status": 2010,
+        "error": "topic_id_not_found",
+        "certain": true
+      },
+      {
+        "status": 2011,
+        "error": "topic_name_not_found",
+        "certain": true
+      },
+      {
+        "status": 3007,
+        "error": "partition_not_found",
+        "certain": true
+      },
+      {
+        "status": 3024,
+        "error": "too_many_consumer_offsets",
+        "certain": true
+      },
+      {
+        "status": 4100,
+        "error": "invalid_offset",
+        "certain": true
+      },
+      {
+        "status": 5009,
+        "error": "consumer_group_partition_not_owned",
+        "certain": true
+      },
+      {
+        "status": 11001,
+        "error": "shard_communication_error",
+        "certain": false
+      }
+    ],
+    "section": [
+      {
+        "status": 5,
+        "error": "feature_unavailable",
+        "certain": false
+      },
+      {
+        "status": 6,
+        "error": "invalid_identifier",
+        "certain": false
+      },
+      {
+        "status": 30,
+        "error": "stale_client",
+        "certain": false
+      },
+      {
+        "status": 40,
+        "error": "unauthenticated",
+        "certain": false
+      },
+      {
+        "status": 41,
+        "error": "unauthorized",
+        "certain": false
+      },
+      {
+        "status": 57,
+        "error": "transient_not_committed",
+        "certain": false
+      },
+      {
+        "status": 58,
+        "error": "transient_not_accepted",
+        "certain": true
+      },
+      {
+        "status": 72,
+        "error": "cannot_decrypt_data",
+        "certain": false
+      },
+      {
+        "status": 80,
+        "error": "invalid_size_bytes",
+        "certain": true
+      },
+      {
+        "status": 87,
+        "error": "history_unavailable",
+        "certain": true
+      },
+      {
+        "status": 89,
+        "error": "not_primary",
+        "certain": true
+      },
+      {
+        "status": 90,
+        "error": "deferred_poll_capacity_exceeded",
+        "certain": false
+      },
+      {
+        "status": 1009,
+        "error": "stream_id_not_found",
+        "certain": false
+      },
+      {
+        "status": 1010,
+        "error": "stream_name_not_found",
+        "certain": false
+      },
+      {
+        "status": 2010,
+        "error": "topic_id_not_found",
+        "certain": false
+      },
+      {
+        "status": 2011,
+        "error": "topic_name_not_found",
+        "certain": false
+      },
+      {
+        "status": 3007,
+        "error": "partition_not_found",
+        "certain": true
+      },
+      {
+        "status": 3024,
+        "error": "too_many_consumer_offsets",
+        "certain": true
+      },
+      {
+        "status": 4100,
+        "error": "invalid_offset",
+        "certain": false
+      },
+      {
+        "status": 5009,
+        "error": "consumer_group_partition_not_owned",
+        "certain": true
+      },
+      {
+        "status": 11001,
+        "error": "shard_communication_error",
+        "certain": false
+      }
+    ]
+  }
+}
diff --git a/foreign/node/src/wire/fixtures/resource_options.json b/foreign/node/src/wire/fixtures/resource_options.json
index c5cc2ac..202888f 100644
--- a/foreign/node/src/wire/fixtures/resource_options.json
+++ b/foreign/node/src/wire/fixtures/resource_options.json
@@ -16,18 +16,18 @@
   },
   "constants": {
     "protocol_version": {
-      "packed": 12288,
-      "text": "0.12.0",
+      "packed": 12289,
+      "text": "0.12.1",
       "major": 0,
       "minor": 12,
-      "patch": 0
+      "patch": 1
     },
     "protocol_version_min": {
-      "packed": 12288,
-      "text": "0.12.0",
+      "packed": 12289,
+      "text": "0.12.1",
       "major": 0,
       "minor": 12,
-      "patch": 0
+      "patch": 1
     },
     "command_codes": {
       "create_stream": {
diff --git a/foreign/node/src/wire/message/checkpoint.ts b/foreign/node/src/wire/message/checkpoint.ts
new file mode 100644
index 0000000..965f45f
--- /dev/null
+++ b/foreign/node/src/wire/message/checkpoint.ts
@@ -0,0 +1,109 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import { responseError } from '../error.utils.js';
+import { EMPTY_PARTITION_CONTEXT, type PartitionContext } from '../vsr/header.js';
+import {
+  PollingStrategyKind, type PollingStrategy, type PollMessagesResponse, type PollingStrategyKindValue
+} from './poll.utils.js';
+
+const INVALID_COMMAND = 3;
+
+/** The reader and the partition generation that delivered a message. */
+export type ReaderStamp = { readonly reader: number, readonly generation: number };
+
+/**
+ * The position after one delivered message. A commit stores its offset, so it
+ * covers all earlier messages of the partition. A checkpoint keeps the
+ * partition incarnation and owner that delivered the message, so a commit after
+ * a change of the partition history fails. Only a read creates one.
+ */
+export class Checkpoint {
+  /** @internal */
+  constructor(
+    readonly partitionId: number,
+    readonly offset: bigint,
+    /** @internal The partition history that delivered the message. */
+    readonly context: PartitionContext,
+    /** @internal The reader and generation that delivered it, none for a one-shot poll. */
+    readonly stamp?: ReaderStamp
+  ) {}
+}
+
+/**
+ * A polling strategy bound to the partition and history of the batch it
+ * continues. A read of another partition refuses it.
+ */
+export type Continuation = PollingStrategy & {
+  readonly partitionId: number,
+  readonly context: PartitionContext
+};
+
+/** A continuation of `partitionId` under `context`. */
+export const continuation = (
+  partitionId: number, kind: PollingStrategyKindValue, value: bigint, context: PartitionContext
+): Continuation => Object.freeze({ kind, value, partitionId, context }) as Continuation;
+
+/** Refuses a continuation of another partition than `partitionId` with 3 (InvalidCommand). */
+export const validatePartition = (strategy: PollingStrategy, partitionId: number, command: number): void => {
+  const bound = (strategy as Partial<Continuation>).partitionId;
+  if (bound !== undefined && bound !== partitionId)
+    throw responseError(command, INVALID_COMMAND,
+      `a continuation of partition ${bound} cannot read partition ${partitionId}`);
+};
+
+/** A batch of one partition with its checkpoints and its continuation. */
+export type PolledBatch = PollMessagesResponse & {
+  /** The position after the last message, to commit the whole batch. Undefined for an empty batch. */
+  checkpoint(): Checkpoint | undefined,
+  /** The position after `messages[index]`, to commit a processed part of the batch. */
+  checkpointAt(index: number): Checkpoint | undefined,
+  /**
+   * The position after the batch, bound to its partition and history. After
+   * the last message it is the next offset. An empty batch keeps the start of
+   * its read: First becomes offset 0 and Next stays Next.
+   */
+  continuation(): Continuation
+};
+
+const sameContext = (left: PartitionContext, right: PartitionContext): boolean =>
+  left.incarnation === right.incarnation && left.ownerGeneration === right.ownerGeneration &&
+  left.metadataOp === right.metadataOp;
+
+/** `polled` with the checkpoints and the continuation of a read that started at `start`. */
+export const polledBatch = (polled: PollMessagesResponse, start: PollingStrategy, stamp?: ReaderStamp): PolledBatch => ({
+  ...polled,
+  checkpointAt(index: number) {
+    const message = polled.messages[index];
+    return message === undefined ? undefined
+      : new Checkpoint(polled.partitionId, message.headers.offset, polled.context, stamp);
+  },
+  checkpoint() {
+    return this.checkpointAt(polled.messages.length - 1);
+  },
+  continuation() {
+    const last = polled.messages.at(-1);
+    if (last !== undefined)
+      return continuation(polled.partitionId, PollingStrategyKind.Offset, last.headers.offset + 1n, polled.context);
+    // A reply without a history names no partition, so it continues as its read started.
+    if (sameContext(polled.context, EMPTY_PARTITION_CONTEXT))
+      return start as Continuation;
+    return start.kind === PollingStrategyKind.First
+      ? continuation(polled.partitionId, PollingStrategyKind.Offset, 0n, polled.context)
+      : continuation(polled.partitionId, start.kind, start.value, polled.context);
+  }
+});
diff --git a/foreign/node/src/wire/message/commit-outcome.test.ts b/foreign/node/src/wire/message/commit-outcome.test.ts
new file mode 100644
index 0000000..68dcc7f
--- /dev/null
+++ b/foreign/node/src/wire/message/commit-outcome.test.ts
@@ -0,0 +1,37 @@
+// 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.
+
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { describe, it } from 'node:test';
+import { CommitErrorScope, provesNothingStored } from './commit-outcome.js';
+
+type Outcome = { status: number, error: string, certain: boolean };
+
+const fixture = JSON.parse(readFileSync(new URL('../fixtures/deferred_polling.json', import.meta.url), 'utf8'));
+
+describe('commit outcomes', () => {
+  for (const scope of [CommitErrorScope.Request, CommitErrorScope.Section]) {
+    it(`classifies every ${scope} status of the shared fixture as the Rust SDK does`, () => {
+      const outcomes: Outcome[] = fixture.commit_outcomes[scope];
+      assert.ok(outcomes.some(({ certain }) => certain) && outcomes.some(({ certain }) => !certain));
+      const mismatched = outcomes.filter(({ status, certain }) => provesNothingStored(scope, status) !== certain)
+        .map(({ status, error }) => `${status} ${error}`);
+      assert.deepEqual(mismatched, []);
+    });
+  }
+});
diff --git a/foreign/node/src/wire/message/commit-outcome.ts b/foreign/node/src/wire/message/commit-outcome.ts
new file mode 100644
index 0000000..edc5682
--- /dev/null
+++ b/foreign/node/src/wire/message/commit-outcome.ts
@@ -0,0 +1,39 @@
+// 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.
+
+/** Where a committing deferred poll (106) or fetch (109) reported an error. */
+export const CommitErrorScope = {
+  /** The whole reply is the error. */
+  Request: 'request',
+  /** One section of a fetch reply carries the error status. */
+  Section: 'section'
+} as const;
+
+export type CommitErrorScope = typeof CommitErrorScope[keyof typeof CommitErrorScope];
+
+const REQUEST_PROVES_NOTHING_STORED = new Set([5, 6, 58, 80, 87, 89, 90, 1009, 1010, 2010, 2011, 3007, 3024, 4100,
+  5009]);
+// A section whose read may have stored never carries one of these.
+const SECTION_PROVES_NOTHING_STORED = new Set([58, 80, 87, 89, 3007, 3024, 5009]);
+
+/**
+ * Whether `status`, reported in `scope`, proves that the server stored no
+ * offset. Any other status, a lost reply and a transport failure after the
+ * send leave the outcome unknown.
+ */
+export const provesNothingStored = (scope: CommitErrorScope, status: number): boolean =>
+  (scope === CommitErrorScope.Request ? REQUEST_PROVES_NOTHING_STORED : SECTION_PROVES_NOTHING_STORED).has(status);
diff --git a/foreign/node/src/wire/message/deferred-poll.utils.ts b/foreign/node/src/wire/message/deferred-poll.utils.ts
new file mode 100644
index 0000000..db533d2
--- /dev/null
+++ b/foreign/node/src/wire/message/deferred-poll.utils.ts
@@ -0,0 +1,136 @@
+// 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.
+
+import { COMMAND_CODE } from '../command.code.js';
+import { responseError } from '../error.utils.js';
+import { PARTITION_CONTEXT_SIZE } from '../vsr/header.js';
+import { BATCH_HEADER_SIZE, FRAME_HEADER_SIZE } from './iggy-header.utils.js';
+
+const INVALID_COMMAND = 3;
+const INVALID_SIZE_BYTES = 80;
+const INVALID_MESSAGES_COUNT = 4009;
+const MAX_U32 = 0xFFFF_FFFF;
+/** Partition id, current offset and message count, which lead a poll reply. */
+const POLL_REPLY_FIELDS_SIZE = 4 + 8 + 4;
+
+export const US_PER_MS = 1_000n;
+export const MAX_DEFERRED_POLL_WAIT_US = 600_000_000n;
+/** The longest wait, and room for the final read and the reply. */
+export const MAX_DEFERRED_POLL_TIMEOUT_US = 630_000_000n;
+/** The time that a request which waits leaves between its wait and its timeout, for the final read and the reply. */
+export const MIN_DEFERRED_POLL_REPLY_MARGIN_US = 1_000_000n;
+/** The reply header, one batch header and one message header: a smaller byte limit can return no message. */
+export const MIN_DEFERRED_POLL_MAX_BYTES =
+  POLL_REPLY_FIELDS_SIZE + PARTITION_CONTEXT_SIZE + BATCH_HEADER_SIZE + FRAME_HEADER_SIZE;
+/** The most partitions that one fetch names. */
+export const MAX_PARTITIONS_PER_REQUEST = 1_000;
+
+/** The options of a deferred poll (106), which follow the body of a plain poll (100). */
+export const DEFERRED_POLL_OFFSET = {
+  wait: 0,
+  minCount: 8,
+  maxBytes: 12,
+  requestTimeout: 16
+} as const;
+export const DEFERRED_POLL_OPTIONS_SIZE = 8 + 4 + 4 + 8;
+
+/**
+ * How a deferred poll waits on the server for messages. Durations are in
+ * milliseconds.
+ */
+export type DeferredPollOptions = {
+  /** How long the server waits for `minCount` messages before it replies with what it has. At most 600 s. */
+  maxWait: number,
+  /** The number of messages that ends the wait early, from 1 to the count of the poll. */
+  minCount: number,
+  /** The largest reply, with its headers and batch framing. At least 344 bytes. */
+  maxBytes: number,
+  /**
+   * When the whole call gives up, at most 630 s. It must leave 1 s after a
+   * wait for the final read and the reply.
+   */
+  requestTimeout: number
+};
+
+/** The defaults of the Rust SDK: wait 1 s for one message, read at most 1 MiB, give up after 11 s. */
+export const DEFAULT_DEFERRED_POLL_OPTIONS: Readonly<DeferredPollOptions> = Object.freeze({
+  maxWait: 1_000,
+  minCount: 1,
+  maxBytes: 1_048_576,
+  requestTimeout: 11_000
+});
+
+/** The shared wait and request timeout options. */
+export type ReadinessWaitOptions = Pick<DeferredPollOptions, 'maxWait' | 'requestTimeout'>;
+
+/** Whether the server accepts a wait and a request timeout, in microseconds. */
+export const isDeferredBudget = (wait: bigint, timeout: bigint): boolean =>
+  wait <= MAX_DEFERRED_POLL_WAIT_US && timeout >= 1n && timeout <= MAX_DEFERRED_POLL_TIMEOUT_US &&
+  (wait === 0n || timeout >= wait + MIN_DEFERRED_POLL_REPLY_MARGIN_US);
+
+/**
+ * Whether the server accepts a wait and a request timeout of a fetch (109).
+ * A fetch needs the reply margin also at a wait of 0, because the server ends
+ * its partition reads before the request timeout.
+ */
+export const isFetchBudget = (wait: bigint, timeout: bigint): boolean =>
+  isDeferredBudget(wait, timeout) && timeout >= wait + MIN_DEFERRED_POLL_REPLY_MARGIN_US;
+
+/**
+ * `options` over the defaults, checked as the Rust SDK checks them before
+ * anything is sent: a minimum count outside 1 to `count` fails with 4009, a
+ * wait or a request timeout that the server refuses with 3, and a byte limit
+ * that cannot hold one message with 80.
+ */
+export const deferredPollOptions = (
+  options: Partial<DeferredPollOptions>,
+  count: number,
+  command: number = COMMAND_CODE.PollMessagesDeferred
+): DeferredPollOptions => {
+  const minCount = options.minCount ?? DEFAULT_DEFERRED_POLL_OPTIONS.minCount;
+  if (!Number.isInteger(minCount) || minCount < 1 || minCount > count)
+    throw responseError(command, INVALID_MESSAGES_COUNT);
+  const budget = readinessWaitOptions(options, command);
+  const maxBytes = options.maxBytes ?? DEFAULT_DEFERRED_POLL_OPTIONS.maxBytes;
+  if (!Number.isInteger(maxBytes) || maxBytes < MIN_DEFERRED_POLL_MAX_BYTES || maxBytes > MAX_U32)
+    throw responseError(command, INVALID_SIZE_BYTES);
+  return { ...budget, minCount, maxBytes };
+};
+
+/** `options` over the defaults, a wait or a request timeout that the server refuses failing with 3. */
+export const readinessWaitOptions = (
+  options: Partial<ReadinessWaitOptions>,
+  command: number
+): ReadinessWaitOptions => {
+  const maxWait = options.maxWait ?? DEFAULT_DEFERRED_POLL_OPTIONS.maxWait;
+  const requestTimeout = options.requestTimeout ?? DEFAULT_DEFERRED_POLL_OPTIONS.requestTimeout;
+  if (!isDuration(maxWait) || !isDuration(requestTimeout) ||
+      !isDeferredBudget(BigInt(maxWait) * US_PER_MS, BigInt(requestTimeout) * US_PER_MS))
+    throw responseError(command, INVALID_COMMAND);
+  return { maxWait, requestTimeout };
+};
+
+export const serializeDeferredPollOptions = (options: DeferredPollOptions): Buffer => {
+  const b = Buffer.allocUnsafe(DEFERRED_POLL_OPTIONS_SIZE);
+  b.writeBigUInt64LE(BigInt(options.maxWait) * US_PER_MS, DEFERRED_POLL_OFFSET.wait);
+  b.writeUInt32LE(options.minCount, DEFERRED_POLL_OFFSET.minCount);
+  b.writeUInt32LE(options.maxBytes, DEFERRED_POLL_OFFSET.maxBytes);
+  b.writeBigUInt64LE(BigInt(options.requestTimeout) * US_PER_MS, DEFERRED_POLL_OFFSET.requestTimeout);
+  return b;
+};
+
+const isDuration = (milliseconds: number): boolean => Number.isSafeInteger(milliseconds) && milliseconds >= 0;
diff --git a/foreign/node/src/wire/message/fetch-messages.command.test.ts b/foreign/node/src/wire/message/fetch-messages.command.test.ts
new file mode 100644
index 0000000..9782b59
--- /dev/null
+++ b/foreign/node/src/wire/message/fetch-messages.command.test.ts
@@ -0,0 +1,350 @@
+// 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.
+
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { describe, it } from 'node:test';
+import type { CommandResponse, RawClient, SendCommandOptions } from '../../client/client.type.js';
+import { DEFAULT_MAX_RESPONSE_FRAME_SIZE } from '../../client/client.config.js';
+import { FETCH_PROGRESS_UNCERTAIN, PARTITION_LANE_BLOCKED } from '../../client/client.operation.js';
+import { COMMAND_CODE } from '../command.code.js';
+import { DeserializeError, ResponseError } from '../error.utils.js';
+import type { Id } from '../identifier.utils.js';
+import { Consumer, type ConsumerKindValue } from '../offset/offset.utils.js';
+import { HEADER_SIZE, deferredCancellation, encodeRequestHeader } from '../vsr/header.js';
+import { Operation } from '../vsr/operation.js';
+import { PollingStrategy, type PollingStrategyKindValue } from './poll.utils.js';
+import { MAX_PARTITIONS_PER_REQUEST, MIN_DEFERRED_POLL_MAX_BYTES } from './deferred-poll.utils.js';
+import { POLL_MESSAGES } from './poll-messages.command.js';
+import {
+  FETCH_MESSAGES, FETCH_SECTION_SIZE, decodeFetchSection, fetchMessages, type FetchMessages
+} from './fetch-messages.command.js';
+
+type WireId = { kind: 'numeric' | 'named', value: number | string };
+type WireStrategy = { kind: number, value: number };
+type WireRequest = {
+  consumer: { kind: number, id: WireId },
+  stream_id: WireId,
+  topic_id: WireId,
+  count: number,
+  auto_commit: boolean,
+  wait_us: number,
+  min_count: number,
+  max_bytes: number,
+  request_timeout_us: number
+};
+type WirePoll = WireRequest & { partition_id: number, strategy: WireStrategy };
+type WireFetch = WireRequest & {
+  targets: { partition_id: number, strategy: WireStrategy, max_bytes: number, incarnation: number, owner_generation: number }[]
+};
+type WireSection = {
+  partition_id: number, status: number, current_offset: number, messages_count: number,
+  context: { incarnation: number, owner_generation: number, metadata_op: number },
+  next_offset: number, records_len: number,
+  messages: { offset: number, id: string, timestamp: number, origin_timestamp: number, payload: string }[]
+};
+type Vector<T = object> = T & { case: string, body_hex: string };
+
+const fixture = JSON.parse(readFileSync(new URL('../fixtures/deferred_polling.json', import.meta.url), 'utf8'));
+
+const idOf = ({ value }: WireId): Id => value;
+const strategyOf = ({ kind, value }: WireStrategy): PollingStrategy =>
+  ({ kind: kind as PollingStrategyKindValue, value: BigInt(value) }) as PollingStrategy;
+const consumerOf = ({ consumer }: WireRequest): Consumer =>
+  ({ kind: consumer.kind as ConsumerKindValue, id: idOf(consumer.id) });
+const optionsOf = (request: WireRequest) => ({
+  maxWait: request.wait_us / 1_000,
+  requestTimeout: request.request_timeout_us / 1_000,
+  minCount: request.min_count,
+  maxBytes: request.max_bytes
+});
+const fetchOf = (request: WireFetch): FetchMessages => ({
+  streamId: idOf(request.stream_id),
+  topicId: idOf(request.topic_id),
+  consumer: consumerOf(request),
+  targets: request.targets.map(target => ({
+    partitionId: target.partition_id,
+    pollingStrategy: strategyOf(target.strategy),
+    context: { incarnation: BigInt(target.incarnation), ownerGeneration: BigInt(target.owner_generation), metadataOp: 0n }
+  })),
+  count: request.count,
+  autocommit: request.auto_commit,
+  options: optionsOf(request)
+});
+
+/** The fields of a fetch body, read back so a refused vector can be offered to the encoder. */
+const decodeFetch = (body: Buffer): { request: FetchMessages, targetBytes: number[] } => {
+  let at = 0;
+  const id = (): Id => {
+    const kind = body.readUInt8(at);
+    const length = body.readUInt8(at + 1);
+    const value = kind === 1 ? body.readUInt32LE(at + 2) : body.toString('utf8', at + 2, at + 2 + length);
+    at += 2 + length;
+    return value;
+  };
+  const consumerKind = body.readUInt8(at++) as ConsumerKindValue;
+  const consumer = { kind: consumerKind, id: id() };
+  const streamId = id();
+  const topicId = id();
+  const maxWait = Number(body.readBigUInt64LE(at)) / 1_000;
+  const requestTimeout = Number(body.readBigUInt64LE(at + 8)) / 1_000;
+  const minCount = body.readUInt32LE(at + 16);
+  const count = body.readUInt32LE(at + 20);
+  const maxBytes = body.readUInt32LE(at + 24);
+  const autocommit = body.readUInt8(at + 28) === 1;
+  const targetsCount = body.readUInt32LE(at + 29);
+  at += 33;
+  const targets = Array.from({ length: targetsCount }, (_, index) => {
+    const position = at + index * 33;
+    return {
+      partitionId: body.readUInt32LE(position),
+      pollingStrategy: { kind: body.readUInt8(position + 4), value: body.readBigUInt64LE(position + 5) } as PollingStrategy,
+      maxBytes: body.readUInt32LE(position + 13),
+      context: { incarnation: body.readBigUInt64LE(position + 17), ownerGeneration: body.readBigUInt64LE(position + 25), metadataOp: 0n }
+    };
+  });
+  return {
+    request: {
+      streamId, topicId, consumer, count, autocommit,
+      targets: targets.map(({ partitionId, pollingStrategy, context }) => ({ partitionId, pollingStrategy, context })),
+      options: { maxWait, requestTimeout, minCount, maxBytes }
+    },
+    targetBytes: targets.map(target => target.maxBytes)
+  };
+};
+
+const fence = { incarnation: 3n, ownerGeneration: 9n, metadataOp: 0n };
+const golden: FetchMessages = {
+  streamId: 1,
+  topicId: 'orders',
+  consumer: Consumer.Group(7),
+  targets: [
+    { partitionId: 1, pollingStrategy: PollingStrategy.Offset(41n), context: fence },
+    { partitionId: 2, pollingStrategy: PollingStrategy.Next, context: fence }
+  ],
+  count: 100,
+  autocommit: true,
+  options: { maxWait: 5_000, requestTimeout: 6_000, minCount: 1, maxBytes: 1_048_576 }
+};
+
+const reply = (hex: string): CommandResponse => {
+  const data = Buffer.from(hex, 'hex');
+  return { status: 0, length: data.length, data };
+};
+
+const refusedWith = (errorCode: number) => (error: unknown): boolean =>
+  error instanceof ResponseError && error.commandCode === COMMAND_CODE.FetchMessages && error.errorCode === errorCode;
+
+const targetsOf = (count: number) =>
+  Array.from({ length: count }, (_, partitionId) => ({ partitionId, pollingStrategy: PollingStrategy.Next }));
+
+describe('shared wire vectors', () => {
+  it('hold the codes and limits of the client', () => {
+    const { codes, errors, ...limits } = fixture.constants;
+    assert.deepEqual(codes, {
+      cancel_deferred_request: COMMAND_CODE.CancelDeferredRequest,
+      fetch_messages: COMMAND_CODE.FetchMessages,
+      poll_messages: COMMAND_CODE.PollMessages,
+      poll_messages_deferred: COMMAND_CODE.PollMessagesDeferred
+    });
+    assert.deepEqual([errors.partition_lane_blocked, errors.fetch_progress_uncertain],
+      [PARTITION_LANE_BLOCKED, FETCH_PROGRESS_UNCERTAIN]);
+    assert.deepEqual(
+      [limits.min_deferred_poll_max_bytes, limits.max_partitions_per_request, limits.request_header_size,
+        limits.fetch_section_header_size],
+      [MIN_DEFERRED_POLL_MAX_BYTES, MAX_PARTITIONS_PER_REQUEST, HEADER_SIZE, FETCH_SECTION_SIZE]);
+    assert.ok(DEFAULT_MAX_RESPONSE_FRAME_SIZE >= limits.max_record_reply_size);
+  });
+
+  for (const vector of fixture.deferred_polls as Vector<{ request: WirePoll }>[])
+    it(`encodes the deferred poll ${vector.case}`, () => {
+      const { request } = vector;
+      const body = POLL_MESSAGES.serialize({
+        streamId: idOf(request.stream_id),
+        topicId: idOf(request.topic_id),
+        consumer: consumerOf(request),
+        partitionId: request.partition_id,
+        pollingStrategy: strategyOf(request.strategy),
+        count: request.count,
+        autocommit: request.auto_commit,
+        deferred: optionsOf(request)
+      });
+      assert.equal(body.toString('hex'), vector.body_hex);
+    });
+
+  // The client gives every target the limit of the whole reply, since the
+  // server serves a partition only the bytes it holds, so the vectors are
+  // compared with that limit in each target.
+  for (const vector of fixture.fetches as Vector<{ request: WireFetch }>[])
+    it(`encodes the fetch ${vector.case} with the reply limit as each target limit`, () => {
+      const expected = Buffer.from(vector.body_hex, 'hex');
+      const { targetBytes } = decodeFetch(expected);
+      const targetsAt = expected.length - targetBytes.length * 33;
+      targetBytes.forEach((bytes, index) => {
+        assert.equal(bytes, vector.request.targets[index].max_bytes);
+        expected.writeUInt32LE(vector.request.max_bytes, targetsAt + index * 33 + 13);
+      });
+
+      assert.equal(FETCH_MESSAGES.serialize(fetchOf(vector.request)).toString('hex'), expected.toString('hex'));
+    });
+
+  for (const vector of fixture.cancellations as { case: string, cancels_code: number, client: number,
+    request: number, session: number, header_hex: string }[])
+    it(`encodes the cancellation ${vector.case}`, () => {
+      const parked = encodeRequestHeader({
+        size: HEADER_SIZE + 64,
+        client: BigInt(vector.client),
+        request: BigInt(vector.request),
+        operation: Operation.NonReplicated,
+        session: BigInt(vector.session),
+        nonReplicatedCode: vector.cancels_code,
+        context: fence
+      });
+
+      assert.equal(deferredCancellation(parked)?.toString('hex'), vector.header_hex);
+    });
+
+  for (const vector of fixture.fetch_replies as Vector<{ sections: WireSection[] }>[])
+    it(`decodes the fetch reply ${vector.case}`, () => {
+      const sections = FETCH_MESSAGES.deserialize(reply(vector.body_hex));
+
+      assert.deepEqual(sections.map(section => ({ ...section, records: section.records.length })),
+        vector.sections.map(section => ({
+          partitionId: section.partition_id,
+          status: section.status,
+          currentOffset: BigInt(section.current_offset),
+          messagesCount: section.messages_count,
+          context: {
+            incarnation: BigInt(section.context.incarnation),
+            ownerGeneration: BigInt(section.context.owner_generation),
+            metadataOp: BigInt(section.context.metadata_op)
+          },
+          nextOffset: BigInt(section.next_offset),
+          records: section.records_len
+        })));
+      sections.forEach((section, index) => {
+        const messages = section.status === 0 ? decodeFetchSection(section).messages : [];
+        assert.deepEqual(messages.map(({ headers, payload }) => ({
+          offset: headers.offset,
+          id: BigInt(headers.id),
+          timestamp: headers.timestamp.getTime(),
+          originTimestamp: headers.originTimestamp.getTime(),
+          payload: payload.toString()
+        })), vector.sections[index].messages.map(message => ({
+          offset: BigInt(message.offset),
+          id: BigInt(message.id),
+          // Message headers carry a Date, which holds milliseconds.
+          timestamp: Math.floor(message.timestamp / 1_000),
+          originTimestamp: Math.floor(message.origin_timestamp / 1_000),
+          payload: message.payload
+        })));
+        if (section.status !== 0)
+          assert.throws(() => decodeFetchSection(section), (error: unknown) =>
+            refusedWith(section.status)(error) && error instanceof ResponseError && error.fromServer);
+      });
+    });
+
+  for (const vector of fixture.refused.fetch_replies as Vector[])
+    it(`refuses the fetch reply ${vector.case}`, () => {
+      assert.throws(() => FETCH_MESSAGES.deserialize(reply(vector.body_hex)), DeserializeError);
+    });
+
+  // One byte limit goes to every target, so a target above the request limit
+  // cannot be asked for.
+  for (const vector of fixture.refused.fetches as Vector[])
+    it(`refuses the fetch ${vector.case} before anything is sent`, () => {
+      const { request, targetBytes } = decodeFetch(Buffer.from(vector.body_hex, 'hex'));
+      if (targetBytes.some(bytes => bytes > (request.options?.maxBytes ?? 0)))
+        return;
+
+      assert.throws(() => FETCH_MESSAGES.serialize(request), ResponseError);
+    });
+});
+
+describe('fetch encoding', () => {
+  it('writes a target without a context with the route context left to the client', () => {
+    const body = FETCH_MESSAGES.serialize({ ...golden, targets: [{ partitionId: 1, pollingStrategy: PollingStrategy.Next }] });
+    assert.equal(body.subarray(body.length - 16).toString('hex'), '00'.repeat(16));
+  });
+
+  const refusals: [string, FetchMessages][] = [
+    ['no target', { ...golden, targets: [] }],
+    ['more than 1000 targets', { ...golden, targets: targetsOf(1_001) }],
+    ['a target twice', { ...golden, targets: [golden.targets[0], golden.targets[0]] }],
+    ['a First target', { ...golden, targets: [{ partitionId: 1, pollingStrategy: PollingStrategy.First }] }],
+    ['a Last target', { ...golden, targets: [{ partitionId: 1, pollingStrategy: PollingStrategy.Last }] }],
+    ['a Timestamp target', { ...golden, targets: [{ partitionId: 1, pollingStrategy: PollingStrategy.Timestamp(1n) }] }],
+    ['a wait above 600 s', { ...golden, options: { maxWait: 600_001, requestTimeout: 630_000 } }],
+    ['a request timeout above 630 s', { ...golden, options: { maxWait: 1_000, requestTimeout: 630_001 } }],
+    ['a request timeout without 1 s after the wait', { ...golden, options: { maxWait: 5_000, requestTimeout: 5_999 } }],
+    ['a wait of 0 without 1 s before the request timeout', { ...golden, options: { maxWait: 0, requestTimeout: 999 } }],
+    ['a wait of 0 with exactly 1 s before the request timeout', { ...golden, options: { maxWait: 0, requestTimeout: 1_000 } }],
+    ['a fractional millisecond timeout', { ...golden, options: { maxWait: 0, requestTimeout: 1_000.0001 } }]
+  ];
+  for (const [name, request] of refusals)
+    it(`refuses ${name} with 3 before anything is sent`, () => {
+      assert.throws(() => FETCH_MESSAGES.serialize(request), refusedWith(3));
+    });
+
+  for (const [name, request, errorCode] of [
+    ['a min count of 0', { ...golden, options: { ...golden.options, minCount: 0 } }, 4009],
+    ['a min count above the count', { ...golden, options: { ...golden.options, minCount: 101 } }, 4009],
+    ['a byte limit below 344', { ...golden, options: { ...golden.options, maxBytes: 343 } }, 80]
+  ] as const)
+    it(`refuses ${name} with ${errorCode}`, () => {
+      assert.throws(() => FETCH_MESSAGES.serialize(request), refusedWith(errorCode));
+    });
+
+  it('accepts 1000 targets and a wait of 0 with more than 1 s before the request timeout', () => {
+    assert.ok(FETCH_MESSAGES.serialize({ ...golden, targets: targetsOf(1_000) }).length > 0);
+    assert.ok(FETCH_MESSAGES.serialize({ ...golden, options: { maxWait: 0, requestTimeout: 1_001 } }).length > 0);
+  });
+
+  const head = (count: number) => Buffer.from([count, 0, 0, 0]);
+  const section = (status: number, recordsLength: number) => {
+    const header = Buffer.alloc(56);
+    header.writeUInt32LE(1, 0);
+    header.writeUInt32LE(status, 4);
+    header.writeUInt32LE(recordsLength, 52);
+    return header;
+  };
+  for (const [name, data] of [
+    ['a truncated count', Buffer.from([1, 0])],
+    ['more sections than fit its body', head(2)],
+    ['more than 1000 sections', Buffer.from('e9030000', 'hex')],
+    ['records in a refused section', Buffer.concat([head(1), section(89, 1), Buffer.alloc(1)])],
+    ['records past its body', Buffer.concat([head(1), section(0, 2), Buffer.alloc(1)])],
+    ['bytes after its last section', Buffer.concat([head(1), section(0, 0), Buffer.alloc(1)])]
+  ] as const)
+    it(`rejects a reply with ${name}`, () => {
+      assert.throws(() => FETCH_MESSAGES.deserialize({ status: 0, length: data.length, data }), DeserializeError);
+    });
+
+  it('sends the fetch as command 109 with the signal of the caller', async () => {
+    const signal = new AbortController().signal;
+    const sent: { command: number, payload: Buffer, options?: SendCommandOptions }[] = [];
+    const client = {
+      sendCommand: async (command: number, payload: Buffer, options?: SendCommandOptions) => {
+        sent.push({ command, payload, options });
+        return reply('00000000');
+      }
+    } as RawClient;
+    assert.deepEqual(await fetchMessages(async () => client)({ ...golden, signal }), []);
+    assert.deepEqual(sent.map(({ command, payload, options }) => [command, payload, options]),
+      [[COMMAND_CODE.FetchMessages, FETCH_MESSAGES.serialize(golden), { signal }]]);
+  });
+});
diff --git a/foreign/node/src/wire/message/fetch-messages.command.ts b/foreign/node/src/wire/message/fetch-messages.command.ts
new file mode 100644
index 0000000..a04237b
--- /dev/null
+++ b/foreign/node/src/wire/message/fetch-messages.command.ts
@@ -0,0 +1,284 @@
+// 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.
+
+import type { CommandResponse } from '../../client/client.type.js';
+import { FETCH_PROGRESS_UNCERTAIN, FetchProgressUncertainError } from '../../client/client.operation.js';
+import { COMMAND_CODE } from '../command.code.js';
+import { wrapCommand } from '../command.utils.js';
+import { DeserializeError, responseError } from '../error.utils.js';
+import type { ResponseError } from '../error.utils.js';
+import { serializeIdentifier, type Id } from '../identifier.utils.js';
+import { uint8ToBuf } from '../number.utils.js';
+import type { Consumer } from '../offset/offset.utils.js';
+import {
+  EMPTY_PARTITION_CONTEXT, PARTITION_CONTEXT_SIZE, deserializePartitionContext, type PartitionContext
+} from '../vsr/header.js';
+import {
+  PollingStrategyKind, deserializeMessages, type PollingStrategy, type PollMessagesResponse
+} from './poll.utils.js';
+import {
+  MAX_PARTITIONS_PER_REQUEST, MIN_DEFERRED_POLL_REPLY_MARGIN_US, US_PER_MS, deferredPollOptions, isFetchBudget,
+  type DeferredPollOptions
+} from './deferred-poll.utils.js';
+
+const INVALID_COMMAND = 3;
+const STATUS_OK = 0;
+const SECTIONS_COUNT_SIZE = 4;
+/** A split count could loop on or misread the other start points, so the server refuses them. */
+const FETCH_STRATEGY_KINDS = new Set<number>([PollingStrategyKind.Offset, PollingStrategyKind.Next]);
+
+/** The fields that follow the consumer, stream and topic of a fetch. */
+export const FETCH_MESSAGES_FIELD = {
+  wait: 0,
+  requestTimeout: 8,
+  minCount: 16,
+  count: 20,
+  maxBytes: 24,
+  autoCommit: 28,
+  targetsCount: 29
+} as const;
+export const FETCH_MESSAGES_FIELDS_SIZE = 8 + 8 + 4 + 4 + 4 + 1 + 4;
+/** A partition id, the kind and the value of its start point, its byte limit and its fence. */
+export const FETCH_TARGET_FIELD = {
+  partitionId: 0,
+  kind: 4,
+  value: 5,
+  maxBytes: 13,
+  incarnation: 17,
+  ownerGeneration: 25
+} as const;
+export const FETCH_TARGET_SIZE = 4 + 1 + 8 + 4 + 8 + 8;
+/** The fields of one section header of a fetch reply. */
+export const FETCH_SECTION_FIELD = {
+  partitionId: 0,
+  status: 4,
+  currentOffset: 8,
+  messagesCount: 16,
+  context: 20,
+  nextOffset: 44,
+  recordsLength: 52
+} as const;
+export const FETCH_SECTION_SIZE = 4 + 4 + 8 + 4 + PARTITION_CONTEXT_SIZE + 8 + 4;
+
+/** The section count and one section header per target, which lead a fetch reply. */
+export const fetchReplyHeadSize = (targets: number): number => SECTIONS_COUNT_SIZE + targets * FETCH_SECTION_SIZE;
+
+/**
+ * `options` over the defaults, checked as for a deferred poll. A fetch also
+ * needs a timeout greater than 1 s at a wait of 0, so local setup can leave
+ * the server its mandatory 1 s reply margin. Invalid options fail with 3.
+ */
+export const fetchOptions = (options: Partial<DeferredPollOptions>, count: number): DeferredPollOptions => {
+  const command = COMMAND_CODE.FetchMessages;
+  const checked = deferredPollOptions(options, count, command);
+  const timeout = BigInt(checked.requestTimeout) * US_PER_MS;
+  if (timeout <= MIN_DEFERRED_POLL_REPLY_MARGIN_US || !isFetchBudget(BigInt(checked.maxWait) * US_PER_MS, timeout))
+    throw responseError(command, INVALID_COMMAND);
+  return checked;
+};
+
+/** A partition of a fetch and its start point. */
+export type FetchTarget = {
+  partitionId: number,
+  /** Offset or Next. Other start points fail with 3 before anything is sent. */
+  pollingStrategy: PollingStrategy,
+  /**
+   * Context of the section that the offset came from
+   * (`FetchMessagesSection.context`), so a fetch does not read that offset
+   * in a partition deleted and recreated since. Without one the client takes
+   * the incarnation of its cached route.
+   */
+  context?: PartitionContext
+};
+
+/**
+ * Parameters of a fetch (command 109): one wait on many partitions of a
+ * topic, which then reads them into one reply.
+ */
+export type FetchMessages = {
+  /** Stream identifier */
+  streamId: Id,
+  /** Topic identifier */
+  topicId: Id,
+  /** The consumer whose offsets the Next targets and `autocommit` use */
+  consumer: Consumer,
+  /**
+   * 1 to 1000 partitions, each once, with one primary: a group of
+   * `RawClient.readinessGroups`. The server reads them in this order, so a
+   * caller that moves the partitions it read to the end serves them in turn.
+   */
+  targets: FetchTarget[],
+  /** The most messages of the whole reply */
+  count: number,
+  /** Whether the server stores the offset of each partition that it reads */
+  autocommit: boolean,
+  /**
+   * The wait over `DEFAULT_DEFERRED_POLL_OPTIONS`. `maxBytes` limits the
+   * records of the whole reply and of each partition. Unlike a deferred poll,
+   * a fetch with a wait of 0 needs a timeout greater than 1 s so local setup
+   * can leave the server its mandatory 1 s reply margin.
+   */
+  options?: Partial<DeferredPollOptions>,
+  /**
+   * Cancels the fetch. A sent fetch is cancelled on the server too: one
+   * without `autocommit` ends at once, a committing one answers once, with
+   * its messages or with nothing stored (rejected with the abort reason).
+   * Unknown committing outcomes retain local progress blocks.
+   */
+  signal?: AbortSignal
+};
+
+/** The part of a fetch reply about one partition. */
+export type FetchMessagesSection = {
+  partitionId: number,
+  /**
+   * 0 when the partition was read. Else the error code of this partition,
+   * and the other fields carry no position. Local status 14010 includes `progressError`
+   * and blocks this partition until explicit abandonment. Healthy siblings remain usable.
+   */
+  status: number,
+  /** Current offset in the partition */
+  currentOffset: bigint,
+  /** Number of messages in `records` */
+  messagesCount: number,
+  /** Partition incarnation, owner generation and metadata op of the read */
+  context: PartitionContext,
+  /** Where the next fetch of this partition starts, as an Offset target */
+  nextOffset: bigint,
+  /** The batch records of the section, as a poll reply carries them, see `decodeFetchSection` */
+  records: Buffer,
+  /** Explicit local recovery is required for this section. Healthy sibling sections remain usable. */
+  progressError?: FetchProgressUncertainError
+};
+
+/**
+ * Fetch command definition. The reply holds one section per partition that
+ * the server read or refused.
+ */
+export const FETCH_MESSAGES = {
+  code: COMMAND_CODE.FetchMessages,
+
+  serialize: ({ streamId, topicId, consumer, targets, count, autocommit, options = {} }: FetchMessages): Buffer => {
+    const command = COMMAND_CODE.FetchMessages;
+    const { maxWait, minCount, maxBytes, requestTimeout } = fetchOptions(options, count);
+    if (targets.length === 0 || targets.length > MAX_PARTITIONS_PER_REQUEST ||
+        new Set(targets.map(({ partitionId }) => partitionId)).size !== targets.length ||
+        targets.some(({ pollingStrategy }) => !FETCH_STRATEGY_KINDS.has(pollingStrategy.kind)))
+      throw responseError(command, INVALID_COMMAND);
+    const fields = Buffer.allocUnsafe(FETCH_MESSAGES_FIELDS_SIZE);
+    fields.writeBigUInt64LE(BigInt(maxWait) * US_PER_MS, FETCH_MESSAGES_FIELD.wait);
+    fields.writeBigUInt64LE(BigInt(requestTimeout) * US_PER_MS, FETCH_MESSAGES_FIELD.requestTimeout);
+    fields.writeUInt32LE(minCount, FETCH_MESSAGES_FIELD.minCount);
+    fields.writeUInt32LE(count, FETCH_MESSAGES_FIELD.count);
+    fields.writeUInt32LE(maxBytes, FETCH_MESSAGES_FIELD.maxBytes);
+    fields.writeUInt8(autocommit ? 1 : 0, FETCH_MESSAGES_FIELD.autoCommit);
+    fields.writeUInt32LE(targets.length, FETCH_MESSAGES_FIELD.targetsCount);
+    const body = Buffer.allocUnsafe(targets.length * FETCH_TARGET_SIZE);
+    targets.forEach(({ partitionId, pollingStrategy, context = EMPTY_PARTITION_CONTEXT }, index) => {
+      const position = index * FETCH_TARGET_SIZE;
+      body.writeUInt32LE(partitionId, position + FETCH_TARGET_FIELD.partitionId);
+      body.writeUInt8(pollingStrategy.kind, position + FETCH_TARGET_FIELD.kind);
+      body.writeBigUInt64LE(pollingStrategy.value, position + FETCH_TARGET_FIELD.value);
+      // The server serves a partition only the bytes that it knows the
+      // partition holds, so a smaller limit would only split its batches.
+      body.writeUInt32LE(maxBytes, position + FETCH_TARGET_FIELD.maxBytes);
+      body.writeBigUInt64LE(context.incarnation, position + FETCH_TARGET_FIELD.incarnation);
+      body.writeBigUInt64LE(context.ownerGeneration, position + FETCH_TARGET_FIELD.ownerGeneration);
+    });
+    return Buffer.concat([
+      uint8ToBuf(consumer.kind),
+      serializeIdentifier(consumer.id),
+      serializeIdentifier(streamId),
+      serializeIdentifier(topicId),
+      fields,
+      body
+    ]);
+  },
+
+  deserialize: ({ data, fetchProgress }: CommandResponse): FetchMessagesSection[] => {
+    const count = data.length >= SECTIONS_COUNT_SIZE ? data.readUInt32LE(0) : -1;
+    if (count < 0 || count > MAX_PARTITIONS_PER_REQUEST || data.length < fetchReplyHeadSize(count))
+      throw new DeserializeError('fetch reply does not hold its section table');
+    let recordsAt = fetchReplyHeadSize(count);
+    const sections = Array.from({ length: count }, (_, index): FetchMessagesSection => {
+      const position = SECTIONS_COUNT_SIZE + index * FETCH_SECTION_SIZE;
+      const status = data.readUInt32LE(position + FETCH_SECTION_FIELD.status);
+      const recordsLength = data.readUInt32LE(position + FETCH_SECTION_FIELD.recordsLength);
+      if ((status !== STATUS_OK && recordsLength !== 0) || recordsAt + recordsLength > data.length)
+        throw new DeserializeError('fetch reply section does not match its records');
+      const records = data.subarray(recordsAt, recordsAt + recordsLength);
+      recordsAt += recordsLength;
+      const partitionId = data.readUInt32LE(position + FETCH_SECTION_FIELD.partitionId);
+      const progressError = fetchProgress?.partitions.has(partitionId)
+        ? new FetchProgressUncertainError(fetchProgress.operationId, responseError(COMMAND_CODE.FetchMessages, status))
+        : undefined;
+      return {
+        partitionId,
+        status: progressError ? FETCH_PROGRESS_UNCERTAIN : status,
+        currentOffset: data.readBigUInt64LE(position + FETCH_SECTION_FIELD.currentOffset),
+        messagesCount: data.readUInt32LE(position + FETCH_SECTION_FIELD.messagesCount),
+        context: deserializePartitionContext(data, position + FETCH_SECTION_FIELD.context),
+        nextOffset: data.readBigUInt64LE(position + FETCH_SECTION_FIELD.nextOffset),
+        records,
+        ...(progressError ? { progressError } : {})
+      };
+    });
+    if (recordsAt !== data.length)
+      throw new DeserializeError('fetch reply has bytes after the records of its last section');
+    return sections;
+  }
+};
+
+/** The refusal that a section with an error status carries, naming its partition. */
+export const fetchSectionError = ({ partitionId, status, progressError }: FetchMessagesSection): ResponseError | FetchProgressUncertainError => {
+  if (progressError) return progressError;
+  const error = responseError(COMMAND_CODE.FetchMessages, status, `partition ${partitionId}`);
+  error.fromServer = true;
+  return error;
+};
+
+/**
+ * The messages of a section, as a poll of its partition returns them. A
+ * section with an error status throws that error.
+ */
+export const decodeFetchSection = (section: FetchMessagesSection): PollMessagesResponse => {
+  if (section.status !== STATUS_OK)
+    throw fetchSectionError(section);
+  const messages = deserializeMessages(section.records);
+  if (messages.length !== section.messagesCount)
+    throw new DeserializeError('fetch section count does not match its messages', { record: section.records });
+  return {
+    partitionId: section.partitionId,
+    context: section.context,
+    currentOffset: section.currentOffset,
+    count: section.messagesCount,
+    messages
+  };
+};
+
+/**
+ * Executable fetch function. The call goes to the primary of the targets,
+ * and takes one of the deferred request slots of the client while it waits.
+ * A refusal as not the primary, or for a history or an owner that changed,
+ * of the call or of a section, marks the routes of those partitions stale,
+ * so the next `RawClient.readinessGroups` looks them up again. A fetch with
+ * `autocommit` or a Next target is never sent again after it reached the
+ * server. A lost reply of a fetch with `autocommit` fails with
+ * `FetchProgressUncertainError` (14010) and blocks its partitions until
+ * `abandonOperation`. A lost reply of any other fetch fails with 57.
+ */
+export const fetchMessages = wrapCommand(FETCH_MESSAGES, ({ signal }) => ({ signal }));
diff --git a/foreign/node/src/wire/message/index.ts b/foreign/node/src/wire/message/index.ts
index 069e7d0..4b71a79 100644
--- a/foreign/node/src/wire/message/index.ts
+++ b/foreign/node/src/wire/message/index.ts
@@ -16,9 +16,13 @@
 // under the License.
 
 export * from "./poll-messages.command.js";
+export * from "./fetch-messages.command.js";
+export * from "./deferred-poll.utils.js";
 export * from "./send-messages.command.js";
 export * from "./message.utils.js";
 export * from "./poll.utils.js";
+export * from "./poll-options.js";
+export { Checkpoint, type Continuation, type PolledBatch } from "./checkpoint.js";
 
 export { Partitioning } from "./partitioning.utils.js";
 export { HeaderValue, HeaderKeyFactory } from "./header.utils.js";
diff --git a/foreign/node/src/wire/message/message-batch.test.ts b/foreign/node/src/wire/message/message-batch.test.ts
index 3cdfc99..7c29043 100644
--- a/foreign/node/src/wire/message/message-batch.test.ts
+++ b/foreign/node/src/wire/message/message-batch.test.ts
@@ -38,6 +38,7 @@
   deserializePollMessages,
 } from "./poll.utils.js";
 import { deserializePartitionContext } from "../vsr/header.js";
+import { DeserializeError } from "../error.utils.js";
 
 // Golden vectors generated by the Rust implementation for stream id 1,
 // topic id 2, balanced partitioning, and two messages:
@@ -143,6 +144,39 @@
     assert.equal(messages[1].userHeaders.toString(), "user-header-bytes");
   });
 
+  it("keeps the remaining bytes of records whose framing is malformed", () => {
+    const body = Buffer.from(POLL_BODY, "hex").subarray(0, -1);
+    assert.throws(() => deserializeBatchMessages(body, 40), (error: unknown) => {
+      assert.ok(error instanceof DeserializeError, String(error));
+      assert.equal(error.offset, undefined);
+      assert.deepEqual(error.record, body.subarray(40));
+      return true;
+    });
+  });
+
+  it("keeps the frame and the offset of a message whose user headers do not decode", () => {
+    const body = Buffer.from(POLL_BODY, "hex");
+    const frameStart = 40 + BATCH_HEADER_SIZE + FRAME_HEADER_SIZE + "first-payload".length;
+    const frameEnd = frameStart + FRAME_HEADER_SIZE + "second-payload".length + "user-header-bytes".length;
+    assert.throws(() => deserializePollMessages(body), (error: unknown) => {
+      assert.ok(error instanceof DeserializeError, String(error));
+      assert.equal(error.offset, 101n);
+      assert.deepEqual(error.record, body.subarray(frameStart, frameEnd));
+      return true;
+    });
+  });
+
+  it("keeps the whole records of a reply whose count does not match them", () => {
+    const records = encodeMessagesBatch([goldenMessages()[0]]);
+    const body = Buffer.concat([Buffer.from(POLL_BODY, "hex").subarray(0, 40), records]);
+    assert.throws(() => deserializePollMessages(body), (error: unknown) => {
+      assert.ok(error instanceof DeserializeError, String(error));
+      assert.equal(error.offset, undefined);
+      assert.deepEqual(error.record, records);
+      return true;
+    });
+  });
+
   it("rejects a non-zero reserved frame field", () => {
     const body = Buffer.from(POLL_BODY, "hex");
     body.writeUInt8(1, 40 + BATCH_HEADER_SIZE + 40);
diff --git a/foreign/node/src/wire/message/poll-messages.command.test.ts b/foreign/node/src/wire/message/poll-messages.command.test.ts
index 39d3763..01caeaa 100644
--- a/foreign/node/src/wire/message/poll-messages.command.test.ts
+++ b/foreign/node/src/wire/message/poll-messages.command.test.ts
@@ -33,7 +33,11 @@
   PollingStrategy
 } from './poll.utils.js';
 import {
+  DEFAULT_DEFERRED_POLL_OPTIONS, DEFERRED_POLL_OFFSET, DEFERRED_POLL_OPTIONS_SIZE, type DeferredPollOptions
+} from './deferred-poll.utils.js';
+import {
   NO_ASSIGNED_PARTITION,
+  POLL_MESSAGES,
   pollMessages,
   type PollMessages
 } from './poll-messages.command.js';
@@ -596,3 +600,95 @@
     );
   });
 });
+
+describe('deferred poll encoding', () => {
+  // Bodies of the Rust encoder for group 7, stream 1, topic "orders",
+  // partition 3, Next, count 100 and auto-commit, with the default options.
+  const POLL_BODY = '0201040700000001040100000002066f726465727301030000000500000000000000006400000001';
+  const DEFERRED_POLL_BODY = '0201040700000001040100000002066f72646572730103000000050000000000000000' +
+    '640000000140420f00000000000100000000001000c0d8a70000000000';
+  const golden: PollMessages = {
+    streamId: 1,
+    topicId: 'orders',
+    consumer: Consumer.Group(7),
+    partitionId: 3,
+    pollingStrategy: PollingStrategy.Next,
+    count: 100,
+    autocommit: true
+  };
+
+  const wireOptions = (deferred: Partial<DeferredPollOptions>) => {
+    const body = POLL_MESSAGES.serialize({ ...golden, deferred });
+    const at = body.length - DEFERRED_POLL_OPTIONS_SIZE;
+    return {
+      wait: body.readBigUInt64LE(at + DEFERRED_POLL_OFFSET.wait),
+      minCount: body.readUInt32LE(at + DEFERRED_POLL_OFFSET.minCount),
+      maxBytes: body.readUInt32LE(at + DEFERRED_POLL_OFFSET.maxBytes),
+      timeout: body.readBigUInt64LE(at + DEFERRED_POLL_OFFSET.requestTimeout)
+    };
+  };
+
+  it('encodes the golden plain poll body', () => {
+    assert.equal(POLL_MESSAGES.serialize(golden).toString('hex'), POLL_BODY);
+  });
+
+  it('encodes the golden deferred poll body with the default options', () => {
+    assert.equal(POLL_MESSAGES.serialize({ ...golden, deferred: {} }).toString('hex'), DEFERRED_POLL_BODY);
+    assert.equal(
+      POLL_MESSAGES.serialize({ ...golden, deferred: DEFAULT_DEFERRED_POLL_OPTIONS }).toString('hex'),
+      DEFERRED_POLL_BODY
+    );
+  });
+
+  // The Rust SDK checks the minimum count first, then the wait and the
+  // request timeout, then the byte limit.
+  const refusals: [string, Partial<DeferredPollOptions>, number][] = [
+    ['a minimum count of 0', { minCount: 0 }, 4009],
+    ['a minimum count above the poll count', { minCount: 101 }, 4009],
+    ['a minimum count of 0 with a wait above 600 s', { minCount: 0, maxWait: 600_001 }, 4009],
+    ['a wait above 600 s', { maxWait: 600_001, requestTimeout: 630_000 }, 3],
+    ['a negative wait', { maxWait: -1 }, 3],
+    ['a fractional wait', { maxWait: 1.5 }, 3],
+    ['a request timeout of 0', { maxWait: 0, requestTimeout: 0 }, 3],
+    ['a request timeout above 630 s', { requestTimeout: 630_001 }, 3],
+    ['a request timeout without 1 s after the wait', { maxWait: 1_000, requestTimeout: 1_999 }, 3],
+    ['a wait above 600 s with a byte limit below one message', { maxWait: 600_001, maxBytes: 343 }, 3],
+    ['a byte limit below one message', { maxBytes: 343 }, 80],
+    ['a byte limit above u32', { maxBytes: 2 ** 32 }, 80]
+  ];
+  for (const [name, deferred, code] of refusals)
+    it(`refuses ${name} with ${code} before anything is sent`, () => {
+      assert.throws(() => POLL_MESSAGES.serialize({ ...golden, deferred }), (error: unknown) =>
+        error instanceof ResponseError &&
+        error.commandCode === COMMAND_CODE.PollMessagesDeferred &&
+        error.errorCode === code);
+    });
+
+  it('accepts each option at its limits', () => {
+    assert.deepEqual(wireOptions({ maxWait: 0, requestTimeout: 1, minCount: 100, maxBytes: 344 }),
+      { wait: 0n, minCount: 100, maxBytes: 344, timeout: 1_000n });
+    assert.deepEqual(wireOptions({ maxWait: 600_000, requestTimeout: 630_000, maxBytes: 0xFFFF_FFFF }),
+      { wait: 600_000_000n, minCount: 1, maxBytes: 0xFFFF_FFFF, timeout: 630_000_000n });
+    assert.deepEqual(wireOptions({ maxWait: 1_000, requestTimeout: 2_000 }),
+      { wait: 1_000_000n, minCount: 1, maxBytes: 1_048_576, timeout: 2_000_000n });
+  });
+
+  it('sends a deferred poll of one partition as command 106 with the signal of the caller', async () => {
+    const signal = new AbortController().signal;
+    const { client, commands } = stubClient([pollResponse(3)]);
+    await pollMessages(async () => client)({ ...golden, deferred: {}, signal });
+    assert.deepEqual(
+      commands.map(({ command, payload, options }) => [command, payload.toString('hex'), options?.signal]),
+      [[COMMAND_CODE.PollMessagesDeferred, DEFERRED_POLL_BODY, signal]]
+    );
+  });
+
+  it('refuses a deferred consumer-group poll without a partition with 3 before anything is sent, and names the reader',
+    async () => {
+      const { client, commands } = stubClient([]);
+      await assert.rejects(pollMessages(async () => client)({ ...golden, partitionId: null, deferred: {} }),
+        (error: unknown) => error instanceof ResponseError && error.commandCode === COMMAND_CODE.PollMessagesDeferred &&
+          error.errorCode === 3 && error.message.includes('message.reader'));
+      assert.deepEqual(commands, []);
+    });
+});
diff --git a/foreign/node/src/wire/message/poll-messages.command.ts b/foreign/node/src/wire/message/poll-messages.command.ts
index 00e0e90..3ec9f6c 100644
--- a/foreign/node/src/wire/message/poll-messages.command.ts
+++ b/foreign/node/src/wire/message/poll-messages.command.ts
@@ -41,14 +41,22 @@
   serializePollMessages, deserializePollMessages,
   type PollingStrategy, type PollMessagesResponse
 } from './poll.utils.js';
+import {
+  deferredPollOptions, serializeDeferredPollOptions, type DeferredPollOptions
+} from './deferred-poll.utils.js';
+import { pollOptions, wireOptions, type PollOptions } from './poll-options.js';
+import { polledBatch, validatePartition, type Continuation, type PolledBatch } from './checkpoint.js';
 
 const RESYNC_REQUIRED_PARTITION = 0xFFFF_FFFF;
 /** Internal result used when a group member currently owns no partitions. */
 export const NO_ASSIGNED_PARTITION = 0xFFFF_FFFE;
 const GROUP_POLL_MAX_ATTEMPTS = 2;
-const GROUP_ASSIGNMENT_REFRESH_MS = 5_000;
+/** How long an assignment that a member synced stays current. */
+export const GROUP_ASSIGNMENT_REFRESH_MS = 5_000;
 const GROUP_MEMBER_NOT_FOUND = 5006;
 const GROUP_PARTITION_NOT_OWNED = 5009;
+const INVALID_CONFIGURATION = 2;
+const INVALID_COMMAND = 3;
 const TRANSIENT_NOT_COMMITTED = 57;
 
 type GroupCursor = {
@@ -120,7 +128,52 @@
    * partition, one poll can fail with 87 (5009 for a group consumer). The
    * failed route is dropped and the next call routes again.
    */
-  context?: PartitionContext
+  context?: PartitionContext,
+  /**
+   * Makes the server wait for messages before it replies (command 106), with
+   * these options over `DEFAULT_DEFERRED_POLL_OPTIONS`. The call gives up at
+   * `requestTimeout`. A consumer-group poll without a partition does not
+   * wait: with these options it fails with 3 before anything is sent. To wait
+   * on many partitions, read them with `message.reader`.
+   */
+  deferred?: Partial<DeferredPollOptions>,
+  /** Cancels the poll. A poll that waits on the server is cancelled there too. */
+  signal?: AbortSignal
+};
+
+/**
+ * Parameters of the poll of one partition: one batch read with
+ * `PollOptions`, as the Rust `poll_messages` reads it.
+ */
+export type PollPartition = {
+  /** Stream identifier */
+  streamId: Id,
+  /** Topic identifier */
+  topicId: Id,
+  /** The partition to read */
+  partitionId: number,
+  /** The consumer whose position Next means */
+  consumer: Consumer,
+  /** Where the read starts, such as the continuation of the previous batch */
+  strategy: PollingStrategy | Continuation,
+  /**
+   * The limits of the read and the commit policy, over `DEFAULT_POLL_OPTIONS`.
+   * A one-shot poll refuses `CommitPolicy.Auto` with 2 (InvalidConfiguration).
+   */
+  options?: Partial<PollOptions>,
+  /**
+   * Cancels the poll. A waiting poll is cancelled on the server too. A poll
+   * that commits keeps its reply then: the server answers it once, refused
+   * when the cancel won and nothing was stored, so the call rejects with the
+   * abort reason, or with the messages it stored, which the call returns.
+   */
+  signal?: AbortSignal
+};
+
+/** `message.poll`: the poll of one partition with `PollOptions`, or the plain poll of `PollMessages`. */
+export type PollMessagesCall = {
+  (request: PollPartition): Promise<PolledBatch>,
+  (request: PollMessages): Promise<PollMessagesResponse>
 };
 
 /**
@@ -131,11 +184,14 @@
   code: COMMAND_CODE.PollMessages,
 
   serialize: ({
-    streamId, topicId, consumer, partitionId, pollingStrategy, count, autocommit
+    streamId, topicId, consumer, partitionId, pollingStrategy, count, autocommit, deferred
   }: PollMessages) => {
-    return serializePollMessages(
+    const poll = serializePollMessages(
       streamId, topicId, consumer, partitionId, pollingStrategy, count, autocommit
     );
+    return deferred
+      ? Buffer.concat([poll, serializeDeferredPollOptions(deferredPollOptions(deferred, count))])
+      : poll;
   },
 
   deserialize: (r: CommandResponse) => {
@@ -143,13 +199,20 @@
   }
 };
 
-const groupKey = ({ streamId, topicId, consumer }: PollMessages): string =>
+/** A deferred poll goes out as command 106, with the body of a plain poll and its options. */
+const pollCode = ({ deferred }: PollMessages): number =>
+  deferred ? COMMAND_CODE.PollMessagesDeferred : POLL_MESSAGES.code;
+
+/** The consumer-group member that a call names. */
+type GroupMember = Pick<PollMessages, 'streamId' | 'topicId' | 'consumer'>;
+
+const groupKey = ({ streamId, topicId, consumer }: GroupMember): string =>
   `${idKey(streamId)}\0${idKey(topicId)}\0` +
   `${consumer.kind}:${idKey(consumer.id)}`;
 
 const syncAssignment = async (
   client: RawClient,
-  request: PollMessages,
+  request: GroupMember,
   state: GroupState,
   call: SendCommandOptions,
 ): Promise<GroupCursor> => {
@@ -227,6 +290,41 @@
   return cursor;
 };
 
+const currentCursor = async (
+  client: RawClient,
+  member: GroupMember,
+  state: GroupState,
+  call: SendCommandOptions,
+): Promise<GroupCursor> => {
+  const cached = state.cursors.get(groupKey(member));
+  const cacheAge = cached ? Date.now() - cached.synchronizedAt : 0;
+  // As in the Rust SDK, an empty assignment is synced again on every poll:
+  // a member gets its partitions only as each one installs the new owner.
+  return cached &&
+    cached.partitions.length > 0 &&
+    cacheAge >= 0 &&
+    cacheAge < GROUP_ASSIGNMENT_REFRESH_MS
+    ? cached
+    : await syncAssignment(client, member, state, call);
+};
+
+/**
+ * The partitions that a consumer-group member owns, as a poll without a
+ * partition picks from them. The assignment is synced again after
+ * GROUP_ASSIGNMENT_REFRESH_MS, at each heartbeat, while it is empty, and with
+ * `resync`, as after a fence. A member that the group lost joins it again.
+ */
+export const groupAssignment = async (
+  client: RawClient,
+  member: GroupMember,
+  call: SendCommandOptions,
+  resync = false,
+): Promise<number[]> => {
+  const state = getGroupState(client);
+  if (resync) state.cursors.delete(groupKey(member));
+  return (await currentCursor(client, member, state, call)).partitions;
+};
+
 const pollConsumerGroup = async (
   client: RawClient,
   request: PollMessages,
@@ -235,16 +333,7 @@
 ): Promise<PollMessagesResponse> => {
   const key = groupKey(request);
   for (let attempt = 0; attempt < GROUP_POLL_MAX_ATTEMPTS; attempt += 1) {
-    const cached = state.cursors.get(key);
-    const cacheAge = cached ? Date.now() - cached.synchronizedAt : 0;
-    // As in the Rust SDK, an empty assignment is synced again on every poll:
-    // a member gets its partitions only as each one installs the new owner.
-    const cursor = cached &&
-      cached.partitions.length > 0 &&
-      cacheAge >= 0 &&
-      cacheAge < GROUP_ASSIGNMENT_REFRESH_MS
-      ? cached
-      : await syncAssignment(client, request, state, call);
+    const cursor = await currentCursor(client, request, state, call);
     if (cursor.partitions.length === 0)
       return {
         partitionId: NO_ASSIGNED_PARTITION,
@@ -264,9 +353,7 @@
         { ...call, context: request.context },
       );
     } catch (error) {
-      if (!(error instanceof ResponseError) ||
-          (error.errorCode !== GROUP_MEMBER_NOT_FOUND &&
-           error.errorCode !== GROUP_PARTITION_NOT_OWNED))
+      if (!isGroupFence(error))
         throw error;
       state.cursors.delete(key);
       // A caller context names one owner, so a retry under it would be
@@ -292,22 +379,56 @@
   };
 };
 
+/** A refusal of a consumer-group request for a membership or an assignment that ended. */
+export const isGroupFence = (error: unknown): error is ResponseError =>
+  error instanceof ResponseError &&
+  (error.errorCode === GROUP_MEMBER_NOT_FOUND || error.errorCode === GROUP_PARTITION_NOT_OWNED);
+
 /**
  * Executable poll messages command function.
  */
-export const pollMessages = (getClient: ClientProvider) =>
-  async (request: PollMessages): Promise<PollMessagesResponse> => {
+export const pollMessages = (getClient: ClientProvider): PollMessagesCall =>
+  (async (request: PollPartition | PollMessages) => 'strategy' in request
+    ? pollPartition(getClient, request)
+    : pollPlain(getClient, request)) as PollMessagesCall;
+
+const pollPartition = async (getClient: ClientProvider, request: PollPartition): Promise<PolledBatch> => {
+  const command = COMMAND_CODE.PollMessagesDeferred;
+  const options = pollOptions(request.options, command);
+  if (options.commit.kind === 'auto')
+    throw responseError(command, INVALID_CONFIGURATION,
+      'a one-shot poll has nothing to count, so it cannot commit automatically: use a partition reader');
+  validatePartition(request.strategy, request.partitionId, command);
+  const polled = await pollPlain(getClient, {
+    streamId: request.streamId,
+    topicId: request.topicId,
+    consumer: request.consumer,
+    partitionId: request.partitionId,
+    pollingStrategy: request.strategy,
+    count: options.count,
+    autocommit: options.commit.kind === 'onRead',
+    deferred: wireOptions(options, command),
+    context: (request.strategy as Partial<Continuation>).context,
+    signal: request.signal
+  });
+  return polledBatch(polled, request.strategy);
+};
+
+const pollPlain = async (getClient: ClientProvider, request: PollMessages): Promise<PollMessagesResponse> => {
     const client = await getClient();
     const release = client.hold?.();
     try {
       if (request.consumer.kind === ConsumerKind.Group &&
           request.partitionId === null) {
+        if (request.deferred)
+          throw responseError(COMMAND_CODE.PollMessagesDeferred, INVALID_COMMAND,
+            'a deferred group poll needs a partition: read many partitions with message.reader');
         const state = getGroupState(client);
         // As in the Rust SDK, one deadline bounds the whole call, also the
         // attempts after a session end, and no command of the call runs
         // under a session that the caller opened after it started.
         const deadline = Date.now() + OPERATION_TIMEOUT_MS;
-        const call = { deadline, lineage: client.lineage };
+        const call = { deadline, lineage: client.lineage, signal: request.signal };
         while (true) {
           const endedSessions = state.endedSessions;
           try {
@@ -328,12 +449,12 @@
       }
       return POLL_MESSAGES.deserialize(
         await client.sendCommand(
-          POLL_MESSAGES.code,
+          pollCode(request),
           POLL_MESSAGES.serialize(request),
-          { context: request.context }
+          { context: request.context, signal: request.signal }
         )
       );
     } finally {
       release?.();
     }
-  };
+};
diff --git a/foreign/node/src/wire/message/poll-options.test.ts b/foreign/node/src/wire/message/poll-options.test.ts
new file mode 100644
index 0000000..14ded35
--- /dev/null
+++ b/foreign/node/src/wire/message/poll-options.test.ts
@@ -0,0 +1,198 @@
+// 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.
+
+import assert from 'node:assert/strict';
+import { EventEmitter } from 'node:events';
+import { readFileSync } from 'node:fs';
+import { Readable } from 'node:stream';
+import { describe, it } from 'node:test';
+import type { CommandResponse, RawClient, SendCommandOptions } from '../../client/client.type.js';
+import { COMMAND_CODE } from '../command.code.js';
+import type { ResponseError } from '../error.utils.js';
+import { Consumer, ConsumerKind } from '../offset/offset.utils.js';
+import { storePosition, deletePosition } from '../offset/position.command.js';
+import { PollingStrategy, PollingStrategyKind } from './poll.utils.js';
+import { DEFERRED_POLL_OFFSET, DEFERRED_POLL_OPTIONS_SIZE } from './deferred-poll.utils.js';
+import { pollMessages } from './poll-messages.command.js';
+import {
+  CommitAfter, CommitEvery, CommitPolicy, DEFAULT_AUTO_COMMIT, DEFAULT_POLL_OPTIONS, pollOptions
+} from './poll-options.js';
+import { Checkpoint } from './checkpoint.js';
+
+const INVALID_CONFIGURATION = 2;
+const INVALID_COMMAND = 3;
+const INVALID_MESSAGES_COUNT = 4009;
+const SERVED = { incarnation: 11n, ownerGeneration: 4n, metadataOp: 42n };
+
+const fixture = JSON.parse(readFileSync(new URL('../fixtures/deferred_polling.json', import.meta.url), 'utf8'));
+
+/** A poll reply of the fixture section that holds offsets 41 to 43 of partition 1. */
+const servedReply = (): Buffer => {
+  const vector = fixture.fetch_replies.find((reply: { case: string }) => reply.case === 'ok_section_and_empty_start_section');
+  const body = Buffer.from(vector.body_hex, 'hex');
+  const records = body.subarray(4 + 2 * 56, 4 + 2 * 56 + vector.sections[0].records_len);
+  return pollReply(1, 3, records);
+};
+
+const pollReply = (partitionId: number, count: number, records: Buffer = Buffer.alloc(0)): Buffer => {
+  const head = Buffer.alloc(40);
+  head.writeUInt32LE(partitionId, 0);
+  head.writeBigUInt64LE(43n, 4);
+  head.writeUInt32LE(count, 12);
+  head.writeBigUInt64LE(SERVED.incarnation, 16);
+  head.writeBigUInt64LE(SERVED.ownerGeneration, 24);
+  head.writeBigUInt64LE(SERVED.metadataOp, 32);
+  return Buffer.concat([head, records]);
+};
+
+const stubClient = (replies: Buffer[]) => {
+  const emitter = new EventEmitter();
+  const commands: { command: number, payload: Buffer, options?: SendCommandOptions }[] = [];
+  const client = {
+    protocol: 'vsr',
+    isAuthenticated: true,
+    sendCommand: async (command: number, payload: Buffer, options?: SendCommandOptions): Promise<CommandResponse> => {
+      commands.push({ command, payload, options });
+      const data = replies.shift() ?? Buffer.alloc(0);
+      return { status: 0, length: data.length, data };
+    },
+    authenticate: async () => true,
+    destroy: () => {},
+    on: emitter.on.bind(emitter),
+    once: emitter.once.bind(emitter),
+    getReadStream: () => Readable.from([])
+  } as RawClient;
+  return { client, commands };
+};
+
+const consumer = { kind: ConsumerKind.Single, id: 4 };
+
+describe('poll options', () => {
+  it('answers at once and commits nothing by default', () => {
+    assert.deepEqual(DEFAULT_POLL_OPTIONS,
+      { count: 1000, maxWait: 0, minCount: 1, maxBytes: 1_048_576, commit: CommitPolicy.Manual });
+    assert.deepEqual(pollOptions(), DEFAULT_POLL_OPTIONS);
+  });
+
+  it('stores every 10000 messages or 5 seconds after processing by default', () => {
+    assert.deepEqual(DEFAULT_AUTO_COMMIT,
+      { kind: 'auto', interval: 5_000, every: CommitEvery.Messages(10_000), after: CommitAfter.Processing });
+    assert.deepEqual(CommitPolicy.Auto(), DEFAULT_AUTO_COMMIT);
+  });
+
+  it('refuses an automatic commit without an interval or an event, and an invalid count', () => {
+    const refused = (options: Parameters<typeof pollOptions>[0]) => {
+      try {
+        pollOptions(options);
+      } catch (error) {
+        return (error as ResponseError).errorCode;
+      }
+      return 0;
+    };
+
+    assert.equal(refused({ commit: CommitPolicy.Auto({ after: CommitAfter.Delivery }) }), INVALID_CONFIGURATION);
+    assert.equal(refused({ commit: CommitPolicy.Auto({ interval: 0, every: CommitEvery.Batch }) }),
+      INVALID_CONFIGURATION);
+    assert.equal(refused({ count: 0 }), INVALID_MESSAGES_COUNT);
+    assert.equal(refused({ commit: CommitPolicy.Auto({ every: CommitEvery.Batch }) }), 0);
+    assert.throws(() => CommitEvery.Messages(0), RangeError);
+  });
+});
+
+describe('partition poll', () => {
+  for (const onRead of [false, true]) {
+    it(`sends one deferred poll with the limits of its options${onRead ? ' and commits on read' : ''}`, async () => {
+      const { client, commands } = stubClient([pollReply(1, 0)]);
+      const options = { count: 50, maxWait: 1_500, minCount: 2, maxBytes: 4_096,
+        commit: onRead ? CommitPolicy.OnRead : CommitPolicy.Manual };
+
+      await pollMessages(async () => client)({
+        streamId: 1, topicId: 2, partitionId: 1, consumer, strategy: PollingStrategy.Next, options
+      });
+
+      const [{ command, payload }] = commands;
+      assert.equal(command, COMMAND_CODE.PollMessagesDeferred);
+      const tail = payload.subarray(payload.length - DEFERRED_POLL_OPTIONS_SIZE);
+      assert.equal(payload[payload.length - DEFERRED_POLL_OPTIONS_SIZE - 1], onRead ? 1 : 0);
+      assert.equal(payload.readUInt32LE(payload.length - DEFERRED_POLL_OPTIONS_SIZE - 5), 50);
+      assert.equal(tail.readBigUInt64LE(DEFERRED_POLL_OFFSET.wait), 1_500_000n);
+      assert.equal(tail.readUInt32LE(DEFERRED_POLL_OFFSET.minCount), 2);
+      assert.equal(tail.readUInt32LE(DEFERRED_POLL_OFFSET.maxBytes), 4_096);
+      assert.equal(tail.readBigUInt64LE(DEFERRED_POLL_OFFSET.requestTimeout), 11_500_000n);
+    });
+  }
+
+  it('refuses an automatic commit before it sends anything', async () => {
+    const { client, commands } = stubClient([]);
+
+    await assert.rejects(pollMessages(async () => client)({
+      streamId: 1, topicId: 2, partitionId: 1, consumer, strategy: PollingStrategy.Next,
+      options: { commit: CommitPolicy.Auto() }
+    }), (error: ResponseError) => error.errorCode === INVALID_CONFIGURATION);
+    assert.equal(commands.length, 0);
+  });
+
+  it('names the position after each message and continues after the last one on its partition only', async () => {
+    const { client, commands } = stubClient([servedReply(), pollReply(1, 0)]);
+    const poll = pollMessages(async () => client);
+
+    const polled = await poll({ streamId: 1, topicId: 2, partitionId: 1, consumer, strategy: PollingStrategy.Next });
+    await poll({ streamId: 1, topicId: 2, partitionId: 1, consumer, strategy: polled.continuation() });
+
+    assert.deepEqual(polled.checkpoint(), new Checkpoint(1, 43n, SERVED));
+    assert.deepEqual(polled.checkpointAt(0), new Checkpoint(1, 41n, SERVED));
+    assert.equal(polled.checkpointAt(3), undefined);
+    const continued = commands[1];
+    assert.deepEqual(continued.options?.context, SERVED);
+    const strategyAt = continued.payload.length - DEFERRED_POLL_OPTIONS_SIZE - 14;
+    assert.equal(continued.payload[strategyAt], PollingStrategyKind.Offset);
+    assert.equal(continued.payload.readBigUInt64LE(strategyAt + 1), 44n);
+    await assert.rejects(poll({ streamId: 1, topicId: 2, partitionId: 2, consumer, strategy: polled.continuation() }),
+      (error: ResponseError) => error.errorCode === INVALID_COMMAND);
+    assert.equal(commands.length, 2);
+  });
+
+  for (const [start, kind, value] of [
+    [PollingStrategy.Next, PollingStrategyKind.Next, 0n],
+    [PollingStrategy.First, PollingStrategyKind.Offset, 0n],
+    [PollingStrategy.Offset(7n), PollingStrategyKind.Offset, 7n],
+    [PollingStrategy.Last, PollingStrategyKind.Last, 0n]
+  ] as const) {
+    it(`keeps the start ${start.kind} of an empty batch in its continuation`, async () => {
+      const { client } = stubClient([pollReply(1, 0)]);
+
+      const polled = await pollMessages(async () => client)({
+        streamId: 1, topicId: 2, partitionId: 1, consumer, strategy: start
+      });
+
+      assert.equal(polled.checkpoint(), undefined);
+      const continuation = polled.continuation();
+      assert.deepEqual([continuation.kind, continuation.value, continuation.context], [kind, value, SERVED]);
+    });
+  }
+
+  it('stores and deletes a checkpoint under the history that delivered it', async () => {
+    const { client, commands } = stubClient([Buffer.alloc(0), Buffer.alloc(0)]);
+    const checkpoint = new Checkpoint(1, 43n, SERVED);
+
+    await storePosition(async () => client)({ streamId: 1, topicId: 2, consumer: Consumer.Group(3), checkpoint });
+    await deletePosition(async () => client)({ streamId: 1, topicId: 2, consumer: Consumer.Group(3), checkpoint });
+
+    assert.deepEqual(commands.map(({ command, options }) => [command, options?.context]),
+      [[COMMAND_CODE.StoreOffset, SERVED], [COMMAND_CODE.DeleteConsumerOffset, SERVED]]);
+  });
+});
diff --git a/foreign/node/src/wire/message/poll-options.ts b/foreign/node/src/wire/message/poll-options.ts
new file mode 100644
index 0000000..18da779
--- /dev/null
+++ b/foreign/node/src/wire/message/poll-options.ts
@@ -0,0 +1,172 @@
+// 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.
+
+import { COMMAND_CODE } from '../command.code.js';
+import { responseError } from '../error.utils.js';
+import { deferredPollOptions, type DeferredPollOptions } from './deferred-poll.utils.js';
+
+const INVALID_CONFIGURATION = 2;
+const INVALID_MESSAGES_COUNT = 4009;
+/** The request timeout of a read is its wait plus this margin, as in the Rust SDK. */
+const REQUEST_TIMEOUT_MARGIN_MS = 10_000;
+
+/** When a message counts for an automatic commit policy. */
+export const CommitAfter = {
+  /**
+   * Once the application asks for the next message or batch, returns from its
+   * handler with success, or closes the reader. A handler that fails leaves its
+   * message uncounted.
+   */
+  Processing: 'processing',
+  /** When the message is handed over. */
+  Delivery: 'delivery',
+  /** Only when the application marks its checkpoint, for messages processed concurrently. */
+  Marked: 'marked'
+} as const;
+
+export type CommitAfter = typeof CommitAfter[keyof typeof CommitAfter];
+
+/** The event on which an automatic commit policy also stores. */
+export type CommitEvery =
+  | { readonly kind: 'message' }
+  | { readonly kind: 'messages', readonly count: number }
+  | { readonly kind: 'batch' };
+
+export const CommitEvery = {
+  /** Each counted message, or each counted batch of a partition reader. */
+  Message: Object.freeze({ kind: 'message' }) as CommitEvery,
+  /** The last message of each batch. */
+  Batch: Object.freeze({ kind: 'batch' }) as CommitEvery,
+  /** This many counted messages of a partition since its last store. */
+  Messages: (count: number): CommitEvery => {
+    if (!Number.isInteger(count) || count < 1)
+      throw new RangeError('the count of messages between stores must be a positive integer');
+    return Object.freeze({ kind: 'messages', count });
+  }
+};
+
+/** The settings of an automatic commit policy. Durations are in milliseconds. */
+export type AutoCommit = {
+  /** Store the latest counted position at this interval, when it moved. */
+  interval?: number,
+  /** Also store it on this event. */
+  every?: CommitEvery,
+  /** When a message counts, `CommitAfter.Processing` by default. */
+  after?: CommitAfter
+};
+
+/** Who stores the offsets of the messages that a read returns. */
+export type CommitPolicy =
+  | { readonly kind: 'manual' }
+  | { readonly kind: 'onRead' }
+  | { readonly kind: 'auto', readonly interval?: number, readonly every?: CommitEvery, readonly after: CommitAfter };
+
+/** Every 10,000 messages or 5 seconds, whichever comes first, after processing. */
+export const DEFAULT_AUTO_COMMIT: CommitPolicy = Object.freeze({
+  kind: 'auto',
+  interval: 5_000,
+  every: CommitEvery.Messages(10_000),
+  after: CommitAfter.Processing
+});
+
+export const CommitPolicy = {
+  /** Nothing is stored. The application commits checkpoints. The default. */
+  Manual: Object.freeze({ kind: 'manual' }) as CommitPolicy,
+  /**
+   * The server stores the offset of the last message of each read as part of
+   * the read, in one round trip. While the primary stays up a message is
+   * delivered at most once. A failover before the store replicates can
+   * deliver the last batch again.
+   */
+  OnRead: Object.freeze({ kind: 'onRead' }) as CommitPolicy,
+  /**
+   * The client stores the latest counted position, at least once with
+   * `CommitAfter.Processing`. A one-shot poll refuses it, a partition reader and
+   * a consumer accept it. Without settings it stores every 10,000 messages or
+   * 5 seconds after processing.
+   */
+  Auto: (settings?: AutoCommit): CommitPolicy => settings === undefined ? DEFAULT_AUTO_COMMIT : Object.freeze({
+    kind: 'auto',
+    ...(settings.interval === undefined ? {} : { interval: settings.interval }),
+    ...(settings.every === undefined ? {} : { every: settings.every }),
+    after: settings.after ?? CommitAfter.Processing
+  })
+};
+
+/**
+ * The limits of one read and who stores its offsets: a poll of one
+ * partition, or a fetch of several partitions of one primary that a reader
+ * sends, whose sections share them. Durations are in milliseconds.
+ */
+export type PollOptions = {
+  /** Most messages of one batch, 1000 by default. */
+  count: number,
+  /**
+   * How long the server waits for `minCount` messages, at most 600 s. Zero
+   * answers at once and is the default of a poll. The call gives up 10 s later.
+   */
+  maxWait: number,
+  /** How many messages end the wait early, 1 to `count`, 1 by default. */
+  minCount: number,
+  /**
+   * The largest reply, 1 MiB by default. A first message larger than it comes
+   * alone in its read.
+   */
+  maxBytes: number,
+  /** Who stores the offsets, `CommitPolicy.Manual` by default. */
+  commit: CommitPolicy
+};
+
+export const DEFAULT_POLL_OPTIONS: Readonly<PollOptions> = Object.freeze({
+  count: 1_000,
+  maxWait: 0,
+  minCount: 1,
+  maxBytes: 1_048_576,
+  commit: CommitPolicy.Manual
+});
+
+/**
+ * `options` over the defaults, checked before anything is sent: a count below
+ * 1 fails with 4009, an automatic commit without an interval or an event, or
+ * with an interval that is not positive, fails with 2 (InvalidConfiguration),
+ * and the wire limits fail as a deferred poll checks them.
+ */
+export const pollOptions = (
+  options: Partial<PollOptions> = {},
+  command: number = COMMAND_CODE.PollMessagesDeferred
+): PollOptions => {
+  const resolved = { ...DEFAULT_POLL_OPTIONS, ...options };
+  if (!Number.isInteger(resolved.count) || resolved.count < 1)
+    throw responseError(command, INVALID_MESSAGES_COUNT);
+  const { commit } = resolved;
+  if (commit.kind === 'auto' && (commit.interval === undefined && commit.every === undefined ||
+      commit.interval !== undefined && !(commit.interval > 0)))
+    throw responseError(command, INVALID_CONFIGURATION, 'an automatic commit needs a positive interval or an event');
+  wireOptions(resolved, command);
+  return resolved;
+};
+
+/** The wire limits of a read with `options`: the request timeout is the wait plus 10 s. */
+export const wireOptions = (
+  options: PollOptions,
+  command: number = COMMAND_CODE.PollMessagesDeferred
+): DeferredPollOptions => deferredPollOptions({
+  maxWait: options.maxWait,
+  minCount: options.minCount,
+  maxBytes: options.maxBytes,
+  requestTimeout: options.maxWait + REQUEST_TIMEOUT_MARGIN_MS
+}, options.count, command);
diff --git a/foreign/node/src/wire/message/poll.utils.ts b/foreign/node/src/wire/message/poll.utils.ts
index cd9936f..1df89c8 100644
--- a/foreign/node/src/wire/message/poll.utils.ts
+++ b/foreign/node/src/wire/message/poll.utils.ts
@@ -19,6 +19,7 @@
 import { type Id } from "../identifier.utils.js";
 import { type ValueOf, reverseRecord } from "../../type.utils.js";
 import { toDate } from "../serialize.utils.js";
+import { DeserializeError } from "../error.utils.js";
 import { serializeGetOffset, type Consumer } from "../offset/offset.utils.js";
 import { deserializeHeaders, type ParsedHeaderEntry } from "./header.utils.js";
 import { Transform, type TransformCallback } from "node:stream";
@@ -260,6 +261,8 @@
   payload: Buffer;
   /** Raw user header bytes */
   userHeaders: Buffer;
+  /** The frame as received: its header, payload and user headers */
+  frame: Buffer;
 };
 
 /**
@@ -271,7 +274,8 @@
  * @param b - Buffer containing serialized batch records
  * @param pos - Starting position in the buffer
  * @returns Array of decoded message frames
- * @throws Error if a record or frame is malformed
+ * @throws DeserializeError if a record or frame is malformed, with the
+ * remaining bytes from where decoding stopped as its `record`
  */
 export const deserializeBatchMessages = (
   b: Buffer,
@@ -280,27 +284,24 @@
   const messages: BatchMessage[] = [];
   const len = b.length;
   while (pos < len) {
+    const malformed = (message: string) => new DeserializeError(message, { record: b.subarray(pos) });
     if (pos + BATCH_HEADER_SIZE > len)
-      throw new Error("truncated batch header in poll response");
+      throw malformed("truncated batch header in poll response");
     const batch = deserializeBatchHeader(b, pos);
     const recordEnd = pos + Number(batch.batchLength);
     if (Number(batch.batchLength) < BATCH_HEADER_SIZE || recordEnd > len)
-      throw new Error(
-        `invalid batch length ${batch.batchLength} in poll response`,
-      );
+      throw malformed(`invalid batch length ${batch.batchLength} in poll response`);
     pos += BATCH_HEADER_SIZE;
     while (pos < recordEnd) {
       if (pos + FRAME_HEADER_SIZE > recordEnd)
-        throw new Error("truncated message frame in poll response");
+        throw malformed("truncated message frame in poll response");
       const frame = deserializeFrameHeader(b, pos);
       if (frame.reserved !== 0n)
-        throw new Error(
-          `non-zero reserved field ${frame.reserved} in message frame`,
-        );
+        throw malformed(`non-zero reserved field ${frame.reserved} in message frame`);
       const payloadEnd = pos + FRAME_HEADER_SIZE + frame.payloadLength;
       const frameEnd = payloadEnd + frame.userHeadersLength;
       if (frameEnd > recordEnd)
-        throw new Error("truncated message frame in poll response");
+        throw malformed("truncated message frame in poll response");
       messages.push({
         checksum: frame.checksum,
         id: frame.id,
@@ -310,6 +311,7 @@
           batch.originTimestamp + BigInt(frame.timestampDelta),
         payload: b.subarray(pos + FRAME_HEADER_SIZE, payloadEnd),
         userHeaders: b.subarray(payloadEnd, frameEnd),
+        frame: b.subarray(pos, frameEnd),
       });
       pos = frameEnd;
     }
@@ -323,6 +325,9 @@
  * @param b - Buffer containing serialized batch records
  * @param pos - Starting position in the buffer
  * @returns Array of deserialized messages
+ * @throws DeserializeError if a record or frame is malformed, or the user
+ * headers of a message do not decode, then with the offset and the frame of
+ * that message as its `offset` and `record`
  */
 export const deserializeMessages = (b: Buffer, pos = 0): Message[] =>
   deserializeBatchMessages(b, pos).map((message) => ({
@@ -337,12 +342,24 @@
       reserved: 0n,
     },
     payload: message.payload,
-    userHeaders:
-      message.userHeaders.length > 0
-        ? deserializeHeaders(message.userHeaders)
-        : ([] as ParsedHeaderEntry[]),
+    userHeaders: deserializeUserHeaders(message),
   }));
 
+const deserializeUserHeaders = (message: BatchMessage): ParsedHeaderEntry[] => {
+  if (message.userHeaders.length === 0) return [];
+  try {
+    return deserializeHeaders(message.userHeaders);
+  } catch (cause) {
+    throw new DeserializeError(
+      `user headers of the message at offset ${message.offset} do not decode`,
+      { cause, offset: message.offset, record: message.frame },
+    );
+  }
+};
+
+/** The partition, current offset, count and context before the records of a poll response. */
+const POLL_RESPONSE_HEADER_SIZE = 4 + 8 + 4 + PARTITION_CONTEXT_SIZE;
+
 /**
  * Deserializes a poll messages response from a buffer.
  *
@@ -351,12 +368,16 @@
  * @returns Parsed PollMessagesResponse
  */
 export const deserializePollMessages = (r: Buffer, pos = 0) => {
+  if (r.length < pos + POLL_RESPONSE_HEADER_SIZE)
+    throw new DeserializeError("truncated poll response header", { record: r.subarray(pos) });
   const partitionId = r.readUInt32LE(pos);
   const currentOffset = r.readBigUInt64LE(pos + 4);
   const count = r.readUInt32LE(pos + 12);
   const context = deserializePartitionContext(r, pos + 16);
-  const messages = deserializeMessages(r, pos + 16 + PARTITION_CONTEXT_SIZE);
-  if (messages.length !== count) throw new Error("poll response count does not match its messages");
+  const records = r.subarray(pos + POLL_RESPONSE_HEADER_SIZE);
+  const messages = deserializeMessages(records);
+  if (messages.length !== count)
+    throw new DeserializeError("poll response count does not match its messages", { record: records });
 
   return {
     partitionId,
diff --git a/foreign/node/src/wire/offset/index.ts b/foreign/node/src/wire/offset/index.ts
index 4840da1..94fb5e2 100644
--- a/foreign/node/src/wire/offset/index.ts
+++ b/foreign/node/src/wire/offset/index.ts
@@ -17,4 +17,5 @@
 
 export * from './get-offset.command.js';
 export * from './store-offset.command.js';
+export * from './position.command.js';
 export { Consumer } from './offset.utils.js';
diff --git a/foreign/node/src/wire/offset/position.command.ts b/foreign/node/src/wire/offset/position.command.ts
new file mode 100644
index 0000000..edf3ec0
--- /dev/null
+++ b/foreign/node/src/wire/offset/position.command.ts
@@ -0,0 +1,54 @@
+// 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.
+
+import type { ClientProvider } from '../../client/client.type.js';
+import type { Id } from '../identifier.utils.js';
+import type { Checkpoint } from '../message/checkpoint.js';
+import type { Consumer } from './offset.utils.js';
+import { storeOffset } from './store-offset.command.js';
+import { deleteOffset } from './delete-offset.command.js';
+
+/** Parameters of a store or delete of the position of a checkpoint. */
+export type ConsumerPosition = {
+  /** Stream identifier (ID or name) */
+  streamId: Id,
+  /** Topic identifier (ID or name) */
+  topicId: Id,
+  /** Consumer identifier (single or group) */
+  consumer: Consumer,
+  /** A checkpoint of a read, such as `PolledBatch.checkpoint()` */
+  checkpoint: Checkpoint
+};
+
+/**
+ * Commits a checkpoint: stores its offset under the partition history that
+ * delivered its message. The server refuses it with 87 when the partition was
+ * deleted and recreated since, and with 5009 when the partition moved to
+ * another member of the consumer group.
+ */
+export const storePosition = (getClient: ClientProvider) =>
+  ({ streamId, topicId, consumer, checkpoint }: ConsumerPosition): Promise<boolean> =>
+    storeOffset(getClient)({
+      streamId, topicId, consumer, partitionId: checkpoint.partitionId, offset: checkpoint.offset,
+      context: checkpoint.context
+    });
+
+/** Deletes the offset of the partition of a checkpoint under the history that delivered it. */
+export const deletePosition = (getClient: ClientProvider) =>
+  ({ streamId, topicId, consumer, checkpoint }: ConsumerPosition): Promise<boolean> =>
+    deleteOffset(getClient)({ streamId, topicId, consumer, partitionId: checkpoint.partitionId,
+      context: checkpoint.context });
diff --git a/foreign/node/src/wire/vsr/header.test.ts b/foreign/node/src/wire/vsr/header.test.ts
index ce57da7..2fad040 100644
--- a/foreign/node/src/wire/vsr/header.test.ts
+++ b/foreign/node/src/wire/vsr/header.test.ts
@@ -17,12 +17,15 @@
 
 import assert from 'node:assert/strict';
 import { describe, it } from 'node:test';
+import { COMMAND_CODE } from '../command.code.js';
 import {
   Command,
+  deferredCancellation,
   encodeRequestHeader,
   HEADER_SIZE,
   REQUEST_OFFSET
 } from './header.js';
+import { Operation } from './operation.js';
 
 describe('VSR request header', () => {
   it('encodes every field at its canonical offset', () => {
@@ -93,3 +96,41 @@
     assert.equal(header.readBigUInt64LE(REQUEST_OFFSET.session), maximum);
   });
 });
+
+describe('deferred request cancellation', () => {
+  const CLIENT = 0x1122334455667788_99AABBCCDDEEFF00n;
+  const BODY = Buffer.from('deferred body');
+  const request = (operation: number, code?: number): Buffer => Buffer.concat([
+    encodeRequestHeader({
+      size: HEADER_SIZE + BODY.length,
+      client: CLIENT,
+      request: 17n,
+      operation,
+      session: 42n,
+      nonReplicatedCode: code,
+      context: { incarnation: 7n, ownerGeneration: 8n, metadataOp: 9n }
+    }),
+    BODY
+  ]);
+
+  for (const code of [COMMAND_CODE.PollMessagesDeferred, COMMAND_CODE.FetchMessages])
+    it(`cancels command ${code} with a header-only frame of the same client, session and request`, () => {
+      const expected = encodeRequestHeader({
+        size: HEADER_SIZE,
+        client: CLIENT,
+        request: 17n,
+        operation: Operation.NonReplicated,
+        session: 42n,
+        nonReplicatedCode: COMMAND_CODE.CancelDeferredRequest
+      });
+      const cancellation = deferredCancellation(request(Operation.NonReplicated, code));
+      assert.deepEqual(cancellation, expected);
+      assert.equal(cancellation?.readUInt32LE(REQUEST_OFFSET.size), HEADER_SIZE);
+      assert.equal(cancellation?.readBigUInt64LE(REQUEST_OFFSET.partitionIncarnation), 0n);
+    });
+
+  it('has no cancellation for a request that the server never parks', () => {
+    assert.equal(deferredCancellation(request(Operation.NonReplicated, COMMAND_CODE.PollMessages)), undefined);
+    assert.equal(deferredCancellation(request(Operation.SendMessages)), undefined);
+  });
+});
diff --git a/foreign/node/src/wire/vsr/header.ts b/foreign/node/src/wire/vsr/header.ts
index 0c829c7..af9c22a 100644
--- a/foreign/node/src/wire/vsr/header.ts
+++ b/foreign/node/src/wire/vsr/header.ts
@@ -23,6 +23,9 @@
  * the headers lead with unaligned u128 fields.
  */
 
+import { COMMAND_CODE } from '../command.code.js';
+import { Operation } from './operation.js';
+
 /** Size of every consensus header, both directions. */
 export const HEADER_SIZE = 256;
 
@@ -147,6 +150,10 @@
 };
 
 const U64_MASK = 0xFFFFFFFFFFFFFFFFn;
+/** The requests that park on the server until their wait ends. */
+const DEFERRED_CODES = new Set<number>([
+  COMMAND_CODE.PollMessagesDeferred, COMMAND_CODE.FetchMessages
+]);
 
 /** Encodes one request identity and its captured partition context. */
 export const encodeRequestHeader = (fields: RequestHeaderFields): Buffer => {
@@ -169,6 +176,28 @@
 };
 
 /**
+ * The header-only frame that cancels a deferred poll (106) or a fetch (109)
+ * parked on the server. It names the client, session
+ * and request id of `request`, as `RequestHeader::deferred_cancellation` in
+ * `core/binary_protocol/src/consensus/request.rs` does. Other requests do not
+ * park, so they have none.
+ */
+export const deferredCancellation = (request: Buffer): Buffer | undefined => {
+  if (request.readUInt8(REQUEST_OFFSET.operation) !== Operation.NonReplicated ||
+      !DEFERRED_CODES.has(request.readUInt32LE(REQUEST_OFFSET.reserved)))
+    return undefined;
+  return encodeRequestHeader({
+    size: HEADER_SIZE,
+    client: request.readBigUInt64LE(REQUEST_OFFSET.client) |
+      (request.readBigUInt64LE(REQUEST_OFFSET.client + 8) << 64n),
+    request: request.readBigUInt64LE(REQUEST_OFFSET.request),
+    operation: Operation.NonReplicated,
+    session: request.readBigUInt64LE(REQUEST_OFFSET.session),
+    nonReplicatedCode: COMMAND_CODE.CancelDeferredRequest
+  });
+};
+
+/**
  * Reads the frame discriminant. `command` sits at the same offset in every
  * consensus header, so one byte discriminates the frame type.
  */
diff --git a/foreign/node/src/wire/vsr/register.ts b/foreign/node/src/wire/vsr/register.ts
index af22915..a09930b 100644
--- a/foreign/node/src/wire/vsr/register.ts
+++ b/foreign/node/src/wire/vsr/register.ts
@@ -30,11 +30,11 @@
 
 /**
  * Packed protocol semver of the wire contract this port implements,
- * `pack(0, 12, 0)` per `core/binary_protocol/src/version.rs`. Update with the
+ * `pack(0, 12, 1)` per `core/binary_protocol/src/version.rs`. Update with the
  * Rust constant during stable release preparation. Edge builds sharing this
  * number are not guaranteed to have compatible layouts.
  */
-export const IGGY_PROTOCOL_VERSION = (0 << 20) | (12 << 10) | 0;
+export const IGGY_PROTOCOL_VERSION = (0 << 20) | (12 << 10) | 1;
 
 const SDK_NAME = 'node-sdk';
 
diff --git a/foreign/php/Cargo.toml b/foreign/php/Cargo.toml
index bf84f7d..66145cc 100644
--- a/foreign/php/Cargo.toml
+++ b/foreign/php/Cargo.toml
@@ -17,7 +17,7 @@
 
 [package]
 name = "iggy-php"
-version = "0.1.7"
+version = "0.1.8"
 edition = "2024"
 authors = ["Iggy Committers <dev@iggy.apache.org>"]
 license = "Apache-2.0"
@@ -30,8 +30,8 @@
 
 [dependencies]
 bytes = "1.12.1"
-futures = "0.3.34"
 ext-php-rs = "=0.16.3"
+futures = "0.3.34"
 iggy = { path = "../../core/sdk" }
 iggy_binary_protocol = { path = "../../core/binary_protocol" }
 libc = "0.2.191"
diff --git a/foreign/php/README.md b/foreign/php/README.md
index c53dec9..2adfe70 100644
--- a/foreign/php/README.md
+++ b/foreign/php/README.md
@@ -112,6 +112,7 @@
 $stream = 'php-stream';
 $topic = 'php-topic';
 $partitionId = 0;
+$consumerId = 1;
 
 $client->createStream($stream);
 $client->createTopic($stream, $topic, 1, null, null, null, null);
@@ -121,24 +122,25 @@
     new \Iggy\SendMessage('hello from PHP 2'),
 ]);
 
-$messages = $client->pollMessages(
+$polled = $client->pollMessages(
     $stream,
     $topic,
     $partitionId,
+    $consumerId,
     \Iggy\PollingStrategy::first(),
-    10,
-    true,
+    new \Iggy\PollOptions(count: 10),
 );
 
-foreach ($messages as $message) {
+foreach ($polled->messages as $message) {
     echo $message->payload(), PHP_EOL;
 }
+
+$next = $client->pollMessages($stream, $topic, $partitionId, $consumerId, $polled->continuation());
 ```
 
-Consumer group callbacks require a finite message limit. The partition id
-argument is ignored for a consumer group, since the member reads the partitions
-the server assigns to it. After the example above, each loop below consumes one
-of its two messages:
+Consumer group callbacks require a finite message limit. The member reads the
+partitions that the server assigns to it. After the example above, each loop
+below consumes one of its two messages:
 
 ```php
 <?php
@@ -147,33 +149,44 @@
     'php-consumer',
     $stream,
     $topic,
-    null,
     \Iggy\PollingStrategy::next(),
-    10,
-    \Iggy\AutoCommit::disabled(),
-    true,
-    true,
-    null,
-    null,
-    null,
-    null,
-    false,
 );
 
 $consumer->consumeMessages(
     function (\Iggy\ReceiveMessage $message) use ($consumer): void {
         echo $message->payload(), PHP_EOL;
-        $consumer->storePosition($message->position());
+        $consumer->commit($message->checkpoint());
     },
     1,
 );
 
 foreach ($consumer->iterMessages() as $message) {
     echo $message->payload(), PHP_EOL;
-    $consumer->storePosition($message->position());
+    $consumer->commit($message->checkpoint());
 
     break;
 }
+
+$consumer->shutdown();
+```
+
+A reader returns whole batches of one partition, and it reads ahead in the
+background:
+
+```php
+<?php
+
+$reader = $client->reader($stream, $topic, 'php-reader', [$partitionId], \Iggy\PollingStrategy::first());
+
+try {
+    $batch = $reader->next();
+    foreach ($batch->messages as $message) {
+        echo $message->payload(), PHP_EOL;
+    }
+    $reader->commit($batch->checkpoint());
+} finally {
+    $reader->close();
+}
 ```
 
 ## Tests
@@ -322,7 +335,10 @@
   `max_pending_operation_bytes`, for example
   `iggy+quic://iggy:iggy@localhost:8080?max_pending_operations=256&max_pending_operation_bytes=67108864`.
 - `Iggy\Exception\PendingOperationException` preserves the read-only `operation_id`
-  decimal string and `kind` (`uncertain`, `lane_blocked`, or `not_retained`).
+  decimal string and `kind` (`uncertain`, `lane_blocked`, `fetch_progress_uncertain`,
+  or `not_retained`).
+  Its read-only `partition_id` names the blocked partition of a fetch, or is
+  `null` for other pending operations.
   `getCode()` returns the SDK error code. Keep IDs as strings: PHP integers cannot
   represent the full unsigned 64-bit range. `lane_blocked` identifies an earlier
   operation, and the blocked call was not sent. `not_retained` does not prove
@@ -334,9 +350,14 @@
   `abandonOperation(string $operation_id)` releases it without proving whether it
   committed. Both methods reject malformed or overflowing IDs. HTTP does not support
   retained-operation recovery. A session reset, disconnect or shutdown discards
-  retained operations. The client keeps the proved outcomes of its last
+  replayable retained mutations. The client keeps the proved outcomes of its last
   `max_pending_operations` replays, also across a session reset. A later
   `resolveOperation()` of a proved operation returns the same outcome without a request.
+- Unknown committing fetch progress raises code 14010 with kind `fetch_progress_uncertain`. Its local recovery ID and affected progress blocks survive reconnect and session replacement.
+
+  `resolveOperation()` returns that error without I/O. `abandonOperation()` explicitly releases all its local blocks while accepting the unknown outcome. Abandonment does not stop the old request.
+
+  GetOffset and stateless reads remain available for inspection. Raw partial replies keep wire statuses. A later affected progress call raises code 14009 with the partition and recovery ID. Fetch retention uses a separate bounded pool with the configured operation/byte limits and stores target metadata only.
 - Receipt access follows the server's [session and revocation policy](../../core/server/README.md#sessions-and-retry-receipts).
   An unresolved operation cannot recover its receipt after its original session
   ends, even after a new login or account reactivation. A fresh send can duplicate it.
@@ -354,32 +375,76 @@
   current consumer partition, and is rejected until at least one message has been
   polled. Pass an explicit partition id before the first poll.
 - `consumeMessages()` requires an explicit finite limit. It does not run forever
-  by default.
+  by default. An exception of the callback ends the run, and `consumeMessages()`
+  throws it. In the callback, `commit()`, `storeOffset()`, `deleteOffset()` and the
+  getters work. `seek()`, `shutdown()`, `iterMessages()` and `consumeMessages()` throw
+  there.
 - `iterMessages()` returns a PHP `Iterator` and can be used with `foreach`.
   Break out of the loop when the caller's processing limit or shutdown signal is reached.
-- Without an `AutoCommit` argument, the consumer stores no offset, as with
-  `AutoCommit::disabled()`. Store positions after processing, or each restart
-  reads the same messages again.
-- `AutoCommit::when()` may queue an offset commit before the PHP callback runs.
-  If callback success must control commits, use `AutoCommit::disabled()` and call
-  `storePosition($message->position())` after the callback work succeeds.
-- Every received message keeps the partition context of the poll that delivered it:
+  A new `foreach` over the same iterator reads on after the last message it yielded.
+- `pollMessages()` returns an `Iggy\PolledMessages` batch with `partition_id`,
+  `current_offset`, `count` and `messages`. `Iggy\PollOptions` sets `count`,
+  `max_wait_micros`, `min_count`, `max_bytes` and `commit`, and refuses
+  invalid limits before anything is sent.
+- `commit` takes an `Iggy\CommitPolicy`: `CommitPolicy::manual()`, the default, stores
+  nothing, `CommitPolicy::onRead()` makes the server store the offset of each reply, and
+  `CommitPolicy::auto(new AutoCommit(...))` makes a reader or a consumer store the latest
+  counted position every `interval_micros` or on a `CommitEvery` event, and on close or
+  shutdown. A message counts as its `CommitAfter` says: after processing (the default),
+  at delivery, or when `mark($checkpoint)` marks it. `AutoCommit::default()` stores every
+  10,000 messages or 5 seconds after processing. `pollMessages()` refuses `auto()`.
+- With `CommitPolicy::onRead()`, the server stores the offset of each reply. A lost reply, also to a `disconnect()` while the poll waits, then throws `FetchProgressUncertain` (14010), and the partition stays blocked until `abandonOperation()`. `disconnect()` closes the connection until the next `connect()`, after which `loginUser()` signs in again.
+- `consumerGroup()` builds a member of a consumer group, and creates a missing group
+  by default. `reader()` builds a batch reader of chosen partitions as a standalone
+  consumer, and `groupReader()` a batch reader of an existing consumer group.
+- `PartitionReader::next()` waits until a batch arrives. Give `timeout_micros` to
+  limit the wait. At the timeout, `next()` returns `null`, and the script gets control
+  back. A batch that arrives later stays in the reader for the next call.
+- `startPollMessages()` runs the poll of `pollMessages()` in the background and
+  returns a `PendingPoll` at once. `wait($timeout_micros)` returns its batch, or
+  `null` while the poll still runs, and `cancel()` drops the request, which cancels
+  a parked deferred poll on the server. A parked poll holds its connection, so start
+  it on a client that sends nothing else meanwhile.
+- The default options of a reader or a consumer wait 5 seconds for up to 1000
+  messages. A wait of 0 polls an idle topic in a busy loop. `prefetch_bytes`
+  (16 MiB) and `prefetch_messages` bound the requests in flight and the queued
+  batches.
+- With `CommitPolicy::manual()`, the consumer stores no offset. Commit checkpoints
+  after processing, or each restart reads the same messages again.
+- Under `CommitPolicy::auto()`, `consumeMessages()` counts a message after its callback
+  returns with `CommitAfter::Processing`, and before it with `CommitAfter::Delivery`. An
+  exception of the callback ends the run, and under `CommitAfter::Processing` its message
+  stays uncounted, so the next run reads it again.
+- Every received message has a `checkpoint()`: the position after the message, with
   the partition incarnation, which changes when the partition is deleted and recreated,
-  and the consumer group owner generation. `Iggy\Consumer::storePosition()` commits a
-  message's `position()` under that context, so a commit after a recreation or an owner
-  change is refused instead of landing on the newer partition. `storeOffset()` commits
-  under the context of the latest message consumed from that partition.
-- `Iggy\PollingStrategy::withContext()` continues a poll at an offset under the context
-  that produced it, for example
-  `PollingStrategy::offset($message->offset() + 1)->withContext($message->context())`.
-  A stale context fails the poll. To continue in another process, save the context's
-  `incarnation`, `owner_generation` and `metadata_op` with the offset and rebuild it with
-  `new Iggy\PartitionContext(...)`.
-- A poll or offset write without a caller context uses the context of its route. It can
-  fail once with `HistoryUnavailable` (87), or `ConsumerGroupPartitionNotOwned` (5009)
-  for a consumer group, after another client deleted and recreated the partition. The
-  failed route is dropped, and the next call routes again. Both errors surface as
-  `Iggy\Exception\IggyException`.
+  and the consumer group owner generation. Commit it with `Iggy\Consumer::commit()` or
+  `Iggy\PartitionReader::commit()`, on the consumer or the reader that delivered it. A
+  commit after a recreation or an owner change is refused instead of landing on the newer
+  partition. `storeOffset()` commits under the context of the latest message consumed
+  from that partition. `PolledMessages::checkpoint()` covers the whole batch.
+- `PolledMessages::continuation()` continues a poll after the batch, under the
+  partition context of the batch. A read after a recreation fails with
+  `HistoryUnavailable` (87). A continuation cannot move to another process: store a
+  checkpoint there, and start the other process with `PollingStrategy::next()`.
+- `Client::deleteConsumerPosition()` deletes the offset stored for the partition of a
+  checkpoint, under the partition history that delivered it.
+- A failed read of a reader or of `iterMessages()` throws `Iggy\Exception\ReadException`.
+  `consumeMessages()` throws it only for an error that ends its run, such as
+  `HistoryUnavailable` (87), and the consumer retries the other failed reads.
+  `getCode()` is the error code, `getPrevious()` the typed exception that a direct call
+  would throw, `partition_id` the partition or `null`, and `skip` the continuation after
+  a message that does not decode. The reader retries by itself. `HistoryUnavailable`
+  (87) stops the partition until `seek()`.
+- Call `close()` on a reader and `shutdown()` on a consumer before the script ends.
+  Under `CommitPolicy::auto()`, they store the latest counted position first. Under
+  `CommitPolicy::onRead()`, they move the offset back to the last delivered
+  message, so the next run reads the prefetched messages again. Destroying the object
+  without them skips those messages.
+- A poll with a fresh strategy, or an offset write without a checkpoint, uses the
+  context of its route. It can fail once with `HistoryUnavailable` (87), or
+  `ConsumerGroupPartitionNotOwned` (5009) for a consumer group, after another client
+  deleted and recreated the partition. The failed route is dropped, and the next call
+  routes again. Both errors surface as `Iggy\Exception\IggyException`.
 - `Iggy\PollingStrategy::timestamp()` and `Iggy\PollingStrategy::timestampMicros()`
   expect microseconds since the Unix epoch. Use
   `Iggy\PollingStrategy::timestampSeconds()` for PHP `time()` values.
diff --git a/foreign/php/iggy-php.stubs.php b/foreign/php/iggy-php.stubs.php
index ea1e7fc..301f516 100644
--- a/foreign/php/iggy-php.stubs.php
+++ b/foreign/php/iggy-php.stubs.php
@@ -20,57 +20,47 @@
 // Stubs for iggy-php
 
 namespace Iggy {
+    /**
+     * When CommitPolicy::auto() stores. It also stores on close, on shutdown and before a
+     * rebalance takes a partition away. A store only moves forward.
+     */
     class AutoCommit {
-        public function __construct() {}
+        /**
+         * Stores the latest counted position every `interval_micros`, when it moved, and also on
+         * the event `every`. A message counts as `after` says, CommitAfter::Processing when null.
+         * Throws for a zero interval, and without an interval and an event, which would store
+         * only on close.
+         *
+         * @param mixed $interval_micros
+         * @param \Iggy\CommitEvery|null $every
+         * @param \Iggy\CommitAfter|null $after
+         */
+        public function __construct(mixed $interval_micros = null, ?\Iggy\CommitEvery $every = null, ?\Iggy\CommitAfter $after = null) {}
 
         /**
+         * Every 10,000 messages or every 5 seconds, whichever comes first, after processing.
+         *
          * @return \Iggy\AutoCommit
          */
-        public static function disabled(): \Iggy\AutoCommit {}
-
-        /**
-         * @param int $interval_micros
-         * @return \Iggy\AutoCommit
-         */
-        public static function interval(int $interval_micros): \Iggy\AutoCommit {}
-
-        /**
-         * @param int $interval_micros
-         * @param \Iggy\AutoCommitWhen $when
-         * @return \Iggy\AutoCommit
-         */
-        public static function intervalOrWhen(int $interval_micros, \Iggy\AutoCommitWhen $when): \Iggy\AutoCommit {}
-
-        /**
-         * @param \Iggy\AutoCommitWhen $when
-         * @return \Iggy\AutoCommit
-         */
-        public static function when(\Iggy\AutoCommitWhen $when): \Iggy\AutoCommit {}
+        public static function default(): \Iggy\AutoCommit {}
     }
 
-    class AutoCommitWhen {
+    /**
+     * A PHP class representing the position after one delivered message.
+     *
+     * A commit stores its offset, so it covers every earlier message of the partition.
+     */
+    class Checkpoint {
+        /**
+         * The offset of the delivered message, which a commit stores.
+         *
+         * @var int
+         */
+        public readonly int $offset;
+
+        public readonly int $partition_id;
+
         public function __construct() {}
-
-        /**
-         * @return \Iggy\AutoCommitWhen
-         */
-        public static function consumingAllMessages(): \Iggy\AutoCommitWhen {}
-
-        /**
-         * @return \Iggy\AutoCommitWhen
-         */
-        public static function consumingEachMessage(): \Iggy\AutoCommitWhen {}
-
-        /**
-         * @param int $n
-         * @return \Iggy\AutoCommitWhen
-         */
-        public static function consumingEveryNthMessage(int $n): \Iggy\AutoCommitWhen {}
-
-        /**
-         * @return \Iggy\AutoCommitWhen
-         */
-        public static function pollingMessages(): \Iggy\AutoCommitWhen {}
     }
 
     /**
@@ -101,29 +91,54 @@
         public function connect(): void {}
 
         /**
-         * Creates and initializes a consumer group consumer.
-         *
-         * `$partition_id` is ignored for a consumer group: the member reads the partitions
-         * the server assigns to it. `$poll_interval_micros` must be null: the consumer waits
-         * on the server for new messages.
+         * Creates and initializes a standalone consumer of `partitions`, identified by `name`: it
+         * stores its offsets under that consumer name and joins no group. `partitions` must not be
+         * empty. The other arguments are those of consumerGroup().
          *
          * @param string $name
          * @param string $stream
          * @param string $topic
-         * @param mixed $partition_id
+         * @param array $partitions
          * @param \Iggy\PollingStrategy|null $polling_strategy
-         * @param mixed $batch_length
-         * @param \Iggy\AutoCommit|null $auto_commit
-         * @param bool $create_consumer_group_if_not_exists
-         * @param bool $auto_join_consumer_group
-         * @param mixed $poll_interval_micros
+         * @param \Iggy\PollOptions|null $poll_options
+         * @param mixed $prefetch_bytes
+         * @param mixed $prefetch_messages
          * @param mixed $polling_retry_interval_micros
          * @param mixed $init_retries
          * @param mixed $init_retry_interval_micros
          * @param bool $allow_replay
          * @return \Iggy\Consumer
          */
-        public function consumerGroup(string $name, string $stream, string $topic, mixed $partition_id = null, ?\Iggy\PollingStrategy $polling_strategy = null, mixed $batch_length = null, ?\Iggy\AutoCommit $auto_commit = null, bool $create_consumer_group_if_not_exists = true, bool $auto_join_consumer_group = true, mixed $poll_interval_micros = null, mixed $polling_retry_interval_micros = null, mixed $init_retries = null, mixed $init_retry_interval_micros = null, bool $allow_replay = false): \Iggy\Consumer {}
+        public function consumer(string $name, string $stream, string $topic, array $partitions, ?\Iggy\PollingStrategy $polling_strategy = null, ?\Iggy\PollOptions $poll_options = null, mixed $prefetch_bytes = null, mixed $prefetch_messages = null, mixed $polling_retry_interval_micros = null, mixed $init_retries = null, mixed $init_retry_interval_micros = null, bool $allow_replay = false): \Iggy\Consumer {}
+
+        /**
+         * Creates and initializes a consumer group consumer.
+         *
+         * The member reads the partitions that the server assigns to it. `polling_strategy` is
+         * the start of a partition without a stored offset, PollingStrategy::next() when null, and
+         * a continuation is refused. `poll_options` defaults to a wait of 5 seconds for up to 1000
+         * messages per request: a consumer that waits 0 polls an idle topic in a busy loop.
+         * The `commit` of `poll_options` says when offsets are stored. CommitPolicy::manual(), the
+         * default, stores nothing: commit checkpoints after processing, or each restart reads the
+         * same messages again. With CommitPolicy::onRead() the server stores the offset of each
+         * batch that it reads, and CommitPolicy::auto() stores counted positions.
+         * `prefetch_bytes` and `prefetch_messages` are those of reader().
+         *
+         * @param string $name
+         * @param string $stream
+         * @param string $topic
+         * @param \Iggy\PollingStrategy|null $polling_strategy
+         * @param \Iggy\PollOptions|null $poll_options
+         * @param mixed $prefetch_bytes
+         * @param mixed $prefetch_messages
+         * @param bool $create_consumer_group_if_not_exists
+         * @param mixed $polling_retry_interval_micros
+         * @param mixed $init_retries
+         * @param mixed $init_retry_interval_micros
+         * @param bool $allow_replay
+         * @return \Iggy\Consumer
+         */
+        public function consumerGroup(string $name, string $stream, string $topic, ?\Iggy\PollingStrategy $polling_strategy = null, ?\Iggy\PollOptions $poll_options = null, mixed $prefetch_bytes = null, mixed $prefetch_messages = null, bool $create_consumer_group_if_not_exists = true, mixed $polling_retry_interval_micros = null, mixed $init_retries = null, mixed $init_retry_interval_micros = null, bool $allow_replay = false): \Iggy\Consumer {}
 
         /**
          * Creates a new stream.
@@ -173,6 +188,39 @@
         public function createTopic(mixed $stream, string $name, int $partitions_count, ?string $compression_algorithm = null, mixed $message_expiry_micros = null, mixed $max_topic_size = null, mixed $segment_size = null, ?\Iggy\Durability $durability = null, ?\Iggy\Durability $consumer_offset_durability = null, mixed $messages_required_to_save = null, mixed $size_of_messages_required_to_save = null, ?bool $preallocate_segments = null, ?string $partition_resize_policy = null, ?array $raw = null): void {}
 
         /**
+         * Deletes the offset stored on `partition_id` for a standalone consumer, or for a consumer
+         * group when `group` is true. Throws ConsumerOffsetNotFound (3021) when nothing is stored.
+         * Prefer deleteConsumerPosition() with a checkpoint.
+         *
+         * @param mixed $stream
+         * @param mixed $topic
+         * @param mixed $consumer
+         * @param int $partition_id
+         * @param bool|null $group
+         * @return void
+         */
+        public function deleteConsumerOffset(mixed $stream, mixed $topic, mixed $consumer, int $partition_id, ?bool $group = null): void {}
+
+        /**
+         * Deletes the stored offset of the partition of a checkpoint for a standalone consumer, or
+         * for a consumer group when `group` is true, under the partition history that delivered
+         * the checkpoint. The offset of the checkpoint is not used.
+         *
+         * Throws HistoryUnavailable (87) after the partition was deleted and created again,
+         * ConsumerGroupPartitionNotOwned (5009) after its consumer group owner changed, and
+         * ConsumerOffsetNotFound (3021) when no offset is stored. HTTP sends no partition history,
+         * so there the server deletes the offset of the history it holds.
+         *
+         * @param mixed $stream
+         * @param mixed $topic
+         * @param mixed $consumer
+         * @param \Iggy\Checkpoint $checkpoint
+         * @param bool|null $group
+         * @return void
+         */
+        public function deleteConsumerPosition(mixed $stream, mixed $topic, mixed $consumer, \Iggy\Checkpoint $checkpoint, ?bool $group = null): void {}
+
+        /**
          * Deletes a stream by id or name.
          *
          * @param mixed $stream_id
@@ -199,6 +247,17 @@
         public function describeOptions(\Iggy\OptionsScope $scope): array {}
 
         /**
+         * Closes the current connection. Repeated calls are safe. Call connect() to use the client
+         * again, and loginUser() after it, because the sign-in is dropped. Until then requests
+         * fail, and nothing connects the client in the background. A poll with
+         * CommitPolicy::onRead() that waits throws FetchProgressUncertain (14010), and its partition
+         * stays blocked across the reconnect until abandonOperation().
+         *
+         * @return void
+         */
+        public function disconnect(): void {}
+
+        /**
          * Fingerprint of a stream key given as canonical padded standard base64 of 32 bytes, the
          * value `StreamDetails::$encryption_key_fingerprint` reports for a stream created with it.
          * The key is marked `#[\SensitiveParameter]`, so exception traces never hold it.
@@ -219,6 +278,20 @@
         public static function fromConnectionString(string $connection_string): \Iggy\Client {}
 
         /**
+         * Returns the offset stored on `partition_id` for a standalone consumer, or for a consumer
+         * group when `group` is true, or null when nothing is stored. In a cluster the node of the
+         * connection answers from its own replica, which can lag a store that just returned.
+         *
+         * @param mixed $stream
+         * @param mixed $topic
+         * @param mixed $consumer
+         * @param int $partition_id
+         * @param bool|null $group
+         * @return \Iggy\ConsumerOffsetInfo|null
+         */
+        public function getConsumerOffset(mixed $stream, mixed $topic, mixed $consumer, int $partition_id, ?bool $group = null): ?\Iggy\ConsumerOffsetInfo {}
+
+        /**
          * Reads one page of the metadata change log, or of the current state.
          *
          * Needs root or the global `manage_servers` permission. `groups` takes
@@ -255,6 +328,24 @@
         public function getTopic(mixed $stream_id, mixed $topic_id): ?\Iggy\TopicDetails {}
 
         /**
+         * Builds a reader of the partitions that a consumer group assigns to this member.
+         *
+         * The group must exist: the reader joins it, joins again after a session loss, and leaves
+         * it in close(). HTTP cannot join a group, so it throws FeatureUnavailable. The other
+         * arguments are those of reader().
+         *
+         * @param mixed $stream
+         * @param mixed $topic
+         * @param mixed $group
+         * @param \Iggy\PollingStrategy|null $start
+         * @param \Iggy\PollOptions|null $options
+         * @param mixed $prefetch_bytes
+         * @param mixed $prefetch_messages
+         * @return \Iggy\PartitionReader
+         */
+        public function groupReader(mixed $stream, mixed $topic, mixed $group, ?\Iggy\PollingStrategy $start = null, ?\Iggy\PollOptions $options = null, mixed $prefetch_bytes = null, mixed $prefetch_messages = null): \Iggy\PartitionReader {}
+
+        /**
          * Logs in the user with the given credentials.
          *
          * The password is marked `#[\SensitiveParameter]`, so exception traces never hold it.
@@ -273,29 +364,66 @@
         public function ping(): void {}
 
         /**
-         * Polls messages from the specified topic and partition.
+         * Polls messages from one partition of a topic, as the standalone consumer `consumer`.
          *
-         * Each message keeps the partition context of this poll. To continue at an offset under
-         * that context, pass PollingStrategy::offset($offset)->withContext($message->context()).
-         * A stale context fails with HistoryUnavailable (87).
+         * PollingStrategy::next() starts at the stored offset of `consumer`. `options` defaults to
+         * `new PollOptions()`: up to 1000 messages and no wait. With CommitPolicy::onRead(),
+         * the server stores the offset of the batch for `consumer`.
          *
-         * Without a context, the poll uses the context of its route. It can fail once with
+         * To read on, pass `$polled->continuation()` as the next strategy. It keeps the partition
+         * history of the batch, so a read after the partition was recreated fails with
+         * HistoryUnavailable (87), and a read of another partition refuses it with InvalidCommand
+         * (3).
+         *
+         * A fresh strategy uses the context of its route. It can fail once with
          * HistoryUnavailable (87) after another client deleted and recreated the partition. The
          * failed route is dropped, and the next call routes again.
          *
+         * `consumer` polls as a standalone consumer, or as a member of the consumer group of that
+         * id when `group` is true, which reads only the partitions assigned to it.
+         *
+         * Under CommitPolicy::onRead(), a lost reply throws FetchProgressUncertain (14010),
+         * because the server can still store the offset. The partition stays blocked, and the
+         * next poll of it throws PartitionLaneBlocked (14009), until abandonOperation().
+         *
          * @param mixed $stream
          * @param mixed $topic
          * @param int $partition_id
+         * @param mixed $consumer
          * @param \Iggy\PollingStrategy $polling_strategy
-         * @param int $count
-         * @param bool $auto_commit
-         * @return array
+         * @param \Iggy\PollOptions|null $options
+         * @param bool|null $group
+         * @return \Iggy\PolledMessages
          */
-        public function pollMessages(mixed $stream, mixed $topic, int $partition_id, \Iggy\PollingStrategy $polling_strategy, int $count, bool $auto_commit): array {}
+        public function pollMessages(mixed $stream, mixed $topic, int $partition_id, mixed $consumer, \Iggy\PollingStrategy $polling_strategy, ?\Iggy\PollOptions $options = null, ?bool $group = null): \Iggy\PolledMessages {}
+
+        /**
+         * Builds a reader of chosen partitions as a standalone consumer.
+         *
+         * `partitions` must not be empty. `start` is the start of a partition without a stored
+         * offset, PollingStrategy::next() when null, and a continuation is refused. `options`
+         * defaults to a wait of 5 seconds for up to 1000 messages per request: a reader that waits
+         * 0 polls an idle topic in a busy loop. `prefetch_bytes`, 16 MiB when null, and
+         * `prefetch_messages`, the larger of 16000 and `count` when null, bound the requests in
+         * flight and the queued batches.
+         *
+         * @param mixed $stream
+         * @param mixed $topic
+         * @param mixed $consumer
+         * @param array $partitions
+         * @param \Iggy\PollingStrategy|null $start
+         * @param \Iggy\PollOptions|null $options
+         * @param mixed $prefetch_bytes
+         * @param mixed $prefetch_messages
+         * @return \Iggy\PartitionReader
+         */
+        public function reader(mixed $stream, mixed $topic, mixed $consumer, array $partitions, ?\Iggy\PollingStrategy $start = null, ?\Iggy\PollOptions $options = null, mixed $prefetch_bytes = null, mixed $prefetch_messages = null): \Iggy\PartitionReader {}
 
         /**
          * Replays an exact retained operation. A committed rejection raises its original error
          * and releases its lane; uncertainty keeps it retained. HTTP is unsupported.
+         * A fetch progress block raises code 14010 without replay. Only explicit
+         * abandonOperation releases it, accepting the unknown server outcome.
          * Operation IDs use decimal strings to preserve the full unsigned 64-bit range.
          *
          * @param string $operation_id
@@ -341,6 +469,56 @@
         public function sendMessages(mixed $stream, mixed $topic, int $partition_id, array $messages): \Iggy\SendMessagesResponse {}
 
         /**
+         * Starts the poll of pollMessages() in the background and returns at once, so the caller
+         * can wait for its batch in steps with PendingPoll::wait() or cancel it with
+         * PendingPoll::cancel(). Invalid arguments throw here, a failed poll throws from wait().
+         *
+         * @param mixed $stream
+         * @param mixed $topic
+         * @param int $partition_id
+         * @param mixed $consumer
+         * @param \Iggy\PollingStrategy $polling_strategy
+         * @param \Iggy\PollOptions|null $options
+         * @param bool|null $group
+         * @return \Iggy\PendingPoll
+         */
+        public function startPollMessages(mixed $stream, mixed $topic, int $partition_id, mixed $consumer, \Iggy\PollingStrategy $polling_strategy, ?\Iggy\PollOptions $options = null, ?bool $group = null): \Iggy\PendingPoll {}
+
+        /**
+         * Stores `offset` on `partition_id` for a standalone consumer, or for a consumer group
+         * when `group` is true, under the partition history of its route. After another client
+         * deleted and created the partition again, it can throw HistoryUnavailable (87) once, and
+         * the next call routes again. Prefer storeConsumerPosition() with a checkpoint.
+         *
+         * @param mixed $stream
+         * @param mixed $topic
+         * @param mixed $consumer
+         * @param int $partition_id
+         * @param int $offset
+         * @param bool|null $group
+         * @return void
+         */
+        public function storeConsumerOffset(mixed $stream, mixed $topic, mixed $consumer, int $partition_id, int $offset, ?bool $group = null): void {}
+
+        /**
+         * Stores the offset of a checkpoint for a standalone consumer, or for a consumer group
+         * when `group` is true, under the partition history that delivered the checkpoint, which
+         * covers every earlier message of its partition.
+         *
+         * Throws HistoryUnavailable (87) after the partition was deleted and created again, and
+         * ConsumerGroupPartitionNotOwned (5009) after its consumer group owner changed, instead of
+         * storing the offset on the new partition.
+         *
+         * @param mixed $stream
+         * @param mixed $topic
+         * @param mixed $consumer
+         * @param \Iggy\Checkpoint $checkpoint
+         * @param bool|null $group
+         * @return void
+         */
+        public function storeConsumerPosition(mixed $stream, mixed $topic, mixed $consumer, \Iggy\Checkpoint $checkpoint, ?bool $group = null): void {}
+
+        /**
          * Renames a stream.
          *
          * A null name keeps the current one. No stream option is resettable, so a non-empty
@@ -383,31 +561,146 @@
     }
 
     /**
+     * When a message counts for CommitPolicy::auto().
+     */
+    enum CommitAfter: string {
+    /**
+     * When the application asks for the next message or batch, returns from its callback, or
+     * closes the reader. A callback that throws leaves its message uncounted.
+     */
+      case Processing = 'processing';
+    /**
+     * When the message or the batch is handed over.
+     */
+      case Delivery = 'delivery';
+    /**
+     * Only when the application calls mark() with its checkpoint.
+     */
+      case Marked = 'marked';
+    }
+
+    /**
+     * The event on which CommitPolicy::auto() also stores.
+     */
+    class CommitEvery {
+        public function __construct() {}
+
+        /**
+         * The last message of each batch.
+         *
+         * @return \Iggy\CommitEvery
+         */
+        public static function batch(): \Iggy\CommitEvery {}
+
+        /**
+         * Each counted message, or each counted batch of a reader.
+         *
+         * @return \Iggy\CommitEvery
+         */
+        public static function message(): \Iggy\CommitEvery {}
+
+        /**
+         * `count` counted messages of a partition since its last store. Throws for zero.
+         *
+         * @param int $count
+         * @return \Iggy\CommitEvery
+         */
+        public static function messages(int $count): \Iggy\CommitEvery {}
+    }
+
+    /**
+     * When the offsets of the messages that a read returns are stored.
+     */
+    class CommitPolicy {
+        /**
+         * `manual`, `on_read` or `auto`.
+         *
+         * @var string
+         */
+        public readonly string $kind;
+
+        public function __construct() {}
+
+        /**
+         * A reader or a consumer stores the latest counted position, at least once with
+         * CommitAfter::Processing. pollMessages() refuses it with InvalidConfiguration (2).
+         *
+         * @param \Iggy\AutoCommit $auto
+         * @return \Iggy\CommitPolicy
+         */
+        public static function auto(\Iggy\AutoCommit $auto): \Iggy\CommitPolicy {}
+
+        /**
+         * Nothing is stored. The application commits checkpoints. The default.
+         *
+         * @return \Iggy\CommitPolicy
+         */
+        public static function manual(): \Iggy\CommitPolicy {}
+
+        /**
+         * The server stores the offset of the last message of each read as part of the read, in one
+         * round trip. While the primary stays up a message is delivered at most once. A failover
+         * before the store replicates can deliver the last batch again.
+         *
+         * @return \Iggy\CommitPolicy
+         */
+        public static function onRead(): \Iggy\CommitPolicy {}
+    }
+
+    /**
      * A PHP class representing the Iggy consumer.
      */
     class Consumer {
         public function __construct() {}
 
         /**
-         * Consumes messages with a PHP callback.
+         * Stores the offset of a checkpoint that this consumer delivered, which covers every
+         * earlier message of its partition.
+         *
+         * Pass $message->checkpoint() to commit a message after later polls or a rebalance. A
+         * checkpoint of an older generation of its partition, after seek() or a new assignment, or
+         * at or below the last commit of its generation, stores nothing. One from a deleted or
+         * recreated partition fails with HistoryUnavailable (87), and one from a previous owner
+         * fails with ConsumerGroupPartitionNotOwned (5009). A checkpoint that this consumer did not
+         * deliver, and every commit under CommitPolicy::onRead(), fail with InvalidCommand (3).
+         *
+         * @param \Iggy\Checkpoint $checkpoint
+         * @return void
+         */
+        public function commit(\Iggy\Checkpoint $checkpoint): void {}
+
+        /**
+         * Consumes messages with a PHP callback, and returns how many it consumed.
          *
          * The callback is called as callback(ReceiveMessage $message). A finite limit is required.
+         * The run ends after `limit` messages, after shutdown(), or with an exception.
          *
-         * With AutoCommit::when(), offsets may already be queued for commit before the
-         * PHP callback runs. Use AutoCommit::disabled() and call
-         * storePosition($message->position()) after a successful callback when at-least-once
-         * callback processing is required.
+         * Under CommitPolicy::auto(), a message counts before its callback with
+         * CommitAfter::Delivery, and after its callback returns with CommitAfter::Processing. An
+         * exception of the callback ends the run, and consumeMessages() throws it. Its message
+         * then stays uncounted under CommitAfter::Processing, so the next run reads it again.
          *
-         * After another client deleted and recreated a partition, or after its consumer group
-         * owner changed, a poll can fail once with HistoryUnavailable (87) or
-         * ConsumerGroupPartitionNotOwned (5009). The failed route is dropped, and the next call
-         * routes again.
+         * The consumer retries a failed read by itself and does not throw it. An error that ends
+         * the run throws Iggy\Exception\ReadException: HistoryUnavailable (87), which stops its
+         * partition until seek(), and under CommitPolicy::onRead() an unknown outcome, which
+         * stops its partition until its operation is abandoned and the partition is sought. A
+         * blocked lane, a shut down client and any error while the client cannot reconnect by
+         * itself also end the run.
+         *
+         * With `idle_timeout_micros`, a run that gets no message for that long ends and returns
+         * the count of the messages it consumed, so a single-threaded worker regains control on an
+         * idle topic. Null waits without a limit.
+         *
+         * The callback can call commit(), mark(), storeOffset(), deleteOffset() and the getters. seek(),
+         * shutdown(), iterMessages() and consumeMessages() throw in the callback, because the run
+         * holds the consumer.
          *
          * @param callable $callback
          * @param int $limit
+         * @param mixed $idle_timeout_micros
          * @return int
          */
-        public function consumeMessages(callable $callback, int $limit): int {}
+        public function consumeMessages(callable $callback, int $limit, mixed $idle_timeout_micros = null): int {}
 
         /**
          * Deletes the stored offset for the provided partition id.
@@ -442,14 +735,30 @@
         /**
          * Returns an iterator over messages for use with foreach.
          *
-         * After another client deleted and recreated a partition, or after its consumer group
-         * owner changed, a poll can fail once with HistoryUnavailable (87) or
-         * ConsumerGroupPartitionNotOwned (5009). The failed route is dropped, and the next call
-         * routes again.
+         * With `idle_timeout_micros`, the iteration ends when no message arrives for that long,
+         * and a later foreach over the same iterator reads on. Null waits without a limit.
          *
+         * Under CommitPolicy::auto(), a message counts when the iterator yields it with
+         * CommitAfter::Delivery, and when the iterator moves past it with CommitAfter::Processing. A
+         * failed read throws Iggy\Exception\ReadException. The consumer stays usable and retries by
+         * itself. HistoryUnavailable (87) stops its partition until seek().
+         *
+         * @param mixed $idle_timeout_micros
          * @return \Iggy\MessageIterator
          */
-        public function iterMessages(): \Iggy\MessageIterator {}
+        public function iterMessages(mixed $idle_timeout_micros = null): \Iggy\MessageIterator {}
+
+        /**
+         * Counts a checkpoint that this consumer delivered under CommitPolicy::auto() and stores
+         * nothing now: the policy stores the latest counted position of its partition. With
+         * CommitAfter::Marked only marks count. It works also in a consumeMessages() callback.
+         * Without CommitPolicy::auto(), and for a checkpoint that this consumer did not deliver,
+         * it throws InvalidCommand (3).
+         *
+         * @param \Iggy\Checkpoint $checkpoint
+         * @return void
+         */
+        public function mark(\Iggy\Checkpoint $checkpoint): void {}
 
         /**
          * Gets the name of the consumer group.
@@ -466,15 +775,51 @@
         public function partitionId(): int {}
 
         /**
+         * Sets the next read position of a partition and drops the rest of its current batch. Under
+         * CommitPolicy::onRead(), a seek to an offset above 0 first stores the offset before it, so
+         * a restart resumes at the new position, and a failed store fails the seek with its
+         * exception and moves nothing. Every other seek stores nothing.
+         *
+         * It also resumes a partition that stopped, for example after HistoryUnavailable (87), or
+         * after a message that does not decode: seek to the `skip` of its ReadException. A
+         * partition that the consumer does not read, and a continuation of another partition, fail
+         * with InvalidCommand (3). Under CommitPolicy::onRead(), a read of the partition with
+         * an unknown outcome fails with PartitionLaneBlocked (14009): abandon its operation first.
+         *
+         * @param int $partition_id
+         * @param \Iggy\PollingStrategy $start
+         * @return void
+         */
+        public function seek(int $partition_id, \Iggy\PollingStrategy $start): void {}
+
+        /**
+         * Stops the consumer after its queued offset stores.
+         *
+         * Under CommitPolicy::auto(), it stores the latest counted position of each partition. With
+         * CommitPolicy::onRead(), it moves the offset of each
+         * partition back to the last delivered message, so the next run reads the prefetched
+         * messages again. A group member then leaves its group. Afterwards consumeMessages()
+         * returns at once and iterMessages() ends, and a second call does nothing. Destroying the
+         * consumer without shutdown() sends only the queued stores and does not leave the group.
+         *
+         * The first failed store of the OnRead rewind is thrown. Otherwise a partition with an
+         * unknown outcome throws PartitionLaneBlocked (14009): abandon its operation before a new
+         * consumer reads the partition.
+         *
+         * @return void
+         */
+        public function shutdown(): void {}
+
+        /**
          * Stores the provided offset for the provided partition id.
          *
          * If partition_id is null, at least one message must have been polled first.
          *
-         * The offset is committed under the partition context of the latest message consumed
-         * from that partition. After the partition was recreated or changed owner, that context
-         * can be newer than the message's, and the offset then lands on the newer partition. Use
-         * storePosition($message->position()) to commit under the context that delivered the
-         * message.
+         * It always sends, also an offset below the stored one. The offset is committed under the
+         * partition context of the latest message consumed from that partition. After the
+         * partition was recreated or changed owner, that context can be newer than the message's,
+         * and the offset then lands on the newer partition. Use commit($message->checkpoint()) to
+         * commit under the context that delivered the message.
          *
          * When nothing was consumed from that partition, the store uses the context of its route
          * and can fail once with HistoryUnavailable (87), or ConsumerGroupPartitionNotOwned
@@ -488,19 +833,6 @@
         public function storeOffset(int $offset, ?int $partition_id = null): void {}
 
         /**
-         * Stores a message position under the partition incarnation and owner that delivered it.
-         *
-         * Pass $message->position() to commit a message after later polls or a rebalance. A
-         * position from a deleted or recreated partition fails with HistoryUnavailable (87), and
-         * one from a previous owner fails with ConsumerGroupPartitionNotOwned (5009). The stored
-         * offset is then unchanged.
-         *
-         * @param \Iggy\ConsumerPosition $position
-         * @return void
-         */
-        public function storePosition(\Iggy\ConsumerPosition $position): void {}
-
-        /**
          * Gets the stream identifier this consumer is configured for.
          *
          * @return string
@@ -516,33 +848,31 @@
     }
 
     /**
-     * A PHP class representing a consumed position to commit with Consumer::storePosition().
+     * The offset that a consumer stored on a partition.
      */
-    class ConsumerPosition {
+    class ConsumerOffsetInfo {
         /**
-         * The partition incarnation and owner captured by the poll that delivered the message.
-         *
-         * @var \Iggy\PartitionContext
-         */
-        public readonly \Iggy\PartitionContext $context;
-
-        /**
-         * The inclusive message offset to commit.
+         * The offset of the last message of the partition when the offset was read.
          *
          * @var int
          */
-        public readonly int $offset;
+        public readonly int $current_offset;
 
+        /**
+         * The partition of the offset.
+         *
+         * @var int
+         */
         public readonly int $partition_id;
 
         /**
-         * Rebuilds a position from values saved with a processed message.
+         * The stored offset.
          *
-         * @param int $partition_id
-         * @param int $offset
-         * @param \Iggy\PartitionContext $context
+         * @var int
          */
-        public function __construct(int $partition_id, int $offset, \Iggy\PartitionContext $context) {}
+        public readonly int $stored_offset;
+
+        public function __construct() {}
     }
 
     enum Durability: string {
@@ -569,6 +899,9 @@
         public function next(): void {}
 
         /**
+         * Reads the first message, and on a started iterator the message after the last one it
+         * yielded, as a stream has no earlier messages to go back to.
+         *
          * @return void
          */
         public function rewind(): void {}
@@ -1769,43 +2102,106 @@
     }
 
     /**
-     * A PHP class representing the partition incarnation and owner that a poll captured.
+     * Reads the partitions of a topic in batches, with routing, prefetch and the position of each
+     * partition inside. Build one with Client::reader() or Client::groupReader().
      *
-     * Keep it with an offset that is continued or committed later. The server then refuses the
-     * request after the partition was deleted and recreated, or after its consumer group owner
-     * changed.
+     * Each partition delivers its batches in offset order, from its first read, from each seek()
+     * and from each new assignment to a group member. The `commit` of its PollOptions says when
+     * offsets are stored. With CommitPolicy::manual() and CommitPolicy::auto(), commit() stores the
+     * offset of a checkpoint, and CommitPolicy::auto() also stores the latest counted position by
+     * itself, which mark() counts under CommitAfter::Marked. With CommitPolicy::onRead(), the server
+     * stores the offset of each reply, the reader reads only while next() waits and then fills its
+     * budget (one fetch per primary that may wait for messages, the partitions with messages left
+     * behind spread over the other slots), a seek() to an offset stores the offset before it, and
+     * close() moves the offset back to the last delivered message. Destroying the reader without
+     * close() stops it without waiting, moves no offset back and does not leave the group.
      */
-    class PartitionContext {
-        /**
-         * The partition's creation revision. It changes when the partition is deleted and
-         * recreated.
-         *
-         * @var int
-         */
-        public readonly int $incarnation;
+    class PartitionReader {
+        public function __construct() {}
 
         /**
-         * The metadata operation the server must have applied before it serves the request.
+         * Stops the reader. It waits at most until the deadlines of the reads in flight. Under
+         * CommitPolicy::manual() it commits nothing, and under CommitPolicy::auto() it stores the
+         * latest counted position of each partition. A group reader then leaves its group. A second call does nothing, and
+         * every other method of a closed reader throws.
          *
-         * @var int
+         * Under CommitPolicy::onRead(), it moves the offset of each partition with undelivered
+         * batches back to its last delivered message, and the first failed store is thrown.
+         * Otherwise a partition with an unknown outcome throws PartitionLaneBlocked (14009):
+         * abandon its operation before a new reader reads the partition.
+         *
+         * @return void
          */
-        public readonly int $metadata_op;
+        public function close(): void {}
 
         /**
-         * The consumer group owner generation, or 0 for a poll without a consumer group.
+         * Stores the offset of a checkpoint that this reader delivered. The commits of one
+         * partition go out in order.
          *
-         * @var int
+         * A checkpoint of an older generation of its partition, or at or below the last commit of
+         * its generation, stores nothing. A checkpoint that this reader did not deliver, and every
+         * commit under CommitPolicy::onRead(), fail with InvalidCommand (3).
+         *
+         * @param \Iggy\Checkpoint $checkpoint
+         * @return void
          */
-        public readonly int $owner_generation;
+        public function commit(\Iggy\Checkpoint $checkpoint): void {}
 
         /**
-         * Rebuilds a context from values saved with an offset, for example by another process.
+         * Counts a checkpoint that this reader delivered under CommitPolicy::auto() and stores
+         * nothing now: the policy stores the latest counted position of its partition. Without
+         * CommitPolicy::auto(), and for a checkpoint that this reader did not deliver, it throws
+         * InvalidCommand (3).
          *
-         * @param int $incarnation
-         * @param int $owner_generation
-         * @param int $metadata_op
+         * @param \Iggy\Checkpoint $checkpoint
+         * @return void
          */
-        public function __construct(int $incarnation, int $owner_generation, int $metadata_op) {}
+        public function mark(\Iggy\Checkpoint $checkpoint): void {}
+
+        /**
+         * The next batch of one partition. It is never empty. Without `timeout_micros`, the call
+         * blocks until a batch arrives. With it, the call returns null when no batch arrives in
+         * that time, and a later batch stays in the reader for the next call. A timeout of 0 does
+         * not wait: it returns a batch only if one has already arrived.
+         *
+         * A failed read throws Iggy\Exception\ReadException. The reader stays usable and retries
+         * by itself. HistoryUnavailable (87) stops its partition until seek(). Under
+         * CommitPolicy::onRead(), an unknown outcome stops its partition until the operation is
+         * abandoned on the client and the partition is sought.
+         *
+         * @param mixed $timeout_micros
+         * @return \Iggy\PolledMessages|null
+         */
+        public function next(mixed $timeout_micros = null): ?\Iggy\PolledMessages {}
+
+        /**
+         * The partitions that the reader reads: those of a standalone consumer, or the current
+         * assignment of a group member.
+         *
+         * @return array
+         */
+        public function partitions(): array {}
+
+        /**
+         * Sets the next read position of a partition and starts a new generation of it. It drops
+         * the queued batches of the partition, ignores its late replies and resumes a stopped
+         * partition.
+         *
+         * Under CommitPolicy::onRead(), a seek to an offset above 0 first stores the offset before
+         * it, under the history of a continuation or else of the last delivered message, so a
+         * restart resumes at the new position. A failed store fails the seek with its exception and
+         * nothing moves. Every other seek stores nothing. The new position waits for the end of the
+         * committing read in flight, whose reply the new generation drops.
+         *
+         * A partition that the reader does not read, and a continuation of another partition,
+         * fail with InvalidCommand (3). Under CommitPolicy::onRead(), a read with an unknown
+         * outcome fails with PartitionLaneBlocked (14009): abandon its operation first.
+         *
+         * @param int $partition_id
+         * @param \Iggy\PollingStrategy $start
+         * @return void
+         */
+        public function seek(int $partition_id, \Iggy\PollingStrategy $start): void {}
     }
 
     /**
@@ -1825,6 +2221,128 @@
         public function __construct(mixed $max_operations = null, mixed $max_bytes = null) {}
     }
 
+    /**
+     * A poll that runs in the background, from Client::startPollMessages(). Wait for its batch in
+     * steps, or cancel it. Destroying it cancels the poll.
+     */
+    class PendingPoll {
+        public function __construct() {}
+
+        /**
+         * Drops the request. A parked deferred poll is then cancelled on the server, and one with
+         * CommitPolicy::onRead() that the server had not answered stores nothing. It returns after
+         * the request was dropped and does nothing after the poll ended.
+         *
+         * @return void
+         */
+        public function cancel(): void {}
+
+        /**
+         * The batch of the poll. Without `timeout_micros`, the call blocks until the poll ends.
+         * With it, the call returns null while the poll still runs. A deferred poll that found no
+         * message in its max wait returns an empty batch. A failed poll throws, and so does a call
+         * after the batch was returned or the poll was cancelled.
+         *
+         * @param mixed $timeout_micros
+         * @return \Iggy\PolledMessages|null
+         */
+        public function wait(mixed $timeout_micros = null): ?\Iggy\PolledMessages {}
+    }
+
+    /**
+     * The limits of one read: a poll of one partition, or a fetch that a partition reader sends
+     * for several partitions of one primary. A fetch applies `count`, `min_count` and `max_bytes`
+     * to all of its partitions together. A reader that commits nothing runs several fetches at
+     * once, each within these limits, for partitions with more messages waiting.
+     */
+    class PollOptions {
+        public readonly \Iggy\CommitPolicy $commit;
+
+        public readonly int $count;
+
+        public readonly int $max_bytes;
+
+        public readonly int $max_wait_micros;
+
+        public readonly int $min_count;
+
+        /**
+         * Every argument left null keeps its default: up to 1000 messages, no wait, at least 1
+         * message, 1 MiB and CommitPolicy::manual().
+         *
+         * `max_wait_micros` is at most 600 seconds, and the server waits that long for
+         * `min_count` messages, which must be 1 to `count`. A reader or a consumer that waits 0
+         * polls an idle topic in a busy loop, so give it a wait. A reply holds at most
+         * `max_bytes`, and a message larger than that comes alone. Invalid limits are refused
+         * here, before anything is sent.
+         *
+         * @param mixed $count
+         * @param mixed $max_wait_micros
+         * @param mixed $min_count
+         * @param mixed $max_bytes
+         * @param \Iggy\CommitPolicy|null $commit
+         */
+        public function __construct(mixed $count = null, mixed $max_wait_micros = null, mixed $min_count = null, mixed $max_bytes = null, ?\Iggy\CommitPolicy $commit = null) {}
+    }
+
+    /**
+     * A PHP class representing one batch of one partition.
+     */
+    class PolledMessages {
+        public readonly int $count;
+
+        /**
+         * The offset of the newest message in the partition when the batch was read.
+         *
+         * @var int
+         */
+        public readonly int $current_offset;
+
+        /**
+         * The messages of the batch, in offset order.
+         *
+         * Each read of this property copies the messages, so keep the result in a variable to
+         * read them more than once.
+         *
+         * @var array
+         */
+        public readonly array $messages;
+
+        public readonly int $partition_id;
+
+        public function __construct() {}
+
+        /**
+         * The position after the last message, to commit the whole batch. Null for an empty
+         * batch.
+         *
+         * @return \Iggy\Checkpoint|null
+         */
+        public function checkpoint(): ?\Iggy\Checkpoint {}
+
+        /**
+         * The position after the message at `index`, to commit a processed part of the batch.
+         * Null for an index out of range.
+         *
+         * @param int $index
+         * @return \Iggy\Checkpoint|null
+         */
+        public function checkpointAt(int $index): ?\Iggy\Checkpoint {}
+
+        /**
+         * The position after the batch, for the next read of its partition.
+         *
+         * After the last message it is the next offset. An empty batch keeps the start of its
+         * request, except that first() becomes offset 0, so a start can take more than one read.
+         * The continuation belongs to the partition of the batch, and a read of another partition
+         * refuses it with InvalidCommand (3). It keeps the partition history of the batch, so a
+         * read after the partition was recreated fails with HistoryUnavailable (87).
+         *
+         * @return \Iggy\PollingStrategy
+         */
+        public function continuation(): \Iggy\PollingStrategy {}
+    }
+
     class PollingStrategy {
         public function __construct() {}
 
@@ -1872,17 +2390,6 @@
          * @return \Iggy\PollingStrategy
          */
         public static function timestampSeconds(int $value): \Iggy\PollingStrategy {}
-
-        /**
-         * Continues at an offset under the partition incarnation and owner that produced it.
-         *
-         * A stale context fails the poll instead of reading that offset from a recreated
-         * partition. Without a context, the poll uses the context of its route.
-         *
-         * @param \Iggy\PartitionContext $context
-         * @return \Iggy\PollingStrategy
-         */
-        public function withContext(\Iggy\PartitionContext $context): \Iggy\PollingStrategy {}
     }
 
     /**
@@ -1894,6 +2401,19 @@
         public function __construct() {}
 
         /**
+         * Retrieves the position after this message, which commits it and every earlier message
+         * of its partition.
+         *
+         * It keeps the partition incarnation and owner that delivered the message, so a commit
+         * after the partition was recreated or changed owner is refused instead of landing on
+         * the newer partition. Only the reader or the consumer that delivered the message accepts
+         * it in commit().
+         *
+         * @return \Iggy\Checkpoint
+         */
+        public function checkpoint(): \Iggy\Checkpoint {}
+
+        /**
          * Retrieves the checksum of the received message.
          *
          * The checksum represents the integrity of the message within its topic.
@@ -1903,14 +2423,6 @@
         public function checksum(): string {}
 
         /**
-         * Retrieves the partition incarnation and owner captured by the poll that delivered
-         * this message.
-         *
-         * @return \Iggy\PartitionContext
-         */
-        public function context(): \Iggy\PartitionContext {}
-
-        /**
          * Retrieves the id of the received message.
          *
          * The id represents unique identifier of the message within its topic.
@@ -1956,17 +2468,6 @@
         public function payload(): string {}
 
         /**
-         * Retrieves the position to commit for this message with Consumer::storePosition().
-         *
-         * It keeps the partition incarnation and owner that delivered the message, so a commit
-         * after the partition was recreated or changed owner is refused instead of landing on
-         * the newer partition.
-         *
-         * @return \Iggy\ConsumerPosition
-         */
-        public function position(): \Iggy\ConsumerPosition {}
-
-        /**
          * Retrieves the timestamp of the received message.
          *
          * The timestamp represents the time of the message within its topic.
@@ -2188,6 +2689,34 @@
          */
         public readonly string $operation_id;
 
+        public readonly ?int $partition_id;
+
+        public function __construct() {}
+    }
+
+    /**
+     * A failed read of a PartitionReader or a Consumer. The reader stays usable and retries by
+     * itself.
+     *
+     * The code is the code of the error, and the previous exception is the error itself, typed as
+     * any other call would throw it, for example a PendingOperationException.
+     */
+    class ReadException extends \Iggy\Exception\IggyException {
+        /**
+         * The partition of the error, or null for an error of the whole reader.
+         *
+         * @var int|null
+         */
+        public readonly ?int $partition_id;
+
+        /**
+         * For a message that does not decode: the continuation after it. The reader reads the
+         * message again until a seek of its partition to this continuation skips it.
+         *
+         * @var \Iggy\PollingStrategy|null
+         */
+        public readonly ?\Iggy\PollingStrategy $skip;
+
         public function __construct() {}
     }
 
diff --git a/foreign/php/src/client.rs b/foreign/php/src/client.rs
index 9d65add..dc26f86 100644
--- a/foreign/php/src/client.rs
+++ b/foreign/php/src/client.rs
@@ -28,17 +28,20 @@
 use iggy::prelude::{
     CompressionAlgorithm, Consumer as RustConsumer, IggyClient as RustIggyClient,
     IggyClientBuilder, IggyExpiry, IggyMessage as RustMessage, MaxTopicSize, Partitioning,
-    PollingStrategy as RustPollingStrategy, *,
+    PollOptions as RustPollOptions, PollingStrategy as RustPollingStrategy, *,
 };
 use tokio::sync::Mutex;
 
-use crate::consumer::{AutoCommit, IggyConsumer};
+use crate::consumer::{ConsumerOffsetInfo, IggyConsumer};
 use crate::durability::Durability as PhpDurability;
 use crate::error::to_php_exception;
 use crate::identifier::PhpIdentifier;
 use crate::metadata_changes::{MetadataChangeGroup, MetadataChangeStart, MetadataChanges};
 use crate::options::{OptionSpec, OptionsScope, StreamOptionKey, TopicOptionKey, reset_names};
-use crate::receive_message::{PollingStrategy, ReceiveMessage};
+use crate::pending_poll::PendingPoll;
+use crate::poll_options::PollOptions;
+use crate::reader::PartitionReader;
+use crate::receive_message::{Checkpoint, PolledMessages, PollingStrategy};
 use crate::runtime::runtime;
 use crate::send_message::{SendMessage, SendMessagesResponse};
 use crate::stream::StreamDetails;
@@ -160,8 +163,20 @@
         runtime().block_on(async move { inner.connect().await.map_err(to_php_exception) })
     }
 
+    /// Closes the current connection. Repeated calls are safe. Call connect() to use the client
+    /// again, and loginUser() after it, because the sign-in is dropped. Until then requests
+    /// fail, and nothing connects the client in the background. A poll with
+    /// CommitPolicy::onRead() that waits throws FetchProgressUncertain (14010), and its partition
+    /// stays blocked across the reconnect until abandonOperation().
+    pub fn disconnect(&self) -> PhpResult {
+        let inner = self.inner.clone();
+        runtime().block_on(async move { inner.disconnect().await.map_err(to_php_exception) })
+    }
+
     /// Replays an exact retained operation. A committed rejection raises its original error
     /// and releases its lane; uncertainty keeps it retained. HTTP is unsupported.
+    /// A fetch progress block raises code 14010 without replay. Only explicit
+    /// abandonOperation releases it, accepting the unknown server outcome.
     /// Operation IDs use decimal strings to preserve the full unsigned 64-bit range.
     pub fn resolve_operation(
         &self,
@@ -509,56 +524,276 @@
         })
     }
 
-    /// Polls messages from the specified topic and partition.
+    /// Polls messages from one partition of a topic, as the standalone consumer `consumer`.
     ///
-    /// Each message keeps the partition context of this poll. To continue at an offset under
-    /// that context, pass PollingStrategy::offset($offset)->withContext($message->context()).
-    /// A stale context fails with HistoryUnavailable (87).
+    /// PollingStrategy::next() starts at the stored offset of `consumer`. `options` defaults to
+    /// `new PollOptions()`: up to 1000 messages and no wait. With CommitPolicy::onRead(),
+    /// the server stores the offset of the batch for `consumer`.
     ///
-    /// Without a context, the poll uses the context of its route. It can fail once with
+    /// To read on, pass `$polled->continuation()` as the next strategy. It keeps the partition
+    /// history of the batch, so a read after the partition was recreated fails with
+    /// HistoryUnavailable (87), and a read of another partition refuses it with InvalidCommand
+    /// (3).
+    ///
+    /// A fresh strategy uses the context of its route. It can fail once with
     /// HistoryUnavailable (87) after another client deleted and recreated the partition. The
     /// failed route is dropped, and the next call routes again.
+    ///
+    /// `consumer` polls as a standalone consumer, or as a member of the consumer group of that
+    /// id when `group` is true, which reads only the partitions assigned to it.
+    ///
+    /// Under CommitPolicy::onRead(), a lost reply throws FetchProgressUncertain (14010),
+    /// because the server can still store the offset. The partition stays blocked, and the
+    /// next poll of it throws PartitionLaneBlocked (14009), until abandonOperation().
+    #[allow(clippy::too_many_arguments)]
     pub fn poll_messages(
         &self,
         stream: PhpIdentifier,
         topic: PhpIdentifier,
         partition_id: u32,
+        consumer: PhpIdentifier,
         polling_strategy: &PollingStrategy,
-        count: u32,
-        auto_commit: bool,
-    ) -> PhpResult<Vec<ReceiveMessage>> {
-        let consumer = RustConsumer::default();
+        options: Option<&PollOptions>,
+        group: Option<bool>,
+    ) -> PhpResult<PolledMessages> {
+        let consumer = consumer_identity(consumer, group)?;
         let stream: Identifier = stream.try_into()?;
         let topic: Identifier = topic.try_into()?;
         let strategy: RustPollingStrategy = polling_strategy.into();
+        let options = options.map_or_else(RustPollOptions::default, |options| options.inner);
         let inner = self.inner.clone();
 
         runtime().block_on(async move {
-            let polled_messages = inner
-                .poll_messages(
-                    &stream,
-                    &topic,
-                    Some(partition_id),
-                    &consumer,
-                    &strategy,
-                    count,
-                    auto_commit,
-                )
+            inner
+                .poll_messages(&stream, &topic, partition_id, &consumer, &strategy, options)
+                .await
+                .map(PolledMessages::from)
+                .map_err(to_php_exception)
+        })
+    }
+
+    /// Starts the poll of pollMessages() in the background and returns at once, so the caller
+    /// can wait for its batch in steps with PendingPoll::wait() or cancel it with
+    /// PendingPoll::cancel(). Invalid arguments throw here, a failed poll throws from wait().
+    #[allow(clippy::too_many_arguments)]
+    pub fn start_poll_messages(
+        &self,
+        stream: PhpIdentifier,
+        topic: PhpIdentifier,
+        partition_id: u32,
+        consumer: PhpIdentifier,
+        polling_strategy: &PollingStrategy,
+        options: Option<&PollOptions>,
+        group: Option<bool>,
+    ) -> PhpResult<PendingPoll> {
+        let consumer = consumer_identity(consumer, group)?;
+        let stream: Identifier = stream.try_into()?;
+        let topic: Identifier = topic.try_into()?;
+        let strategy: RustPollingStrategy = polling_strategy.into();
+        let options = options.map_or_else(RustPollOptions::default, |options| options.inner);
+        let inner = self.inner.clone();
+
+        let task = runtime().spawn(async move {
+            inner
+                .poll_messages(&stream, &topic, partition_id, &consumer, &strategy, options)
+                .await
+        });
+        Ok(PendingPoll::from(task))
+    }
+
+    /// Deletes the stored offset of the partition of a checkpoint for a standalone consumer, or
+    /// for a consumer group when `group` is true, under the partition history that delivered
+    /// the checkpoint. The offset of the checkpoint is not used.
+    ///
+    /// Throws HistoryUnavailable (87) after the partition was deleted and created again,
+    /// ConsumerGroupPartitionNotOwned (5009) after its consumer group owner changed, and
+    /// ConsumerOffsetNotFound (3021) when no offset is stored. HTTP sends no partition history,
+    /// so there the server deletes the offset of the history it holds.
+    pub fn delete_consumer_position(
+        &self,
+        stream: PhpIdentifier,
+        topic: PhpIdentifier,
+        consumer: PhpIdentifier,
+        checkpoint: &Checkpoint,
+        group: Option<bool>,
+    ) -> PhpResult {
+        let consumer = consumer_identity(consumer, group)?;
+        let stream: Identifier = stream.try_into()?;
+        let topic: Identifier = topic.try_into()?;
+        let checkpoint = checkpoint.inner;
+        let inner = self.inner.clone();
+
+        runtime().block_on(async move {
+            inner
+                .delete_consumer_position(&consumer, &stream, &topic, &checkpoint)
+                .await
+                .map_err(to_php_exception)
+        })
+    }
+
+    /// Stores the offset of a checkpoint for a standalone consumer, or for a consumer group
+    /// when `group` is true, under the partition history that delivered the checkpoint, which
+    /// covers every earlier message of its partition.
+    ///
+    /// Throws HistoryUnavailable (87) after the partition was deleted and created again, and
+    /// ConsumerGroupPartitionNotOwned (5009) after its consumer group owner changed, instead of
+    /// storing the offset on the new partition.
+    pub fn store_consumer_position(
+        &self,
+        stream: PhpIdentifier,
+        topic: PhpIdentifier,
+        consumer: PhpIdentifier,
+        checkpoint: &Checkpoint,
+        group: Option<bool>,
+    ) -> PhpResult {
+        let consumer = consumer_identity(consumer, group)?;
+        let stream: Identifier = stream.try_into()?;
+        let topic: Identifier = topic.try_into()?;
+        let checkpoint = checkpoint.inner;
+        let inner = self.inner.clone();
+
+        runtime().block_on(async move {
+            inner
+                .store_consumer_position(&consumer, &stream, &topic, &checkpoint)
+                .await
+                .map_err(to_php_exception)
+        })
+    }
+
+    /// Stores `offset` on `partition_id` for a standalone consumer, or for a consumer group
+    /// when `group` is true, under the partition history of its route. After another client
+    /// deleted and created the partition again, it can throw HistoryUnavailable (87) once, and
+    /// the next call routes again. Prefer storeConsumerPosition() with a checkpoint.
+    pub fn store_consumer_offset(
+        &self,
+        stream: PhpIdentifier,
+        topic: PhpIdentifier,
+        consumer: PhpIdentifier,
+        partition_id: u32,
+        offset: u64,
+        group: Option<bool>,
+    ) -> PhpResult {
+        let consumer = consumer_identity(consumer, group)?;
+        let stream: Identifier = stream.try_into()?;
+        let topic: Identifier = topic.try_into()?;
+        let inner = self.inner.clone();
+
+        runtime().block_on(async move {
+            inner
+                .store_consumer_offset(&consumer, &stream, &topic, Some(partition_id), offset)
+                .await
+                .map_err(to_php_exception)
+        })
+    }
+
+    /// Returns the offset stored on `partition_id` for a standalone consumer, or for a consumer
+    /// group when `group` is true, or null when nothing is stored. In a cluster the node of the
+    /// connection answers from its own replica, which can lag a store that just returned.
+    pub fn get_consumer_offset(
+        &self,
+        stream: PhpIdentifier,
+        topic: PhpIdentifier,
+        consumer: PhpIdentifier,
+        partition_id: u32,
+        group: Option<bool>,
+    ) -> PhpResult<Option<ConsumerOffsetInfo>> {
+        let consumer = consumer_identity(consumer, group)?;
+        let stream: Identifier = stream.try_into()?;
+        let topic: Identifier = topic.try_into()?;
+        let inner = self.inner.clone();
+
+        runtime().block_on(async move {
+            let offset = inner
+                .get_consumer_offset(&consumer, &stream, &topic, Some(partition_id))
                 .await
                 .map_err(to_php_exception)?;
-
-            Ok(polled_messages
-                .messages
-                .into_iter()
-                .map(|message| ReceiveMessage {
-                    inner: message,
-                    partition_id,
-                    context: polled_messages.context,
-                })
-                .collect())
+            Ok(offset.map(|offset| ConsumerOffsetInfo {
+                partition_id: offset.partition_id,
+                current_offset: offset.current_offset,
+                stored_offset: offset.stored_offset,
+            }))
         })
     }
 
+    /// Deletes the offset stored on `partition_id` for a standalone consumer, or for a consumer
+    /// group when `group` is true. Throws ConsumerOffsetNotFound (3021) when nothing is stored.
+    /// Prefer deleteConsumerPosition() with a checkpoint.
+    pub fn delete_consumer_offset(
+        &self,
+        stream: PhpIdentifier,
+        topic: PhpIdentifier,
+        consumer: PhpIdentifier,
+        partition_id: u32,
+        group: Option<bool>,
+    ) -> PhpResult {
+        let consumer = consumer_identity(consumer, group)?;
+        let stream: Identifier = stream.try_into()?;
+        let topic: Identifier = topic.try_into()?;
+        let inner = self.inner.clone();
+
+        runtime().block_on(async move {
+            inner
+                .delete_consumer_offset(&consumer, &stream, &topic, Some(partition_id))
+                .await
+                .map_err(to_php_exception)
+        })
+    }
+
+    /// Builds a reader of chosen partitions as a standalone consumer.
+    ///
+    /// `partitions` must not be empty. `start` is the start of a partition without a stored
+    /// offset, PollingStrategy::next() when null, and a continuation is refused. `options`
+    /// defaults to a wait of 5 seconds for up to 1000 messages per request: a reader that waits
+    /// 0 polls an idle topic in a busy loop. `prefetch_bytes`, 16 MiB when null, and
+    /// `prefetch_messages`, the larger of 16000 and `count` when null, bound the requests in
+    /// flight and the queued batches.
+    #[allow(clippy::too_many_arguments)]
+    pub fn reader(
+        &self,
+        stream: PhpIdentifier,
+        topic: PhpIdentifier,
+        consumer: PhpIdentifier,
+        partitions: Vec<u32>,
+        start: Option<&PollingStrategy>,
+        options: Option<&PollOptions>,
+        prefetch_bytes: Option<&Zval>,
+        prefetch_messages: Option<&Zval>,
+    ) -> PhpResult<PartitionReader> {
+        let stream: Identifier = stream.try_into()?;
+        let topic: Identifier = topic.try_into()?;
+        let consumer = RustConsumer::new(consumer.try_into()?);
+        let builder = self
+            .inner
+            .reader(&stream, &topic, &consumer)
+            .partitions(partitions);
+
+        build_reader(builder, start, options, prefetch_bytes, prefetch_messages)
+    }
+
+    /// Builds a reader of the partitions that a consumer group assigns to this member.
+    ///
+    /// The group must exist: the reader joins it, joins again after a session loss, and leaves
+    /// it in close(). HTTP cannot join a group, so it throws FeatureUnavailable. The other
+    /// arguments are those of reader().
+    #[allow(clippy::too_many_arguments)]
+    pub fn group_reader(
+        &self,
+        stream: PhpIdentifier,
+        topic: PhpIdentifier,
+        group: PhpIdentifier,
+        start: Option<&PollingStrategy>,
+        options: Option<&PollOptions>,
+        prefetch_bytes: Option<&Zval>,
+        prefetch_messages: Option<&Zval>,
+    ) -> PhpResult<PartitionReader> {
+        let stream: Identifier = stream.try_into()?;
+        let topic: Identifier = topic.try_into()?;
+        let group = RustConsumer::group(group.try_into()?);
+        let builder = self.inner.reader(&stream, &topic, &group);
+
+        build_reader(builder, start, options, prefetch_bytes, prefetch_messages)
+    }
+
     /// Deletes a stream by id or name.
     pub fn delete_stream(&self, stream_id: PhpIdentifier) -> PhpResult {
         let stream_id: Identifier = stream_id.try_into()?;
@@ -588,117 +823,92 @@
 
     /// Creates and initializes a consumer group consumer.
     ///
-    /// `$partition_id` is ignored for a consumer group: the member reads the partitions
-    /// the server assigns to it. `$poll_interval_micros` must be null: the consumer waits
-    /// on the server for new messages.
+    /// The member reads the partitions that the server assigns to it. `polling_strategy` is
+    /// the start of a partition without a stored offset, PollingStrategy::next() when null, and
+    /// a continuation is refused. `poll_options` defaults to a wait of 5 seconds for up to 1000
+    /// messages per request: a consumer that waits 0 polls an idle topic in a busy loop.
+    /// The `commit` of `poll_options` says when offsets are stored. CommitPolicy::manual(), the
+    /// default, stores nothing: commit checkpoints after processing, or each restart reads the
+    /// same messages again. With CommitPolicy::onRead() the server stores the offset of each
+    /// batch that it reads, and CommitPolicy::auto() stores counted positions.
+    /// `prefetch_bytes` and `prefetch_messages` are those of reader().
     #[allow(clippy::too_many_arguments)]
-    #[php(defaults(
-        create_consumer_group_if_not_exists = true,
-        auto_join_consumer_group = true,
-        allow_replay = false
-    ))]
+    #[php(defaults(create_consumer_group_if_not_exists = true, allow_replay = false))]
     pub fn consumer_group(
         &self,
         name: String,
         stream: String,
         topic: String,
-        partition_id: Option<&Zval>,
         polling_strategy: Option<&PollingStrategy>,
-        batch_length: Option<&Zval>,
-        auto_commit: Option<&AutoCommit>,
+        poll_options: Option<&PollOptions>,
+        prefetch_bytes: Option<&Zval>,
+        prefetch_messages: Option<&Zval>,
         create_consumer_group_if_not_exists: bool,
-        auto_join_consumer_group: bool,
-        poll_interval_micros: Option<&Zval>,
         polling_retry_interval_micros: Option<&Zval>,
         init_retries: Option<&Zval>,
         init_retry_interval_micros: Option<&Zval>,
         allow_replay: bool,
     ) -> PhpResult<IggyConsumer> {
-        // The removed setting keeps its slot, so a positional call written for it fails
-        // instead of setting `polling_retry_interval_micros`.
-        if poll_interval_micros.is_some_and(|value| !value.is_null()) {
-            return Err(to_php_exception(
-                "'poll_interval_micros' is no longer supported and must be null: the consumer waits on the server for new messages",
-            ));
-        }
-        let partition_id = optional_unsigned::<u32>(partition_id, "partition_id")?;
-        let batch_length = optional_unsigned::<u32>(batch_length, "batch_length")?;
-        let polling_retry_interval_micros = optional_unsigned::<u64>(
+        let settings = ConsumerSettings::new(
+            polling_strategy,
+            poll_options,
+            prefetch_bytes,
+            prefetch_messages,
             polling_retry_interval_micros,
-            "polling_retry_interval_micros",
+            init_retries,
+            init_retry_interval_micros,
+            allow_replay,
         )?;
-        let init_retries = optional_unsigned::<u32>(init_retries, "init_retries")?;
-        let init_retry_interval_micros =
-            optional_unsigned::<u64>(init_retry_interval_micros, "init_retry_interval_micros")?;
-        let mut builder = self
+        let builder = self
             .inner
             .consumer_group(&name, &stream, &topic)
             .map_err(to_php_exception)?
-            .without_encryptor()
-            .partition(partition_id);
-
-        builder = if create_consumer_group_if_not_exists {
+            .without_encryptor();
+        let builder = if create_consumer_group_if_not_exists {
             builder.create_consumer_group_if_not_exists()
         } else {
             builder.do_not_create_consumer_group_if_not_exists()
         };
-        builder = if auto_join_consumer_group {
-            builder.auto_join_consumer_group()
-        } else {
-            builder.do_not_auto_join_consumer_group()
-        };
-        if let Some(polling_strategy) = polling_strategy {
-            builder = builder.polling_strategy(polling_strategy.into());
-        }
-        if let Some(batch_length) = batch_length {
-            builder = builder.batch_length(batch_length);
-        }
-        if let Some(auto_commit) = auto_commit {
-            builder = builder.auto_commit(auto_commit.into());
-        }
-        if let Some(micros) = polling_retry_interval_micros {
-            builder = builder.polling_retry_interval(non_zero_duration_micros(
-                "polling_retry_interval_micros",
-                micros,
-            )?);
-        }
+        initialized_consumer(settings.apply(builder))
+    }
 
-        match (init_retries, init_retry_interval_micros) {
-            (Some(retries), Some(micros)) => {
-                builder = builder.init_retries(
-                    retries,
-                    non_zero_duration_micros("init_retry_interval_micros", micros)?,
-                );
-            }
-            (Some(_), None) => {
-                return Err(to_php_exception(
-                    "'init_retry_interval_micros' is required if 'init_retries' is set",
-                ));
-            }
-            (None, Some(_)) => {
-                return Err(to_php_exception(
-                    "'init_retries' is required if 'init_retry_interval_micros' is set",
-                ));
-            }
-            (None, None) => {}
-        }
-        if allow_replay {
-            builder = builder.allow_replay();
-        }
-
-        let mut consumer = builder.build();
-        runtime().block_on(async move {
-            consumer.init().await.map_err(to_php_exception)?;
-            let name = consumer.name().to_string();
-            let stream = consumer.stream().to_string();
-            let topic = consumer.topic().to_string();
-            Ok(IggyConsumer {
-                inner: Arc::new(Mutex::new(consumer)),
-                name,
-                stream,
-                topic,
-            })
-        })
+    /// Creates and initializes a standalone consumer of `partitions`, identified by `name`: it
+    /// stores its offsets under that consumer name and joins no group. `partitions` must not be
+    /// empty. The other arguments are those of consumerGroup().
+    #[allow(clippy::too_many_arguments)]
+    #[php(defaults(allow_replay = false))]
+    pub fn consumer(
+        &self,
+        name: String,
+        stream: String,
+        topic: String,
+        partitions: Vec<u32>,
+        polling_strategy: Option<&PollingStrategy>,
+        poll_options: Option<&PollOptions>,
+        prefetch_bytes: Option<&Zval>,
+        prefetch_messages: Option<&Zval>,
+        polling_retry_interval_micros: Option<&Zval>,
+        init_retries: Option<&Zval>,
+        init_retry_interval_micros: Option<&Zval>,
+        allow_replay: bool,
+    ) -> PhpResult<IggyConsumer> {
+        let settings = ConsumerSettings::new(
+            polling_strategy,
+            poll_options,
+            prefetch_bytes,
+            prefetch_messages,
+            polling_retry_interval_micros,
+            init_retries,
+            init_retry_interval_micros,
+            allow_replay,
+        )?;
+        let builder = self
+            .inner
+            .consumer(&name, &stream, &topic)
+            .map_err(to_php_exception)?
+            .without_encryptor()
+            .partitions(partitions);
+        initialized_consumer(settings.apply(builder))
     }
 
     /// Sends a command code with a payload and returns the raw response bytes.
@@ -742,6 +952,148 @@
     }
 }
 
+fn build_reader(
+    mut builder: ReaderBuilder,
+    start: Option<&PollingStrategy>,
+    options: Option<&PollOptions>,
+    prefetch_bytes: Option<&Zval>,
+    prefetch_messages: Option<&Zval>,
+) -> PhpResult<PartitionReader> {
+    if let Some(start) = start {
+        builder = builder.start(start.into());
+    }
+    if let Some(options) = options {
+        builder = builder.options(options.inner);
+    }
+    if let Some(bytes) = optional_unsigned(prefetch_bytes, "prefetch_bytes")? {
+        builder = builder.prefetch_bytes(bytes);
+    }
+    if let Some(messages) = optional_unsigned(prefetch_messages, "prefetch_messages")? {
+        builder = builder.prefetch_messages(messages);
+    }
+
+    runtime()
+        .block_on(builder.build())
+        .map(PartitionReader::from)
+        .map_err(to_php_exception)
+}
+
+/// The consumer identity of a client call: a consumer group when `group` is true.
+fn consumer_identity(consumer: PhpIdentifier, group: Option<bool>) -> PhpResult<RustConsumer> {
+    let id = consumer.try_into()?;
+    Ok(if group.unwrap_or(false) {
+        RustConsumer::group(id)
+    } else {
+        RustConsumer::new(id)
+    })
+}
+
+/// The settings that a standalone consumer and a consumer group member share, checked before
+/// the consumer is built.
+struct ConsumerSettings {
+    polling_strategy: Option<RustPollingStrategy>,
+    poll_options: Option<RustPollOptions>,
+    prefetch_bytes: Option<u32>,
+    prefetch_messages: Option<u32>,
+    polling_retry_interval: Option<NonZeroIggyDuration>,
+    init_retries: Option<(u32, NonZeroIggyDuration)>,
+    allow_replay: bool,
+}
+
+impl ConsumerSettings {
+    #[allow(clippy::too_many_arguments)]
+    fn new(
+        polling_strategy: Option<&PollingStrategy>,
+        poll_options: Option<&PollOptions>,
+        prefetch_bytes: Option<&Zval>,
+        prefetch_messages: Option<&Zval>,
+        polling_retry_interval_micros: Option<&Zval>,
+        init_retries: Option<&Zval>,
+        init_retry_interval_micros: Option<&Zval>,
+        allow_replay: bool,
+    ) -> PhpResult<Self> {
+        let polling_retry_interval = optional_unsigned::<u64>(
+            polling_retry_interval_micros,
+            "polling_retry_interval_micros",
+        )?
+        .map(|micros| non_zero_duration_micros("polling_retry_interval_micros", micros))
+        .transpose()?;
+        let init_retries = match (
+            optional_unsigned::<u32>(init_retries, "init_retries")?,
+            optional_unsigned::<u64>(init_retry_interval_micros, "init_retry_interval_micros")?,
+        ) {
+            (Some(retries), Some(micros)) => Some((
+                retries,
+                non_zero_duration_micros("init_retry_interval_micros", micros)?,
+            )),
+            (Some(_), None) => {
+                return Err(to_php_exception(
+                    "'init_retry_interval_micros' is required if 'init_retries' is set",
+                ));
+            }
+            (None, Some(_)) => {
+                return Err(to_php_exception(
+                    "'init_retries' is required if 'init_retry_interval_micros' is set",
+                ));
+            }
+            (None, None) => None,
+        };
+        Ok(Self {
+            polling_strategy: polling_strategy.map(Into::into),
+            poll_options: poll_options.map(|options| options.inner),
+            prefetch_bytes: optional_unsigned(prefetch_bytes, "prefetch_bytes")?,
+            prefetch_messages: optional_unsigned(prefetch_messages, "prefetch_messages")?,
+            polling_retry_interval,
+            init_retries,
+            allow_replay,
+        })
+    }
+
+    fn apply(self, mut builder: IggyConsumerBuilder) -> IggyConsumerBuilder {
+        if let Some(polling_strategy) = self.polling_strategy {
+            builder = builder.polling_strategy(polling_strategy);
+        }
+        if let Some(poll_options) = self.poll_options {
+            builder = builder.poll_options(poll_options);
+        }
+        if let Some(bytes) = self.prefetch_bytes {
+            builder = builder.prefetch_bytes(bytes);
+        }
+        if let Some(messages) = self.prefetch_messages {
+            builder = builder.prefetch_messages(messages);
+        }
+        if let Some(interval) = self.polling_retry_interval {
+            builder = builder.polling_retry_interval(interval);
+        }
+        if let Some((retries, interval)) = self.init_retries {
+            builder = builder.init_retries(retries, interval);
+        }
+        if self.allow_replay {
+            builder = builder.allow_replay();
+        }
+        builder
+    }
+}
+
+/// Builds the consumer and initializes it, which joins a group member to its group.
+fn initialized_consumer(builder: IggyConsumerBuilder) -> PhpResult<IggyConsumer> {
+    let mut consumer = builder.build();
+    runtime().block_on(async move {
+        consumer.init().await.map_err(to_php_exception)?;
+        let name = consumer.name().to_string();
+        let stream = consumer.stream().to_string();
+        let topic = consumer.topic().to_string();
+        let state = consumer.state();
+        Ok(IggyConsumer {
+            inner: Arc::new(Mutex::new(consumer)),
+            state,
+            name,
+            stream,
+            topic,
+        })
+    })
+}
+
 pub(crate) fn non_zero_duration_micros(field: &str, micros: u64) -> PhpResult<NonZeroIggyDuration> {
     NonZeroIggyDuration::try_from(micros)
         .map_err(|_| to_php_exception(format!("'{field}' must be greater than 0 microseconds")))
@@ -807,7 +1159,10 @@
         .map_err(to_php_exception)
 }
 
-fn optional_unsigned<T: TryFrom<i64>>(value: Option<&Zval>, field: &str) -> PhpResult<Option<T>> {
+pub(crate) fn optional_unsigned<T: TryFrom<i64>>(
+    value: Option<&Zval>,
+    field: &str,
+) -> PhpResult<Option<T>> {
     optional_integer(value, field)?
         .map(|value| {
             T::try_from(value).map_err(|_| {
diff --git a/foreign/php/src/consumer.rs b/foreign/php/src/consumer.rs
index 8b548ce..0099095 100644
--- a/foreign/php/src/consumer.rs
+++ b/foreign/php/src/consumer.rs
@@ -16,26 +16,38 @@
 // under the License.
 
 use std::sync::Arc;
+use std::time::Duration;
 
-use ext_php_rs::{exception::PhpResult, php_class, php_impl, types::ZendCallable};
-use futures::StreamExt;
-use iggy::prelude::{
-    AutoCommit as RustAutoCommit, AutoCommitWhen as RustAutoCommitWhen,
-    IggyConsumer as RustIggyConsumer,
+use ext_php_rs::{
+    exception::PhpResult,
+    php_class, php_impl,
+    types::{ZendCallable, Zval},
 };
-use tokio::sync::Mutex;
+use iggy::consumer_ext::{IggyConsumerMessageExt, MessageConsumer};
+use iggy::prelude::{
+    IggyConsumer as RustIggyConsumer, IggyConsumerState, IggyError, ReadError, ReceivedMessage,
+};
+use tokio::sync::{Mutex, MutexGuard, mpsc, oneshot};
+use tokio::time::timeout;
 
-use crate::client::non_zero_duration_micros;
+use crate::client::optional_unsigned;
 use crate::error::to_php_exception;
 use crate::message_iterator::MessageIterator;
-use crate::receive_message::{ConsumerPosition, ReceiveMessage};
+use crate::receive_message::{Checkpoint, PollingStrategy, ReceiveMessage};
 use crate::runtime::runtime;
 
+/// The handler of a consumeMessages() run waits for the callback of each message before it takes
+/// the next one, so one message at most is on its way to the callback.
+const DELIVERIES_IN_FLIGHT: usize = 1;
+
 /// A PHP class representing the Iggy consumer.
 #[php_class]
 #[php(name = "Iggy\\Consumer")]
 pub struct IggyConsumer {
     pub(crate) inner: Arc<Mutex<RustIggyConsumer>>,
+    /// Reads and commits without `inner`, which a consumeMessages() run holds while its callback
+    /// runs.
+    pub(crate) state: IggyConsumerState,
     pub(crate) name: String,
     pub(crate) stream: String,
     pub(crate) topic: String,
@@ -45,12 +57,12 @@
 impl IggyConsumer {
     /// Get the last consumed offset or null if no offset has been consumed yet.
     pub fn get_last_consumed_offset(&self, partition_id: u32) -> Option<u64> {
-        self.with_consumer(|inner| inner.get_last_consumed_offset(partition_id))
+        self.state.get_last_consumed_offset(partition_id)
     }
 
     /// Get the last stored offset or null if no offset has been stored yet.
     pub fn get_last_stored_offset(&self, partition_id: u32) -> Option<u64> {
-        self.with_consumer(|inner| inner.get_last_stored_offset(partition_id))
+        self.state.get_last_stored_offset(partition_id)
     }
 
     /// Gets the name of the consumer group.
@@ -60,7 +72,7 @@
 
     /// Gets the current partition id or 0 if no messages have been polled yet.
     pub fn partition_id(&self) -> u32 {
-        self.with_consumer(RustIggyConsumer::partition_id)
+        self.state.partition_id()
     }
 
     /// Gets the stream identifier this consumer is configured for.
@@ -77,46 +89,62 @@
     ///
     /// If partition_id is null, at least one message must have been polled first.
     ///
-    /// The offset is committed under the partition context of the latest message consumed
-    /// from that partition. After the partition was recreated or changed owner, that context
-    /// can be newer than the message's, and the offset then lands on the newer partition. Use
-    /// storePosition($message->position()) to commit under the context that delivered the
-    /// message.
+    /// It always sends, also an offset below the stored one. The offset is committed under the
+    /// partition context of the latest message consumed from that partition. After the
+    /// partition was recreated or changed owner, that context can be newer than the message's,
+    /// and the offset then lands on the newer partition. Use commit($message->checkpoint()) to
+    /// commit under the context that delivered the message.
     ///
     /// When nothing was consumed from that partition, the store uses the context of its route
     /// and can fail once with HistoryUnavailable (87), or ConsumerGroupPartitionNotOwned
     /// (5009), after another client deleted and recreated the partition. The failed route is
     /// dropped, and the next call routes again.
     pub fn store_offset(&self, offset: u64, partition_id: Option<u32>) -> PhpResult {
-        let inner = self.inner.clone();
-
-        runtime().block_on(async move {
-            let inner = inner.lock().await;
-            let partition_id = Self::partition_id_or_current_after_poll(&inner, partition_id)?;
-            inner
-                .store_offset(offset, partition_id)
-                .await
-                .map_err(to_php_exception)
-        })
+        let partition_id = self.partition_id_or_current_after_poll(partition_id)?;
+        runtime()
+            .block_on(self.state.store_offset(offset, partition_id))
+            .map_err(to_php_exception)
     }
 
-    /// Stores a message position under the partition incarnation and owner that delivered it.
+    /// Stores the offset of a checkpoint that this consumer delivered, which covers every
+    /// earlier message of its partition.
     ///
-    /// Pass $message->position() to commit a message after later polls or a rebalance. A
-    /// position from a deleted or recreated partition fails with HistoryUnavailable (87), and
-    /// one from a previous owner fails with ConsumerGroupPartitionNotOwned (5009). The stored
-    /// offset is then unchanged.
-    pub fn store_position(&self, position: &ConsumerPosition) -> PhpResult {
-        let position = position.inner;
-        let inner = self.inner.clone();
+    /// Pass $message->checkpoint() to commit a message after later polls or a rebalance. A
+    /// checkpoint of an older generation of its partition, after seek() or a new assignment, or
+    /// at or below the last commit of its generation, stores nothing. One from a deleted or
+    /// recreated partition fails with HistoryUnavailable (87), and one from a previous owner
+    /// fails with ConsumerGroupPartitionNotOwned (5009). A checkpoint that this consumer did not
+    /// deliver, and every commit under CommitPolicy::onRead(), fail with InvalidCommand (3).
+    pub fn commit(&self, checkpoint: &Checkpoint) -> PhpResult {
+        runtime()
+            .block_on(self.state.commit(checkpoint.inner))
+            .map_err(to_php_exception)
+    }
 
-        runtime().block_on(async move {
-            let inner = inner.lock().await;
-            inner
-                .store_position(position)
-                .await
-                .map_err(to_php_exception)
-        })
+    /// Counts a checkpoint that this consumer delivered under CommitPolicy::auto() and stores
+    /// nothing now: the policy stores the latest counted position of its partition. With
+    /// CommitAfter::Marked only marks count. It works also in a consumeMessages() callback.
+    /// Without CommitPolicy::auto(), and for a checkpoint that this consumer did not deliver,
+    /// it throws InvalidCommand (3).
+    pub fn mark(&self, checkpoint: &Checkpoint) -> PhpResult {
+        self.state.mark(checkpoint.inner).map_err(to_php_exception)
+    }
+
+    /// Sets the next read position of a partition and drops the rest of its current batch. Under
+    /// CommitPolicy::onRead(), a seek to an offset above 0 first stores the offset before it, so
+    /// a restart resumes at the new position, and a failed store fails the seek with its
+    /// exception and moves nothing. Every other seek stores nothing.
+    ///
+    /// It also resumes a partition that stopped, for example after HistoryUnavailable (87), or
+    /// after a message that does not decode: seek to the `skip` of its ReadException. A
+    /// partition that the consumer does not read, and a continuation of another partition, fail
+    /// with InvalidCommand (3). Under CommitPolicy::onRead(), a read of the partition with
+    /// an unknown outcome fails with PartitionLaneBlocked (14009): abandon its operation first.
+    pub fn seek(&self, partition_id: u32, start: &PollingStrategy) -> PhpResult {
+        let mut consumer = lock_consumer(&self.inner, "seek()")?;
+        runtime()
+            .block_on(consumer.seek(partition_id, start.into()))
+            .map_err(to_php_exception)
     }
 
     /// Deletes the stored offset for the provided partition id.
@@ -127,93 +155,143 @@
     /// (87), or ConsumerGroupPartitionNotOwned (5009), after another client deleted and
     /// recreated the partition. The failed route is dropped, and the next call routes again.
     pub fn delete_offset(&self, partition_id: Option<u32>) -> PhpResult {
-        let inner = self.inner.clone();
-
-        runtime().block_on(async move {
-            let inner = inner.lock().await;
-            let partition_id = Self::partition_id_or_current_after_poll(&inner, partition_id)?;
-            inner
-                .delete_offset(partition_id)
-                .await
-                .map_err(to_php_exception)
-        })
+        let partition_id = self.partition_id_or_current_after_poll(partition_id)?;
+        runtime()
+            .block_on(self.state.delete_offset(partition_id))
+            .map_err(to_php_exception)
     }
 
-    /// Consumes messages with a PHP callback.
+    /// Consumes messages with a PHP callback, and returns how many it consumed.
     ///
     /// The callback is called as callback(ReceiveMessage $message). A finite limit is required.
+    /// The run ends after `limit` messages, after shutdown(), or with an exception.
     ///
-    /// With AutoCommit::when(), offsets may already be queued for commit before the
-    /// PHP callback runs. Use AutoCommit::disabled() and call
-    /// storePosition($message->position()) after a successful callback when at-least-once
-    /// callback processing is required.
+    /// Under CommitPolicy::auto(), a message counts before its callback with
+    /// CommitAfter::Delivery, and after its callback returns with CommitAfter::Processing. An
+    /// exception of the callback ends the run, and consumeMessages() throws it. Its message
+    /// then stays uncounted under CommitAfter::Processing, so the next run reads it again.
     ///
-    /// After another client deleted and recreated a partition, or after its consumer group
-    /// owner changed, a poll can fail once with HistoryUnavailable (87) or
-    /// ConsumerGroupPartitionNotOwned (5009). The failed route is dropped, and the next call
-    /// routes again.
-    pub fn consume_messages(&self, callback: ZendCallable, limit: u32) -> PhpResult<u32> {
+    /// The consumer retries a failed read by itself and does not throw it. An error that ends
+    /// the run throws Iggy\Exception\ReadException: HistoryUnavailable (87), which stops its
+    /// partition until seek(), and under CommitPolicy::onRead() an unknown outcome, which
+    /// stops its partition until its operation is abandoned and the partition is sought. A
+    /// blocked lane, a shut down client and any error while the client cannot reconnect by
+    /// itself also end the run.
+    ///
+    /// With `idle_timeout_micros`, a run that gets no message for that long ends and returns
+    /// the count of the messages it consumed, so a single-threaded worker regains control on an
+    /// idle topic. Null waits without a limit.
+    ///
+    /// The callback can call commit(), mark(), storeOffset(), deleteOffset() and the getters. seek(),
+    /// shutdown(), iterMessages() and consumeMessages() throw in the callback, because the run
+    /// holds the consumer.
+    pub fn consume_messages(
+        &self,
+        callback: ZendCallable,
+        limit: u32,
+        idle_timeout_micros: Option<&Zval>,
+    ) -> PhpResult<u32> {
+        let idle_timeout = optional_unsigned::<u64>(idle_timeout_micros, "idle_timeout_micros")?
+            .map(Duration::from_micros);
+        let mut consumer = lock_consumer(&self.inner, "consumeMessages()")?;
+        if limit == 0 {
+            return Ok(0);
+        }
+        let (deliveries, mut delivered) = mpsc::channel(DELIVERIES_IN_FLIGHT);
+        let handoff = Handoff { deliveries };
+        let (stop, stopped) = oneshot::channel();
+        let mut stop = Some(stop);
+        let mut run = consumer.consume_messages(&handoff, stopped);
         let mut consumed = 0;
 
-        while consumed < limit {
-            let Some(message) = self.next_message()? else {
-                break;
+        loop {
+            let waited = runtime().block_on(async {
+                match idle_timeout.filter(|_| stop.is_some()) {
+                    Some(idle_timeout) => {
+                        timeout(idle_timeout, next_delivery(&mut delivered, &mut run))
+                            .await
+                            .ok()
+                    }
+                    None => Some(next_delivery(&mut delivered, &mut run).await),
+                }
+            });
+            let Some(delivery) = waited else {
+                // A message that the run took before the stop still reaches the callback.
+                if let Some(stop) = stop.take() {
+                    let _ = stop.send(());
+                }
+                continue;
             };
-
-            callback
-                .try_call(vec![&message])
-                .map_err(to_php_exception)?;
+            let Some(Delivery { message, answer }) = delivery? else {
+                return Ok(consumed);
+            };
+            let message = ReceiveMessage::from(message);
+            // Outside `block_on`, because a binding call in the callback blocks on the runtime
+            // again, and a nested `block_on` panics.
+            let called = callback.try_call(vec![&message]);
             consumed += 1;
+            if called.is_ok() && consumed < limit {
+                let _ = answer.send(Ok(()));
+                continue;
+            }
+            // The biased select of the run sees this signal before it takes another message, so
+            // the run ends right after it counts this one.
+            if let Some(stop) = stop.take() {
+                let _ = stop.send(());
+            }
+            let _ = answer.send(called.as_ref().map(|_| ()).map_err(|_| IggyError::Error));
+            runtime().block_on(run).map_err(to_php_exception)?;
+            return called.map(|_| consumed).map_err(to_php_exception);
         }
-
-        Ok(consumed)
     }
 
     /// Returns an iterator over messages for use with foreach.
     ///
-    /// After another client deleted and recreated a partition, or after its consumer group
-    /// owner changed, a poll can fail once with HistoryUnavailable (87) or
-    /// ConsumerGroupPartitionNotOwned (5009). The failed route is dropped, and the next call
-    /// routes again.
-    pub fn iter_messages(&self) -> MessageIterator {
-        MessageIterator::new(self.inner.clone())
+    /// With `idle_timeout_micros`, the iteration ends when no message arrives for that long,
+    /// and a later foreach over the same iterator reads on. Null waits without a limit.
+    ///
+    /// Under CommitPolicy::auto(), a message counts when the iterator yields it with
+    /// CommitAfter::Delivery, and when the iterator moves past it with CommitAfter::Processing. A
+    /// failed read throws Iggy\Exception\ReadException. The consumer stays usable and retries by
+    /// itself. HistoryUnavailable (87) stops its partition until seek().
+    pub fn iter_messages(&self, idle_timeout_micros: Option<&Zval>) -> PhpResult<MessageIterator> {
+        let idle_timeout = optional_unsigned::<u64>(idle_timeout_micros, "idle_timeout_micros")?
+            .map(Duration::from_micros);
+        Ok(MessageIterator::new(self.inner.clone(), idle_timeout))
+    }
+
+    /// Stops the consumer after its queued offset stores.
+    ///
+    /// Under CommitPolicy::auto(), it stores the latest counted position of each partition. With
+    /// CommitPolicy::onRead(), it moves the offset of each
+    /// partition back to the last delivered message, so the next run reads the prefetched
+    /// messages again. A group member then leaves its group. Afterwards consumeMessages()
+    /// returns at once and iterMessages() ends, and a second call does nothing. Destroying the
+    /// consumer without shutdown() sends only the queued stores and does not leave the group.
+    ///
+    /// The first failed store of the OnRead rewind is thrown. Otherwise a partition with an
+    /// unknown outcome throws PartitionLaneBlocked (14009): abandon its operation before a new
+    /// consumer reads the partition.
+    pub fn shutdown(&self) -> PhpResult {
+        let mut consumer = lock_consumer(&self.inner, "shutdown()")?;
+        runtime()
+            .block_on(consumer.shutdown())
+            .map_err(to_php_exception)
     }
 }
 
 impl IggyConsumer {
-    fn with_consumer<T>(&self, f: impl FnOnce(&RustIggyConsumer) -> T) -> T {
-        let inner = self.inner.clone();
-
-        runtime().block_on(async move {
-            let inner = inner.lock().await;
-            f(&inner)
-        })
-    }
-
-    fn next_message(&self) -> PhpResult<Option<ReceiveMessage>> {
-        let inner = self.inner.clone();
-
-        runtime().block_on(async move {
-            let mut inner = inner.lock().await;
-
-            match inner.next().await {
-                Some(Ok(message)) => Ok(Some(ReceiveMessage::from(message))),
-                Some(Err(err)) => Err(to_php_exception(err)),
-                None => Ok(None),
-            }
-        })
-    }
-
     fn partition_id_or_current_after_poll(
-        inner: &RustIggyConsumer,
+        &self,
         partition_id: Option<u32>,
     ) -> PhpResult<Option<u32>> {
         if let Some(partition_id) = partition_id {
             return Ok(Some(partition_id));
         }
 
-        let current_partition_id = inner.partition_id();
-        if inner
+        let current_partition_id = self.state.partition_id();
+        if self
+            .state
             .get_last_consumed_offset(current_partition_id)
             .is_none()
         {
@@ -226,88 +304,80 @@
     }
 }
 
+/// The offset that a consumer stored on a partition.
 #[php_class]
-#[php(name = "Iggy\\AutoCommit")]
-#[derive(Clone, Copy)]
-pub struct AutoCommit {
-    pub(crate) inner: RustAutoCommit,
+#[php(name = "Iggy\\ConsumerOffsetInfo")]
+pub struct ConsumerOffsetInfo {
+    pub(crate) partition_id: u32,
+    pub(crate) current_offset: u64,
+    pub(crate) stored_offset: u64,
 }
 
 #[php_impl]
-impl AutoCommit {
-    pub fn disabled() -> Self {
-        Self {
-            inner: RustAutoCommit::Disabled,
-        }
+impl ConsumerOffsetInfo {
+    /// The partition of the offset.
+    #[php(getter, change_case = "snake_case")]
+    pub fn partition_id(&self) -> u32 {
+        self.partition_id
     }
 
-    pub fn interval(interval_micros: u64) -> PhpResult<Self> {
-        Ok(Self {
-            inner: RustAutoCommit::Interval(non_zero_duration_micros(
-                "interval_micros",
-                interval_micros,
-            )?),
-        })
+    /// The offset of the last message of the partition when the offset was read.
+    #[php(getter, change_case = "snake_case")]
+    pub fn current_offset(&self) -> u64 {
+        self.current_offset
     }
 
-    pub fn interval_or_when(interval_micros: u64, when: &AutoCommitWhen) -> PhpResult<Self> {
-        Ok(Self {
-            inner: RustAutoCommit::IntervalOrWhen(
-                non_zero_duration_micros("interval_micros", interval_micros)?,
-                when.inner,
-            ),
-        })
-    }
-
-    pub fn when(when: &AutoCommitWhen) -> Self {
-        Self {
-            inner: RustAutoCommit::When(when.inner),
-        }
+    /// The stored offset.
+    #[php(getter, change_case = "snake_case")]
+    pub fn stored_offset(&self) -> u64 {
+        self.stored_offset
     }
 }
 
-impl From<&AutoCommit> for RustAutoCommit {
-    fn from(value: &AutoCommit) -> Self {
-        value.inner
+/// Locks the consumer for a call that needs it alone.
+///
+/// A consumeMessages() run holds the consumer while its callback runs on this thread, so a call
+/// from the callback that waited for the lock would wait forever.
+pub(crate) fn lock_consumer<'a>(
+    consumer: &'a Mutex<RustIggyConsumer>,
+    call: &str,
+) -> PhpResult<MutexGuard<'a, RustIggyConsumer>> {
+    consumer
+        .try_lock()
+        .map_err(|_| to_php_exception(format!("{call} cannot run while consumeMessages() runs")))
+}
+
+/// A message of a consumeMessages() run on its way to the callback, with the answer that its
+/// handler waits for.
+struct Delivery {
+    message: ReceivedMessage,
+    answer: oneshot::Sender<Result<(), IggyError>>,
+}
+
+/// The handler of a consumeMessages() run. Only the PHP thread can call the callback, so the
+/// handler hands each message over and returns the answer for its callback.
+struct Handoff {
+    deliveries: mpsc::Sender<Delivery>,
+}
+
+impl MessageConsumer for Handoff {
+    async fn consume(&self, message: ReceivedMessage) -> Result<(), IggyError> {
+        let (answer, answered) = oneshot::channel();
+        self.deliveries
+            .send(Delivery { message, answer })
+            .await
+            .map_err(|_| IggyError::Error)?;
+        answered.await.unwrap_or(Err(IggyError::Error))
     }
 }
 
-#[php_class]
-#[php(name = "Iggy\\AutoCommitWhen")]
-#[derive(Clone, Copy)]
-pub struct AutoCommitWhen {
-    pub(crate) inner: RustAutoCommitWhen,
-}
-
-#[php_impl]
-impl AutoCommitWhen {
-    pub fn polling_messages() -> Self {
-        Self {
-            inner: RustAutoCommitWhen::PollingMessages,
-        }
-    }
-
-    pub fn consuming_all_messages() -> Self {
-        Self {
-            inner: RustAutoCommitWhen::ConsumingAllMessages,
-        }
-    }
-
-    pub fn consuming_each_message() -> Self {
-        Self {
-            inner: RustAutoCommitWhen::ConsumingEachMessage,
-        }
-    }
-
-    pub fn consuming_every_nth_message(n: u32) -> Self {
-        Self {
-            inner: RustAutoCommitWhen::ConsumingEveryNthMessage(n),
-        }
-    }
-}
-
-impl From<&AutoCommitWhen> for RustAutoCommitWhen {
-    fn from(value: &AutoCommitWhen) -> Self {
-        value.inner
+/// Polls the run until its handler hands over a message, or until the run ends.
+async fn next_delivery(
+    delivered: &mut mpsc::Receiver<Delivery>,
+    run: &mut (impl Future<Output = Result<(), ReadError>> + Unpin),
+) -> PhpResult<Option<Delivery>> {
+    tokio::select! {
+        Some(delivery) = delivered.recv() => Ok(Some(delivery)),
+        result = run => result.map(|()| None).map_err(to_php_exception),
     }
 }
diff --git a/foreign/php/src/error.rs b/foreign/php/src/error.rs
index d095779..9b91c19 100644
--- a/foreign/php/src/error.rs
+++ b/foreign/php/src/error.rs
@@ -24,10 +24,12 @@
     exception::PhpException,
     ffi::zend_call_known_function,
     php_class, php_impl,
-    types::Zval,
-    zend::{ExecutorGlobals, ce},
+    types::{ZendObject, Zval},
+    zend::{ClassEntry, ExecutorGlobals, ce},
 };
-use iggy::prelude::IggyError;
+use iggy::prelude::{IggyError, ReadError};
+
+use crate::receive_message::PollingStrategy;
 
 #[php_class]
 #[php(name = "Iggy\\Exception\\IggyException")]
@@ -66,6 +68,7 @@
 pub struct PendingOperationException {
     operation_id: String,
     kind: String,
+    partition_id: Option<u32>,
 }
 #[php_impl]
 impl PendingOperationException {
@@ -79,33 +82,62 @@
     pub fn kind(&self) -> &str {
         &self.kind
     }
+
+    #[php(getter, change_case = "snake_case")]
+    pub fn partition_id(&self) -> Option<u32> {
+        self.partition_id
+    }
 }
 
 impl PendingOperationException {
-    fn into_exception_object(self, message: &str, code: i32) -> Result<Zval, Error> {
-        let mut object = self.into_zval(false)?;
-        let mut arguments = [message.into_zval(false)?, code.into_zval(false)?];
-        let mut result = Zval::new();
-        let receiver = object.object_mut().ok_or(Error::Object)?;
+    fn from_error(error: &IggyError) -> Option<Self> {
+        let (operation_id, kind, partition_id) = match error {
+            IggyError::OperationUncertain(operation_id) => (*operation_id, "uncertain", None),
+            IggyError::FetchProgressUncertain(operation_id) => {
+                (*operation_id, "fetch_progress_uncertain", None)
+            }
+            IggyError::LaneBlocked(operation_id) => (*operation_id, "lane_blocked", None),
+            IggyError::PartitionLaneBlocked(partition_id, operation_id) => {
+                (*operation_id, "lane_blocked", Some(*partition_id))
+            }
+            IggyError::OperationNotRetained(operation_id) => (*operation_id, "not_retained", None),
+            _ => return None,
+        };
+        Some(Self {
+            operation_id: operation_id.to_string(),
+            kind: kind.to_owned(),
+            partition_id,
+        })
+    }
+}
 
-        // SAFETY: The initialized object extends Exception, and the argument and result
-        // zvals remain live for the call. Its native constructor initializes inherited
-        // fields that the generated Rust constructor leaves untouched.
-        unsafe {
-            zend_call_known_function(
-                ce::exception().constructor,
-                receiver,
-                receiver.ce,
-                &raw mut result,
-                arguments.len().try_into()?,
-                arguments.as_mut_ptr(),
-                ptr::null_mut(),
-            );
-        }
-        if let Some(class) = ExecutorGlobals::pending_exception_class() {
-            return Err(Error::ExceptionPending { class });
-        }
-        Ok(object)
+/// A failed read of a PartitionReader or a Consumer. The reader stays usable and retries by
+/// itself.
+///
+/// The code is the code of the error, and the previous exception is the error itself, typed as
+/// any other call would throw it, for example a PendingOperationException.
+#[php_class]
+#[php(name = "Iggy\\Exception\\ReadException")]
+#[php(extends(IggyException))]
+#[derive(Default)]
+pub struct ReadException {
+    partition_id: Option<u32>,
+    skip: Option<PollingStrategy>,
+}
+
+#[php_impl]
+impl ReadException {
+    /// The partition of the error, or null for an error of the whole reader.
+    #[php(getter, change_case = "snake_case")]
+    pub fn partition_id(&self) -> Option<u32> {
+        self.partition_id
+    }
+
+    /// For a message that does not decode: the continuation after it. The reader reads the
+    /// message again until a seek of its partition to this continuation skips it.
+    #[php(getter)]
+    pub fn skip(&self) -> Option<PollingStrategy> {
+        self.skip.clone()
     }
 }
 
@@ -120,32 +152,28 @@
 impl IntoPhpException for IggyError {
     fn into_php_exception(self) -> PhpException {
         let message = self.to_string();
+        let code = self.as_code() as i32;
 
-        let pending = match &self {
-            IggyError::OperationUncertain(operation_id) => Some((*operation_id, "uncertain")),
-            IggyError::LaneBlocked(operation_id) => Some((*operation_id, "lane_blocked")),
-            IggyError::OperationNotRetained(operation_id) => Some((*operation_id, "not_retained")),
-            _ => None,
-        };
-        if let Some((operation_id, kind)) = pending {
-            let exception = PendingOperationException {
-                operation_id: operation_id.to_string(),
-                kind: kind.to_owned(),
-            };
-            return match exception.into_exception_object(&message, self.as_code() as i32) {
-                Ok(object) => PhpException::from_message(String::new()).with_object(object),
-                Err(error) => error.into_php_exception(),
-            };
+        match PendingOperationException::from_error(&self) {
+            Some(exception) => thrown(exception_object(exception, &message, code, None)),
+            None => PhpException::new(message, code, exception_class(&self)),
         }
+    }
+}
 
-        let class = match PhpExceptionKind::from(&self) {
-            PhpExceptionKind::Connection => ConnectionException::get_metadata().ce(),
-            PhpExceptionKind::Authentication => AuthenticationException::get_metadata().ce(),
-            PhpExceptionKind::NotFound => NotFoundException::get_metadata().ce(),
-            PhpExceptionKind::Transient => TransientException::get_metadata().ce(),
-            PhpExceptionKind::Base => IggyException::get_metadata().ce(),
+impl IntoPhpException for ReadError {
+    fn into_php_exception(self) -> PhpException {
+        let message = self.to_string();
+        let code = self.error.as_code() as i32;
+        let exception = ReadException {
+            partition_id: self.partition_id,
+            skip: self.skip.map(PollingStrategy::from),
         };
-        PhpException::new(message, self.as_code() as i32, class)
+
+        thrown(
+            error_object(&self.error)
+                .and_then(|error| exception_object(exception, &message, code, Some(error))),
+        )
     }
 }
 
@@ -169,6 +197,73 @@
     }
 }
 
+fn thrown(object: Result<Zval, Error>) -> PhpException {
+    match object {
+        Ok(object) => PhpException::from_message(String::new()).with_object(object),
+        Err(error) => error.into_php_exception(),
+    }
+}
+
+/// An exception object that carries Rust fields or a previous exception, which the generated
+/// constructor of its class cannot set.
+fn exception_object(
+    exception: impl IntoZval,
+    message: &str,
+    code: i32,
+    previous: Option<Zval>,
+) -> Result<Zval, Error> {
+    let mut object = exception.into_zval(false)?;
+    let mut arguments = vec![message.into_zval(false)?, code.into_zval(false)?];
+    arguments.extend(previous);
+    let mut result = Zval::new();
+    let receiver = object.object_mut().ok_or(Error::Object)?;
+
+    // SAFETY: The initialized object extends Exception, and the argument and result
+    // zvals remain live for the call. Its native constructor initializes inherited
+    // fields that the generated Rust constructor leaves untouched.
+    unsafe {
+        zend_call_known_function(
+            ce::exception().constructor,
+            receiver,
+            receiver.ce,
+            &raw mut result,
+            arguments.len().try_into()?,
+            arguments.as_mut_ptr(),
+            ptr::null_mut(),
+        );
+    }
+    if let Some(class) = ExecutorGlobals::pending_exception_class() {
+        return Err(Error::ExceptionPending { class });
+    }
+    Ok(object)
+}
+
+/// The exception object that a call failing with `error` throws.
+fn error_object(error: &IggyError) -> Result<Zval, Error> {
+    let message = error.to_string();
+    let code = error.as_code() as i32;
+
+    match PendingOperationException::from_error(error) {
+        Some(exception) => exception_object(exception, &message, code, None),
+        None => exception_object(
+            ZendObject::new(exception_class(error)),
+            &message,
+            code,
+            None,
+        ),
+    }
+}
+
+fn exception_class(error: &IggyError) -> &'static ClassEntry {
+    match PhpExceptionKind::from(error) {
+        PhpExceptionKind::Connection => ConnectionException::get_metadata().ce(),
+        PhpExceptionKind::Authentication => AuthenticationException::get_metadata().ce(),
+        PhpExceptionKind::NotFound => NotFoundException::get_metadata().ce(),
+        PhpExceptionKind::Transient => TransientException::get_metadata().ce(),
+        PhpExceptionKind::Base => IggyException::get_metadata().ce(),
+    }
+}
+
 enum PhpExceptionKind {
     Base,
     Connection,
@@ -252,3 +347,43 @@
         }
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn pending_operation_errors_preserve_exact_identity_and_kind() {
+        for operation_id in [(1u64 << 53) + 17, u64::MAX] {
+            for (error, kind, partition_id) in [
+                (
+                    IggyError::OperationUncertain(operation_id),
+                    "uncertain",
+                    None,
+                ),
+                (IggyError::LaneBlocked(operation_id), "lane_blocked", None),
+                (
+                    IggyError::FetchProgressUncertain(operation_id),
+                    "fetch_progress_uncertain",
+                    None,
+                ),
+                (
+                    IggyError::PartitionLaneBlocked(u32::MAX, operation_id),
+                    "lane_blocked",
+                    Some(u32::MAX),
+                ),
+                (
+                    IggyError::OperationNotRetained(operation_id),
+                    "not_retained",
+                    None,
+                ),
+            ] {
+                let exception = PendingOperationException::from_error(&error).unwrap();
+                assert_eq!(exception.operation_id(), operation_id.to_string());
+                assert_eq!(exception.kind(), kind);
+                assert_eq!(exception.partition_id(), partition_id);
+            }
+        }
+        assert!(PendingOperationException::from_error(&IggyError::InvalidCommand).is_none());
+    }
+}
diff --git a/foreign/php/src/lib.rs b/foreign/php/src/lib.rs
index b9f860f..5acc204 100644
--- a/foreign/php/src/lib.rs
+++ b/foreign/php/src/lib.rs
@@ -23,6 +23,9 @@
 pub mod message_iterator;
 pub mod metadata_changes;
 pub mod options;
+pub mod pending_poll;
+pub mod poll_options;
+pub mod reader;
 pub mod receive_message;
 pub mod runtime;
 pub mod send_message;
@@ -33,10 +36,10 @@
 use ext_php_rs::prelude::*;
 
 use crate::client::{IggyClient, PendingOperationsConfig};
-use crate::consumer::{AutoCommit, AutoCommitWhen, IggyConsumer};
+use crate::consumer::{ConsumerOffsetInfo, IggyConsumer};
 use crate::error::{
     AuthenticationException, ConnectionException, IggyException, NotFoundException,
-    PendingOperationException, TransientException,
+    PendingOperationException, ReadException, TransientException,
 };
 use crate::message_iterator::MessageIterator;
 use crate::metadata_changes::{
@@ -52,7 +55,10 @@
     MetadataUnknownItem, MetadataUserChange, MetadataUserIdentity, MetadataUserPermissionsUpdated,
 };
 use crate::options::{OptionSpec, OptionsScope, StreamOptionKey, TopicOptionKey};
-use crate::receive_message::{ConsumerPosition, PartitionContext, PollingStrategy, ReceiveMessage};
+use crate::pending_poll::PendingPoll;
+use crate::poll_options::{AutoCommit, CommitAfter, CommitEvery, CommitPolicy, PollOptions};
+use crate::reader::PartitionReader;
+use crate::receive_message::{Checkpoint, PolledMessages, PollingStrategy, ReceiveMessage};
 use crate::send_message::{SendMessage, SendMessagesConfirmation, SendMessagesResponse};
 use crate::stream::StreamDetails;
 use crate::topic::TopicDetails;
@@ -65,6 +71,10 @@
         .enumeration::<StreamOptionKey>()
         .enumeration::<TopicOptionKey>()
         .enumeration::<OptionsScope>()
+        .enumeration::<CommitAfter>()
+        .class::<CommitPolicy>()
+        .class::<AutoCommit>()
+        .class::<CommitEvery>()
         .enumeration::<MetadataChangeGroup>()
         .enumeration::<MetadataChangeKind>()
         .enumeration::<MetadataChangeStatus>()
@@ -81,16 +91,19 @@
         .class::<NotFoundException>()
         .class::<TransientException>()
         .class::<PendingOperationException>()
+        .class::<ReadException>()
         .class::<PendingOperationsConfig>()
         .class::<IggyClient>()
         .class::<IggyConsumer>()
+        .class::<ConsumerOffsetInfo>()
         .class::<MessageIterator>()
-        .class::<AutoCommit>()
-        .class::<AutoCommitWhen>()
-        .class::<PartitionContext>()
-        .class::<ConsumerPosition>()
+        .class::<PollOptions>()
         .class::<PollingStrategy>()
+        .class::<Checkpoint>()
         .class::<ReceiveMessage>()
+        .class::<PolledMessages>()
+        .class::<PartitionReader>()
+        .class::<PendingPoll>()
         .class::<SendMessage>()
         .class::<SendMessagesConfirmation>()
         .class::<SendMessagesResponse>()
diff --git a/foreign/php/src/message_iterator.rs b/foreign/php/src/message_iterator.rs
index 4b84151..4cae503 100644
--- a/foreign/php/src/message_iterator.rs
+++ b/foreign/php/src/message_iterator.rs
@@ -16,12 +16,15 @@
 // under the License.
 
 use std::sync::Arc;
+use std::time::Duration;
 
 use ext_php_rs::{exception::PhpResult, php_class, php_impl, zend::ce};
 use futures::StreamExt;
 use iggy::prelude::IggyConsumer as RustIggyConsumer;
 use tokio::sync::Mutex;
+use tokio::time::timeout;
 
+use crate::consumer::lock_consumer;
 use crate::error::to_php_exception;
 use crate::receive_message::ReceiveMessage;
 use crate::runtime::runtime;
@@ -31,15 +34,17 @@
 #[php(implements(ce = ce::iterator, stub = "\\Iterator"))]
 pub struct MessageIterator {
     pub(crate) inner: Arc<Mutex<RustIggyConsumer>>,
+    idle_timeout: Option<Duration>,
     current: Option<ReceiveMessage>,
     key: u64,
     started: bool,
 }
 
 impl MessageIterator {
-    pub(crate) fn new(inner: Arc<Mutex<RustIggyConsumer>>) -> Self {
+    pub(crate) fn new(inner: Arc<Mutex<RustIggyConsumer>>, idle_timeout: Option<Duration>) -> Self {
         Self {
             inner,
+            idle_timeout,
             current: None,
             key: 0,
             started: false,
@@ -47,12 +52,22 @@
     }
 
     fn poll_next(&mut self) -> PhpResult {
-        let inner = self.inner.clone();
+        // A read that throws leaves no message, so the message before it is not yielded again.
+        self.current = None;
+        let mut consumer = lock_consumer(&self.inner, "iterMessages()")?;
+        let idle_timeout = self.idle_timeout;
 
-        self.current = runtime().block_on(async move {
-            let mut inner = inner.lock().await;
-
-            match inner.next().await {
+        self.current = runtime().block_on(async {
+            // An idle iteration ends without a message. The read is cancel safe, so a message
+            // that arrives later stays in the consumer for the next one.
+            let next = match idle_timeout {
+                Some(idle_timeout) => match timeout(idle_timeout, consumer.next()).await {
+                    Ok(next) => next,
+                    Err(_) => return Ok(None),
+                },
+                None => consumer.next().await,
+            };
+            match next {
                 Some(Ok(message)) => Ok(Some(ReceiveMessage::from(message))),
                 Some(Err(err)) => Err(to_php_exception(err)),
                 None => Ok(None),
@@ -84,13 +99,15 @@
         self.poll_next()
     }
 
+    /// Reads the first message, and on a started iterator the message after the last one it
+    /// yielded, as a stream has no earlier messages to go back to.
     pub fn rewind(&mut self) -> PhpResult {
         if !self.started {
             self.started = true;
-            self.poll_next()?;
+            return self.poll_next();
         }
 
-        Ok(())
+        self.next()
     }
 
     pub fn valid(&self) -> bool {
diff --git a/foreign/php/src/pending_poll.rs b/foreign/php/src/pending_poll.rs
new file mode 100644
index 0000000..3f2719b
--- /dev/null
+++ b/foreign/php/src/pending_poll.rs
@@ -0,0 +1,95 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::time::Duration;
+
+use ext_php_rs::{exception::PhpResult, php_class, php_impl, types::Zval};
+use iggy::prelude::{IggyError, PolledMessages as RustPolledMessages};
+use tokio::task::JoinHandle;
+
+use crate::client::optional_unsigned;
+use crate::error::to_php_exception;
+use crate::receive_message::PolledMessages;
+use crate::runtime::runtime;
+
+const ENDED: &str = "the poll already ended";
+
+pub(crate) type PollTask = JoinHandle<Result<RustPolledMessages, IggyError>>;
+
+/// A poll that runs in the background, from Client::startPollMessages(). Wait for its batch in
+/// steps, or cancel it. Destroying it cancels the poll.
+#[php_class]
+#[php(name = "Iggy\\PendingPoll")]
+pub struct PendingPoll {
+    task: Option<PollTask>,
+}
+
+impl From<PollTask> for PendingPoll {
+    fn from(task: PollTask) -> Self {
+        Self { task: Some(task) }
+    }
+}
+
+#[php_impl]
+impl PendingPoll {
+    /// The batch of the poll. Without `timeout_micros`, the call blocks until the poll ends.
+    /// With it, the call returns null while the poll still runs. A deferred poll that found no
+    /// message in its max wait returns an empty batch. A failed poll throws, and so does a call
+    /// after the batch was returned or the poll was cancelled.
+    pub fn wait(&mut self, timeout_micros: Option<&Zval>) -> PhpResult<Option<PolledMessages>> {
+        let timeout = optional_unsigned::<u64>(timeout_micros, "timeout_micros")?;
+        let task = self.task.as_mut().ok_or_else(|| to_php_exception(ENDED))?;
+        // The timer needs the runtime context, so it is built inside `block_on`.
+        let joined = runtime().block_on(async {
+            match timeout {
+                Some(micros) => tokio::time::timeout(Duration::from_micros(micros), task)
+                    .await
+                    .ok(),
+                None => Some(task.await),
+            }
+        });
+        let Some(joined) = joined else {
+            return Ok(None);
+        };
+        self.task = None;
+        match joined {
+            Ok(polled) => polled
+                .map(|batch| Some(PolledMessages::from(batch)))
+                .map_err(to_php_exception),
+            Err(error) => Err(to_php_exception(format!("the poll failed: {error}"))),
+        }
+    }
+
+    /// Drops the request. A parked deferred poll is then cancelled on the server, and one with
+    /// CommitPolicy::onRead() that the server had not answered stores nothing. It returns after
+    /// the request was dropped and does nothing after the poll ended.
+    pub fn cancel(&mut self) {
+        if let Some(task) = self.task.take() {
+            task.abort();
+            // An aborted task drops the request before the join ends.
+            let _cancelled = runtime().block_on(task);
+        }
+    }
+}
+
+impl Drop for PendingPoll {
+    fn drop(&mut self) {
+        if let Some(task) = &self.task {
+            task.abort();
+        }
+    }
+}
diff --git a/foreign/php/src/poll_options.rs b/foreign/php/src/poll_options.rs
new file mode 100644
index 0000000..567635f
--- /dev/null
+++ b/foreign/php/src/poll_options.rs
@@ -0,0 +1,282 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::num::NonZeroU32;
+
+use ext_php_rs::{exception::PhpResult, php_class, php_enum, php_impl, types::Zval};
+use iggy::prelude::{
+    AutoCommit as RustAutoCommit, CommitAfter as RustCommitAfter, CommitEvery as RustCommitEvery,
+    CommitPolicy as RustCommitPolicy, IggyDuration, PollOptions as RustPollOptions,
+};
+
+use crate::client::{non_zero_duration_micros, optional_unsigned};
+use crate::error::to_php_exception;
+
+/// When the offsets of the messages that a read returns are stored.
+#[php_class]
+#[php(name = "Iggy\\CommitPolicy")]
+#[derive(Clone, Copy)]
+pub struct CommitPolicy {
+    pub(crate) inner: RustCommitPolicy,
+}
+
+#[php_impl]
+impl CommitPolicy {
+    /// Nothing is stored. The application commits checkpoints. The default.
+    pub fn manual() -> Self {
+        Self {
+            inner: RustCommitPolicy::Manual,
+        }
+    }
+
+    /// The server stores the offset of the last message of each read as part of the read, in one
+    /// round trip. While the primary stays up a message is delivered at most once. A failover
+    /// before the store replicates can deliver the last batch again.
+    pub fn on_read() -> Self {
+        Self {
+            inner: RustCommitPolicy::OnRead,
+        }
+    }
+
+    /// A reader or a consumer stores the latest counted position, at least once with
+    /// CommitAfter::Processing. pollMessages() refuses it with InvalidConfiguration (2).
+    #[php(name = "auto")]
+    pub fn automatic(auto: &AutoCommit) -> Self {
+        Self {
+            inner: RustCommitPolicy::Auto(auto.inner),
+        }
+    }
+
+    /// `manual`, `on_read` or `auto`.
+    #[php(getter)]
+    pub fn kind(&self) -> &'static str {
+        match self.inner {
+            RustCommitPolicy::Manual => "manual",
+            RustCommitPolicy::OnRead => "on_read",
+            RustCommitPolicy::Auto(_) => "auto",
+        }
+    }
+}
+
+/// When CommitPolicy::auto() stores. It also stores on close, on shutdown and before a
+/// rebalance takes a partition away. A store only moves forward.
+#[php_class]
+#[php(name = "Iggy\\AutoCommit")]
+#[derive(Clone, Copy)]
+pub struct AutoCommit {
+    pub(crate) inner: RustAutoCommit,
+}
+
+#[php_impl]
+impl AutoCommit {
+    /// Stores the latest counted position every `interval_micros`, when it moved, and also on
+    /// the event `every`. A message counts as `after` says, CommitAfter::Processing when null.
+    /// Throws for a zero interval, and without an interval and an event, which would store
+    /// only on close.
+    #[php(constructor)]
+    pub fn __construct(
+        interval_micros: Option<&Zval>,
+        every: Option<&CommitEvery>,
+        after: Option<CommitAfter>,
+    ) -> PhpResult<Self> {
+        let interval = optional_unsigned::<u64>(interval_micros, "interval_micros")?
+            .map(|micros| non_zero_duration_micros("interval_micros", micros))
+            .transpose()?;
+        let every = every.map(|every| every.inner);
+        if interval.is_none() && every.is_none() {
+            return Err(to_php_exception(
+                "an AutoCommit needs an 'interval_micros' or an 'every' event",
+            ));
+        }
+        Ok(Self {
+            inner: RustAutoCommit {
+                interval,
+                every,
+                after: after.map_or_else(RustCommitAfter::default, Into::into),
+            },
+        })
+    }
+
+    /// Every 10,000 messages or every 5 seconds, whichever comes first, after processing.
+    #[php(name = "default")]
+    pub fn defaults() -> Self {
+        Self {
+            inner: RustAutoCommit::default(),
+        }
+    }
+}
+
+/// The event on which CommitPolicy::auto() also stores.
+#[php_class]
+#[php(name = "Iggy\\CommitEvery")]
+#[derive(Clone, Copy)]
+pub struct CommitEvery {
+    pub(crate) inner: RustCommitEvery,
+}
+
+#[php_impl]
+impl CommitEvery {
+    /// Each counted message, or each counted batch of a reader.
+    pub fn message() -> Self {
+        Self {
+            inner: RustCommitEvery::Message,
+        }
+    }
+
+    /// `count` counted messages of a partition since its last store. Throws for zero.
+    pub fn messages(count: u32) -> PhpResult<Self> {
+        let count = NonZeroU32::new(count).ok_or_else(|| {
+            to_php_exception("'count' of CommitEvery::messages() must not be zero")
+        })?;
+        Ok(Self {
+            inner: RustCommitEvery::Messages(count),
+        })
+    }
+
+    /// The last message of each batch.
+    pub fn batch() -> Self {
+        Self {
+            inner: RustCommitEvery::Batch,
+        }
+    }
+}
+
+/// When a message counts for CommitPolicy::auto().
+#[php_enum]
+#[php(name = "Iggy\\CommitAfter")]
+#[derive(Clone, Copy)]
+pub enum CommitAfter {
+    /// When the application asks for the next message or batch, returns from its callback, or
+    /// closes the reader. A callback that throws leaves its message uncounted.
+    #[php(value = "processing")]
+    Processing,
+    /// When the message or the batch is handed over.
+    #[php(value = "delivery")]
+    Delivery,
+    /// Only when the application calls mark() with its checkpoint.
+    #[php(value = "marked")]
+    Marked,
+}
+
+impl From<CommitAfter> for RustCommitAfter {
+    fn from(value: CommitAfter) -> Self {
+        match value {
+            CommitAfter::Processing => Self::Processing,
+            CommitAfter::Delivery => Self::Delivery,
+            CommitAfter::Marked => Self::Marked,
+        }
+    }
+}
+
+/// The limits of one read: a poll of one partition, or a fetch that a partition reader sends
+/// for several partitions of one primary. A fetch applies `count`, `min_count` and `max_bytes`
+/// to all of its partitions together. A reader that commits nothing runs several fetches at
+/// once, each within these limits, for partitions with more messages waiting.
+#[php_class]
+#[php(name = "Iggy\\PollOptions")]
+#[derive(Clone, Copy)]
+pub struct PollOptions {
+    pub(crate) inner: RustPollOptions,
+}
+
+#[php_impl]
+impl PollOptions {
+    /// Every argument left null keeps its default: up to 1000 messages, no wait, at least 1
+    /// message, 1 MiB and CommitPolicy::manual().
+    ///
+    /// `max_wait_micros` is at most 600 seconds, and the server waits that long for
+    /// `min_count` messages, which must be 1 to `count`. A reader or a consumer that waits 0
+    /// polls an idle topic in a busy loop, so give it a wait. A reply holds at most
+    /// `max_bytes`, and a message larger than that comes alone. Invalid limits are refused
+    /// here, before anything is sent.
+    #[php(constructor)]
+    pub fn __construct(
+        count: Option<&Zval>,
+        max_wait_micros: Option<&Zval>,
+        min_count: Option<&Zval>,
+        max_bytes: Option<&Zval>,
+        commit: Option<&CommitPolicy>,
+    ) -> PhpResult<Self> {
+        let defaults = RustPollOptions::default();
+        let inner = RustPollOptions {
+            count: optional_unsigned(count, "count")?.unwrap_or(defaults.count),
+            max_wait: optional_unsigned::<u64>(max_wait_micros, "max_wait_micros")?
+                .map_or(defaults.max_wait, IggyDuration::from),
+            min_count: optional_unsigned(min_count, "min_count")?.unwrap_or(defaults.min_count),
+            max_bytes: optional_unsigned(max_bytes, "max_bytes")?.unwrap_or(defaults.max_bytes),
+            commit: commit.map_or(defaults.commit, |commit| commit.inner),
+        };
+        inner.validate().map_err(to_php_exception)?;
+        Ok(Self { inner })
+    }
+
+    #[php(getter)]
+    pub fn count(&self) -> u32 {
+        self.inner.count
+    }
+
+    #[php(getter, change_case = "snake_case")]
+    pub fn max_wait_micros(&self) -> u64 {
+        self.inner.max_wait.as_micros()
+    }
+
+    #[php(getter, change_case = "snake_case")]
+    pub fn min_count(&self) -> u32 {
+        self.inner.min_count
+    }
+
+    #[php(getter, change_case = "snake_case")]
+    pub fn max_bytes(&self) -> u32 {
+        self.inner.max_bytes
+    }
+
+    #[php(getter)]
+    pub fn commit(&self) -> CommitPolicy {
+        CommitPolicy {
+            inner: self.inner.commit,
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn given_no_arguments_when_constructed_should_keep_the_sdk_defaults() {
+        let options = PollOptions::__construct(None, None, None, None, None).unwrap();
+
+        assert_eq!(options.inner, RustPollOptions::default());
+    }
+
+    #[test]
+    fn given_auto_commit_parts_when_constructed_should_keep_them() {
+        let every = CommitEvery::messages(7).unwrap();
+
+        let auto = AutoCommit::__construct(None, Some(&every), Some(CommitAfter::Marked)).unwrap();
+
+        assert_eq!(
+            auto.inner,
+            RustAutoCommit {
+                interval: None,
+                every: NonZeroU32::new(7).map(RustCommitEvery::Messages),
+                after: RustCommitAfter::Marked,
+            }
+        );
+        assert_eq!(AutoCommit::defaults().inner, RustAutoCommit::default());
+    }
+}
diff --git a/foreign/php/src/reader.rs b/foreign/php/src/reader.rs
new file mode 100644
index 0000000..5b79a1b
--- /dev/null
+++ b/foreign/php/src/reader.rs
@@ -0,0 +1,158 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::time::Duration;
+
+use ext_php_rs::{exception::PhpResult, php_class, php_impl, types::Zval};
+use iggy::prelude::PartitionReader as RustPartitionReader;
+
+use crate::client::optional_unsigned;
+use crate::error::to_php_exception;
+use crate::receive_message::{Checkpoint, PolledMessages, PollingStrategy};
+use crate::runtime::runtime;
+
+const CLOSED: &str = "the reader is closed";
+
+/// Reads the partitions of a topic in batches, with routing, prefetch and the position of each
+/// partition inside. Build one with Client::reader() or Client::groupReader().
+///
+/// Each partition delivers its batches in offset order, from its first read, from each seek()
+/// and from each new assignment to a group member. The `commit` of its PollOptions says when
+/// offsets are stored. With CommitPolicy::manual() and CommitPolicy::auto(), commit() stores the
+/// offset of a checkpoint, and CommitPolicy::auto() also stores the latest counted position by
+/// itself, which mark() counts under CommitAfter::Marked. With CommitPolicy::onRead(), the server
+/// stores the offset of each reply, the reader reads only while next() waits and then fills its
+/// budget (one fetch per primary that may wait for messages, the partitions with messages left
+/// behind spread over the other slots), a seek() to an offset stores the offset before it, and
+/// close() moves the offset back to the last delivered message. Destroying the reader without
+/// close() stops it without waiting, moves no offset back and does not leave the group.
+#[php_class]
+#[php(name = "Iggy\\PartitionReader")]
+pub struct PartitionReader {
+    inner: Option<RustPartitionReader>,
+}
+
+impl From<RustPartitionReader> for PartitionReader {
+    fn from(inner: RustPartitionReader) -> Self {
+        Self { inner: Some(inner) }
+    }
+}
+
+#[php_impl]
+impl PartitionReader {
+    /// The next batch of one partition. It is never empty. Without `timeout_micros`, the call
+    /// blocks until a batch arrives. With it, the call returns null when no batch arrives in
+    /// that time, and a later batch stays in the reader for the next call. A timeout of 0 does
+    /// not wait: it returns a batch only if one has already arrived.
+    ///
+    /// A failed read throws Iggy\Exception\ReadException. The reader stays usable and retries
+    /// by itself. HistoryUnavailable (87) stops its partition until seek(). Under
+    /// CommitPolicy::onRead(), an unknown outcome stops its partition until the operation is
+    /// abandoned on the client and the partition is sought.
+    pub fn next(&mut self, timeout_micros: Option<&Zval>) -> PhpResult<Option<PolledMessages>> {
+        let timeout = optional_unsigned::<u64>(timeout_micros, "timeout_micros")?;
+        let reader = self.open_mut()?;
+        // The timer needs the runtime context, so it is built inside `block_on`. `next` is
+        // cancel safe, so dropping it at the timeout leaves a late batch in the reader.
+        let read = runtime().block_on(async {
+            match timeout {
+                Some(micros) => tokio::time::timeout(Duration::from_micros(micros), reader.next())
+                    .await
+                    .ok(),
+                None => Some(reader.next().await),
+            }
+        });
+        read.transpose()
+            .map(|batch| batch.map(PolledMessages::from))
+            .map_err(to_php_exception)
+    }
+
+    /// Stores the offset of a checkpoint that this reader delivered. The commits of one
+    /// partition go out in order.
+    ///
+    /// A checkpoint of an older generation of its partition, or at or below the last commit of
+    /// its generation, stores nothing. A checkpoint that this reader did not deliver, and every
+    /// commit under CommitPolicy::onRead(), fail with InvalidCommand (3).
+    pub fn commit(&self, checkpoint: &Checkpoint) -> PhpResult {
+        let reader = self.open()?;
+        runtime()
+            .block_on(reader.commit(checkpoint.inner))
+            .map_err(to_php_exception)
+    }
+
+    /// Counts a checkpoint that this reader delivered under CommitPolicy::auto() and stores
+    /// nothing now: the policy stores the latest counted position of its partition. Without
+    /// CommitPolicy::auto(), and for a checkpoint that this reader did not deliver, it throws
+    /// InvalidCommand (3).
+    pub fn mark(&self, checkpoint: &Checkpoint) -> PhpResult {
+        self.open()?
+            .mark(checkpoint.inner)
+            .map_err(to_php_exception)
+    }
+
+    /// Sets the next read position of a partition and starts a new generation of it. It drops
+    /// the queued batches of the partition, ignores its late replies and resumes a stopped
+    /// partition.
+    ///
+    /// Under CommitPolicy::onRead(), a seek to an offset above 0 first stores the offset before
+    /// it, under the history of a continuation or else of the last delivered message, so a
+    /// restart resumes at the new position. A failed store fails the seek with its exception and
+    /// nothing moves. Every other seek stores nothing. The new position waits for the end of the
+    /// committing read in flight, whose reply the new generation drops.
+    ///
+    /// A partition that the reader does not read, and a continuation of another partition,
+    /// fail with InvalidCommand (3). Under CommitPolicy::onRead(), a read with an unknown
+    /// outcome fails with PartitionLaneBlocked (14009): abandon its operation first.
+    pub fn seek(&mut self, partition_id: u32, start: &PollingStrategy) -> PhpResult {
+        let reader = self.open_mut()?;
+        runtime()
+            .block_on(reader.seek(partition_id, start.into()))
+            .map_err(to_php_exception)
+    }
+
+    /// The partitions that the reader reads: those of a standalone consumer, or the current
+    /// assignment of a group member.
+    pub fn partitions(&self) -> PhpResult<Vec<u32>> {
+        Ok(self.open()?.partitions())
+    }
+
+    /// Stops the reader. It waits at most until the deadlines of the reads in flight. Under
+    /// CommitPolicy::manual() it commits nothing, and under CommitPolicy::auto() it stores the
+    /// latest counted position of each partition. A group reader then leaves its group. A second call does nothing, and
+    /// every other method of a closed reader throws.
+    ///
+    /// Under CommitPolicy::onRead(), it moves the offset of each partition with undelivered
+    /// batches back to its last delivered message, and the first failed store is thrown.
+    /// Otherwise a partition with an unknown outcome throws PartitionLaneBlocked (14009):
+    /// abandon its operation before a new reader reads the partition.
+    pub fn close(&mut self) -> PhpResult {
+        match self.inner.take() {
+            Some(reader) => runtime().block_on(reader.close()).map_err(to_php_exception),
+            None => Ok(()),
+        }
+    }
+}
+
+impl PartitionReader {
+    fn open(&self) -> PhpResult<&RustPartitionReader> {
+        self.inner.as_ref().ok_or_else(|| to_php_exception(CLOSED))
+    }
+
+    fn open_mut(&mut self) -> PhpResult<&mut RustPartitionReader> {
+        self.inner.as_mut().ok_or_else(|| to_php_exception(CLOSED))
+    }
+}
diff --git a/foreign/php/src/receive_message.rs b/foreign/php/src/receive_message.rs
index 50127bb..db8d9c7 100644
--- a/foreign/php/src/receive_message.rs
+++ b/foreign/php/src/receive_message.rs
@@ -17,9 +17,8 @@
 
 use ext_php_rs::{binary::Binary, exception::PhpResult, php_class, php_impl};
 use iggy::prelude::{
-    ConsumerPosition as RustConsumerPosition, IggyMessage as RustReceiveMessage, IggyMessageHeader,
-    PartitionContext as RustPartitionContext, PollingStrategy as RustPollingStrategy,
-    ReceivedMessage,
+    Checkpoint as RustCheckpoint, IggyMessage as RustReceiveMessage, IggyMessageHeader,
+    PolledMessages as RustPolledMessages, PollingStrategy as RustPollingStrategy, ReceivedMessage,
 };
 
 use crate::error::to_php_exception;
@@ -31,16 +30,14 @@
 #[php(name = "Iggy\\ReceiveMessage")]
 pub struct ReceiveMessage {
     pub(crate) inner: RustReceiveMessage,
-    pub(crate) partition_id: u32,
-    pub(crate) context: RustPartitionContext,
+    pub(crate) checkpoint: RustCheckpoint,
 }
 
 impl From<ReceivedMessage> for ReceiveMessage {
     fn from(message: ReceivedMessage) -> Self {
         Self {
+            checkpoint: message.checkpoint(),
             inner: message.message,
-            partition_id: message.partition_id,
-            context: message.context,
         }
     }
 }
@@ -48,22 +45,8 @@
 impl Clone for ReceiveMessage {
     fn clone(&self) -> Self {
         Self {
-            inner: RustReceiveMessage {
-                header: IggyMessageHeader {
-                    checksum: self.inner.header.checksum,
-                    id: self.inner.header.id,
-                    offset: self.inner.header.offset,
-                    timestamp: self.inner.header.timestamp,
-                    origin_timestamp: self.inner.header.origin_timestamp,
-                    user_headers_length: self.inner.header.user_headers_length,
-                    payload_length: self.inner.header.payload_length,
-                    reserved: self.inner.header.reserved,
-                },
-                payload: self.inner.payload.clone(),
-                user_headers: self.inner.user_headers.clone(),
-            },
-            partition_id: self.partition_id,
-            context: self.context,
+            inner: copy_message(&self.inner),
+            checkpoint: self.checkpoint,
         }
     }
 }
@@ -116,125 +99,121 @@
 
     /// Retrieves the partition this message belongs to.
     pub fn partition_id(&self) -> u32 {
-        self.partition_id
+        self.checkpoint.partition_id()
     }
 
-    /// Retrieves the partition incarnation and owner captured by the poll that delivered
-    /// this message.
-    pub fn context(&self) -> PartitionContext {
-        self.context.into()
-    }
-
-    /// Retrieves the position to commit for this message with Consumer::storePosition().
+    /// Retrieves the position after this message, which commits it and every earlier message
+    /// of its partition.
     ///
     /// It keeps the partition incarnation and owner that delivered the message, so a commit
     /// after the partition was recreated or changed owner is refused instead of landing on
-    /// the newer partition.
-    pub fn position(&self) -> ConsumerPosition {
-        RustConsumerPosition {
-            partition_id: self.partition_id,
-            offset: self.inner.header.offset,
-            context: self.context,
-        }
-        .into()
+    /// the newer partition. Only the reader or the consumer that delivered the message accepts
+    /// it in commit().
+    pub fn checkpoint(&self) -> Checkpoint {
+        self.checkpoint.into()
     }
 }
 
-/// A PHP class representing the partition incarnation and owner that a poll captured.
+/// A PHP class representing the position after one delivered message.
 ///
-/// Keep it with an offset that is continued or committed later. The server then refuses the
-/// request after the partition was deleted and recreated, or after its consumer group owner
-/// changed.
+/// A commit stores its offset, so it covers every earlier message of the partition.
 #[php_class]
-#[php(name = "Iggy\\PartitionContext")]
+#[php(name = "Iggy\\Checkpoint")]
 #[derive(Clone, Copy)]
-pub struct PartitionContext {
-    pub(crate) inner: RustPartitionContext,
+pub struct Checkpoint {
+    pub(crate) inner: RustCheckpoint,
 }
 
-impl From<RustPartitionContext> for PartitionContext {
-    fn from(inner: RustPartitionContext) -> Self {
+impl From<RustCheckpoint> for Checkpoint {
+    fn from(inner: RustCheckpoint) -> Self {
         Self { inner }
     }
 }
 
 #[php_impl]
-impl PartitionContext {
-    /// Rebuilds a context from values saved with an offset, for example by another process.
-    #[php(constructor)]
-    pub fn __construct(incarnation: u64, owner_generation: u64, metadata_op: u64) -> Self {
-        Self {
-            inner: RustPartitionContext {
-                incarnation,
-                owner_generation,
-                metadata_op,
-            },
-        }
+impl Checkpoint {
+    #[php(getter, change_case = "snake_case")]
+    pub fn partition_id(&self) -> u32 {
+        self.inner.partition_id()
     }
 
-    /// The partition's creation revision. It changes when the partition is deleted and
-    /// recreated.
+    /// The offset of the delivered message, which a commit stores.
     #[php(getter)]
-    pub fn incarnation(&self) -> u64 {
-        self.inner.incarnation
-    }
-
-    /// The consumer group owner generation, or 0 for a poll without a consumer group.
-    #[php(getter, change_case = "snake_case")]
-    pub fn owner_generation(&self) -> u64 {
-        self.inner.owner_generation
-    }
-
-    /// The metadata operation the server must have applied before it serves the request.
-    #[php(getter, change_case = "snake_case")]
-    pub fn metadata_op(&self) -> u64 {
-        self.inner.metadata_op
+    pub fn offset(&self) -> u64 {
+        self.inner.offset()
     }
 }
 
-/// A PHP class representing a consumed position to commit with Consumer::storePosition().
+/// A PHP class representing one batch of one partition.
 #[php_class]
-#[php(name = "Iggy\\ConsumerPosition")]
-#[derive(Clone, Copy)]
-pub struct ConsumerPosition {
-    pub(crate) inner: RustConsumerPosition,
+#[php(name = "Iggy\\PolledMessages")]
+pub struct PolledMessages {
+    inner: RustPolledMessages,
 }
 
-impl From<RustConsumerPosition> for ConsumerPosition {
-    fn from(inner: RustConsumerPosition) -> Self {
+impl From<RustPolledMessages> for PolledMessages {
+    fn from(inner: RustPolledMessages) -> Self {
         Self { inner }
     }
 }
 
 #[php_impl]
-impl ConsumerPosition {
-    /// Rebuilds a position from values saved with a processed message.
-    #[php(constructor)]
-    pub fn __construct(partition_id: u32, offset: u64, context: &PartitionContext) -> Self {
-        Self {
-            inner: RustConsumerPosition {
-                partition_id,
-                offset,
-                context: context.inner,
-            },
-        }
-    }
-
+impl PolledMessages {
     #[php(getter, change_case = "snake_case")]
     pub fn partition_id(&self) -> u32 {
         self.inner.partition_id
     }
 
-    /// The inclusive message offset to commit.
-    #[php(getter)]
-    pub fn offset(&self) -> u64 {
-        self.inner.offset
+    /// The offset of the newest message in the partition when the batch was read.
+    #[php(getter, change_case = "snake_case")]
+    pub fn current_offset(&self) -> u64 {
+        self.inner.current_offset
     }
 
-    /// The partition incarnation and owner captured by the poll that delivered the message.
     #[php(getter)]
-    pub fn context(&self) -> PartitionContext {
-        self.inner.context.into()
+    pub fn count(&self) -> u32 {
+        self.inner.count
+    }
+
+    /// The messages of the batch, in offset order.
+    ///
+    /// Each read of this property copies the messages, so keep the result in a variable to
+    /// read them more than once.
+    #[php(getter)]
+    pub fn messages(&self) -> Vec<ReceiveMessage> {
+        let checkpoints = (0..).map_while(|index| self.inner.checkpoint_at(index));
+        self.inner
+            .messages
+            .iter()
+            .zip(checkpoints)
+            .map(|(message, checkpoint)| ReceiveMessage {
+                inner: copy_message(message),
+                checkpoint,
+            })
+            .collect()
+    }
+
+    /// The position after the last message, to commit the whole batch. Null for an empty
+    /// batch.
+    pub fn checkpoint(&self) -> Option<Checkpoint> {
+        self.inner.checkpoint().map(Checkpoint::from)
+    }
+
+    /// The position after the message at `index`, to commit a processed part of the batch.
+    /// Null for an index out of range.
+    pub fn checkpoint_at(&self, index: usize) -> Option<Checkpoint> {
+        self.inner.checkpoint_at(index).map(Checkpoint::from)
+    }
+
+    /// The position after the batch, for the next read of its partition.
+    ///
+    /// After the last message it is the next offset. An empty batch keeps the start of its
+    /// request, except that first() becomes offset 0, so a start can take more than one read.
+    /// The continuation belongs to the partition of the batch, and a read of another partition
+    /// refuses it with InvalidCommand (3). It keeps the partition history of the batch, so a
+    /// read after the partition was recreated fails with HistoryUnavailable (87).
+    pub fn continuation(&self) -> PollingStrategy {
+        self.inner.continuation().into()
     }
 }
 
@@ -251,6 +230,12 @@
     }
 }
 
+impl From<RustPollingStrategy> for PollingStrategy {
+    fn from(inner: RustPollingStrategy) -> Self {
+        Self { inner }
+    }
+}
+
 #[php_impl]
 impl PollingStrategy {
     pub fn offset(value: u64) -> Self {
@@ -297,70 +282,103 @@
             inner: RustPollingStrategy::next(),
         }
     }
+}
 
-    /// Continues at an offset under the partition incarnation and owner that produced it.
-    ///
-    /// A stale context fails the poll instead of reading that offset from a recreated
-    /// partition. Without a context, the poll uses the context of its route.
-    pub fn with_context(&self, context: &PartitionContext) -> Self {
-        Self {
-            inner: self.inner.with_context(context.inner),
-        }
+// The SDK message has no Clone. Its payload and headers are reference counted.
+fn copy_message(message: &RustReceiveMessage) -> RustReceiveMessage {
+    RustReceiveMessage {
+        header: IggyMessageHeader {
+            checksum: message.header.checksum,
+            id: message.header.id,
+            offset: message.header.offset,
+            timestamp: message.header.timestamp,
+            origin_timestamp: message.header.origin_timestamp,
+            user_headers_length: message.header.user_headers_length,
+            payload_length: message.header.payload_length,
+            reserved: message.header.reserved,
+        },
+        payload: message.payload.clone(),
+        user_headers: message.user_headers.clone(),
     }
 }
 
 #[cfg(test)]
 mod tests {
-    use iggy::prelude::PollingKind;
+    use bytes::Bytes;
+    use iggy::prelude::{PartitionContext, PollingKind};
 
     use super::*;
 
-    const CONTEXT: RustPartitionContext = RustPartitionContext {
+    const PARTITION_ID: u32 = 2;
+    const CONTEXT: PartitionContext = PartitionContext {
         incarnation: 7,
         owner_generation: 3,
         metadata_op: 11,
     };
 
-    #[test]
-    fn given_consumed_message_when_converted_should_keep_its_context_and_position() {
+    fn message(offset: u64) -> RustReceiveMessage {
         let mut message = RustReceiveMessage::default();
-        message.header.offset = 42;
-        let received = ReceivedMessage::new(message, 50, 2, CONTEXT);
-        let expected = received.position();
+        message.header.offset = offset;
+        message.payload = Bytes::from(offset.to_string());
+        message
+    }
+
+    fn batch(offsets: &[u64]) -> PolledMessages {
+        let messages: Vec<_> = offsets.iter().copied().map(message).collect();
+        let count = u32::try_from(messages.len()).unwrap();
+        RustPolledMessages::new(CONTEXT, PARTITION_ID, 50, count, messages).into()
+    }
+
+    #[test]
+    fn given_consumed_message_when_converted_should_keep_its_checkpoint() {
+        let received = ReceivedMessage::new(message(42), 50, PARTITION_ID, CONTEXT);
+        let expected = received.checkpoint();
 
         let message = ReceiveMessage::from(received);
 
-        assert_eq!(message.context().inner, CONTEXT);
-        assert_eq!(message.position().inner, expected);
+        assert_eq!(message.checkpoint().inner, expected);
+        assert_eq!(message.partition_id(), PARTITION_ID);
+        assert_eq!(message.offset(), 42);
     }
 
     #[test]
-    fn given_offset_strategy_when_given_context_should_stamp_it_into_the_poll() {
-        let strategy = PollingStrategy::offset(42).with_context(&PartitionContext::from(CONTEXT));
+    fn given_batch_when_listing_messages_should_give_each_message_its_own_checkpoint() {
+        let polled = batch(&[4, 5, 6]);
 
+        let messages = polled.messages();
+
+        let offsets: Vec<_> = messages.iter().map(ReceiveMessage::offset).collect();
+        assert_eq!(offsets, [4, 5, 6]);
+        for (index, message) in messages.iter().enumerate() {
+            assert_eq!(
+                Some(message.checkpoint().inner),
+                polled.inner.checkpoint_at(index)
+            );
+        }
+        assert_eq!(messages[1].payload().as_slice(), b"5");
         assert_eq!(
-            strategy.inner,
-            RustPollingStrategy {
-                kind: PollingKind::Offset,
-                value: 42,
-                context: Some(CONTEXT),
-            }
+            polled.checkpoint().map(|checkpoint| checkpoint.inner),
+            polled.inner.checkpoint()
         );
+        assert!(polled.checkpoint_at(3).is_none());
     }
 
     #[test]
-    fn given_saved_checkpoint_when_rebuilt_should_match_the_rust_position() {
-        let context = PartitionContext::__construct(7, 3, 11);
+    fn given_batch_when_continuing_should_read_after_its_last_message_of_the_same_partition() {
+        let continuation = batch(&[4, 5]).continuation().inner;
 
-        let position = ConsumerPosition::__construct(2, 42, &context);
+        assert_eq!(continuation.kind, PollingKind::Offset);
+        assert_eq!(continuation.value, 6);
+        assert_eq!(continuation.partition_id(), Some(PARTITION_ID));
+        assert!(continuation.validate_partition(PARTITION_ID + 1).is_err());
+    }
 
-        assert_eq!(
-            position.inner,
-            RustConsumerPosition {
-                partition_id: 2,
-                offset: 42,
-                context: CONTEXT,
-            }
-        );
+    #[test]
+    fn given_empty_batch_when_inspected_should_have_no_checkpoint() {
+        let polled = batch(&[]);
+
+        assert!(polled.messages().is_empty());
+        assert!(polled.checkpoint().is_none());
+        assert!(polled.checkpoint_at(0).is_none());
     }
 }
diff --git a/foreign/php/tests/ErrorTest.php b/foreign/php/tests/ErrorTest.php
index 0934e03..54e6702 100644
--- a/foreign/php/tests/ErrorTest.php
+++ b/foreign/php/tests/ErrorTest.php
@@ -18,12 +18,12 @@
 
 declare(strict_types=1);
 
-use Iggy\AutoCommit;
 use Iggy\Client as IggyClient;
 use Iggy\Exception\AuthenticationException;
 use Iggy\Exception\ConnectionException;
 use Iggy\Exception\IggyException;
 use Iggy\Exception\NotFoundException;
+use Iggy\Exception\ReadException;
 use Iggy\Exception\TransientException;
 use Iggy\PollingStrategy;
 use Iggy\ReceiveMessage;
@@ -89,6 +89,12 @@
         assert_true(is_subclass_of(TransientException::class, IggyException::class));
     }
 
+    #[TestDox('Read exceptions share the Iggy exception base class')]
+    public function testReadExceptionUsesIggyBase(): void
+    {
+        assert_true(is_subclass_of(ReadException::class, IggyException::class));
+    }
+
     #[TestDox('Callback exceptions are rethrown without changing their PHP type')]
     public function testCallbackExceptionIsRethrownAsOriginalException(): void
     {
@@ -106,22 +112,7 @@
                 [new SendMessage('callback exception payload')],
             );
 
-            $consumer = $client->consumerGroup(
-                $consumerName,
-                $streamName,
-                $topicName,
-                0,
-                PollingStrategy::next(),
-                10,
-                AutoCommit::disabled(),
-                true,
-                true,
-                null,
-                null,
-                null,
-                null,
-                false,
-            );
+            $consumer = $client->consumerGroup($consumerName, $streamName, $topicName, PollingStrategy::next());
 
             $throwable = assert_throws(
                 static fn () => $consumer->consumeMessages(
diff --git a/foreign/php/tests/IggySdkTest.php b/foreign/php/tests/IggySdkTest.php
index 5f99911..321189d 100644
--- a/foreign/php/tests/IggySdkTest.php
+++ b/foreign/php/tests/IggySdkTest.php
@@ -19,10 +19,15 @@
 declare(strict_types=1);
 
 use Iggy\AutoCommit;
-use Iggy\AutoCommitWhen;
 use Iggy\Client as IggyClient;
-use Iggy\PartitionContext;
+use Iggy\CommitAfter;
+use Iggy\CommitEvery;
+use Iggy\CommitPolicy;
+use Iggy\Exception\NotFoundException;
+use Iggy\Exception\PendingOperationException;
+use Iggy\PolledMessages;
 use Iggy\PollingStrategy;
+use Iggy\PollOptions;
 use Iggy\ReceiveMessage;
 use Iggy\SendMessage;
 use Iggy\SendMessagesResponse;
@@ -36,6 +41,15 @@
     private const GET_ME_CODE = 20;
     private const ACTIVE_USER_STATUS = 1;
     private const STRING_IDENTIFIER_KIND = 2;
+    private const CONSUMER_ID = 1;
+    private const INVALID_COMMAND_CODE = 3;
+    private const HISTORY_UNAVAILABLE_CODE = 87;
+    private const PARTITION_LANE_BLOCKED_CODE = 14009;
+    private const FETCH_PROGRESS_UNCERTAIN_CODE = 14010;
+    private const EMPTY_READ_TIMEOUT_MICROS = 100_000;
+    private const DEADLINE_SECONDS = 10;
+    private const UNCOUNTED_WAIT_SECONDS = 0.5;
+    private const CONSUMER_BUSY = 'cannot run while consumeMessages() runs';
 
     #[TestDox('A connected client can ping the server')]
     public function testPing(): void
@@ -278,7 +292,14 @@
                 array_map(static fn (string $payload): SendMessage => new SendMessage($payload), $messages),
             );
 
-            $polled = $client->pollMessages($streamName, $topicName, $partitionId, PollingStrategy::first(), 10, true);
+            $polled = $client->pollMessages(
+                $streamName,
+                $topicName,
+                $partitionId,
+                self::CONSUMER_ID,
+                PollingStrategy::first(),
+                new PollOptions(count: 10, commit: CommitPolicy::onRead()),
+            )->messages;
             assert_count(count($messages), $polled);
             assert_same($messages, collect_payloads($polled));
         } finally {
@@ -328,7 +349,14 @@
             create_stream_and_topic($client, $streamName, $topicName);
             $client->sendMessages($streamName, $topicName, $partitionId, [new SendMessage($payload)]);
 
-            $polled = $client->pollMessages($streamName, $topicName, $partitionId, PollingStrategy::last(), 1, true);
+            $polled = $client->pollMessages(
+                $streamName,
+                $topicName,
+                $partitionId,
+                self::CONSUMER_ID,
+                PollingStrategy::last(),
+                new PollOptions(count: 1, commit: CommitPolicy::onRead()),
+            )->messages;
             assert_count(1, $polled);
 
             $message = $polled[0];
@@ -339,6 +367,8 @@
             assert_true(ctype_digit($message->checksum()), 'expected numeric checksum');
             assert_true($message->length() > 0, 'expected positive length');
             assert_same($partitionId, $message->partitionId());
+            assert_same($partitionId, $message->checkpoint()->partition_id);
+            assert_same($message->offset(), $message->checkpoint()->offset);
         } finally {
             cleanup_stream_with_topics($client, $streamName, [$topicName]);
         }
@@ -365,11 +395,12 @@
                 array_map(static fn (string $payload): SendMessage => new SendMessage($payload), $messages),
             );
 
-            $firstMessages = $client->pollMessages($streamName, $topicName, $partitionId, PollingStrategy::first(), 1, false);
+            $one = new PollOptions(count: 1);
+            $firstMessages = $client->pollMessages($streamName, $topicName, $partitionId, self::CONSUMER_ID, PollingStrategy::first(), $one)->messages;
             assert_count(1, $firstMessages);
             assert_same([$messages[0]], collect_payloads($firstMessages));
 
-            $lastMessages = $client->pollMessages($streamName, $topicName, $partitionId, PollingStrategy::last(), 1, false);
+            $lastMessages = $client->pollMessages($streamName, $topicName, $partitionId, self::CONSUMER_ID, PollingStrategy::last(), $one)->messages;
             assert_count(1, $lastMessages);
             assert_same([$messages[4]], collect_payloads($lastMessages));
 
@@ -378,23 +409,24 @@
                 $streamName,
                 $topicName,
                 $partitionId,
+                self::CONSUMER_ID,
                 PollingStrategy::offset($offset),
-                2,
-                false,
-            );
+                new PollOptions(count: 2),
+            )->messages;
             assert_count(2, $offsetMessages);
             assert_same([$messages[2], $messages[3]], collect_payloads($offsetMessages));
             assert_same([$offset, $offset + 1], collect_offsets($offsetMessages));
 
-            $nextFirstBatch = $client->pollMessages($streamName, $topicName, $partitionId, PollingStrategy::next(), 2, true);
+            $storing = new PollOptions(count: 2, commit: CommitPolicy::onRead());
+            $nextFirstBatch = $client->pollMessages($streamName, $topicName, $partitionId, self::CONSUMER_ID, PollingStrategy::next(), $storing)->messages;
             assert_count(2, $nextFirstBatch);
             assert_same([$messages[0], $messages[1]], collect_payloads($nextFirstBatch));
 
-            $nextSecondBatch = $client->pollMessages($streamName, $topicName, $partitionId, PollingStrategy::next(), 2, true);
+            $nextSecondBatch = $client->pollMessages($streamName, $topicName, $partitionId, self::CONSUMER_ID, PollingStrategy::next(), $storing)->messages;
             assert_count(2, $nextSecondBatch);
             assert_same([$messages[2], $messages[3]], collect_payloads($nextSecondBatch));
 
-            $nextThirdBatch = $client->pollMessages($streamName, $topicName, $partitionId, PollingStrategy::next(), 2, true);
+            $nextThirdBatch = $client->pollMessages($streamName, $topicName, $partitionId, self::CONSUMER_ID, PollingStrategy::next(), $storing)->messages;
             assert_count(1, $nextThirdBatch);
             assert_same([$messages[4]], collect_payloads($nextThirdBatch));
         } finally {
@@ -402,17 +434,18 @@
         }
     }
 
-    #[TestDox('A poll continues at an offset under the partition context of the message that preceded it')]
-    public function testPollContinuesUnderTheCapturedPartitionContext(): void
+    #[TestDox('A continuation reads after its batch, only in its partition and partition history')]
+    public function testPollContinuesAfterTheBatch(): void
     {
         $client = new_client();
-        $streamName = unique_name('context-stream');
-        $topicName = unique_name('context-topic');
+        $streamName = unique_name('continuation-stream');
+        $topicName = unique_name('continuation-topic');
         $partitionId = 0;
-        $messages = ['context-first', 'context-second'];
+        $messages = ['continuation-first', 'continuation-second'];
+        $one = new PollOptions(count: 1);
 
         try {
-            create_stream_and_topic($client, $streamName, $topicName);
+            create_stream_and_topic($client, $streamName, $topicName, 2);
             $client->sendMessages(
                 $streamName,
                 $topicName,
@@ -420,24 +453,179 @@
                 array_map(static fn (string $payload): SendMessage => new SendMessage($payload), $messages),
             );
 
-            $first = $client->pollMessages($streamName, $topicName, $partitionId, PollingStrategy::first(), 1, false)[0];
-            $context = $first->context();
-            assert_true($context->incarnation > 0, 'a poll must capture the partition incarnation');
+            $first = $client->pollMessages($streamName, $topicName, $partitionId, self::CONSUMER_ID, PollingStrategy::first(), $one);
+            assert_instance_of(PolledMessages::class, $first);
+            assert_same($partitionId, $first->partition_id);
+            assert_same(1, $first->count);
+            assert_same(count($messages) - 1, $first->current_offset);
+            assert_same($first->messages[0]->offset(), $first->checkpoint()->offset);
+            assert_null($first->checkpointAt(1));
 
-            $position = $first->position();
-            assert_same($partitionId, $position->partition_id);
-            assert_same($first->offset(), $position->offset);
-            assert_same($context->incarnation, $position->context->incarnation);
+            $continued = $client->pollMessages($streamName, $topicName, $partitionId, self::CONSUMER_ID, $first->continuation(), $one);
+            assert_same([$messages[1]], collect_payloads($continued->messages));
 
-            $next = PollingStrategy::offset($first->offset() + 1);
-            $continued = $client->pollMessages($streamName, $topicName, $partitionId, $next->withContext($context), 1, false);
-            assert_same([$messages[1]], collect_payloads($continued));
+            $otherPartition = assert_throws(
+                static fn () => $client->pollMessages($streamName, $topicName, $partitionId + 1, self::CONSUMER_ID, $first->continuation(), $one),
+            );
+            assert_same(self::INVALID_COMMAND_CODE, $otherPartition->getCode());
 
-            $recreated = new PartitionContext($context->incarnation + 1, $context->owner_generation, $context->metadata_op);
-            assert_throws(
-                static fn () => $client->pollMessages($streamName, $topicName, $partitionId, $next->withContext($recreated), 1, false),
+            $client->deleteTopic($streamName, $topicName);
+            $client->createTopic($streamName, $topicName, 2, null, null, null, null);
+            $recreated = assert_throws(
+                static fn () => $client->pollMessages($streamName, $topicName, $partitionId, self::CONSUMER_ID, $first->continuation(), $one),
                 'partition history is no longer available',
             );
+            assert_same(self::HISTORY_UNAVAILABLE_CODE, $recreated->getCode());
+        } finally {
+            cleanup_stream_with_topics($client, $streamName, [$topicName]);
+        }
+    }
+
+    #[TestDox('An on read reader close moves the offset back to its last delivered message')]
+    public function testOnReadReaderCloseMovesTheOffsetBackAndASecondSessionResumes(): void
+    {
+        $client = new_client();
+        $streamName = unique_name('on-read-close-stream');
+        $topicName = unique_name('on-read-close-topic');
+        $onRead = new PollOptions(count: 4, commit: CommitPolicy::onRead());
+
+        try {
+            create_stream_and_topic($client, $streamName, $topicName, 1);
+            $client->sendMessages(
+                $streamName,
+                $topicName,
+                0,
+                array_map(static fn (int $index): SendMessage => new SendMessage("on-read-{$index}"), range(0, 9)),
+            );
+
+            $reader = $client->reader($streamName, $topicName, self::CONSUMER_ID, [0], PollingStrategy::offset(0), $onRead);
+            $batch = $reader->next(micros(self::DEADLINE_SECONDS));
+            assert_not_null($batch);
+            assert_same([0, 1, 2, 3], collect_offsets($batch->messages));
+            $reader->close();
+            assert_same(3, $client->getConsumerOffset($streamName, $topicName, self::CONSUMER_ID, 0)?->stored_offset);
+
+            $secondSession = new_client();
+            $resumed = $secondSession->reader($streamName, $topicName, self::CONSUMER_ID, [0], null, $onRead);
+            $next = $resumed->next(micros(self::DEADLINE_SECONDS));
+            assert_not_null($next);
+            assert_same([4, 5, 6, 7], collect_offsets($next->messages));
+            $resumed->close();
+        } finally {
+            cleanup_stream_with_topics($client, $streamName, [$topicName]);
+        }
+    }
+
+    #[TestDox('A poll started in the background returns the message that arrives while it is parked')]
+    public function testPendingPollReturnsTheBatchOfAParkedPollWhenAMessageArrives(): void
+    {
+        $client = new_client();
+        $streamName = unique_name('pending-stream');
+        $topicName = unique_name('pending-topic');
+
+        try {
+            create_stream_and_topic($client, $streamName, $topicName, 1);
+            // A parked poll holds its connection, so it runs on a client of its own.
+            $poller = new_client();
+            $poll = $poller->startPollMessages(
+                $streamName,
+                $topicName,
+                0,
+                self::CONSUMER_ID,
+                PollingStrategy::offset(0),
+                new PollOptions(max_wait_micros: micros(self::DEADLINE_SECONDS)),
+            );
+
+            assert_null($poll->wait(self::EMPTY_READ_TIMEOUT_MICROS));
+            $client->sendMessages($streamName, $topicName, 0, [new SendMessage('wake-up')]);
+            $batch = $poll->wait(micros(self::DEADLINE_SECONDS));
+            assert_instance_of(PolledMessages::class, $batch);
+            assert_same(['wake-up'], collect_payloads($batch->messages));
+            assert_throws(static fn () => $poll->wait(self::EMPTY_READ_TIMEOUT_MICROS), 'the poll already ended');
+        } finally {
+            cleanup_stream_with_topics($client, $streamName, [$topicName]);
+        }
+    }
+
+    #[TestDox('A cancelled parked poll that commits on read stores no offset')]
+    public function testCancelledPendingPollThatCommitsOnReadStoresNoOffset(): void
+    {
+        $client = new_client();
+        $streamName = unique_name('cancel-stream');
+        $topicName = unique_name('cancel-topic');
+
+        try {
+            create_stream_and_topic($client, $streamName, $topicName, 1);
+            $client->sendMessages($streamName, $topicName, 0, [new SendMessage('unseen')]);
+            $poll = $client->startPollMessages(
+                $streamName,
+                $topicName,
+                0,
+                self::CONSUMER_ID,
+                PollingStrategy::next(),
+                new PollOptions(max_wait_micros: micros(1), min_count: 2, commit: CommitPolicy::onRead()),
+            );
+
+            assert_null($poll->wait(self::EMPTY_READ_TIMEOUT_MICROS));
+            $poll->cancel();
+            $poll->cancel();
+            assert_throws(static fn () => $poll->wait(self::EMPTY_READ_TIMEOUT_MICROS), 'the poll already ended');
+
+            // Past the max wait, after which an uncancelled poll would have stored its offset.
+            sleep(2);
+            assert_null($client->getConsumerOffset($streamName, $topicName, self::CONSUMER_ID, 0));
+        } finally {
+            cleanup_stream_with_topics($client, $streamName, [$topicName]);
+        }
+    }
+
+    #[TestDox('A committing poll cut off by a disconnect blocks its partition until its operation is abandoned')]
+    public function testCommittingPollLostToADisconnectBlocksItsPartitionUntilAbandoned(): void
+    {
+        $client = new_client();
+        $streamName = unique_name('lost-stream');
+        $topicName = unique_name('lost-topic');
+
+        try {
+            create_stream_and_topic($client, $streamName, $topicName, 1);
+            // A parked poll holds its connection, so it runs on a client of its own.
+            $owner = new_client();
+            $poll = $owner->startPollMessages(
+                $streamName,
+                $topicName,
+                0,
+                self::CONSUMER_ID,
+                PollingStrategy::next(),
+                new PollOptions(max_wait_micros: micros(self::DEADLINE_SECONDS), commit: CommitPolicy::onRead()),
+            );
+            assert_null($poll->wait(self::EMPTY_READ_TIMEOUT_MICROS));
+
+            $owner->disconnect();
+            $lost = assert_throws(static fn () => $poll->wait(micros(self::DEADLINE_SECONDS)));
+            assert_instance_of(PendingOperationException::class, $lost, $lost->getMessage());
+            assert_same(self::FETCH_PROGRESS_UNCERTAIN_CODE, $lost->getCode());
+            assert_same('fetch_progress_uncertain', $lost->kind);
+
+            $owner->connect();
+            $owner->loginUser(env_or_default('IGGY_ROOT_USERNAME', 'iggy'), env_or_default('IGGY_ROOT_PASSWORD', 'iggy'));
+            $pollNext = static fn (): PolledMessages => $owner->pollMessages(
+                $streamName,
+                $topicName,
+                0,
+                self::CONSUMER_ID,
+                PollingStrategy::next(),
+                new PollOptions(count: 1, commit: CommitPolicy::onRead()),
+            );
+            $blocked = assert_throws($pollNext);
+            assert_instance_of(PendingOperationException::class, $blocked, $blocked->getMessage());
+            assert_same(self::PARTITION_LANE_BLOCKED_CODE, $blocked->getCode());
+            assert_same('lane_blocked', $blocked->kind);
+            assert_same(0, $blocked->partition_id);
+            assert_same($lost->operation_id, $blocked->operation_id);
+
+            $owner->abandonOperation($lost->operation_id);
+            $client->sendMessages($streamName, $topicName, 0, [new SendMessage('after')]);
+            assert_same(['after'], collect_payloads($pollNext()->messages));
         } finally {
             cleanup_stream_with_topics($client, $streamName, [$topicName]);
         }
@@ -490,17 +678,8 @@
                 $consumerName,
                 $streamName,
                 $topicName,
-                $partitionId,
                 PollingStrategy::next(),
-                10,
-                AutoCommit::interval(micros(5)),
-                true,
-                true,
-                null,
-                null,
-                null,
-                null,
-                false,
+                poll_options: new PollOptions(max_wait_micros: micros(1), commit: CommitPolicy::auto(new AutoCommit(micros(5), after: CommitAfter::Delivery))),
             );
 
             assert_same($consumerName, $consumer->name());
@@ -526,12 +705,12 @@
             create_stream_and_topic($client, $streamName, $topicName);
 
             assert_throws(
-                static fn () => AutoCommit::interval(0),
+                static fn () => new AutoCommit(0),
                 'interval_micros',
             );
 
             assert_throws(
-                static fn () => AutoCommit::intervalOrWhen(0, AutoCommitWhen::pollingMessages()),
+                static fn () => new AutoCommit(0, CommitEvery::batch()),
                 'interval_micros',
             );
 
@@ -540,17 +719,8 @@
                     $consumerName,
                     $streamName,
                     $topicName,
-                    0,
                     PollingStrategy::next(),
-                    10,
-                    AutoCommit::disabled(),
-                    true,
-                    true,
-                    null,
-                    0,
-                    null,
-                    null,
-                    false,
+                    polling_retry_interval_micros: 0,
                 ),
                 'polling_retry_interval_micros',
             );
@@ -560,17 +730,9 @@
                     $consumerName,
                     $streamName,
                     $topicName,
-                    0,
                     PollingStrategy::next(),
-                    10,
-                    AutoCommit::disabled(),
-                    true,
-                    true,
-                    null,
-                    null,
-                    0,
-                    0,
-                    false,
+                    init_retries: 0,
+                    init_retry_interval_micros: 0,
                 ),
                 'init_retry_interval_micros',
             );
@@ -589,22 +751,7 @@
 
         try {
             create_stream_and_topic($client, $streamName, $topicName);
-            $consumer = $client->consumerGroup(
-                $consumerName,
-                $streamName,
-                $topicName,
-                0,
-                PollingStrategy::next(),
-                10,
-                AutoCommit::disabled(),
-                true,
-                true,
-                null,
-                null,
-                null,
-                null,
-                false,
-            );
+            $consumer = $client->consumerGroup($consumerName, $streamName, $topicName, PollingStrategy::next());
 
             assert_throws(static fn () => $consumer->storeOffset(0, null), 'partition_id');
             assert_throws(static fn () => $consumer->deleteOffset(null), 'partition_id');
@@ -639,17 +786,12 @@
                 $consumerName,
                 $streamName,
                 $topicName,
-                $partitionId,
                 PollingStrategy::next(),
-                10,
-                AutoCommit::interval(micros(5)),
-                true,
-                true,
-                null,
-                null,
-                null,
-                null,
-                false,
+                new PollOptions(
+                    count: 10,
+                    max_wait_micros: micros(1),
+                    commit: CommitPolicy::auto(new AutoCommit(micros(5), after: CommitAfter::Delivery)),
+                ),
             );
             $received = [];
             $count = $consumer->consumeMessages(
@@ -692,17 +834,8 @@
                 $consumerName,
                 $streamName,
                 $topicName,
-                $partitionId,
                 PollingStrategy::next(),
-                10,
-                AutoCommit::interval(micros(5)),
-                true,
-                true,
-                null,
-                null,
-                null,
-                null,
-                false,
+                poll_options: new PollOptions(max_wait_micros: micros(1), commit: CommitPolicy::auto(new AutoCommit(micros(5), after: CommitAfter::Delivery))),
             );
             $iterator = $consumer->iterMessages();
             $received = [];
@@ -726,8 +859,81 @@
         }
     }
 
-    #[TestDox('A consumer stores the position of a message after it consumed later messages')]
-    public function testConsumerStoresTheDeliveredPosition(): void
+    #[TestDox('A new foreach over a message iterator reads on after the message it yielded last')]
+    public function testIteratorResumesAfterTheLastYieldedMessage(): void
+    {
+        $client = new_client();
+        $consumerName = unique_name('consumer-group-resume');
+        $streamName = unique_name('consumer-group-stream');
+        $topicName = unique_name('consumer-group-topic');
+        $partitionId = 0;
+        $messages = ['resume-first', 'resume-second'];
+
+        try {
+            create_stream_and_topic($client, $streamName, $topicName);
+            $client->sendMessages(
+                $streamName,
+                $topicName,
+                $partitionId,
+                array_map(static fn (string $payload): SendMessage => new SendMessage($payload), $messages),
+            );
+
+            $consumer = $client->consumerGroup($consumerName, $streamName, $topicName, PollingStrategy::next());
+            $iterator = $consumer->iterMessages();
+            $received = [];
+            foreach ($iterator as $key => $message) {
+                $received[$key] = $message->payload();
+                break;
+            }
+            foreach ($iterator as $key => $message) {
+                $received[$key] = $message->payload();
+                break;
+            }
+
+            assert_same($messages, $received);
+            $consumer->shutdown();
+        } finally {
+            cleanup_stream_with_topics($client, $streamName, [$topicName]);
+        }
+    }
+
+    #[TestDox('A message iterator whose read threw holds no message from before the failure')]
+    public function testIteratorHoldsNoMessageAfterAFailedRead(): void
+    {
+        $client = new_client();
+        $consumerName = unique_name('consumer-group-failed-read');
+        $streamName = unique_name('consumer-group-stream');
+        $topicName = unique_name('consumer-group-topic');
+        $partitionId = 0;
+
+        try {
+            create_stream_and_topic($client, $streamName, $topicName);
+            $client->sendMessages($streamName, $topicName, $partitionId, [new SendMessage('failed-read')]);
+
+            $consumer = $client->consumerGroup($consumerName, $streamName, $topicName, PollingStrategy::first());
+            $iterator = $consumer->iterMessages();
+            $iterator->rewind();
+            assert_same('failed-read', $iterator->current()?->payload());
+            $client->sendMessages($streamName, $topicName, $partitionId, [new SendMessage('callback-trigger')]);
+            $heldAfterFailure = 'not called';
+            // The run holds the consumer, so a read of the iterator in its callback throws.
+            $consumer->consumeMessages(
+                static function (ReceiveMessage $message) use ($iterator, &$heldAfterFailure): void {
+                    assert_throws(static fn () => $iterator->next(), 'iterMessages() ' . self::CONSUMER_BUSY);
+                    $heldAfterFailure = $iterator->valid() ? $iterator->current()?->payload() : null;
+                },
+                1,
+            );
+
+            assert_null($heldAfterFailure);
+            $consumer->shutdown();
+        } finally {
+            cleanup_stream_with_topics($client, $streamName, [$topicName]);
+        }
+    }
+
+    #[TestDox('A consumer commits the checkpoint of a message after it consumed later messages')]
+    public function testConsumerCommitsTheDeliveredCheckpoint(): void
     {
         $client = new_client();
         $consumerName = unique_name('consumer-group-position');
@@ -749,17 +955,8 @@
                 $consumerName,
                 $streamName,
                 $topicName,
-                $partitionId,
                 PollingStrategy::next(),
-                10,
-                AutoCommit::disabled(),
-                true,
-                true,
-                null,
-                micros(1),
-                null,
-                null,
-                false,
+                polling_retry_interval_micros: micros(1),
             );
             $received = [];
             $consumer->consumeMessages(
@@ -771,8 +968,10 @@
             assert_count(count($messages), $received);
 
             $first = $received[0];
-            assert_true($first->context()->incarnation > 0, 'a consumed message must keep its partition incarnation');
-            $consumer->storePosition($first->position());
+            $checkpoint = $first->checkpoint();
+            assert_same($first->partitionId(), $checkpoint->partition_id);
+            assert_same($first->offset(), $checkpoint->offset);
+            $consumer->commit($checkpoint);
 
             assert_same($first->offset(), $consumer->getLastStoredOffset($first->partitionId()));
         } finally {
@@ -790,24 +989,518 @@
 
         try {
             create_stream_and_topic($client, $streamName, $topicName);
+            $consumer = $client->consumerGroup($consumerName, $streamName, $topicName, PollingStrategy::next());
+
+            assert_throws(static fn () => $consumer->consumeMessages(static fn (ReceiveMessage $message): null => null, null));
+        } finally {
+            cleanup_stream_with_topics($client, $streamName, [$topicName]);
+        }
+    }
+
+    #[TestDox('A consumer reads again from a sought offset and delivers nothing after shutdown')]
+    public function testConsumerSeeksAndShutsDown(): void
+    {
+        $client = new_client();
+        $consumerName = unique_name('consumer-group-seek');
+        $streamName = unique_name('consumer-group-stream');
+        $topicName = unique_name('consumer-group-topic');
+        $partitionId = 0;
+        $messages = ['seek-first', 'seek-second', 'seek-third'];
+
+        try {
+            create_stream_and_topic($client, $streamName, $topicName);
+            $client->sendMessages(
+                $streamName,
+                $topicName,
+                $partitionId,
+                array_map(static fn (string $payload): SendMessage => new SendMessage($payload), $messages),
+            );
+
+            $consumer = $client->consumerGroup($consumerName, $streamName, $topicName, PollingStrategy::next());
+            $received = [];
+            $collect = static function (ReceiveMessage $message) use (&$received): void {
+                $received[] = $message->payload();
+            };
+            assert_same(count($messages), $consumer->consumeMessages($collect, count($messages)));
+
+            $consumer->seek($partitionId, PollingStrategy::offset(1));
+            assert_same(2, $consumer->consumeMessages($collect, 2));
+            assert_same([...$messages, $messages[1], $messages[2]], $received);
+
+            $consumer->shutdown();
+            $consumer->shutdown();
+            assert_same(0, $consumer->consumeMessages($collect, 1));
+        } finally {
+            cleanup_stream_with_topics($client, $streamName, [$topicName]);
+        }
+    }
+
+    #[TestDox('consumeMessages() counts a message after its callback returns and takes no message past its limit')]
+    public function testConsumeMessagesCountsAfterTheCallbackAndStopsAtItsLimit(): void
+    {
+        $client = new_client();
+        $consumerName = unique_name('consumer-group-count');
+        $streamName = unique_name('consumer-group-stream');
+        $topicName = unique_name('consumer-group-topic');
+        $partitionId = 0;
+        $messages = ['count-first', 'count-second', 'count-third'];
+
+        try {
+            create_stream_and_topic($client, $streamName, $topicName);
+            $client->sendMessages(
+                $streamName,
+                $topicName,
+                $partitionId,
+                array_map(static fn (string $payload): SendMessage => new SendMessage($payload), $messages),
+            );
+
             $consumer = $client->consumerGroup(
                 $consumerName,
                 $streamName,
                 $topicName,
-                0,
                 PollingStrategy::next(),
-                10,
-                AutoCommit::disabled(),
-                true,
-                true,
-                null,
-                null,
-                null,
-                null,
-                false,
+                poll_options: new PollOptions(max_wait_micros: micros(1), commit: CommitPolicy::auto(new AutoCommit(every: CommitEvery::message()))),
+            );
+            $stored = static fn (): ?int => $consumer->getLastStoredOffset($partitionId);
+            $received = [];
+            $storedInCallback = [];
+            $collect = static function (ReceiveMessage $message) use ($stored, &$received, &$storedInCallback): void {
+                $received[] = $message->payload();
+                // The wait gives a count at delivery the time to store this offset before the callback returns.
+                $storedInCallback[$message->offset()] = read_until($stored, $message->offset(), self::UNCOUNTED_WAIT_SECONDS);
+            };
+
+            assert_same(2, $consumer->consumeMessages($collect, 2));
+            assert_same(array_slice($messages, 0, 2), $received);
+            foreach ($storedInCallback as $offset => $storedOffset) {
+                assert_true($storedOffset !== $offset, "message {$offset} must count only after its callback returns");
+            }
+            assert_same(1, $consumer->getLastConsumedOffset($partitionId), 'the run must take no message past its limit');
+            assert_same(1, read_until($stored, 1, self::DEADLINE_SECONDS), 'the last message must count after its callback');
+
+            assert_same(1, $consumer->consumeMessages($collect, 1));
+            assert_same($messages, $received);
+            $consumer->shutdown();
+        } finally {
+            cleanup_stream_with_topics($client, $streamName, [$topicName]);
+        }
+    }
+
+    #[TestDox('An exception of a consumeMessages() callback ends the run, is thrown, and leaves its message uncounted')]
+    public function testConsumeMessagesThrowsTheCallbackExceptionAndLeavesItsMessageUncounted(): void
+    {
+        $client = new_client();
+        $consumerName = unique_name('consumer-group-throw');
+        $streamName = unique_name('consumer-group-stream');
+        $topicName = unique_name('consumer-group-topic');
+        $partitionId = 0;
+        $messages = ['throw-first', 'throw-second'];
+
+        try {
+            create_stream_and_topic($client, $streamName, $topicName);
+            $client->sendMessages(
+                $streamName,
+                $topicName,
+                $partitionId,
+                array_map(static fn (string $payload): SendMessage => new SendMessage($payload), $messages),
             );
 
-            assert_throws(static fn () => $consumer->consumeMessages(static fn (ReceiveMessage $message): null => null, null));
+            $consumer = $client->consumerGroup(
+                $consumerName,
+                $streamName,
+                $topicName,
+                PollingStrategy::next(),
+                poll_options: new PollOptions(max_wait_micros: micros(1), commit: CommitPolicy::auto(new AutoCommit(every: CommitEvery::message()))),
+            );
+            $failure = new RuntimeException('callback failed');
+            $thrown = assert_throws(
+                static fn () => $consumer->consumeMessages(
+                    static function (ReceiveMessage $message) use ($failure): void {
+                        throw $failure;
+                    },
+                    count($messages),
+                ),
+            );
+
+            assert_same($failure, $thrown);
+            assert_same(0, $consumer->getLastConsumedOffset($partitionId), 'the exception must end the run at its message');
+            assert_null(
+                read_until(static fn (): ?int => $consumer->getLastStoredOffset($partitionId), 0, self::UNCOUNTED_WAIT_SECONDS),
+                'the message of the exception must not count',
+            );
+
+            $received = [];
+            $collect = static function (ReceiveMessage $message) use (&$received): void {
+                $received[] = $message->payload();
+            };
+            assert_same(1, $consumer->consumeMessages($collect, 1));
+            assert_same([$messages[1]], $received);
+            $consumer->shutdown();
+        } finally {
+            cleanup_stream_with_topics($client, $streamName, [$topicName]);
+        }
+    }
+
+    #[TestDox('A consumeMessages() callback can commit, and its calls that need the consumer alone throw')]
+    public function testConsumeMessagesCallbackCommitsButCannotSeek(): void
+    {
+        $client = new_client();
+        $consumerName = unique_name('consumer-group-callback');
+        $streamName = unique_name('consumer-group-stream');
+        $topicName = unique_name('consumer-group-topic');
+        $partitionId = 0;
+        $payload = 'callback-calls';
+
+        try {
+            create_stream_and_topic($client, $streamName, $topicName);
+            $client->sendMessages($streamName, $topicName, $partitionId, [new SendMessage($payload)]);
+
+            $consumer = $client->consumerGroup($consumerName, $streamName, $topicName, PollingStrategy::next());
+            $storedInCallback = null;
+            $consumer->consumeMessages(
+                static function (ReceiveMessage $message) use ($consumer, $partitionId, &$storedInCallback): void {
+                    $consumer->commit($message->checkpoint());
+                    $storedInCallback = $consumer->getLastStoredOffset($partitionId);
+
+                    assert_throws(
+                        static fn () => $consumer->seek($partitionId, PollingStrategy::first()),
+                        'seek() ' . self::CONSUMER_BUSY,
+                    );
+                    assert_throws(static fn () => $consumer->shutdown(), 'shutdown() ' . self::CONSUMER_BUSY);
+                    assert_throws(
+                        static fn () => $consumer->consumeMessages(static fn (ReceiveMessage $message): null => null, 1),
+                        'consumeMessages() ' . self::CONSUMER_BUSY,
+                    );
+                    assert_throws(
+                        static fn () => iterator_to_array($consumer->iterMessages()),
+                        'iterMessages() ' . self::CONSUMER_BUSY,
+                    );
+                },
+                1,
+            );
+            assert_same(0, $storedInCallback);
+
+            $consumer->seek($partitionId, PollingStrategy::offset(0));
+            $received = [];
+            $consumer->consumeMessages(
+                static function (ReceiveMessage $message) use (&$received): void {
+                    $received[] = $message->payload();
+                },
+                1,
+            );
+            assert_same([$payload], $received);
+            $consumer->shutdown();
+        } finally {
+            cleanup_stream_with_topics($client, $streamName, [$topicName]);
+        }
+    }
+
+    #[TestDox('A reader delivers a batch, commits its checkpoint, seeks and closes')]
+    public function testReaderReadsCommitsSeeksAndCloses(): void
+    {
+        $client = new_client();
+        $readerName = unique_name('reader');
+        $streamName = unique_name('reader-stream');
+        $topicName = unique_name('reader-topic');
+        $partitionId = 0;
+        $messages = ['reader-first', 'reader-second', 'reader-third'];
+
+        try {
+            create_stream_and_topic($client, $streamName, $topicName);
+            $client->sendMessages(
+                $streamName,
+                $topicName,
+                $partitionId,
+                array_map(static fn (string $payload): SendMessage => new SendMessage($payload), $messages),
+            );
+
+            $reader = $client->reader($streamName, $topicName, $readerName, [$partitionId], PollingStrategy::first());
+            assert_same([$partitionId], $reader->partitions());
+
+            $batch = $reader->next();
+            assert_same($partitionId, $batch->partition_id);
+            assert_same($messages, collect_payloads($batch->messages));
+
+            $reader->commit($batch->checkpoint());
+            $afterCommit = $client->pollMessages($streamName, $topicName, $partitionId, $readerName, PollingStrategy::next());
+            assert_same(0, $afterCommit->count, 'the commit must store the offset of the last message');
+
+            $reader->seek($partitionId, PollingStrategy::offset(1));
+            assert_same(array_slice($messages, 1), collect_payloads($reader->next()->messages));
+
+            $reader->close();
+            $reader->close();
+            assert_throws(static fn () => $reader->next(), 'the reader is closed');
+        } finally {
+            cleanup_stream_with_topics($client, $streamName, [$topicName]);
+        }
+    }
+
+    #[TestDox('deleteConsumerPosition() deletes the offset that a reader committed for the partition')]
+    public function testDeleteConsumerPositionDeletesTheCommittedOffset(): void
+    {
+        $client = new_client();
+        $readerName = unique_name('reader');
+        $streamName = unique_name('reader-stream');
+        $topicName = unique_name('reader-topic');
+        $partitionId = 0;
+        $messages = ['delete-first', 'delete-second'];
+
+        try {
+            create_stream_and_topic($client, $streamName, $topicName);
+            $client->sendMessages(
+                $streamName,
+                $topicName,
+                $partitionId,
+                array_map(static fn (string $payload): SendMessage => new SendMessage($payload), $messages),
+            );
+            $reader = $client->reader($streamName, $topicName, $readerName, [$partitionId], PollingStrategy::first());
+            $checkpoint = $reader->next(micros(self::DEADLINE_SECONDS))->checkpoint();
+            $reader->commit($checkpoint);
+            $reader->close();
+
+            $client->deleteConsumerPosition($streamName, $topicName, $readerName, $checkpoint);
+
+            $afterDelete = $client->pollMessages($streamName, $topicName, $partitionId, $readerName, PollingStrategy::next());
+            assert_same($messages, collect_payloads($afterDelete->messages));
+            $missing = assert_throws(
+                static fn () => $client->deleteConsumerPosition($streamName, $topicName, $readerName, $checkpoint),
+            );
+            assert_instance_of(NotFoundException::class, $missing);
+        } finally {
+            cleanup_stream_with_topics($client, $streamName, [$topicName]);
+        }
+    }
+
+    #[TestDox('A standalone consumer reads its partitions and stores each counted position on shutdown')]
+    public function testStandaloneConsumerReadsItsPartitionsAndStoresOnShutdown(): void
+    {
+        $client = new_client();
+        $consumerName = unique_name('standalone-consumer');
+        $streamName = unique_name('standalone-stream');
+        $topicName = unique_name('standalone-topic');
+        $partitions = [0, 1];
+
+        try {
+            create_stream_and_topic($client, $streamName, $topicName, count($partitions));
+            foreach ($partitions as $partitionId) {
+                $client->sendMessages(
+                    $streamName,
+                    $topicName,
+                    $partitionId,
+                    [new SendMessage("standalone {$partitionId} 0"), new SendMessage("standalone {$partitionId} 1")],
+                );
+            }
+            $consumer = $client->consumer(
+                $consumerName,
+                $streamName,
+                $topicName,
+                $partitions,
+                PollingStrategy::first(),
+                new PollOptions(
+                    max_wait_micros: micros(1),
+                    commit: CommitPolicy::auto(new AutoCommit(every: CommitEvery::message(), after: CommitAfter::Delivery)),
+                ),
+            );
+            $received = [];
+            $count = $consumer->consumeMessages(
+                static function (ReceiveMessage $message) use (&$received): void {
+                    $received[] = $message->payload();
+                },
+                4,
+                micros(self::DEADLINE_SECONDS),
+            );
+            $consumer->shutdown();
+            sort($received);
+
+            assert_same(4, $count);
+            assert_same(['standalone 0 0', 'standalone 0 1', 'standalone 1 0', 'standalone 1 1'], $received);
+            foreach ($partitions as $partitionId) {
+                assert_same(1, $client->getConsumerOffset($streamName, $topicName, $consumerName, $partitionId)?->stored_offset);
+            }
+        } finally {
+            cleanup_stream_with_topics($client, $streamName, [$topicName]);
+        }
+    }
+
+    #[TestDox('The client stores, reads and deletes consumer offsets by partition and by checkpoint')]
+    public function testClientStoresReadsAndDeletesConsumerOffsets(): void
+    {
+        $client = new_client();
+        $consumerName = unique_name('offset-consumer');
+        $streamName = unique_name('offset-stream');
+        $topicName = unique_name('offset-topic');
+        $partitionId = 0;
+
+        try {
+            create_stream_and_topic($client, $streamName, $topicName);
+            $client->sendMessages(
+                $streamName,
+                $topicName,
+                $partitionId,
+                [new SendMessage('offset 0'), new SendMessage('offset 1'), new SendMessage('offset 2')],
+            );
+            $polled = $client->pollMessages($streamName, $topicName, $partitionId, $consumerName, PollingStrategy::first());
+            $offset = static fn (): ?int => $client->getConsumerOffset($streamName, $topicName, $consumerName, $partitionId)?->stored_offset;
+
+            assert_null($offset());
+            $client->storeConsumerOffset($streamName, $topicName, $consumerName, $partitionId, 1);
+            $info = $client->getConsumerOffset($streamName, $topicName, $consumerName, $partitionId);
+            assert_same([$partitionId, 2, 1], [$info->partition_id, $info->current_offset, $info->stored_offset]);
+            $client->storeConsumerPosition($streamName, $topicName, $consumerName, $polled->checkpoint());
+            assert_same(2, $offset());
+            $client->deleteConsumerOffset($streamName, $topicName, $consumerName, $partitionId);
+            assert_null($offset());
+            assert_instance_of(
+                NotFoundException::class,
+                assert_throws(static fn () => $client->deleteConsumerOffset($streamName, $topicName, $consumerName, $partitionId)),
+            );
+        } finally {
+            cleanup_stream_with_topics($client, $streamName, [$topicName]);
+        }
+    }
+
+    #[TestDox('pollMessages() reads as a consumer group member and stores the group offset on read')]
+    public function testPollMessagesReadsAsAConsumerGroupMember(): void
+    {
+        $client = new_client();
+        $groupName = unique_name('poll-group');
+        $streamName = unique_name('poll-group-stream');
+        $topicName = unique_name('poll-group-topic');
+        $partitionId = 0;
+
+        try {
+            create_stream_and_topic($client, $streamName, $topicName);
+            $client->sendMessages($streamName, $topicName, $partitionId, [new SendMessage('group 0'), new SendMessage('group 1')]);
+            // The member joins the group, so this client may poll as the group.
+            $member = $client->consumerGroup($groupName, $streamName, $topicName, PollingStrategy::first());
+            // The assignment can reach the member after the join, and a poll before it throws.
+            $poll = static function () use ($client, $streamName, $topicName, $partitionId, $groupName): int {
+                try {
+                    return $client->pollMessages(
+                        $streamName,
+                        $topicName,
+                        $partitionId,
+                        $groupName,
+                        PollingStrategy::first(),
+                        new PollOptions(count: 10, commit: CommitPolicy::onRead()),
+                        true,
+                    )->count;
+                } catch (Throwable) {
+                    return -1;
+                }
+            };
+            $polled = read_until($poll, 2, self::DEADLINE_SECONDS);
+
+            assert_same(2, $polled);
+            assert_same(1, $client->getConsumerOffset($streamName, $topicName, $groupName, $partitionId, true)?->stored_offset);
+            $member->shutdown();
+        } finally {
+            cleanup_stream_with_topics($client, $streamName, [$topicName]);
+        }
+    }
+
+    #[TestDox('An idle timeout returns control from consumeMessages() and iterMessages() on an idle topic')]
+    public function testIdleTimeoutReturnsControlOnAnIdleTopic(): void
+    {
+        $client = new_client();
+        $consumerName = unique_name('idle-consumer');
+        $streamName = unique_name('idle-stream');
+        $topicName = unique_name('idle-topic');
+        $partitionId = 0;
+
+        try {
+            create_stream_and_topic($client, $streamName, $topicName);
+            $consumer = $client->consumerGroup(
+                $consumerName,
+                $streamName,
+                $topicName,
+                PollingStrategy::first(),
+                new PollOptions(max_wait_micros: micros(1)),
+            );
+            $collect = static function (ReceiveMessage $message): void {
+            };
+
+            assert_same(0, $consumer->consumeMessages($collect, 1, self::EMPTY_READ_TIMEOUT_MICROS));
+            // Long enough for a message sent later to arrive before the second loop gives up.
+            $iterator = $consumer->iterMessages(micros(2));
+            assert_same([], iterator_to_array($iterator, false));
+
+            $client->sendMessages($streamName, $topicName, $partitionId, [new SendMessage('after idle')]);
+            $received = [];
+            foreach ($iterator as $message) {
+                $received[] = $message->payload();
+                break;
+            }
+            assert_same(['after idle'], $received);
+            $consumer->shutdown();
+        } finally {
+            cleanup_stream_with_topics($client, $streamName, [$topicName]);
+        }
+    }
+
+    #[TestDox('A reader returns null at the timeout of next() and delivers a later batch on the next call')]
+    public function testReaderNextReturnsNullAtItsTimeout(): void
+    {
+        $client = new_client();
+        $readerName = unique_name('reader');
+        $streamName = unique_name('reader-stream');
+        $topicName = unique_name('reader-topic');
+        $partitionId = 0;
+
+        try {
+            create_stream_and_topic($client, $streamName, $topicName);
+            $reader = $client->reader($streamName, $topicName, $readerName, [$partitionId], PollingStrategy::first());
+            try {
+                assert_null($reader->next(self::EMPTY_READ_TIMEOUT_MICROS), 'an empty partition must time out');
+                assert_null($reader->next(0), 'a zero timeout must not wait');
+                assert_throws(static fn () => $reader->next(-1), "'timeout_micros' must be non-negative");
+
+                $client->sendMessages($streamName, $topicName, $partitionId, [new SendMessage('after-timeout')]);
+                // A bound instead of no limit: a lost batch fails the test instead of hanging it.
+                $batch = $reader->next(micros(self::DEADLINE_SECONDS));
+                assert_not_null($batch, 'the batch sent after the timeouts must arrive');
+                assert_same(['after-timeout'], collect_payloads($batch->messages));
+            } finally {
+                $reader->close();
+            }
+        } finally {
+            cleanup_stream_with_topics($client, $streamName, [$topicName]);
+        }
+    }
+
+    #[TestDox('A group reader joins an existing consumer group and reads its assigned partition')]
+    public function testGroupReaderReadsTheAssignedPartition(): void
+    {
+        $client = new_client();
+        $groupName = unique_name('reader-group');
+        $streamName = unique_name('reader-stream');
+        $topicName = unique_name('reader-topic');
+        $partitionId = 0;
+        $messages = ['group-reader-first', 'group-reader-second'];
+
+        try {
+            create_stream_and_topic($client, $streamName, $topicName);
+            $client->sendMessages(
+                $streamName,
+                $topicName,
+                $partitionId,
+                array_map(static fn (string $payload): SendMessage => new SendMessage($payload), $messages),
+            );
+
+            $missing = assert_throws(static fn () => $client->groupReader($streamName, $topicName, $groupName));
+            assert_instance_of(NotFoundException::class, $missing);
+
+            // The client has no call that only creates a group: a consumer creates it and leaves.
+            $client->consumerGroup($groupName, $streamName, $topicName)->shutdown();
+
+            $reader = $client->groupReader($streamName, $topicName, $groupName, PollingStrategy::first());
+            try {
+                assert_same($messages, collect_payloads($reader->next()->messages));
+                assert_same([$partitionId], $reader->partitions());
+            } finally {
+                $reader->close();
+            }
         } finally {
             cleanup_stream_with_topics($client, $streamName, [$topicName]);
         }
diff --git a/foreign/php/tests/RawCommandTest.php b/foreign/php/tests/RawCommandTest.php
index 7d6ae38..bd632cb 100644
--- a/foreign/php/tests/RawCommandTest.php
+++ b/foreign/php/tests/RawCommandTest.php
@@ -18,10 +18,14 @@
 
 declare(strict_types=1);
 
+use Iggy\AutoCommit;
 use Iggy\Client;
+use Iggy\CommitEvery;
+use Iggy\CommitPolicy;
 use Iggy\Exception\IggyException;
 use Iggy\Exception\PendingOperationException;
 use Iggy\PendingOperationsConfig;
+use Iggy\PollOptions;
 use PHPUnit\Framework\Attributes\DataProvider;
 use PHPUnit\Framework\Attributes\TestDox;
 use PHPUnit\Framework\TestCase;
@@ -34,6 +38,10 @@
     private const CREATE_STREAM_CODE = 202;
     private const SERVER_REPLICA_ID = 0;
     private const UNKNOWN_REPLICA_ID = 9;
+    private const INVALID_COMMAND_CODE = 3;
+    private const INVALID_SIZE_BYTES_CODE = 80;
+    private const INVALID_MESSAGES_COUNT_CODE = 4009;
+    private const MAX_POLL_WAIT_MICROS = 600_000_000;
 
     #[TestDox('A raw ping command returns an empty successful response')]
     public function testRawPingReturnsEmptyResponse(): void
@@ -217,13 +225,23 @@
                     'messages_required_to_save', 'size_of_messages_required_to_save'],
             ],
             'consumerGroup' => [
-                ['name' => 'g', 'stream' => 's', 'topic' => 't', 'partition_id' => null,
-                    'polling_strategy' => null, 'batch_length' => null, 'auto_commit' => null,
-                    'create_consumer_group_if_not_exists' => false, 'auto_join_consumer_group' => false,
-                    'poll_interval_micros' => null, 'polling_retry_interval_micros' => null,
-                    'init_retries' => null, 'init_retry_interval_micros' => null, 'allow_replay' => false],
-                ['partition_id', 'batch_length', 'poll_interval_micros',
-                    'polling_retry_interval_micros', 'init_retries', 'init_retry_interval_micros'],
+                ['name' => 'g', 'stream' => 's', 'topic' => 't', 'polling_strategy' => null,
+                    'poll_options' => null, 'prefetch_bytes' => null,
+                    'prefetch_messages' => null, 'create_consumer_group_if_not_exists' => false,
+                    'polling_retry_interval_micros' => null, 'init_retries' => null,
+                    'init_retry_interval_micros' => null, 'allow_replay' => false],
+                ['prefetch_bytes', 'prefetch_messages', 'polling_retry_interval_micros',
+                    'init_retries', 'init_retry_interval_micros'],
+            ],
+            'reader' => [
+                ['stream' => 's', 'topic' => 't', 'consumer' => 'c', 'partitions' => [0], 'start' => null,
+                    'options' => null, 'prefetch_bytes' => null, 'prefetch_messages' => null],
+                ['prefetch_bytes', 'prefetch_messages'],
+            ],
+            'groupReader' => [
+                ['stream' => 's', 'topic' => 't', 'group' => 'g', 'start' => null, 'options' => null,
+                    'prefetch_bytes' => null, 'prefetch_messages' => null],
+                ['prefetch_bytes', 'prefetch_messages'],
             ],
         ];
         foreach ($methods as $method => [$required, $fields]) {
@@ -234,16 +252,83 @@
                 }
             }
         }
-        foreach (['partition_id', 'batch_length', 'init_retries'] as $field) {
-            $arguments = array_replace($methods['consumerGroup'][0], [$field => 4294967296]);
-            assert_throws(static fn () => $client->consumerGroup(...$arguments), $field);
+        $u32Fields = [
+            'consumerGroup' => ['prefetch_bytes', 'prefetch_messages', 'init_retries'],
+            'reader' => ['prefetch_bytes', 'prefetch_messages'],
+            'groupReader' => ['prefetch_bytes', 'prefetch_messages'],
+        ];
+        foreach ($u32Fields as $method => $fields) {
+            foreach ($fields as $field) {
+                $arguments = array_replace($methods[$method][0], [$field => 4294967296]);
+                assert_throws(static fn () => $client->$method(...$arguments), $field);
+            }
         }
-        $arguments = array_replace($methods['consumerGroup'][0], ['poll_interval_micros' => 1_000]);
-        assert_throws(static fn () => $client->consumerGroup(...$arguments), 'poll_interval_micros');
         $arguments = array_replace($methods['createTopic'][0], ['messages_required_to_save' => 4294967296]);
         assert_throws(static fn () => $client->createTopic(...$arguments), 'messages_required_to_save');
     }
 
+    public function testPollOptionsKeepDefaultsAndRefuseInvalidLimits(): void
+    {
+        $defaults = new PollOptions();
+        assert_same(1000, $defaults->count);
+        assert_same(0, $defaults->max_wait_micros);
+        assert_same(1, $defaults->min_count);
+        assert_same(1024 * 1024, $defaults->max_bytes);
+        assert_same('manual', $defaults->commit->kind);
+
+        $options = new PollOptions(10, 5_000, 2, 65_536, CommitPolicy::onRead());
+        assert_same(10, $options->count);
+        assert_same(5_000, $options->max_wait_micros);
+        assert_same(2, $options->min_count);
+        assert_same(65_536, $options->max_bytes);
+        assert_same('on_read', $options->commit->kind);
+        assert_same('auto', (new PollOptions(commit: CommitPolicy::auto(AutoCommit::default())))->commit->kind);
+
+        foreach (['count', 'max_wait_micros', 'min_count', 'max_bytes'] as $field) {
+            foreach ([-1, 1.5, true, '18446744073709551616'] as $invalid) {
+                assert_throws(static fn () => new PollOptions(...[$field => $invalid]), $field);
+            }
+        }
+        foreach (['count', 'min_count', 'max_bytes'] as $field) {
+            assert_throws(static fn () => new PollOptions(...[$field => 4294967296]), $field);
+        }
+        assert_same(
+            self::INVALID_MESSAGES_COUNT_CODE,
+            assert_throws(static fn () => new PollOptions(count: 2, min_count: 3))->getCode(),
+        );
+        assert_same(
+            self::INVALID_MESSAGES_COUNT_CODE,
+            assert_throws(static fn () => new PollOptions(min_count: 0))->getCode(),
+        );
+        assert_same(
+            self::INVALID_COMMAND_CODE,
+            assert_throws(static fn () => new PollOptions(max_wait_micros: self::MAX_POLL_WAIT_MICROS + 1))->getCode(),
+        );
+        assert_same(
+            self::INVALID_SIZE_BYTES_CODE,
+            assert_throws(static fn () => new PollOptions(max_bytes: 1))->getCode(),
+        );
+    }
+
+    public function testAutoCommitRefusesSettingsThatNeverStore(): void
+    {
+        foreach ([0, -1, 1.5, true, '18446744073709551616'] as $invalid) {
+            assert_throws(static fn () => new AutoCommit($invalid), 'interval_micros');
+        }
+        assert_throws(static fn () => new AutoCommit(), "'interval_micros' or an 'every'");
+        assert_throws(static fn () => CommitEvery::messages(0), 'must not be zero');
+    }
+
+    public function testReaderRefusesAnEmptyPartitionListBeforeConnecting(): void
+    {
+        $client = new Client('127.0.0.1:1');
+
+        assert_same(
+            self::INVALID_COMMAND_CODE,
+            assert_throws(static fn () => $client->reader('s', 't', 'c', []))->getCode(),
+        );
+    }
+
     public function testConnectionStringBudgetsReachEveryBinaryTransport(): void
     {
         foreach (['tcp', 'quic', 'ws'] as $transport) {
diff --git a/foreign/php/tests/ResourceOptionsTest.php b/foreign/php/tests/ResourceOptionsTest.php
index beb2d04..92daca4 100644
--- a/foreign/php/tests/ResourceOptionsTest.php
+++ b/foreign/php/tests/ResourceOptionsTest.php
@@ -24,6 +24,7 @@
 use Iggy\OptionSpec;
 use Iggy\OptionsScope;
 use Iggy\PollingStrategy;
+use Iggy\PollOptions;
 use Iggy\SendMessage;
 use Iggy\StreamDetails;
 use Iggy\StreamOptionKey;
@@ -37,6 +38,7 @@
 final class ResourceOptionsTest extends TestCase
 {
     private const FINGERPRINT_PATTERN = '/^[0-9a-f]{16}$/';
+    private const CONSUMER_ID = 1;
     private const CATEGORY_MESSAGES = [
         'invalid_command' => 'Invalid command',
         'invalid_encryption_key' => 'Invalid encryption key',
@@ -125,7 +127,7 @@
         assert_same($created->encryption_kek_fingerprint, $stream->encryption_kek_fingerprint);
         assert_same(
             ['before-rename'],
-            collect_payloads($client->pollMessages($created->id, 'events', 0, PollingStrategy::offset(0), 10, false)),
+            collect_payloads($client->pollMessages($created->id, 'events', 0, self::CONSUMER_ID, PollingStrategy::offset(0), new PollOptions(count: 10))->messages),
         );
     }
 
diff --git a/foreign/php/tests/TlsTest.php b/foreign/php/tests/TlsTest.php
index 8ce63b0..d83f008 100644
--- a/foreign/php/tests/TlsTest.php
+++ b/foreign/php/tests/TlsTest.php
@@ -19,17 +19,21 @@
 declare(strict_types=1);
 
 use Iggy\Client as IggyClient;
+use Iggy\CommitPolicy;
 use Iggy\MetadataChangeGroup;
 use Iggy\MetadataChangeKind;
 use Iggy\MetadataChangeStart;
 use Iggy\MetadataChangeStatus;
 use Iggy\MetadataStreamChange;
 use Iggy\PollingStrategy;
+use Iggy\PollOptions;
 use Iggy\SendMessage;
 use PHPUnit\Framework\TestCase;
 
 final class TlsTest extends TestCase
 {
+    private const CONSUMER_ID = 1;
+
     public function testConnectionStringWithTlsParams(): void
     {
         $connectionString = getenv('IGGY_TLS_CONNECTION_STRING');
@@ -69,7 +73,14 @@
                 array_map(static fn (string $payload): SendMessage => new SendMessage($payload), $messages),
             );
 
-            $polled = $client->pollMessages($streamName, $topicName, $partitionId, PollingStrategy::first(), 10, true);
+            $polled = $client->pollMessages(
+                $streamName,
+                $topicName,
+                $partitionId,
+                self::CONSUMER_ID,
+                PollingStrategy::first(),
+                new PollOptions(count: 10, commit: CommitPolicy::onRead()),
+            )->messages;
             assert_count(count($messages), $polled, 'expected TLS messages');
             assert_same($messages, collect_payloads($polled));
         } finally {
diff --git a/foreign/php/tests/bootstrap.php b/foreign/php/tests/bootstrap.php
index 5cde652..57ae9f2 100644
--- a/foreign/php/tests/bootstrap.php
+++ b/foreign/php/tests/bootstrap.php
@@ -22,6 +22,8 @@
 use Iggy\ReceiveMessage;
 use PHPUnit\Framework\Assert;
 
+const READ_UNTIL_POLL_MICROS = 10_000;
+
 function assert_true(bool $condition, string $message = 'expected condition to be true'): void
 {
     Assert::assertTrue($condition, $message);
@@ -265,6 +267,23 @@
     return $seconds * 1_000_000;
 }
 
+/**
+ * Calls $read until it returns $expected or the timeout ends, and returns the last value read.
+ */
+function read_until(callable $read, mixed $expected, float $timeoutSeconds): mixed
+{
+    $deadline = microtime(true) + $timeoutSeconds;
+
+    while (true) {
+        $value = $read();
+        if ($value === $expected || microtime(true) >= $deadline) {
+            return $value;
+        }
+
+        usleep(READ_UNTIL_POLL_MICROS);
+    }
+}
+
 if (!extension_loaded('iggy-php')) {
     Assert::fail('The iggy-php extension is not loaded.');
 }
diff --git a/foreign/php/tests/fixtures/deferred_polling.json b/foreign/php/tests/fixtures/deferred_polling.json
new file mode 100644
index 0000000..452b461
--- /dev/null
+++ b/foreign/php/tests/fixtures/deferred_polling.json
@@ -0,0 +1,610 @@
+{
+  "schema_version": 1,
+  "generated_by": "core/common/examples/deferred_polling_fixtures.rs",
+  "conventions": {
+    "body_hex": "Every *_hex field is lowercase hex without separators. body_hex is the command payload without framing.",
+    "header_hex": "header_hex is the whole request header of a cancellation, which has no body, before the transport stamps its checksum and checksum_body.",
+    "oversized": "A reply may carry a first record above max_bytes, up to max_record_size, so a client reply cap is at least max_record_reply_size.",
+    "refused": "Every refused fetch fails to decode or to validate. Every refused fetch reply fails to decode.",
+    "status": "A section status of status_ok was read. Any other status is the error code of that partition alone, and its section holds no records.",
+    "strategy": "strategy.kind is the wire kind: 1 offset, 2 timestamp, 3 first, 4 last, 5 next."
+  },
+  "constants": {
+    "codes": {
+      "cancel_deferred_request": 108,
+      "fetch_messages": 109,
+      "poll_messages": 100,
+      "poll_messages_deferred": 106
+    },
+    "status_ok": 0,
+    "errors": {
+      "deferred_poll_capacity_exceeded": 90,
+      "fetch_progress_uncertain": 14010,
+      "history_unavailable": 87,
+      "invalid_size_bytes": 80,
+      "not_primary": 89,
+      "partition_lane_blocked": 14009,
+      "too_many_consumer_offsets": 3024,
+      "transient_not_accepted": 58,
+      "transient_not_committed": 57
+    },
+    "min_deferred_poll_max_bytes": 344,
+    "max_deferred_poll_wait_us": 600000000,
+    "max_deferred_poll_timeout_us": 630000000,
+    "min_deferred_poll_reply_margin_us": 1000000,
+    "fetch_reply_margin_us": 200000,
+    "max_partitions_per_request": 1000,
+    "max_record_size": 64100304,
+    "max_record_reply_size": 64156564,
+    "request_header_size": 256,
+    "fetch_section_header_size": 56
+  },
+  "deferred_polls": [
+    {
+      "case": "consumer_offset_without_commit",
+      "request": {
+        "consumer": {
+          "kind": 1,
+          "id": {
+            "kind": "numeric",
+            "value": 1
+          }
+        },
+        "stream_id": {
+          "kind": "numeric",
+          "value": 1
+        },
+        "topic_id": {
+          "kind": "named",
+          "value": "orders"
+        },
+        "partition_id": 2,
+        "strategy": {
+          "kind": 1,
+          "value": 41
+        },
+        "count": 100,
+        "auto_commit": false,
+        "wait_us": 5000000,
+        "min_count": 1,
+        "max_bytes": 1048576,
+        "request_timeout_us": 6000000
+      },
+      "body_hex": "0101040100000001040100000002066f726465727301020000000129000000000000006400000000404b4c00000000000100000000001000808d5b0000000000"
+    },
+    {
+      "case": "group_next_with_commit",
+      "request": {
+        "consumer": {
+          "kind": 2,
+          "id": {
+            "kind": "named",
+            "value": "orders-group"
+          }
+        },
+        "stream_id": {
+          "kind": "numeric",
+          "value": 1
+        },
+        "topic_id": {
+          "kind": "named",
+          "value": "orders"
+        },
+        "partition_id": 2,
+        "strategy": {
+          "kind": 5,
+          "value": 0
+        },
+        "count": 100,
+        "auto_commit": true,
+        "wait_us": 5000000,
+        "min_count": 1,
+        "max_bytes": 1048576,
+        "request_timeout_us": 6000000
+      },
+      "body_hex": "02020c6f72646572732d67726f757001040100000002066f726465727301020000000500000000000000006400000001404b4c00000000000100000000001000808d5b0000000000"
+    }
+  ],
+  "fetches": [
+    {
+      "case": "consumer_two_targets_without_commit",
+      "request": {
+        "consumer": {
+          "kind": 1,
+          "id": {
+            "kind": "numeric",
+            "value": 1
+          }
+        },
+        "stream_id": {
+          "kind": "numeric",
+          "value": 1
+        },
+        "topic_id": {
+          "kind": "numeric",
+          "value": 2
+        },
+        "wait_us": 5000000,
+        "request_timeout_us": 6000000,
+        "min_count": 1,
+        "count": 1000,
+        "max_bytes": 1048576,
+        "auto_commit": false,
+        "targets": [
+          {
+            "partition_id": 1,
+            "strategy": {
+              "kind": 1,
+              "value": 41
+            },
+            "max_bytes": 524288,
+            "incarnation": 11,
+            "owner_generation": 0
+          },
+          {
+            "partition_id": 3,
+            "strategy": {
+              "kind": 5,
+              "value": 0
+            },
+            "max_bytes": 524288,
+            "incarnation": 11,
+            "owner_generation": 0
+          }
+        ]
+      },
+      "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b000000000001000000e803000000001000000200000001000000012900000000000000000008000b00000000000000000000000000000003000000050000000000000000000008000b000000000000000000000000000000"
+    },
+    {
+      "case": "group_three_targets_with_commit",
+      "request": {
+        "consumer": {
+          "kind": 2,
+          "id": {
+            "kind": "numeric",
+            "value": 9
+          }
+        },
+        "stream_id": {
+          "kind": "numeric",
+          "value": 1
+        },
+        "topic_id": {
+          "kind": "numeric",
+          "value": 2
+        },
+        "wait_us": 5000000,
+        "request_timeout_us": 6000000,
+        "min_count": 1,
+        "count": 1000,
+        "max_bytes": 1048576,
+        "auto_commit": true,
+        "targets": [
+          {
+            "partition_id": 1,
+            "strategy": {
+              "kind": 5,
+              "value": 0
+            },
+            "max_bytes": 524288,
+            "incarnation": 11,
+            "owner_generation": 4
+          },
+          {
+            "partition_id": 2,
+            "strategy": {
+              "kind": 5,
+              "value": 0
+            },
+            "max_bytes": 524288,
+            "incarnation": 11,
+            "owner_generation": 4
+          },
+          {
+            "partition_id": 4,
+            "strategy": {
+              "kind": 1,
+              "value": 7
+            },
+            "max_bytes": 524288,
+            "incarnation": 11,
+            "owner_generation": 4
+          }
+        ]
+      },
+      "body_hex": "02010409000000010401000000010402000000404b4c0000000000808d5b000000000001000000e803000000001000010300000001000000050000000000000000000008000b00000000000000040000000000000002000000050000000000000000000008000b00000000000000040000000000000004000000010700000000000000000008000b000000000000000400000000000000"
+    }
+  ],
+  "cancellations": [
+    {
+      "case": "cancel_deferred_poll",
+      "cancels_code": 106,
+      "client": 7,
+      "request": 21,
+      "session": 3,
+      "header_hex": "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000050000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007000000000000000000000000000000000000000000000000000000000000000000000000000000150000000000000002000000000000000300000000000000000000006c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
+    },
+    {
+      "case": "cancel_fetch",
+      "cancels_code": 109,
+      "client": 7,
+      "request": 22,
+      "session": 3,
+      "header_hex": "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000050000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007000000000000000000000000000000000000000000000000000000000000000000000000000000160000000000000002000000000000000300000000000000000000006c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
+    }
+  ],
+  "fetch_replies": [
+    {
+      "case": "ok_section_and_empty_start_section",
+      "sections": [
+        {
+          "partition_id": 1,
+          "status": 0,
+          "current_offset": 44,
+          "messages_count": 3,
+          "context": {
+            "incarnation": 11,
+            "owner_generation": 4,
+            "metadata_op": 42
+          },
+          "next_offset": 44,
+          "records_len": 416,
+          "messages": [
+            {
+              "offset": 41,
+              "id": "258",
+              "timestamp": 1700000000000500,
+              "origin_timestamp": 1700000000000000,
+              "payload": "first"
+            },
+            {
+              "offset": 42,
+              "id": "259",
+              "timestamp": 1700000000000500,
+              "origin_timestamp": 1700000000000000,
+              "payload": "second"
+            },
+            {
+              "offset": 43,
+              "id": "260",
+              "timestamp": 1700000000000500,
+              "origin_timestamp": 1700000000000000,
+              "payload": "third"
+            }
+          ]
+        },
+        {
+          "partition_id": 3,
+          "status": 0,
+          "current_offset": 9,
+          "messages_count": 0,
+          "context": {
+            "incarnation": 11,
+            "owner_generation": 4,
+            "metadata_op": 42
+          },
+          "next_offset": 10,
+          "records_len": 0,
+          "messages": []
+        }
+      ],
+      "body_hex": "0200000001000000000000002c00000000000000030000000b0000000000000004000000000000002a000000000000002c00000000000000a001000003000000000000000900000000000000000000000b0000000000000004000000000000002a000000000000000a000000000000000000000001000000000000002900000000000000f4411e18240a060000401e18240a0600a00100000000000088fc3268f2c0e02103000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000bb5b1cd43b71761a02010000000000000000000000000000000000000000000000000000050000000000000000000000666972737453c25c376f98c265030100000000000000000000000000000100000000000000000000000600000000000000000000007365636f6e64a761c18f632fb022040100000000000000000000000000000200000000000000000000000500000000000000000000007468697264"
+    },
+    {
+      "case": "ok_section_beside_error_sections",
+      "sections": [
+        {
+          "partition_id": 1,
+          "status": 89,
+          "current_offset": 0,
+          "messages_count": 0,
+          "context": {
+            "incarnation": 0,
+            "owner_generation": 0,
+            "metadata_op": 0
+          },
+          "next_offset": 0,
+          "records_len": 0,
+          "messages": []
+        },
+        {
+          "partition_id": 2,
+          "status": 0,
+          "current_offset": 7,
+          "messages_count": 1,
+          "context": {
+            "incarnation": 11,
+            "owner_generation": 4,
+            "metadata_op": 42
+          },
+          "next_offset": 7,
+          "records_len": 308,
+          "messages": [
+            {
+              "offset": 6,
+              "id": "340282366920938463463374607431768211455",
+              "timestamp": 1700000000000500,
+              "origin_timestamp": 1700000000000000,
+              "payload": "only"
+            }
+          ]
+        },
+        {
+          "partition_id": 4,
+          "status": 58,
+          "current_offset": 0,
+          "messages_count": 0,
+          "context": {
+            "incarnation": 0,
+            "owner_generation": 0,
+            "metadata_op": 0
+          },
+          "next_offset": 0,
+          "records_len": 0,
+          "messages": []
+        }
+      ],
+      "body_hex": "03000000010000005900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000700000000000000010000000b0000000000000004000000000000002a00000000000000070000000000000034010000040000003a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000600000000000000f4411e18240a060000401e18240a060034010000000000003e89bfefc17b805301000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000fbb4aeb35a85bdaeffffffffffffffffffffffffffffffff0000000000000000000000000400000000000000000000006f6e6c79"
+    },
+    {
+      "case": "no_sections",
+      "sections": [],
+      "body_hex": "00000000"
+    }
+  ],
+  "refused": {
+    "fetches": [
+      {
+        "case": "no_targets",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b000000000001000000e8030000000010000000000000"
+      },
+      {
+        "case": "duplicate_partition",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b000000000001000000e803000000001000000200000001000000010000000000000000000008000b00000000000000000000000000000001000000050000000000000000000008000b000000000000000000000000000000"
+      },
+      {
+        "case": "first_start_point",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b000000000001000000e803000000001000000100000001000000030000000000000000000008000b000000000000000000000000000000"
+      },
+      {
+        "case": "min_count_above_count",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b0000000000e9030000e803000000001000000100000001000000010000000000000000000008000b000000000000000000000000000000"
+      },
+      {
+        "case": "target_bytes_above_request_bytes",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b000000000001000000e803000000001000000100000001000000010000000000000000010010000b000000000000000000000000000000"
+      },
+      {
+        "case": "timeout_without_reply_margin",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000404b4c000000000001000000e803000000001000000100000001000000010000000000000000000008000b000000000000000000000000000000"
+      }
+    ],
+    "fetch_replies": [
+      {
+        "case": "error_section_with_records",
+        "body_hex": "01000000010000005900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000"
+      },
+      {
+        "case": "truncated_table",
+        "body_hex": "02000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
+      }
+    ]
+  },
+  "commit_outcomes": {
+    "rule": "A committing deferred poll (106) or fetch (109) that fails with a certain status stored nothing. Every other status, a status not listed here, a lost reply and a transport failure after the send leave the outcome unknown.",
+    "request": [
+      {
+        "status": 5,
+        "error": "feature_unavailable",
+        "certain": true
+      },
+      {
+        "status": 6,
+        "error": "invalid_identifier",
+        "certain": true
+      },
+      {
+        "status": 30,
+        "error": "stale_client",
+        "certain": false
+      },
+      {
+        "status": 40,
+        "error": "unauthenticated",
+        "certain": false
+      },
+      {
+        "status": 41,
+        "error": "unauthorized",
+        "certain": false
+      },
+      {
+        "status": 57,
+        "error": "transient_not_committed",
+        "certain": false
+      },
+      {
+        "status": 58,
+        "error": "transient_not_accepted",
+        "certain": true
+      },
+      {
+        "status": 72,
+        "error": "cannot_decrypt_data",
+        "certain": false
+      },
+      {
+        "status": 80,
+        "error": "invalid_size_bytes",
+        "certain": true
+      },
+      {
+        "status": 87,
+        "error": "history_unavailable",
+        "certain": true
+      },
+      {
+        "status": 89,
+        "error": "not_primary",
+        "certain": true
+      },
+      {
+        "status": 90,
+        "error": "deferred_poll_capacity_exceeded",
+        "certain": true
+      },
+      {
+        "status": 1009,
+        "error": "stream_id_not_found",
+        "certain": true
+      },
+      {
+        "status": 1010,
+        "error": "stream_name_not_found",
+        "certain": true
+      },
+      {
+        "status": 2010,
+        "error": "topic_id_not_found",
+        "certain": true
+      },
+      {
+        "status": 2011,
+        "error": "topic_name_not_found",
+        "certain": true
+      },
+      {
+        "status": 3007,
+        "error": "partition_not_found",
+        "certain": true
+      },
+      {
+        "status": 3024,
+        "error": "too_many_consumer_offsets",
+        "certain": true
+      },
+      {
+        "status": 4100,
+        "error": "invalid_offset",
+        "certain": true
+      },
+      {
+        "status": 5009,
+        "error": "consumer_group_partition_not_owned",
+        "certain": true
+      },
+      {
+        "status": 11001,
+        "error": "shard_communication_error",
+        "certain": false
+      }
+    ],
+    "section": [
+      {
+        "status": 5,
+        "error": "feature_unavailable",
+        "certain": false
+      },
+      {
+        "status": 6,
+        "error": "invalid_identifier",
+        "certain": false
+      },
+      {
+        "status": 30,
+        "error": "stale_client",
+        "certain": false
+      },
+      {
+        "status": 40,
+        "error": "unauthenticated",
+        "certain": false
+      },
+      {
+        "status": 41,
+        "error": "unauthorized",
+        "certain": false
+      },
+      {
+        "status": 57,
+        "error": "transient_not_committed",
+        "certain": false
+      },
+      {
+        "status": 58,
+        "error": "transient_not_accepted",
+        "certain": true
+      },
+      {
+        "status": 72,
+        "error": "cannot_decrypt_data",
+        "certain": false
+      },
+      {
+        "status": 80,
+        "error": "invalid_size_bytes",
+        "certain": true
+      },
+      {
+        "status": 87,
+        "error": "history_unavailable",
+        "certain": true
+      },
+      {
+        "status": 89,
+        "error": "not_primary",
+        "certain": true
+      },
+      {
+        "status": 90,
+        "error": "deferred_poll_capacity_exceeded",
+        "certain": false
+      },
+      {
+        "status": 1009,
+        "error": "stream_id_not_found",
+        "certain": false
+      },
+      {
+        "status": 1010,
+        "error": "stream_name_not_found",
+        "certain": false
+      },
+      {
+        "status": 2010,
+        "error": "topic_id_not_found",
+        "certain": false
+      },
+      {
+        "status": 2011,
+        "error": "topic_name_not_found",
+        "certain": false
+      },
+      {
+        "status": 3007,
+        "error": "partition_not_found",
+        "certain": true
+      },
+      {
+        "status": 3024,
+        "error": "too_many_consumer_offsets",
+        "certain": true
+      },
+      {
+        "status": 4100,
+        "error": "invalid_offset",
+        "certain": false
+      },
+      {
+        "status": 5009,
+        "error": "consumer_group_partition_not_owned",
+        "certain": true
+      },
+      {
+        "status": 11001,
+        "error": "shard_communication_error",
+        "certain": false
+      }
+    ]
+  }
+}
diff --git a/foreign/php/tests/fixtures/resource_options.json b/foreign/php/tests/fixtures/resource_options.json
index c5cc2ac..202888f 100644
--- a/foreign/php/tests/fixtures/resource_options.json
+++ b/foreign/php/tests/fixtures/resource_options.json
@@ -16,18 +16,18 @@
   },
   "constants": {
     "protocol_version": {
-      "packed": 12288,
-      "text": "0.12.0",
+      "packed": 12289,
+      "text": "0.12.1",
       "major": 0,
       "minor": 12,
-      "patch": 0
+      "patch": 1
     },
     "protocol_version_min": {
-      "packed": 12288,
-      "text": "0.12.0",
+      "packed": 12289,
+      "text": "0.12.1",
       "major": 0,
       "minor": 12,
-      "patch": 0
+      "patch": 1
     },
     "command_codes": {
       "create_stream": {
diff --git a/foreign/python/Cargo.toml b/foreign/python/Cargo.toml
index eab18ec..78ce2b3 100644
--- a/foreign/python/Cargo.toml
+++ b/foreign/python/Cargo.toml
@@ -17,7 +17,7 @@
 
 [package]
 name = "apache-iggy"
-version = "0.9.1-dev5"
+version = "0.9.1-dev6"
 edition = "2024"
 authors = ["Iggy Committers <dev@iggy.apache.org>"]
 license = "Apache-2.0"
@@ -37,9 +37,9 @@
 [dependencies]
 bytes = "1.12.1"
 futures = "0.3.34"
-iggy = { path = "../../core/sdk", version = "0.11.1-edge.5" }
+iggy = { path = "../../core/sdk", version = "0.11.1-edge.6" }
 paste = "1"
-pyo3 = "0.29.3"
+pyo3 = { version = "0.29.3", features = ["experimental-async"] }
 pyo3-async-runtimes = { version = "0.29.0", features = [
     "attributes",
     "tokio-runtime",
diff --git a/foreign/python/README.md b/foreign/python/README.md
index a7ac8f6..e8302f6 100644
--- a/foreign/python/README.md
+++ b/foreign/python/README.md
@@ -187,15 +187,9 @@
 `HttpConfig` differs from TCP in two ways. There is no reconnection policy and no
 `AutoLogin`: `connect()` does not dial over HTTP, but it does start the
 heartbeat that `heartbeat_interval` configures, so call it and then
-`login_user(...)`. And HTTP is single-consumer only: the `consumer_group(...)`
-path always fails with `Feature is unavailable`, at the join by default and at
-the returned consumer's first poll if you disable `auto_join_consumer_group`,
-so disabling it is not a workaround. A direct
-`poll_messages(consumer=Consumer.Group(...))` fails the same way unless you
-pass an explicit `partition_id`, and with one it degrades silently instead: the
-consumer kind is not carried on the HTTP wire, so the group is served as an
-ordinary consumer named after it, with no membership or partition assignment
-behind it. Use `Consumer.Single(...)` with `poll_messages(...)`. Delivery is
+`login_user(...)`. And HTTP is single-consumer only: `consumer_group(...)`, and
+`poll_messages(...)` or `reader(...)` with a `Consumer.Group(...)`, fail with
+`Feature is unavailable` (code 5). Use `Consumer.Single(...)`. Delivery is
 also at-least-once: the default `retries=3` replays the full request body, so a
 send whose response was lost is applied twice, and only `retries=0` opts out.
 
@@ -441,24 +435,35 @@
 - 57 (`TransientNotCommitted`): the outcome is unknown, and the server possibly applied the write. Read the state before you write it again.
 - 40 (`Unauthenticated`): the request needs a session. Sign in, then retry.
 
-`OptionError`, `PendingOperationError` and `ProducerSendError` are `IggyError` subclasses. An error that the binding raises itself, such as a `ValueError`, a `TypeError`, or a plain `RuntimeError` for an invalid argument or a shut-down producer, is not an `IggyError` and carries no code.
+`OptionError`, `PendingOperationError`, `ProducerSendError` and `ReadError` are `IggyError` subclasses. An error that the binding raises itself, such as a `ValueError`, a `TypeError`, or a plain `RuntimeError` for an invalid argument or a shut-down producer, is not an `IggyError` and carries no code.
 
 `PendingOperationError` exposes the exact integer `operation_id`, the numeric
-`code`, and `kind` (`"uncertain"`, `"lane_blocked"`, or `"not_retained"`). On the
+`code`, and `kind` (`"uncertain"`, `"lane_blocked"`, `"fetch_progress_uncertain"`,
+or `"not_retained"`). Its
+`partition_id` names the blocked partition of a fetch, or is `None` for other
+pending operations. On the
 same live client, `await client.resolve_operation(operation_id,
 timeout=timedelta(seconds=30))` replays the original operation. A committed
 business rejection raises its original error and releases the lane; uncertainty
 keeps the operation retained. The default timeout is 30 seconds; it must be a
 positive `timedelta`. `await client.abandon_operation(operation_id)` releases the
 lane without establishing whether the operation committed. A session reset
-forgets retained operations, so recovery must use the original session.
+forgets replayable retained mutations, so their recovery must use the original session.
 The client keeps the proved outcomes of its last `max_operations` replays, also
 across a session reset. A later `resolve_operation` of a proved operation
 returns the same outcome without a request.
 
+A committing fetch with an unknown outcome raises code 14010 and kind `"fetch_progress_uncertain"`. Its local ID and affected progress blocks survive reconnect and session replacement.
+
+`resolve_operation` returns the same error without sending anything. Explicit `abandon_operation` releases all its local blocks and accepts the unknown outcome. It does not stop the old server request.
+
+GetOffset and stateless reads remain available for inspection. Raw partial replies preserve their wire section statuses. A later progress call on an affected target raises code 14009 with its partition and recovery ID. Consumers report the block and stop affected targets. Abandon explicitly before starting a replacement consumer.
+
 `TcpConfig`, `QuicConfig`, and `WebSocketConfig` accept `pending_operations=
 PendingOperationsConfig(max_operations=256, max_bytes=64 * 1024 * 1024)`. These
 client-wide defaults bound active and retained mutations across every partition.
+Committing fetch reservations and retained progress blocks use a separate pool
+with those same limits, charging target metadata rather than replay frames.
 Admission waits before allocating a request ID. Queued writer frames keep their
 bytes charged until their last reference is released, even after abandonment.
 TCP, QUIC and WebSocket connection strings accept `max_pending_operations` and
@@ -554,6 +559,125 @@
 asynchronous cleanup; dropping a background producer without `shutdown()` can
 lose buffered messages.
 
+## Reading Messages
+
+`poll_messages()` reads one batch of one partition. `PollOptions` sets the
+limits of the read and who stores its offset. The default `PollOptions()`
+returns at most 1,000 messages and 1 MiB, answers at once and stores no offset.
+A first message larger than `max_bytes` comes alone in its own batch. To read on, pass `continuation()` of the batch as the next
+`polling_strategy`.
+
+```python
+import asyncio
+
+from apache_iggy import Consumer, IggyClient, PollingStrategy, PollOptions
+
+
+async def main():
+    client = IggyClient.from_connection_string("iggy+tcp://iggy:iggy@127.0.0.1:8090")
+    await client.connect()
+
+    strategy = PollingStrategy.First()
+    for _ in range(3):
+        polled = await client.poll_messages(
+            "orders",
+            "created",
+            partition_id=0,
+            consumer=Consumer.Single("billing"),
+            polling_strategy=strategy,
+            options=PollOptions(count=100),
+        )
+        for message in polled.messages:
+            print(message.offset(), message.payload())
+        strategy = polled.continuation()
+
+
+asyncio.run(main())
+```
+
+A continuation keeps the partition history of its batch. If the partition was
+deleted and created again, the next poll raises `HistoryUnavailable` (87)
+instead of reading the new partition. With
+`PollOptions(commit=CommitPolicy.OnRead())`, the server stores the offset of the
+last message as part of the reply, so `PollingStrategy.Next()` reads on after a
+restart. A lost reply then blocks the partition with a `PendingOperationError`
+until `abandon_operation()`, as described above. `poll_messages()` refuses
+`CommitPolicy.Auto`, which only a reader or a consumer can count.
+
+`reader()` returns a `PartitionReader`, which reads the partitions of a topic
+with prefetch and keeps the position of each one. A `Consumer.Single` reads the
+`partitions` that you pass. A `Consumer.Group` joins its group and reads the
+partitions that the server assigns to it. `next()` returns the next batch of
+one partition and never an empty one, and `commit()` stores the offset of a
+checkpoint. The default options wait up to five seconds for messages. `next()`
+has no time limit. To bound it, use `asyncio.wait_for()`: a cancelled `next()`
+leaves its batch in the reader. Call `close()` when you are done, or use the
+reader in `async with`, which closes it. `async for` reads batches until
+`close()`. `delete_consumer_position()` deletes the offset stored for the
+partition of a checkpoint.
+
+```python
+from apache_iggy import Consumer, IggyClient, PollingStrategy
+
+
+async def read_batches(client: IggyClient):
+    reader = await client.reader(
+        "orders",
+        "created",
+        consumer=Consumer.Single("billing"),
+        partitions=[0, 1, 2],
+        start=PollingStrategy.First(),
+    )
+    try:
+        for _ in range(10):
+            batch = await reader.next()
+            for message in batch.messages:
+                print(batch.partition_id, message.offset(), message.payload())
+            await reader.commit(batch.messages[-1].checkpoint())
+    finally:
+        await reader.close()
+```
+
+`consumer_group()` returns an `IggyConsumer`, which joins a consumer group and
+yields its messages one at a time. The `commit` of its `poll_options` says when
+offsets are stored. `CommitPolicy.Manual()`, the default, stores none: commit the
+checkpoint of a message after you process it. `CommitPolicy.OnRead()` makes the
+server store the offset of each read. `CommitPolicy.Auto(AutoCommit(...))` makes
+the consumer store the latest counted position at an `interval` or on a
+`CommitEvery` event, and on shutdown. A message counts after its processing with
+`CommitAfter.Processing`, when it is handed over with `CommitAfter.Delivery`, or
+when you call `mark(checkpoint)` with `CommitAfter.Marked`.
+`AutoCommit.default()` stores every 10,000 messages or 5 seconds after
+processing. With `CommitAfter.Processing`, a `consume_messages()` callback that
+raises ends the run with its exception, and its message is read again by the
+next run. A reader takes the same `commit`. Call `shutdown()` before you drop the consumer, or use it in
+`async with`, which shuts it down. `shutdown()` also ends an `async for` that
+waits for a message. In a `consume_messages()` callback, `shutdown()`, iterating
+the consumer and a nested `consume_messages()` raise `RuntimeError`, because the
+run waits for the callback: set the shutdown event instead.
+
+```python
+from apache_iggy import IggyClient
+
+
+async def consume(client: IggyClient):
+    consumer = await client.consumer_group("billing", "orders", "created")
+    try:
+        async for message in consumer.iter_messages():
+            print(message.partition_id(), message.offset(), message.payload())
+            await consumer.commit(message.checkpoint())
+    finally:
+        await consumer.shutdown()
+```
+
+A failed read of a reader or a consumer raises `ReadError`. Its `partition_id`
+names the partition, or is `None` for the whole reader, and `error` is the
+underlying `IggyError`. The reader or the consumer stays usable and retries by
+itself. A partition that was deleted and created again stops with
+`HistoryUnavailable` (87) until `seek(partition_id, start)` picks a new start.
+`consume_messages()` then raises the error and stops. Call it again after the
+seek. If a message does not decode, pass `skip` to `seek()` to read past it.
+
 ## Examples
 
 Refer to the [examples/python/](https://github.com/apache/iggy/tree/master/examples/python) directory for usage examples.
diff --git a/foreign/python/apache_iggy.pyi b/foreign/python/apache_iggy.pyi
index d7ef8f8..844febf 100644
--- a/foreign/python/apache_iggy.pyi
+++ b/foreign/python/apache_iggy.pyi
@@ -28,18 +28,20 @@
 __all__ = [
     "Durability",
     "AutoCommit",
-    "AutoCommitAfter",
-    "AutoCommitWhen",
     "AutoLogin",
     "BackgroundProducerConfig",
     "BackpressureMode",
     "CacheMetrics",
     "CacheMetricsKey",
+    "Checkpoint",
+    "CommitAfter",
+    "CommitEvery",
+    "CommitPolicy",
     "Consumer",
     "ConsumerGroup",
     "ConsumerGroupDetails",
     "ConsumerGroupMember",
-    "ConsumerPosition",
+    "ConsumerOffsetInfo",
     "DirectProducerConfig",
     "GlobalPermissions",
     "HeaderKey",
@@ -94,16 +96,20 @@
     "OptionSpec",
     "Partition",
     "PartitionContext",
+    "PartitionReader",
     "PartitionResizePolicy",
     "Partitioning",
     "PendingOperationError",
     "PendingOperationsConfig",
     "Permissions",
+    "PollOptions",
+    "PolledMessages",
     "PollingStrategy",
     "ProducerSendError",
     "ProducerSharding",
     "QuicConfig",
     "QuicReconnectionConfig",
+    "ReadError",
     "ReceiveMessage",
     "SendMessage",
     "SendMessagesConfirmation",
@@ -131,198 +137,54 @@
     "encryption_key_fingerprint",
 ]
 
+@typing.final
 class AutoCommit:
     r"""
-    The auto-commit configuration for storing the offset on the server.
+    When `CommitPolicy.Auto` stores. It also stores on close, on shutdown and before a rebalance
+    takes a partition away. A store only moves forward.
     """
-    @typing.final
-    class Disabled(AutoCommit):
+    @property
+    def interval(self) -> datetime.timedelta | None:
         r"""
-        The auto-commit is disabled and the offset must be stored manually by the consumer.
+        The interval of the stores, or `None`.
         """
-
-        __match_args__ = ()
-        def __new__(cls) -> AutoCommit.Disabled: ...
-        def __len__(self) -> builtins.int: ...
-        def __getitem__(self, key: builtins.int, /) -> typing.Any: ...
-
-    @typing.final
-    class Interval(AutoCommit):
+    @property
+    def every(self) -> CommitEvery | None:
         r"""
-        The auto-commit is enabled and the offset is stored on the server after a certain interval.
+        The event of the stores, or `None`.
         """
-
-        __match_args__ = ("_0",)
-        @property
-        def _0(self) -> datetime.timedelta: ...
-        def __new__(cls, _0: datetime.timedelta) -> AutoCommit.Interval: ...
-        def __len__(self) -> builtins.int: ...
-        def __getitem__(self, key: builtins.int, /) -> typing.Any: ...
-
-    @typing.final
-    class IntervalOrWhen(AutoCommit):
+    @property
+    def after(self) -> CommitAfter:
         r"""
-        The auto-commit is enabled and the offset is stored on the server after a certain interval or depending on the mode when consuming the messages.
+        When a message counts.
         """
-
-        __match_args__ = (
-            "_0",
-            "_1",
-        )
-        @property
-        def _0(self) -> datetime.timedelta: ...
-        @property
-        def _1(self) -> AutoCommitWhen: ...
-        def __new__(
-            cls, _0: datetime.timedelta, _1: AutoCommitWhen
-        ) -> AutoCommit.IntervalOrWhen: ...
-        def __len__(self) -> builtins.int: ...
-        def __getitem__(self, key: builtins.int, /) -> typing.Any: ...
-
-    @typing.final
-    class IntervalOrAfter(AutoCommit):
+    def __eq__(self, other: builtins.object, /) -> builtins.bool: ...
+    def __new__(
+        cls,
+        *,
+        interval: datetime.timedelta | None = None,
+        every: CommitEvery | None = None,
+        after: CommitAfter | None = None,
+    ) -> AutoCommit:
         r"""
-        The auto-commit is enabled and the offset is stored on the server after a certain interval or depending on the mode after consuming the messages.
+        Constructs when `CommitPolicy.Auto` stores. `AutoCommit.default()` stores every 10,000
+        messages or every 5 seconds, whichever comes first, after processing.
+
+        Args:
+            interval: Store the latest counted position at this interval, when it moved.
+            every: Also store it on this event.
+            after: When a message counts. `None` counts after processing, as
+                `CommitAfter.Processing`.
+
+        Raises `ValueError` for a negative or zero `interval`, for `CommitEvery.Messages(0)`, and
+        without `interval` and `every`, which would store only on close.
         """
-
-        __match_args__ = (
-            "_0",
-            "_1",
-        )
-        @property
-        def _0(self) -> datetime.timedelta: ...
-        @property
-        def _1(self) -> AutoCommitAfter: ...
-        def __new__(
-            cls, _0: datetime.timedelta, _1: AutoCommitAfter
-        ) -> AutoCommit.IntervalOrAfter: ...
-        def __len__(self) -> builtins.int: ...
-        def __getitem__(self, key: builtins.int, /) -> typing.Any: ...
-
-    @typing.final
-    class When(AutoCommit):
+    @staticmethod
+    def default() -> AutoCommit:
         r"""
-        The auto-commit is enabled and the offset is stored on the server depending on the mode when consuming the messages.
+        Every 10,000 messages or every 5 seconds, whichever comes first, after processing.
         """
-
-        __match_args__ = ("_0",)
-        @property
-        def _0(self) -> AutoCommitWhen: ...
-        def __new__(cls, _0: AutoCommitWhen) -> AutoCommit.When: ...
-        def __len__(self) -> builtins.int: ...
-        def __getitem__(self, key: builtins.int, /) -> typing.Any: ...
-
-    @typing.final
-    class After(AutoCommit):
-        r"""
-        The auto-commit is enabled and the offset is stored on the server depending on the mode after consuming the messages.
-        """
-
-        __match_args__ = ("_0",)
-        @property
-        def _0(self) -> AutoCommitAfter: ...
-        def __new__(cls, _0: AutoCommitAfter) -> AutoCommit.After: ...
-        def __len__(self) -> builtins.int: ...
-        def __getitem__(self, key: builtins.int, /) -> typing.Any: ...
-
-    ...
-
-class AutoCommitAfter:
-    r"""
-    The auto-commit mode for storing the offset on the server **after** receiving the messages.
-    """
-    @typing.final
-    class ConsumingAllMessages(AutoCommitAfter):
-        r"""
-        The offset is stored on the server after all the messages are consumed.
-        """
-
-        __match_args__ = ()
-        def __new__(cls) -> AutoCommitAfter.ConsumingAllMessages: ...
-        def __len__(self) -> builtins.int: ...
-        def __getitem__(self, key: builtins.int, /) -> typing.Any: ...
-
-    @typing.final
-    class ConsumingEachMessage(AutoCommitAfter):
-        r"""
-        The offset is stored on the server after consuming each message.
-        """
-
-        __match_args__ = ()
-        def __new__(cls) -> AutoCommitAfter.ConsumingEachMessage: ...
-        def __len__(self) -> builtins.int: ...
-        def __getitem__(self, key: builtins.int, /) -> typing.Any: ...
-
-    @typing.final
-    class ConsumingEveryNthMessage(AutoCommitAfter):
-        r"""
-        The offset is stored on the server after consuming every Nth message.
-        """
-
-        __match_args__ = ("_0",)
-        @property
-        def _0(self) -> builtins.int: ...
-        def __new__(
-            cls, _0: builtins.int
-        ) -> AutoCommitAfter.ConsumingEveryNthMessage: ...
-        def __len__(self) -> builtins.int: ...
-        def __getitem__(self, key: builtins.int, /) -> typing.Any: ...
-
-    ...
-
-class AutoCommitWhen:
-    r"""
-    The auto-commit mode for storing the offset on the server.
-    """
-    @typing.final
-    class PollingMessages(AutoCommitWhen):
-        r"""
-        The offset is stored on the server when the messages are received.
-        """
-
-        __match_args__ = ()
-        def __new__(cls) -> AutoCommitWhen.PollingMessages: ...
-        def __len__(self) -> builtins.int: ...
-        def __getitem__(self, key: builtins.int, /) -> typing.Any: ...
-
-    @typing.final
-    class ConsumingAllMessages(AutoCommitWhen):
-        r"""
-        The offset is stored on the server when all the messages are consumed.
-        """
-
-        __match_args__ = ()
-        def __new__(cls) -> AutoCommitWhen.ConsumingAllMessages: ...
-        def __len__(self) -> builtins.int: ...
-        def __getitem__(self, key: builtins.int, /) -> typing.Any: ...
-
-    @typing.final
-    class ConsumingEachMessage(AutoCommitWhen):
-        r"""
-        The offset is stored on the server when consuming each message.
-        """
-
-        __match_args__ = ()
-        def __new__(cls) -> AutoCommitWhen.ConsumingEachMessage: ...
-        def __len__(self) -> builtins.int: ...
-        def __getitem__(self, key: builtins.int, /) -> typing.Any: ...
-
-    @typing.final
-    class ConsumingEveryNthMessage(AutoCommitWhen):
-        r"""
-        The offset is stored on the server when consuming every Nth message.
-        """
-
-        __match_args__ = ("_0",)
-        @property
-        def _0(self) -> builtins.int: ...
-        def __new__(
-            cls, _0: builtins.int
-        ) -> AutoCommitWhen.ConsumingEveryNthMessage: ...
-        def __len__(self) -> builtins.int: ...
-        def __getitem__(self, key: builtins.int, /) -> typing.Any: ...
-
-    ...
+    def __repr__(self) -> builtins.str: ...
 
 @typing.final
 class AutoLogin:
@@ -511,6 +373,135 @@
     ) -> CacheMetricsKey: ...
     def __repr__(self) -> builtins.str: ...
 
+@typing.final
+class Checkpoint:
+    r"""
+    The position after one received message: its partition, its offset and the partition
+    history that delivered it. A commit stores its offset, which marks every earlier message
+    of the partition as consumed.
+
+    Get it from `ReceiveMessage.checkpoint()` or `PolledMessages.checkpoint()`, and commit it
+    with `PartitionReader.commit()` or `IggyConsumer.commit()`. A commit after the partition was
+    deleted and created again, or after its consumer group owner changed, is refused instead of
+    landing on the new partition.
+    """
+    @property
+    def partition_id(self) -> builtins.int:
+        r"""
+        The partition the message was read from.
+        """
+    @property
+    def offset(self) -> builtins.int:
+        r"""
+        The offset of the message. Committing it marks the message as consumed.
+        """
+    def __eq__(self, other: builtins.object, /) -> builtins.bool: ...
+    def __repr__(self) -> builtins.str: ...
+    def __hash__(self) -> builtins.int: ...
+
+@typing.final
+class CommitAfter:
+    r"""
+    When a message counts for `CommitPolicy.Auto`.
+    """
+
+    Processing: typing.ClassVar[CommitAfter]
+    r"""
+    When the application asks for the next message or batch, returns from its callback, or
+    closes the reader. A callback that raises leaves its message uncounted.
+    """
+    Delivery: typing.ClassVar[CommitAfter]
+    r"""
+    When the message or the batch is handed over.
+    """
+    Marked: typing.ClassVar[CommitAfter]
+    r"""
+    Only when the application calls `mark()` with its checkpoint.
+    """
+
+class CommitEvery:
+    r"""
+    The event on which `AutoCommit` also stores.
+    """
+    def __repr__(self) -> builtins.str: ...
+    @typing.final
+    class Message(CommitEvery):
+        r"""
+        Each counted message, or each counted batch of a `PartitionReader`.
+        """
+
+        __match_args__ = ()
+        def __new__(cls) -> CommitEvery.Message: ...
+        def __len__(self) -> builtins.int: ...
+        def __getitem__(self, key: builtins.int, /) -> typing.Any: ...
+
+    @typing.final
+    class Messages(CommitEvery):
+        r"""
+        This many counted messages of a partition since its last store. Not zero.
+        """
+
+        __match_args__ = ("_0",)
+        @property
+        def _0(self) -> builtins.int: ...
+        def __new__(cls, _0: builtins.int) -> CommitEvery.Messages: ...
+        def __len__(self) -> builtins.int: ...
+        def __getitem__(self, key: builtins.int, /) -> typing.Any: ...
+
+    @typing.final
+    class Batch(CommitEvery):
+        r"""
+        The last message of each batch.
+        """
+
+        __match_args__ = ()
+        def __new__(cls) -> CommitEvery.Batch: ...
+        def __len__(self) -> builtins.int: ...
+        def __getitem__(self, key: builtins.int, /) -> typing.Any: ...
+
+class CommitPolicy:
+    r"""
+    When the offsets of the messages that a read returns are stored.
+    """
+    def __repr__(self) -> builtins.str: ...
+    @typing.final
+    class Manual(CommitPolicy):
+        r"""
+        Nothing is stored. The application commits checkpoints. The default.
+        """
+
+        __match_args__ = ()
+        def __new__(cls) -> CommitPolicy.Manual: ...
+        def __len__(self) -> builtins.int: ...
+        def __getitem__(self, key: builtins.int, /) -> typing.Any: ...
+
+    @typing.final
+    class OnRead(CommitPolicy):
+        r"""
+        The server stores the offset of the last message of each read as part of the read, in one
+        round trip. While the primary stays up a message is delivered at most once. A failover
+        before the store replicates can deliver the last batch again.
+        """
+
+        __match_args__ = ()
+        def __new__(cls) -> CommitPolicy.OnRead: ...
+        def __len__(self) -> builtins.int: ...
+        def __getitem__(self, key: builtins.int, /) -> typing.Any: ...
+
+    @typing.final
+    class Auto(CommitPolicy):
+        r"""
+        A `PartitionReader` or an `IggyConsumer` stores the latest counted position, at least
+        once with `CommitAfter.Processing`. `IggyClient.poll_messages()` refuses it.
+        """
+
+        __match_args__ = ("_0",)
+        @property
+        def _0(self) -> AutoCommit: ...
+        def __new__(cls, _0: AutoCommit) -> CommitPolicy.Auto: ...
+        def __len__(self) -> builtins.int: ...
+        def __getitem__(self, key: builtins.int, /) -> typing.Any: ...
+
 class Consumer:
     r"""
     The consumer polling the messages. It selects both the consumer kind and the
@@ -610,31 +601,25 @@
         """
 
 @typing.final
-class ConsumerPosition:
+class ConsumerOffsetInfo:
     r"""
-    The position of a received message: its partition, its offset and the context of
-    the poll that delivered it. Commit it with `IggyConsumer.store_position()`.
+    The offset that a consumer stored on a partition.
     """
     @property
     def partition_id(self) -> builtins.int:
         r"""
-        The partition the message was read from.
+        The partition of the offset.
         """
     @property
-    def offset(self) -> builtins.int:
+    def current_offset(self) -> builtins.int:
         r"""
-        The offset of the message. Committing it marks the message as consumed.
+        The offset of the last message of the partition when the offset was read.
         """
     @property
-    def context(self) -> PartitionContext:
+    def stored_offset(self) -> builtins.int:
         r"""
-        The partition incarnation and owner the poll was served under.
+        The stored offset.
         """
-    def __eq__(self, other: builtins.object, /) -> builtins.bool: ...
-    def __hash__(self) -> builtins.int: ...
-    def __new__(
-        cls, partition_id: builtins.int, offset: builtins.int, context: PartitionContext
-    ) -> ConsumerPosition: ...
     def __repr__(self) -> builtins.str: ...
 
 @typing.final
@@ -1120,12 +1105,8 @@
     There is no `AutoLogin` and no reconnection policy, and `connect()` does not
     dial: it only starts the heartbeat, so `login_user(...)` has to follow it.
 
-    HTTP is single-consumer only. `consumer_group(...)` fails with
-    `Feature is unavailable`, and so does a `Consumer.Group(...)` poll unless it
-    names an explicit `partition_id`. With one, the consumer kind is not carried
-    on the HTTP wire, so the poll is served as an ordinary consumer named after
-    the group, with no membership, no partition assignment, and no rebalancing
-    behind it. Pass `Consumer.Single(...)` explicitly.
+    HTTP is single-consumer only. `consumer_group(...)`, and a `Consumer.Group(...)`
+    poll or reader, fail with `Feature is unavailable`. Pass `Consumer.Single(...)`.
     """
     @property
     def api_url(self) -> builtins.str: ...
@@ -1532,6 +1513,8 @@
         Replays the exact retained operation until its final result or the deadline.
         A committed rejection raises its original error and frees the lane.
         Uncertainty keeps the operation retained. HTTP does not support this operation.
+        A fetch progress block raises code 14010 without replaying. Only explicit
+        abandon_operation releases it, accepting the unknown server outcome.
         `timeout` must be a positive timedelta; omission or None uses 30 seconds.
         """
     def abandon_operation(
@@ -1539,6 +1522,7 @@
     ) -> collections.abc.Awaitable[None]:
         r"""
         Releases a retained operation without claiming whether it committed.
+        For a fetch, this releases every affected partition without stopping the old request.
         """
     def shutdown(self) -> collections.abc.Awaitable[None]:
         r"""
@@ -2061,67 +2045,205 @@
         Raises `ValueError` for invalid names or numeric ranges and `RuntimeError`
         when stream/topic initialization fails.
         """
-    def poll_messages(
+    async def poll_messages(
+        self,
+        stream: builtins.str | builtins.int,
+        topic: builtins.str | builtins.int,
+        *,
+        partition_id: builtins.int,
+        consumer: Consumer,
+        polling_strategy: PollingStrategy,
+        options: PollOptions | None = None,
+    ) -> PolledMessages:
+        r"""
+        Polls one batch of messages of a partition on behalf of the given consumer.
+
+        `options` defaults to `PollOptions()`: at most 1000 messages and 1 MiB, answered at
+        once, and no offset stored. With `CommitPolicy.OnRead()`, the server stores the
+        offset of the last message as part of the reply. If that reply is lost, the poll raises
+        `PendingOperationError` of kind `fetch_progress_uncertain`, and every later poll of the
+        partition raises it of kind `lane_blocked` until `abandon_operation()` releases it. A
+        cancelled `OnRead` poll that already sent its request blocks the partition the same way,
+        so a cancel never loses a batch without a trace. `CommitPolicy.Auto` raises `IggyError`
+        with `InvalidConfiguration` (2): a one-shot poll has nothing to count.
+
+        To read on, pass `polled.continuation()` as the next `polling_strategy`. The poll is
+        then refused with `HistoryUnavailable` (87) if the partition was deleted and created
+        again, or if its consumer group owner changed, instead of reading the new partition at
+        that offset. A continuation of another partition is refused with `InvalidCommand` (3).
+
+        A consumer group member reads only the partitions assigned to it: another partition
+        raises `ConsumerGroupPartitionNotOwned` (5009).
+        Returns the batch, or raises `IggyError` on failure.
+        """
+    def store_consumer_position(
         self,
         stream: builtins.str | builtins.int,
         topic: builtins.str | builtins.int,
         *,
         consumer: Consumer,
-        polling_strategy: PollingStrategy,
-        count: builtins.int,
-        auto_commit: builtins.bool,
-        partition_id: builtins.int | None = None,
-        context: PartitionContext | None = None,
-    ) -> collections.abc.Awaitable[list[ReceiveMessage]]:
+        checkpoint: Checkpoint,
+    ) -> collections.abc.Awaitable[None]:
         r"""
-        Polls for messages from the specified topic on behalf of the given consumer.
-        Omitting `partition_id` reads partition 0 for a regular consumer, and
-        polls the member's assigned partitions for a consumer group.
-        `context` continues from a message received earlier, usually with
-        `PollingStrategy.Offset`: pass that message's `context()` and `partition_id()`.
-        The poll is then refused if the partition was deleted and created again, or if its
-        consumer group owner changed, instead of reading the new partition at that offset.
-        Without `context`, the poll takes the context its route reports. After another
-        client deleted and created the partition again, one such poll can fail with
-        `HistoryUnavailable` (87), or `ConsumerGroupPartitionNotOwned` (5009) for a
-        consumer group member. The failed route is dropped, and the next call routes again.
-        Returns a list of received messages or a RuntimeError on failure.
+        Stores the offset of `checkpoint` for `consumer`, under the partition history that
+        delivered the checkpoint, which covers every earlier message of its partition.
+
+        Raises `IggyError` with `HistoryUnavailable` (87) after the partition was deleted and
+        created again, and `ConsumerGroupPartitionNotOwned` (5009) after its consumer group owner
+        changed, instead of storing the offset on the new partition.
+        """
+    def store_consumer_offset(
+        self,
+        stream: builtins.str | builtins.int,
+        topic: builtins.str | builtins.int,
+        *,
+        consumer: Consumer,
+        partition_id: builtins.int,
+        offset: builtins.int,
+    ) -> collections.abc.Awaitable[None]:
+        r"""
+        Stores `offset` for `consumer` on `partition_id`, under the partition history of its
+        route. After another client deleted and created the partition again, it can fail once
+        with `HistoryUnavailable` (87), and the next call routes again. Prefer
+        `store_consumer_position()` with a checkpoint.
+        """
+    def get_consumer_offset(
+        self,
+        stream: builtins.str | builtins.int,
+        topic: builtins.str | builtins.int,
+        *,
+        consumer: Consumer,
+        partition_id: builtins.int,
+    ) -> collections.abc.Awaitable[ConsumerOffsetInfo | None]:
+        r"""
+        Returns the offset stored for `consumer` on `partition_id`, or `None` when nothing is
+        stored. In a cluster, the node of the connection answers from its own replica, which can
+        lag a store that just returned.
+        """
+    def delete_consumer_offset(
+        self,
+        stream: builtins.str | builtins.int,
+        topic: builtins.str | builtins.int,
+        *,
+        consumer: Consumer,
+        partition_id: builtins.int,
+    ) -> collections.abc.Awaitable[None]:
+        r"""
+        Deletes the offset stored for `consumer` on `partition_id`, under the partition history
+        of its route. Raises `IggyError` with `ConsumerOffsetNotFound` (3021) when nothing is
+        stored. Prefer `delete_consumer_position()` with a checkpoint.
+        """
+    def delete_consumer_position(
+        self,
+        stream: builtins.str | builtins.int,
+        topic: builtins.str | builtins.int,
+        *,
+        consumer: Consumer,
+        checkpoint: Checkpoint,
+    ) -> collections.abc.Awaitable[None]:
+        r"""
+        Deletes the stored offset of the partition of `checkpoint` for `consumer`, under the
+        partition history that delivered the checkpoint. The offset of the checkpoint is not
+        used.
+
+        Raises `IggyError` with `HistoryUnavailable` (87) after the partition was deleted and
+        created again, `ConsumerGroupPartitionNotOwned` (5009) after its consumer group owner
+        changed, and `ConsumerOffsetNotFound` (3021) when nothing is stored. HTTP sends no
+        partition history, so there the server deletes the offset of the history it holds.
+        """
+    def reader(
+        self,
+        stream: builtins.str | builtins.int,
+        topic: builtins.str | builtins.int,
+        *,
+        consumer: Consumer,
+        partitions: list[builtins.int] | None = None,
+        start: PollingStrategy | None = None,
+        options: PollOptions | None = None,
+        prefetch_bytes: builtins.int | None = None,
+        prefetch_messages: builtins.int | None = None,
+    ) -> collections.abc.Awaitable[PartitionReader]:
+        r"""
+        Builds a `PartitionReader` of `topic` in `stream` that reads as `consumer`.
+
+        A `Consumer.Single` reads `partitions`, which it requires. A `Consumer.Group` reads the
+        assignment of its member, so `partitions` must be `None`: the reader joins the group
+        here. Consumer groups are not available over HTTP.
+
+        `start` is where a partition without a stored position starts, `PollingStrategy.Next()`
+        by default. A continuation is refused, because it belongs to one partition. `options`
+        sets each request, and defaults to `PollOptions(max_wait=timedelta(seconds=5))`: a
+        reader without a wait polls an idle topic in a busy loop. `prefetch_bytes` (16 MiB by
+        default) and `prefetch_messages` (the larger of 16,000 and `options.count` by default)
+        bound the requests in flight and the queued batches.
+
+        Raises `IggyError` with `InvalidCommand` (3) for partitions of a group, a
+        `Consumer.Single` without partitions or a continuation as `start`,
+        `FeatureUnavailable` for a consumer group over HTTP, the errors of `options`, and the
+        error of the group join. A join with an unknown outcome raises `PendingOperationError`:
+        the client keeps it, so a new `reader()` raises `lane_blocked` until
+        `resolve_operation()` or `abandon_operation()` settles it.
         """
     def consumer_group(
         self,
         name: builtins.str,
         stream: builtins.str,
         topic: builtins.str,
-        partition_id: builtins.int | None = None,
-        polling_strategy: PollingStrategy | None = None,
-        batch_length: builtins.int | None = None,
-        auto_commit: AutoCommit | None = None,
-        create_consumer_group_if_not_exists: builtins.bool = True,
-        auto_join_consumer_group: builtins.bool = True,
         *,
+        polling_strategy: PollingStrategy | None = None,
+        poll_options: PollOptions | None = None,
+        prefetch_bytes: builtins.int | None = None,
+        prefetch_messages: builtins.int | None = None,
+        create_consumer_group_if_not_exists: builtins.bool = True,
         polling_retry_interval: datetime.timedelta | None = None,
         init_retries: builtins.int | None = None,
         init_retry_interval: datetime.timedelta | None = None,
         allow_replay: builtins.bool = False,
     ) -> collections.abc.Awaitable[IggyConsumer]:
         r"""
-        Creates a new consumer group consumer.
-        `partition_id` is ignored for a consumer group: the member reads the partitions
-        the server assigns to it.
-        `auto_commit` defaults to `AutoCommit.Disabled()`: the consumer stores no offset,
-        so store positions after processing, or each restart reads the same messages again.
-        Returns the consumer or a RuntimeError on failure. Raises `ValueError` if
-        `polling_retry_interval`, `init_retry_interval` or an `AutoCommit` interval
-        is negative or zero.
+        Creates a new consumer group consumer, which joins the group before it returns.
+        The member reads the partitions the server assigns to it.
 
-        Consumer groups are not available over HTTP. With `auto_join_consumer_group`
-        left on, this call fails at the join with `Feature is unavailable`.
-        Turning it off is not a workaround: the join is skipped, but a group
-        member always polls without a partition, so the first poll fails with
-        the same error. Use `Consumer.Single(...)` with `poll_messages(...)`
-        instead - a `Consumer.Group(...)` poll with an explicit `partition_id`
-        does reach the server, but is served as an ordinary consumer named
-        after the group.
+        `polling_strategy` is where a partition without a stored offset starts,
+        `PollingStrategy.Next()` by default. `poll_options` sets each request, and defaults to
+        `PollOptions(max_wait=timedelta(seconds=5))`: a consumer without a wait polls an idle
+        topic in a busy loop. Its `commit` says when offsets are stored: `CommitPolicy.Manual()`,
+        the default, stores none, so commit checkpoints after processing, or each restart reads
+        the same messages again. `CommitPolicy.OnRead()` makes the server store the offset of
+        each reply, and `CommitPolicy.Auto(...)` makes the consumer store counted positions.
+        `prefetch_bytes` (16 MiB by default) and `prefetch_messages` (the larger of 16,000 and
+        the poll count by default) bound the requests in flight and the queued batches.
+        Call `shutdown()` before dropping the consumer.
+        Returns the consumer or raises `IggyError` on failure. Raises `ValueError` if
+        `polling_retry_interval` or `init_retry_interval` is negative or zero.
+
+        Consumer groups are not available over HTTP: this call raises `FeatureUnavailable`.
+        Use `Consumer.Single(...)` with `poll_messages(...)` or `reader(...)` there.
+        """
+    def consumer(
+        self,
+        name: builtins.str,
+        stream: builtins.str,
+        topic: builtins.str,
+        *,
+        partitions: typing.Sequence[builtins.int],
+        polling_strategy: PollingStrategy | None = None,
+        poll_options: PollOptions | None = None,
+        prefetch_bytes: builtins.int | None = None,
+        prefetch_messages: builtins.int | None = None,
+        polling_retry_interval: datetime.timedelta | None = None,
+        init_retries: builtins.int | None = None,
+        init_retry_interval: datetime.timedelta | None = None,
+        allow_replay: builtins.bool = False,
+    ) -> collections.abc.Awaitable[IggyConsumer]:
+        r"""
+        Creates a standalone consumer of `partitions` of `topic`, identified by `name`: it
+        stores its offsets as `Consumer.Single(name)` and joins no group. A partition without a
+        stored offset starts at `polling_strategy`, `PollingStrategy.Next()` by default.
+
+        The other arguments are those of `consumer_group()`. Call `shutdown()` before dropping
+        the consumer. Raises `IggyError` with `InvalidCommand` (3) without partitions, and the
+        errors of `poll_options`.
         """
     def send_binary_request(
         self, code: builtins.int, payload: builtins.bytes
@@ -2174,6 +2296,11 @@
     A Python class representing the Iggy consumer.
     It provides asynchronous functionality through the contained runtime.
     """
+    @property
+    def commit_policy(self) -> CommitPolicy:
+        r"""
+        When the offsets of this consumer are stored, from its `PollOptions`.
+        """
     def get_last_consumed_offset(
         self, partition_id: builtins.int
     ) -> builtins.int | None:
@@ -2184,7 +2311,9 @@
     def get_last_stored_offset(self, partition_id: builtins.int) -> builtins.int | None:
         r"""
         Get the last offset successfully stored for this partition, or `None` until
-        this consumer has stored one. Polling under `AutoCommit.Disabled()` leaves it unset.
+        this consumer has stored one. `CommitPolicy.Manual()`, the default, leaves it unset
+        until a commit. Under `CommitPolicy.OnRead()` it is the end of the last batch that the
+        consumer took from its reader.
         """
     def name(self) -> builtins.str:
         r"""
@@ -2208,31 +2337,52 @@
         r"""
         Stores the provided offset for the provided partition id or if none is specified
         uses the current partition id for the consumer group.
-        The offset is stored under the context of the latest message consumed from the
-        partition. To commit a message held across a newer poll or an ownership change,
-        pass `message.position()` to `store_position()` instead.
-        Before a message is consumed from the partition, the write takes the context its
-        route reports. After another client deleted and created the partition again, one
-        such write can fail with `HistoryUnavailable` (87), or
-        `ConsumerGroupPartitionNotOwned` (5009) for a consumer group member. The failed
-        route is dropped, and the next call routes again.
-        Raises `RuntimeError` if the operation fails.
+        It always sends, also an offset below the stored one. The offset is stored under the
+        context of the latest message consumed from the partition, or without a context when
+        nothing was consumed from it. To commit a message held across a newer read or an
+        ownership change, pass `message.checkpoint()` to `commit()` instead.
+        Raises `IggyError` if the operation fails, for example `HistoryUnavailable` (87)
+        after the partition was deleted and created again.
         """
-    def store_position(
-        self, position: ConsumerPosition
-    ) -> collections.abc.Awaitable[None]:
+    def commit(self, checkpoint: Checkpoint) -> collections.abc.Awaitable[None]:
         r"""
-        Stores the position of a received message, taken from `ReceiveMessage.position()`.
-        The write keeps the context that delivered the message, so it fails with
+        Stores the offset of a checkpoint that this consumer yielded, taken from
+        `ReceiveMessage.checkpoint()`, which covers every earlier message of its partition.
+        The write keeps the partition history that delivered the message, so it fails with
         `HistoryUnavailable` (87) after the partition was deleted and created again, and
         with `ConsumerGroupPartitionNotOwned` (5009) after its consumer group owner
         changed, instead of committing the offset to the new partition.
-        A position that is not ahead of the last one stored under the same partition
-        incarnation and owner is skipped without a request, unless the consumer was
-        created with `allow_replay=True`. The same holds for an offset that
-        `store_offset()` stored before a message was consumed from the partition,
-        but a position at offset 0 is never skipped in that case.
-        Raises `RuntimeError` if the operation fails.
+        A checkpoint of an older generation of its partition, after `seek()` or a new
+        assignment, or at or below the last commit of its generation, stores nothing.
+        Raises `IggyError` with `InvalidCommand` (3) under `CommitPolicy.OnRead()` and for a
+        checkpoint that this consumer did not yield, or if the operation fails.
+        """
+    def mark(self, checkpoint: Checkpoint) -> None:
+        r"""
+        Counts a checkpoint that this consumer yielded under `CommitPolicy.Auto` and stores
+        nothing now: the policy stores the latest counted position of its partition. With
+        `CommitAfter.Marked` only marks count, so messages processed concurrently count once
+        they are done. It works also in a `consume_messages()` callback.
+        Raises `IggyError` with `InvalidCommand` (3) without `CommitPolicy.Auto` and for a
+        checkpoint that this consumer did not yield.
+        """
+    def seek(
+        self, partition_id: builtins.int, start: PollingStrategy
+    ) -> collections.abc.Awaitable[None]:
+        r"""
+        Sets the next read position of a partition and drops the rest of its batch that the
+        consumer yields from. With `CommitPolicy.OnRead()`, a seek to an offset above 0 first
+        stores the offset before it, so a restart resumes at the new position. Every other seek
+        stores nothing. It resumes a stopped partition.
+        `start` is a new strategy or a continuation of the same partition, such as
+        `ReadError.skip`. After `HistoryUnavailable` (87), the partition reads nothing until
+        this call picks a start in its new history.
+        Raises `IggyError` with `InvalidCommand` (3) for a partition that the consumer does not
+        read and for a continuation of another partition, `PendingOperationError` under
+        `CommitPolicy.OnRead()` while a read of the partition has an unknown outcome
+        (abandon its operation first), the errors of the offset store with `OnRead`, and
+        `RuntimeError` while `consume_messages()` runs, also from its callback: seek after the
+        run ends.
         """
     def delete_offset(
         self, partition_id: builtins.int | None
@@ -2240,26 +2390,43 @@
         r"""
         Deletes the offset for the provided partition id or if none is specified
         uses the current partition id for the consumer group.
-        The write takes the context its route reports. After another client deleted and
-        created the partition again, one such write can fail with `HistoryUnavailable` (87),
-        or `ConsumerGroupPartitionNotOwned` (5009) for a consumer group member. The failed
-        route is dropped, and the next call routes again.
-        Raises `RuntimeError` if the operation fails.
+        Raises `IggyError` if the operation fails, for example `ConsumerOffsetNotFound`
+        when nothing is stored for the partition.
+        """
+    def shutdown(self) -> collections.abc.Awaitable[None]:
+        r"""
+        Shuts the consumer down. Under `CommitPolicy.Auto` it stores the latest counted position
+        of each partition first, then it closes the reader and leaves the consumer group. With
+        `CommitPolicy.OnRead()`, it moves the offset back to the last yielded message, which a
+        dropped consumer does not do.
+        It ends a parked iteration at once, which then raises `StopAsyncIteration`, and it waits
+        for a running `consume_messages()` to end. Repeated calls are safe.
+        Raises `IggyError` for the first failed store of the `OnRead` close, or else
+        `PendingOperationError` for a partition with an unknown outcome: abandon its operation
+        before another consumer reads the partition. Raises `RuntimeError` in a
+        `consume_messages()` callback of this consumer, because the run waits for the callback:
+        set the shutdown event or cancel the run instead.
+        """
+    def __aenter__(self) -> collections.abc.Awaitable[IggyConsumer]: ...
+    def __aexit__(
+        self, _exc_type: typing.Any, _exc_value: typing.Any, _traceback: typing.Any
+    ) -> collections.abc.Awaitable[builtins.bool]:
+        r"""
+        Shuts the consumer down as `shutdown()` does, also when the block raised.
         """
     def iter_messages(self) -> collections.abc.AsyncIterator[ReceiveMessage]:
         r"""
         Asynchronously iterate over `ReceiveMessage`s.
-        Returns an async iterator that raises `StopAsyncIteration` when no more messages are available
-        or a `RuntimeError` on failure.
-        Note: This method does not currently support `AutoCommit.After`.
-        For `AutoCommit.IntervalOrAfter(datetime.timedelta, AutoCommitAfter)`,
-        only the interval part is applied; the `after` mode is ignored.
-        Use `consume_messages()` if you need commit-after-processing semantics.
-        After another client deleted and created a partition again, or after the consumer
-        group moved a partition to another member, one iteration can raise `RuntimeError`
-        with `HistoryUnavailable` (87), or `ConsumerGroupPartitionNotOwned` (5009) for a
-        consumer group member. The consumer drops the failed route and the position it
-        continued from, and the next iteration routes again.
+        Returns an async iterator that raises `StopAsyncIteration` when no more messages are
+        available or a `ReadError` on a failed read. The consumer stays usable after it and
+        retries by itself.
+        Under `CommitPolicy.Auto`, a message counts when it is yielded with
+        `CommitAfter.Delivery`, and when the next iteration starts with `CommitAfter.Processing`.
+        After another client deleted and created a partition again, one iteration raises
+        `ReadError` with `HistoryUnavailable` (87), and the partition reads nothing until
+        `seek()` picks a start in its new history.
+        A cancelled iteration leaves its message for the next one. Iterating in a
+        `consume_messages()` callback of this consumer raises `RuntimeError`.
         """
     def consume_messages(
         self,
@@ -2270,11 +2437,21 @@
     ) -> collections.abc.Awaitable[None]:
         r"""
         Consumes messages continuously using a callback function and an optional `asyncio.Event` for signaling shutdown.
-        Returns an awaitable that completes when shutdown is signaled or a RuntimeError on failure.
-        After another client deleted and created a partition again, one poll can fail with
-        `HistoryUnavailable` (87), or `ConsumerGroupPartitionNotOwned` (5009) for a consumer
-        group member. The error is logged, not raised: the failed route is dropped, and the
-        next poll routes again.
+        Returns an awaitable that completes when shutdown is signaled. A cancel of the awaitable
+        ends the run once the running callback returns, with or without an event.
+        Under `CommitPolicy.Auto`, a message counts before its callback with
+        `CommitAfter.Delivery`, and after its callback returns with `CommitAfter.Processing`.
+        With `CommitAfter.Processing`, a callback that raises ends the run with its exception,
+        and its message stays uncounted, so the next run reads it again. Otherwise a callback
+        that raises is logged and the run goes on.
+        A failed read is logged, and the consumer retries by itself. The run raises `ReadError`
+        if the client cannot go on without the caller, or if a read stops its partition until
+        `seek()`: `HistoryUnavailable` (87), or an unknown outcome under
+        `CommitPolicy.OnRead()`. For a stopped partition, `ReadError.partition_id` names it.
+        To resume it, abandon the operation of an unknown outcome, seek the partition and consume
+        again.
+        Raises `RuntimeError` in a callback of a run of this consumer, because the run waits for
+        the callback.
         """
 
 class IggyError(builtins.RuntimeError):
@@ -3638,11 +3815,10 @@
 @typing.final
 class PartitionContext:
     r"""
-    The partition incarnation and consumer group owner a poll was served under.
+    The partition incarnation and consumer group owner a request was served under.
 
-    Keep it with an offset to continue or commit under the same partition later: the
-    server refuses a context whose partition was deleted and created again, or whose
-    consumer group owner changed, instead of applying the offset to the new partition.
+    The server refuses a context whose partition was deleted and created again, or whose
+    consumer group owner changed, instead of applying an offset to the new partition.
     """
     @property
     def incarnation(self) -> builtins.int:
@@ -3672,6 +3848,118 @@
     def __repr__(self) -> builtins.str: ...
 
 @typing.final
+class PartitionReader:
+    r"""
+    Reads the partitions of a topic in batches, with routing, prefetch and the position of each
+    partition inside. Build one with `IggyClient.reader()`.
+
+    - A `Consumer.Single` reads the partitions given to `reader()`. A `Consumer.Group` reads the
+      assignment of its member: the reader joins the group, joins it again after a session loss
+      and leaves it in `close()`.
+    - `next()` returns the next batch of one partition, in offset order within one generation of
+      the partition: from its first read, from each `seek()` and from each new assignment to
+      this member.
+    - The `commit` of its `PollOptions` says when offsets are stored. With `CommitPolicy.Manual()`
+      and `CommitPolicy.Auto`, `commit()` stores the offset of a checkpoint. `CommitPolicy.Auto`
+      also stores the latest counted position by itself, and `mark()` counts one. With
+      `CommitPolicy.OnRead()`, the server stores the offset of each reply, the reader reads only
+      while `next()` waits and then fills its budget (one fetch per primary that may wait for
+      messages, the partitions with messages left behind spread over the other slots), a `seek()`
+      to an offset stores the offset before it, and `close()` moves the offset back to the last
+      delivered message.
+    - The reader stays usable after a `ReadError` and retries by itself. `HistoryUnavailable`
+      (87) stops its partition until `seek()`. With `OnRead`, an unknown outcome stops its
+      partition until `IggyClient.abandon_operation()` and `seek()`.
+    - The calls on one reader run one at a time, so a call waits for a pending `next()`.
+      `next()` is cancel safe: cancel it before `close()`.
+    - Dropping the reader stops its tasks without waiting, and it moves no offset back.
+    - `async for` reads its batches until `close()`, and `async with` closes it on exit.
+    """
+    async def next(self) -> PolledMessages:
+        r"""
+        Returns the next batch of one partition. It is never empty, and it has no time limit.
+        A cancelled call leaves its batch in the reader for the next call.
+
+        Raises `ReadError` for a failed read, and `RuntimeError` after `close()`.
+        """
+    def commit(self, checkpoint: Checkpoint) -> collections.abc.Awaitable[None]:
+        r"""
+        Stores the offset of a checkpoint that this reader delivered, which covers every earlier
+        message of its partition. A checkpoint of an older generation of its partition, or at or
+        below the last commit of its generation, stores nothing.
+
+        Raises `IggyError` with `InvalidCommand` (3) under `CommitPolicy.OnRead()` and for a
+        checkpoint that this reader did not deliver, the errors of the offset store, and
+        `RuntimeError` after `close()`.
+        """
+    def mark(self, checkpoint: Checkpoint) -> collections.abc.Awaitable[None]:
+        r"""
+        Counts a checkpoint that this reader delivered under `CommitPolicy.Auto` and stores
+        nothing now: the policy stores the latest counted position of its partition. With
+        `CommitAfter.Marked` only marks count, so batches processed concurrently count once they
+        are done. Like every call of the reader, it waits for a pending `next()`.
+
+        Raises `IggyError` with `InvalidCommand` (3) without `CommitPolicy.Auto` and for a
+        checkpoint that this reader did not deliver, and `RuntimeError` after `close()`.
+        """
+    def seek(
+        self, partition_id: builtins.int, start: PollingStrategy
+    ) -> collections.abc.Awaitable[None]:
+        r"""
+        Sets the next read position of a partition and starts a new generation of it. It drops
+        the queued batches of the partition, ignores its late replies and resumes a stopped
+        partition. `start` is a new strategy or a continuation of the same partition, such as
+        `ReadError.skip`.
+
+        With `CommitPolicy.OnRead()`, a seek to an offset above 0 first stores the offset before
+        it, under the history of a continuation or else of the last delivered message, so a
+        restart resumes at the new position. A failed store fails the seek and nothing moves.
+        Every other seek stores nothing. The new position waits for the end of the committing
+        read in flight, whose reply the new generation drops.
+
+        Raises `IggyError` with `InvalidCommand` (3) for a partition that the reader does not read
+        and for a continuation of another partition, `PendingOperationError` with `OnRead` while
+        a read of the partition has an unknown outcome (abandon its operation first), the errors
+        of the offset store with `OnRead`, and `RuntimeError` after `close()`.
+        """
+    def partitions(self) -> collections.abc.Awaitable[list[builtins.int]]:
+        r"""
+        Returns the partitions that the reader reads: those given to `reader()`, or the current
+        assignment of a consumer group member.
+
+        Raises `RuntimeError` after `close()`.
+        """
+    def close(self) -> collections.abc.Awaitable[None]:
+        r"""
+        Stops the reader. It waits at most until the deadlines of the reads in flight. With
+        `CommitPolicy.Manual()` it commits nothing. With `CommitPolicy.Auto`, it counts the last
+        batch under `CommitAfter.Processing` and stores the latest counted position of each
+        partition. A group reader then leaves its group. Repeated calls are safe.
+
+        With `CommitPolicy.OnRead()`, it moves the offset of each partition with undelivered
+        batches back to its last delivered message. A partition that delivered nothing and
+        started at `Next` gets the offset before its first undelivered message, or no offset at
+        offset 0.
+
+        Raises `IggyError` for the first failed store of the `OnRead` or the `Auto` close, or else
+        `PendingOperationError` for a partition with an unknown outcome: abandon its operation
+        before a new reader reads the partition.
+        """
+    def __aiter__(self) -> PartitionReader: ...
+    def __anext__(self) -> collections.abc.Awaitable[PolledMessages]: ...
+    async def _anext(self) -> PolledMessages:
+        r"""
+        `next()` for `async for`, which ends after `close()`.
+        """
+    def __aenter__(self) -> collections.abc.Awaitable[PartitionReader]: ...
+    def __aexit__(
+        self, _exc_type: typing.Any, _exc_value: typing.Any, _traceback: typing.Any
+    ) -> collections.abc.Awaitable[builtins.bool]:
+        r"""
+        Closes the reader as `close()` does, also when the block raised.
+        """
+
+@typing.final
 class PartitionResizePolicy:
     r"""
     Whether partitions can be added to or deleted from a topic after creation.
@@ -3731,6 +4019,8 @@
     def operation_id(self) -> builtins.int: ...
     @property
     def kind(self) -> builtins.str: ...
+    @property
+    def partition_id(self) -> builtins.int | None: ...
 
 @typing.final
 class PendingOperationsConfig:
@@ -3780,37 +4070,145 @@
                 treated as `None`.
         """
 
+@typing.final
+class PollOptions:
+    r"""
+    The limits of each read and when its offsets are stored. A read is a poll of one partition,
+    or a fetch that a partition reader sends for several partitions of one primary. A fetch
+    applies `count`, `min_count` and `max_bytes` to all of its partitions together. A reader that
+    commits nothing runs several fetches at once, each within these limits, for partitions with
+    more messages waiting.
+    """
+    @property
+    def count(self) -> builtins.int:
+        r"""
+        The most messages in one read, over all partitions of a fetch.
+        """
+    @property
+    def max_wait(self) -> datetime.timedelta:
+        r"""
+        How long the server waits for `min_count` messages. Zero answers at once.
+        """
+    @property
+    def min_count(self) -> builtins.int:
+        r"""
+        The messages to wait for.
+        """
+    @property
+    def max_bytes(self) -> builtins.int:
+        r"""
+        The most bytes in one read, over all partitions of a fetch.
+        """
+    @property
+    def commit(self) -> CommitPolicy:
+        r"""
+        When the offsets of a read are stored.
+        """
+    def __new__(
+        cls,
+        *,
+        count: builtins.int = 1000,
+        max_wait: datetime.timedelta | None = None,
+        min_count: builtins.int = 1,
+        max_bytes: builtins.int = 1048576,
+        commit: CommitPolicy | None = None,
+    ) -> PollOptions:
+        r"""
+        Constructs the options of a read.
+
+        Args:
+            count: The most messages in one read, over all partitions of a fetch.
+            max_wait: How long the server waits for `min_count` messages, at most 600 seconds.
+                `None` answers at once. A reader or a consumer with no wait on an idle topic
+                polls in a busy loop.
+            min_count: The messages to wait for, from 1 to `count`, over all partitions of a
+                fetch.
+            max_bytes: The most bytes in one read, over all partitions of a fetch. A message
+                larger than that comes alone.
+            commit: When offsets are stored. `None` stores none, as `CommitPolicy.Manual()`.
+
+        The limits are checked when the options are used, and a fault raises `IggyError`.
+        """
+    def __repr__(self) -> builtins.str: ...
+
+@typing.final
+class PolledMessages:
+    r"""
+    One batch of messages of one partition, from `IggyClient.poll_messages()` or
+    `PartitionReader.next()`.
+    """
+    @property
+    def partition_id(self) -> builtins.int:
+        r"""
+        The partition the batch was read from.
+        """
+    @property
+    def current_offset(self) -> builtins.int:
+        r"""
+        The offset of the newest message in the partition when the batch was read.
+        """
+    @property
+    def messages(self) -> builtins.tuple[ReceiveMessage, ...]:
+        r"""
+        The messages of the batch, in offset order. Each read returns the same tuple.
+        """
+    def checkpoint(self) -> Checkpoint | None:
+        r"""
+        The position after the last message, to commit the whole batch. `None` for an empty
+        batch.
+        """
+    def checkpoint_at(self, index: builtins.int) -> Checkpoint | None:
+        r"""
+        The position after `messages[index]`, to commit a processed part of the batch. `None`
+        for an index out of range.
+        """
+    def continuation(self) -> PollingStrategy:
+        r"""
+        The position after the batch, for the next read of its partition.
+
+        After the last message, it reads from the next offset. An empty batch keeps the start of
+        its request: `Offset` and `Timestamp` keep their value, `First` reads from offset 0,
+        `Last` stays `Last`, and `Next` reads after the stored offset again.
+
+        The continuation belongs to the partition of the batch, so a read of another partition
+        refuses it with `InvalidCommand` (3). It keeps the partition history of the batch, so a
+        read after the partition was deleted and created again, or after its consumer group
+        owner changed, raises `HistoryUnavailable` (87) instead of reading the new partition.
+        """
+
+@typing.final
 class PollingStrategy:
-    @typing.final
-    class Offset(PollingStrategy):
-        __match_args__ = ("value",)
-        @property
-        def value(self) -> builtins.int: ...
-        def __new__(cls, value: builtins.int) -> PollingStrategy.Offset: ...
-
-    @typing.final
-    class Timestamp(PollingStrategy):
-        __match_args__ = ("value",)
-        @property
-        def value(self) -> builtins.int: ...
-        def __new__(cls, value: builtins.int) -> PollingStrategy.Timestamp: ...
-
-    @typing.final
-    class First(PollingStrategy):
-        __match_args__ = ()
-        def __new__(cls) -> PollingStrategy.First: ...
-
-    @typing.final
-    class Last(PollingStrategy):
-        __match_args__ = ()
-        def __new__(cls) -> PollingStrategy.Last: ...
-
-    @typing.final
-    class Next(PollingStrategy):
-        __match_args__ = ()
-        def __new__(cls) -> PollingStrategy.Next: ...
-
-    ...
+    r"""
+    Where a read starts. A strategy from a constructor reads from its own start in any
+    partition. A continuation from `PolledMessages.continuation()` belongs to the partition of
+    its batch and keeps its partition history.
+    """
+    @staticmethod
+    def Offset(value: builtins.int) -> PollingStrategy:
+        r"""
+        Reads from the offset `value`.
+        """
+    @staticmethod
+    def Timestamp(value: builtins.int) -> PollingStrategy:
+        r"""
+        Reads from the first message at or after the timestamp `value`, in microseconds.
+        """
+    @staticmethod
+    def First() -> PollingStrategy:
+        r"""
+        Reads from the first message of the partition.
+        """
+    @staticmethod
+    def Last() -> PollingStrategy:
+        r"""
+        Reads the last messages of the partition.
+        """
+    @staticmethod
+    def Next() -> PollingStrategy:
+        r"""
+        Reads after the offset stored for the consumer, or from offset 0 without one.
+        """
+    def __repr__(self) -> builtins.str: ...
 
 @typing.final
 class ProducerSendError(IggyError):
@@ -3935,7 +4333,8 @@
             pending_operations: Client-wide mutation limits; defaults to 256 operations and 64 MiB.
             reconnection: Reconnection policy. Defaults to `QuicReconnectionConfig()`.
             heartbeat_interval: Interval of heartbeats sent by the client. Defaults to 5 seconds.
-            response_buffer_size: Size of the response buffer in bytes. Defaults to 10 MB.
+            response_buffer_size: Size of the response buffer in bytes. Defaults to one reply
+                that holds the largest message, about 64 MB.
             max_concurrent_bidi_streams: Maximum number of concurrent bidirectional
                 streams. Defaults to 10,000.
             datagram_send_buffer_size: Size of the datagram send buffer in bytes.
@@ -4013,6 +4412,35 @@
     def __repr__(self) -> builtins.str: ...
 
 @typing.final
+class ReadError(IggyError):
+    r"""
+    A failed read of a `PartitionReader` or an `IggyConsumer`. The reader stays usable after
+    it and retries by itself. `code` is the code of `error`.
+    """
+    @property
+    def partition_id(self) -> builtins.int | None:
+        r"""
+        The partition of the error, or `None` for an error of the whole reader.
+        """
+    @property
+    def offset(self) -> builtins.int | None: ...
+    @property
+    def error(self) -> IggyError:
+        r"""
+        The error as the client raises it for any other call: an `IggyError`, or a
+        `PendingOperationError` with the operation to abandon.
+        """
+    @property
+    def skip(self) -> PollingStrategy | None:
+        r"""
+        For a message that does not decode: the continuation after it. The messages before it
+        arrive in a batch before this error, and the reader reads the message again until
+        `seek()` to this continuation skips it.
+        """
+    @property
+    def record(self) -> bytes | None: ...
+
+@typing.final
 class ReceiveMessage:
     r"""
     A Python class representing a received message.
@@ -4057,17 +4485,11 @@
         r"""
         Retrieves the partition this message belongs to.
         """
-    def context(self) -> PartitionContext:
+    def checkpoint(self) -> Checkpoint:
         r"""
-        Retrieves the partition incarnation and owner the poll that delivered this message
-        was served under.
-        """
-    def position(self) -> ConsumerPosition:
-        r"""
-        The position to commit for this message with `IggyConsumer.store_position()`.
-        It keeps the context that delivered the message, so a commit after the partition
-        was deleted and created again, or after its consumer group owner changed, is
-        refused instead of landing on the new partition.
+        The position after this message, to commit it and every earlier message of its
+        partition with `PartitionReader.commit()` or `IggyConsumer.commit()`. The reader or
+        consumer that delivered the message must commit it.
         """
     def user_headers(self) -> UserHeaders | None:
         r"""
@@ -5237,7 +5659,7 @@
         write_buffer_size: builtins.int | None = None,
         max_write_buffer_size: builtins.int | None = None,
         max_message_size: builtins.int | None = 64 << 20,
-        max_frame_size: builtins.int | None = 16 << 20,
+        max_frame_size: builtins.int | None = 64156564,
         accept_unmasked_frames: builtins.bool | None = None,
     ) -> WebSocketFramingConfig:
         r"""
@@ -5255,9 +5677,9 @@
                 Lifting the limit lets a peer queue an arbitrarily large message
                 in memory, so prefer a finite value.
             max_frame_size: Maximum frame size in bytes, or an explicit `None` to
-                lift the limit entirely. Omitting the argument keeps the
-                underlying default of 16 MiB, with the same caveat as
-                `max_message_size`.
+                lift the limit entirely. Omitting the argument keeps the SDK
+                default of 64,156,564 bytes, which holds the largest reply of one
+                record, with the same caveat as `max_message_size`.
             accept_unmasked_frames: Whether to accept unmasked frames. Defaults to
                 `False`; clients should typically keep this off for RFC compliance.
 
diff --git a/foreign/python/pyproject.toml b/foreign/python/pyproject.toml
index b67e2a5..2027b7d 100644
--- a/foreign/python/pyproject.toml
+++ b/foreign/python/pyproject.toml
@@ -22,7 +22,7 @@
 [project]
 name = "apache-iggy"
 requires-python = ">=3.11"
-version = "0.9.1.dev5"
+version = "0.9.1.dev6"
 description = "Apache Iggy is the persistent message streaming platform written in Rust, supporting QUIC, TCP and HTTP transport protocols, capable of processing millions of messages per second."
 readme = "README.md"
 license = { file = "LICENSE" }
diff --git a/foreign/python/src/bin/stub_gen.rs b/foreign/python/src/bin/stub_gen.rs
index 9f295e2..7ed5b73 100644
--- a/foreign/python/src/bin/stub_gen.rs
+++ b/foreign/python/src/bin/stub_gen.rs
@@ -73,6 +73,7 @@
     content = content.replace("(apacheIggyError)", "(IggyError)");
     // The same split hits a pyclass-typed field of a complex enum variant.
     content = content.replace("apacheMetadataChangeCursor", "MetadataChangeCursor");
+    content = content.replace("apacheAutoCommit", "AutoCommit");
     // The runtime creates a str-backed Enum, whose str() differs from StrEnum.
     content.push_str("\nclass Durability(str, enum.Enum):  # noqa: UP042\n    REPLICATED = 'replicated'\n    PERSISTED = 'persisted'\n");
 
diff --git a/foreign/python/src/client.rs b/foreign/python/src/client.rs
index 0b07dd1..cc2c80d 100644
--- a/foreign/python/src/client.rs
+++ b/foreign/python/src/client.rs
@@ -17,15 +17,15 @@
 
 use bytes::Bytes;
 use iggy::prelude::{
-    AutoCommit as RustAutoCommit, Consumer as RustConsumer, IggyClient as RustIggyClient,
-    IggyExpiry as RustIggyExpiry, IggyMessage as RustMessage, MaxTopicSize as RustMaxTopicSize,
-    OptionKey as RustOptionKey, Partitioning as RustPartitioning,
-    PollingStrategy as RustPollingStrategy, *,
+    Consumer as RustConsumer, IggyClient as RustIggyClient, IggyExpiry as RustIggyExpiry,
+    IggyMessage as RustMessage, MaxTopicSize as RustMaxTopicSize, OptionKey as RustOptionKey,
+    Partitioning as RustPartitioning, PollOptions as RustPollOptions,
+    PollingStrategy as RustPollingStrategy, ReadError as RustReadError, *,
 };
-use pyo3::PyRef;
 use pyo3::exceptions::{PyRuntimeError, PyValueError};
 use pyo3::prelude::*;
 use pyo3::types::{PyBytes, PyDelta, PyList, PyType};
+use pyo3::{PyErrArguments, PyRef};
 use pyo3_async_runtimes::tokio::future_into_py;
 use pyo3_stub_gen::define_stub_info_gatherer;
 use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods};
@@ -37,10 +37,11 @@
 
 use crate::config::PyClientConfig;
 use crate::consumer::{
-    AutoCommit, Consumer as PyConsumer, ConsumerGroup as PyConsumerGroup,
-    ConsumerGroupDetails as PyConsumerGroupDetails, IggyConsumer,
+    Consumer as PyConsumer, ConsumerGroup as PyConsumerGroup,
+    ConsumerGroupDetails as PyConsumerGroupDetails, ConsumerOffsetInfo, IggyConsumer,
 };
 use crate::duration::{py_delta_to_iggy_duration, reject_zero};
+use crate::event_loop::on_event_loop;
 use crate::identifier::PyIdentifier;
 use crate::metadata_changes::{
     MetadataChangeGroup, MetadataChangeStart, MetadataChanges, validated_query,
@@ -48,8 +49,10 @@
 use crate::options::OptionSpec as PyOptionSpec;
 use crate::partitioning::PyPartitioning;
 use crate::permissions::Permissions as PyPermissions;
+use crate::poll_options::PollOptions;
 use crate::producer::{IggyProducer, ProducerMode, u32_param as producer_u32_param};
-use crate::receive_message::{PartitionContext, PollingStrategy, ReceiveMessage};
+use crate::reader::PartitionReader;
+use crate::receive_message::{Checkpoint, PolledBatch, PollingStrategy};
 use crate::resource_options::{OptionKey, StreamOptions, TopicOptions, UserOptions};
 use crate::send_message::{SendMessage, SendMessagesResponse as PySendMessagesResponse};
 use crate::stats::Stats as PyStats;
@@ -59,7 +62,7 @@
     IdentityInfo as PyIdentityInfo, UserInfo as PyUserInfo, UserInfoDetails as PyUserInfoDetails,
     UserStatus as PyUserStatus,
 };
-use tokio::sync::Mutex;
+use tokio::sync::{Mutex, watch};
 
 const DEFAULT_RESOLVE_TIMEOUT: Duration = Duration::from_secs(30);
 
@@ -96,6 +99,8 @@
     operation_id: u64,
     #[pyo3(get)]
     kind: &'static str,
+    #[pyo3(get)]
+    partition_id: Option<u32>,
 }
 
 /// An option refusal, with the server error code and its snake_case name as `kind`.
@@ -106,6 +111,48 @@
     kind: &'static str,
 }
 
+/// A failed read of a `PartitionReader` or an `IggyConsumer`. The reader stays usable after
+/// it and retries by itself. `code` is the code of `error`.
+#[gen_stub_pyclass]
+#[pyclass(frozen, extends=PyIggyError)]
+pub struct ReadError {
+    /// The partition of the error, or `None` for an error of the whole reader.
+    #[pyo3(get)]
+    partition_id: Option<u32>,
+    error: Py<PyAny>,
+    skip: Option<RustPollingStrategy>,
+    #[pyo3(get)]
+    offset: Option<u64>,
+    record: Option<Bytes>,
+}
+
+#[gen_stub_pymethods]
+#[pymethods]
+impl ReadError {
+    /// The error as the client raises it for any other call: an `IggyError`, or a
+    /// `PendingOperationError` with the operation to abandon.
+    #[gen_stub(override_return_type(type_repr = "IggyError"))]
+    #[getter]
+    fn error(&self, py: Python<'_>) -> Py<PyAny> {
+        self.error.clone_ref(py)
+    }
+
+    /// For a message that does not decode: the continuation after it. The messages before it
+    /// arrive in a batch before this error, and the reader reads the message again until
+    /// `seek()` to this continuation skips it.
+    #[gen_stub(override_return_type(type_repr = "PollingStrategy | None"))]
+    #[getter]
+    fn skip(&self) -> Option<PollingStrategy> {
+        self.skip.map(PollingStrategy::from)
+    }
+
+    #[gen_stub(override_return_type(type_repr = "bytes | None"))]
+    #[getter]
+    fn record<'py>(&self, py: Python<'py>) -> Option<Bound<'py, PyBytes>> {
+        self.record.as_ref().map(|record| PyBytes::new(py, record))
+    }
+}
+
 /// The JSON error body the HTTP API returns.
 #[derive(Deserialize)]
 struct HttpErrorBody {
@@ -115,43 +162,109 @@
 
 /// Raises every SDK error with its code, and an operation with its recovery
 /// identity, rather than requiring callers to parse an error message.
+///
+/// The Python exception is built when Python raises it, on a thread that holds the GIL, so the
+/// Tokio worker that fails a call never waits for the GIL.
 pub(crate) fn to_runtime_error(error: IggyError) -> PyErr {
+    PyErr::new::<PyIggyError, _>(RuntimeErrorArguments(error))
+}
+
+/// Raises a read error with its partition and skip, and its cause as `to_runtime_error` raises it.
+pub(crate) fn to_read_error(error: RustReadError) -> PyErr {
+    PyErr::new::<ReadError, _>(ReadErrorArguments(error))
+}
+
+struct RuntimeErrorArguments(IggyError);
+
+impl PyErrArguments for RuntimeErrorArguments {
+    fn arguments(self, py: Python<'_>) -> Py<PyAny> {
+        runtime_error_value(py, self.0)
+    }
+}
+
+struct ReadErrorArguments(RustReadError);
+
+impl PyErrArguments for ReadErrorArguments {
+    fn arguments(self, py: Python<'_>) -> Py<PyAny> {
+        let error = self.0;
+        let message = error.to_string();
+        let coded = PyIggyError::from(&error.error);
+        let read_error = ReadError {
+            partition_id: error.partition_id,
+            error: runtime_error_value(py, error.error),
+            skip: error.skip,
+            offset: error.offset,
+            record: error.record,
+        };
+        exception_value(
+            py,
+            Bound::new(py, PyClassInitializer::from(coded).add_subclass(read_error)),
+            message,
+        )
+    }
+}
+
+/// The exception instance of an SDK error: an `OptionError`, a `PendingOperationError` or an
+/// `IggyError`, with the error message as its only argument.
+fn runtime_error_value(py: Python<'_>, error: IggyError) -> Py<PyAny> {
+    if let Some((code, kind, message)) = option_error(&error) {
+        let instance = Bound::new(
+            py,
+            PyClassInitializer::from(PyIggyError { code }).add_subclass(OptionError { kind }),
+        );
+        return exception_value(py, instance, message);
+    }
     let pending = match error {
-        IggyError::OperationUncertain(operation_id) => Some((operation_id, "uncertain")),
-        IggyError::LaneBlocked(operation_id) => Some((operation_id, "lane_blocked")),
-        IggyError::OperationNotRetained(operation_id) => Some((operation_id, "not_retained")),
-        _ => {
-            if let Some(refusal) = to_option_error(&error) {
-                return refusal;
-            }
-            None
+        IggyError::OperationUncertain(operation_id) => Some((operation_id, "uncertain", None)),
+        IggyError::FetchProgressUncertain(operation_id) => {
+            Some((operation_id, "fetch_progress_uncertain", None))
         }
+        IggyError::LaneBlocked(operation_id) => Some((operation_id, "lane_blocked", None)),
+        IggyError::PartitionLaneBlocked(partition_id, operation_id) => {
+            Some((operation_id, "lane_blocked", Some(partition_id)))
+        }
+        IggyError::OperationNotRetained(operation_id) => Some((operation_id, "not_retained", None)),
+        _ => None,
     };
-    Python::attach(|py| {
-        let coded = PyIggyError::from(&error);
-        let instance = match pending {
-            Some((operation_id, kind)) => Bound::new(
+    let coded = PyIggyError::from(&error);
+    let message = error.to_string();
+    match pending {
+        Some((operation_id, kind, partition_id)) => exception_value(
+            py,
+            Bound::new(
                 py,
-                PyClassInitializer::from(coded)
-                    .add_subclass(PendingOperationError { operation_id, kind }),
-            )
-            .map(Bound::into_any),
-            None => Bound::new(py, coded).map(Bound::into_any),
-        };
-        let instance = match instance {
-            Ok(instance) => instance,
-            Err(error) => return error,
-        };
-        if let Err(error) = instance.setattr("args", (error.to_string(),)) {
-            return error;
-        }
-        PyErr::from_value(instance)
-    })
+                PyClassInitializer::from(coded).add_subclass(PendingOperationError {
+                    operation_id,
+                    kind,
+                    partition_id,
+                }),
+            ),
+            message,
+        ),
+        None => exception_value(py, Bound::new(py, coded), message),
+    }
+}
+
+/// The instance with `message` as its arguments, or the error that building it raised.
+fn exception_value<T>(
+    py: Python<'_>,
+    instance: PyResult<Bound<'_, T>>,
+    message: String,
+) -> Py<PyAny> {
+    let instance = match instance {
+        Ok(instance) => instance.into_any(),
+        Err(error) => return error.into_value(py).into_any(),
+    };
+    match instance.setattr("args", (message,)) {
+        Ok(()) => instance.unbind(),
+        Err(error) => error.into_value(py).into_any(),
+    }
 }
 
 /// Every transport reports an option refusal with the same code: binary by the error itself,
-/// HTTP by the `id` of its JSON error body.
-fn to_option_error(error: &IggyError) -> Option<PyErr> {
+/// HTTP by the `id` of its JSON error body. The code, its snake_case name and the message, or
+/// `None` for any other error.
+fn option_error(error: &IggyError) -> Option<(u32, &'static str, String)> {
     let (code, message) = match error {
         IggyError::HttpResponseError(_, body) => {
             let body = serde_json::from_str::<HttpErrorBody>(body).ok()?;
@@ -162,20 +275,7 @@
     if !is_option_error(&IggyError::from_code(code)) {
         return None;
     }
-    let kind = IggyError::from_code_as_string(code);
-    Some(Python::attach(|py| {
-        let instance = match Bound::new(
-            py,
-            PyClassInitializer::from(PyIggyError { code }).add_subclass(OptionError { kind }),
-        ) {
-            Ok(instance) => instance,
-            Err(error) => return error,
-        };
-        if let Err(error) = instance.setattr("args", (message,)) {
-            return error;
-        }
-        PyErr::from_value(instance.into_any())
-    }))
+    Some((code, IggyError::from_code_as_string(code), message))
 }
 
 fn is_option_error(error: &IggyError) -> bool {
@@ -745,6 +845,8 @@
     /// Replays the exact retained operation until its final result or the deadline.
     /// A committed rejection raises its original error and frees the lane.
     /// Uncertainty keeps the operation retained. HTTP does not support this operation.
+    /// A fetch progress block raises code 14010 without replaying. Only explicit
+    /// abandon_operation releases it, accepting the unknown server outcome.
     /// `timeout` must be a positive timedelta; omission or None uses 30 seconds.
     #[pyo3(signature = (operation_id, *, timeout=None))]
     #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))]
@@ -777,6 +879,7 @@
     }
 
     /// Releases a retained operation without claiming whether it committed.
+    /// For a fetch, this releases every affected partition without stopping the old request.
     #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))]
     fn abandon_operation<'a>(
         &self,
@@ -1702,103 +1805,303 @@
         })
     }
 
-    /// Polls for messages from the specified topic on behalf of the given consumer.
-    /// Omitting `partition_id` reads partition 0 for a regular consumer, and
-    /// polls the member's assigned partitions for a consumer group.
-    /// `context` continues from a message received earlier, usually with
-    /// `PollingStrategy.Offset`: pass that message's `context()` and `partition_id()`.
-    /// The poll is then refused if the partition was deleted and created again, or if its
-    /// consumer group owner changed, instead of reading the new partition at that offset.
-    /// Without `context`, the poll takes the context its route reports. After another
-    /// client deleted and created the partition again, one such poll can fail with
-    /// `HistoryUnavailable` (87), or `ConsumerGroupPartitionNotOwned` (5009) for a
-    /// consumer group member. The failed route is dropped, and the next call routes again.
-    /// Returns a list of received messages or a RuntimeError on failure.
+    /// Polls one batch of messages of a partition on behalf of the given consumer.
+    ///
+    /// `options` defaults to `PollOptions()`: at most 1000 messages and 1 MiB, answered at
+    /// once, and no offset stored. With `CommitPolicy.OnRead()`, the server stores the
+    /// offset of the last message as part of the reply. If that reply is lost, the poll raises
+    /// `PendingOperationError` of kind `fetch_progress_uncertain`, and every later poll of the
+    /// partition raises it of kind `lane_blocked` until `abandon_operation()` releases it. A
+    /// cancelled `OnRead` poll that already sent its request blocks the partition the same way,
+    /// so a cancel never loses a batch without a trace. `CommitPolicy.Auto` raises `IggyError`
+    /// with `InvalidConfiguration` (2): a one-shot poll has nothing to count.
+    ///
+    /// To read on, pass `polled.continuation()` as the next `polling_strategy`. The poll is
+    /// then refused with `HistoryUnavailable` (87) if the partition was deleted and created
+    /// again, or if its consumer group owner changed, instead of reading the new partition at
+    /// that offset. A continuation of another partition is refused with `InvalidCommand` (3).
+    ///
+    /// A consumer group member reads only the partitions assigned to it: another partition
+    /// raises `ConsumerGroupPartitionNotOwned` (5009).
+    /// Returns the batch, or raises `IggyError` on failure.
     #[allow(clippy::too_many_arguments)]
-    #[pyo3(signature = (stream, topic, *, consumer, polling_strategy, count, auto_commit, partition_id = None, context = None))]
-    #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[list[ReceiveMessage]]", imports=("collections.abc")))]
-    fn poll_messages<'a>(
+    #[pyo3(signature = (stream, topic, *, partition_id, consumer, polling_strategy, options=None))]
+    #[gen_stub(override_return_type(type_repr = "PolledMessages"))]
+    async fn poll_messages(
+        &self,
+        stream: PyIdentifier,
+        topic: PyIdentifier,
+        partition_id: u32,
+        consumer: PyConsumer,
+        polling_strategy: PollingStrategy,
+        #[gen_stub(override_type(type_repr = "PollOptions | None"))] options: Option<PollOptions>,
+    ) -> PyResult<PolledBatch> {
+        let consumer = RustConsumer::try_from(&consumer)?;
+        let stream = Identifier::try_from(stream)?;
+        let topic = Identifier::try_from(topic)?;
+        let strategy = polling_strategy.inner;
+        let options = options.map_or_else(RustPollOptions::default, |options| options.inner);
+        // On the event loop a finished poll hands its batch to Python in the same step, so a
+        // cancel never drops a batch whose offset the server stored.
+        on_event_loop(async {
+            let polled = self
+                .inner
+                .poll_messages(&stream, &topic, partition_id, &consumer, &strategy, options)
+                .await
+                .map_err(to_runtime_error)?;
+            Ok(PolledBatch(polled))
+        })
+        .await
+    }
+
+    /// Stores the offset of `checkpoint` for `consumer`, under the partition history that
+    /// delivered the checkpoint, which covers every earlier message of its partition.
+    ///
+    /// Raises `IggyError` with `HistoryUnavailable` (87) after the partition was deleted and
+    /// created again, and `ConsumerGroupPartitionNotOwned` (5009) after its consumer group owner
+    /// changed, instead of storing the offset on the new partition.
+    #[pyo3(signature = (stream, topic, *, consumer, checkpoint))]
+    #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))]
+    fn store_consumer_position<'a>(
         &self,
         py: Python<'a>,
         stream: PyIdentifier,
         topic: PyIdentifier,
         consumer: &PyConsumer,
-        polling_strategy: &PollingStrategy,
-        count: u32,
-        auto_commit: bool,
-        #[gen_stub(override_type(type_repr = "builtins.int | None"))] partition_id: Option<u32>,
-        #[gen_stub(override_type(type_repr = "PartitionContext | None"))] context: Option<
-            &PartitionContext,
+        checkpoint: &Checkpoint,
+    ) -> PyResult<Bound<'a, PyAny>> {
+        let consumer = RustConsumer::try_from(consumer)?;
+        let stream = Identifier::try_from(stream)?;
+        let topic = Identifier::try_from(topic)?;
+        let checkpoint = checkpoint.inner;
+        let inner = self.inner.clone();
+        future_into_py(py, async move {
+            inner
+                .store_consumer_position(&consumer, &stream, &topic, &checkpoint)
+                .await
+                .map_err(to_runtime_error)
+        })
+    }
+
+    /// Stores `offset` for `consumer` on `partition_id`, under the partition history of its
+    /// route. After another client deleted and created the partition again, it can fail once
+    /// with `HistoryUnavailable` (87), and the next call routes again. Prefer
+    /// `store_consumer_position()` with a checkpoint.
+    #[pyo3(signature = (stream, topic, *, consumer, partition_id, offset))]
+    #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))]
+    fn store_consumer_offset<'a>(
+        &self,
+        py: Python<'a>,
+        stream: PyIdentifier,
+        topic: PyIdentifier,
+        consumer: &PyConsumer,
+        partition_id: u32,
+        offset: u64,
+    ) -> PyResult<Bound<'a, PyAny>> {
+        let consumer = RustConsumer::try_from(consumer)?;
+        let stream = Identifier::try_from(stream)?;
+        let topic = Identifier::try_from(topic)?;
+        let inner = self.inner.clone();
+        future_into_py(py, async move {
+            inner
+                .store_consumer_offset(&consumer, &stream, &topic, Some(partition_id), offset)
+                .await
+                .map_err(to_runtime_error)
+        })
+    }
+
+    /// Returns the offset stored for `consumer` on `partition_id`, or `None` when nothing is
+    /// stored. In a cluster, the node of the connection answers from its own replica, which can
+    /// lag a store that just returned.
+    #[pyo3(signature = (stream, topic, *, consumer, partition_id))]
+    #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[ConsumerOffsetInfo | None]", imports=("collections.abc")))]
+    fn get_consumer_offset<'a>(
+        &self,
+        py: Python<'a>,
+        stream: PyIdentifier,
+        topic: PyIdentifier,
+        consumer: &PyConsumer,
+        partition_id: u32,
+    ) -> PyResult<Bound<'a, PyAny>> {
+        let consumer = RustConsumer::try_from(consumer)?;
+        let stream = Identifier::try_from(stream)?;
+        let topic = Identifier::try_from(topic)?;
+        let inner = self.inner.clone();
+        future_into_py(py, async move {
+            let offset = inner
+                .get_consumer_offset(&consumer, &stream, &topic, Some(partition_id))
+                .await
+                .map_err(to_runtime_error)?;
+            Ok(offset.map(|offset| ConsumerOffsetInfo {
+                partition_id: offset.partition_id,
+                current_offset: offset.current_offset,
+                stored_offset: offset.stored_offset,
+            }))
+        })
+    }
+
+    /// Deletes the offset stored for `consumer` on `partition_id`, under the partition history
+    /// of its route. Raises `IggyError` with `ConsumerOffsetNotFound` (3021) when nothing is
+    /// stored. Prefer `delete_consumer_position()` with a checkpoint.
+    #[pyo3(signature = (stream, topic, *, consumer, partition_id))]
+    #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))]
+    fn delete_consumer_offset<'a>(
+        &self,
+        py: Python<'a>,
+        stream: PyIdentifier,
+        topic: PyIdentifier,
+        consumer: &PyConsumer,
+        partition_id: u32,
+    ) -> PyResult<Bound<'a, PyAny>> {
+        let consumer = RustConsumer::try_from(consumer)?;
+        let stream = Identifier::try_from(stream)?;
+        let topic = Identifier::try_from(topic)?;
+        let inner = self.inner.clone();
+        future_into_py(py, async move {
+            inner
+                .delete_consumer_offset(&consumer, &stream, &topic, Some(partition_id))
+                .await
+                .map_err(to_runtime_error)
+        })
+    }
+
+    /// Deletes the stored offset of the partition of `checkpoint` for `consumer`, under the
+    /// partition history that delivered the checkpoint. The offset of the checkpoint is not
+    /// used.
+    ///
+    /// Raises `IggyError` with `HistoryUnavailable` (87) after the partition was deleted and
+    /// created again, `ConsumerGroupPartitionNotOwned` (5009) after its consumer group owner
+    /// changed, and `ConsumerOffsetNotFound` (3021) when nothing is stored. HTTP sends no
+    /// partition history, so there the server deletes the offset of the history it holds.
+    #[pyo3(signature = (stream, topic, *, consumer, checkpoint))]
+    #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))]
+    fn delete_consumer_position<'a>(
+        &self,
+        py: Python<'a>,
+        stream: PyIdentifier,
+        topic: PyIdentifier,
+        consumer: &PyConsumer,
+        checkpoint: &Checkpoint,
+    ) -> PyResult<Bound<'a, PyAny>> {
+        let consumer = RustConsumer::try_from(consumer)?;
+        let stream = Identifier::try_from(stream)?;
+        let topic = Identifier::try_from(topic)?;
+        let checkpoint = checkpoint.inner;
+        let inner = self.inner.clone();
+        future_into_py(py, async move {
+            inner
+                .delete_consumer_position(&consumer, &stream, &topic, &checkpoint)
+                .await
+                .map_err(to_runtime_error)
+        })
+    }
+
+    /// Builds a `PartitionReader` of `topic` in `stream` that reads as `consumer`.
+    ///
+    /// A `Consumer.Single` reads `partitions`, which it requires. A `Consumer.Group` reads the
+    /// assignment of its member, so `partitions` must be `None`: the reader joins the group
+    /// here. Consumer groups are not available over HTTP.
+    ///
+    /// `start` is where a partition without a stored position starts, `PollingStrategy.Next()`
+    /// by default. A continuation is refused, because it belongs to one partition. `options`
+    /// sets each request, and defaults to `PollOptions(max_wait=timedelta(seconds=5))`: a
+    /// reader without a wait polls an idle topic in a busy loop. `prefetch_bytes` (16 MiB by
+    /// default) and `prefetch_messages` (the larger of 16,000 and `options.count` by default)
+    /// bound the requests in flight and the queued batches.
+    ///
+    /// Raises `IggyError` with `InvalidCommand` (3) for partitions of a group, a
+    /// `Consumer.Single` without partitions or a continuation as `start`,
+    /// `FeatureUnavailable` for a consumer group over HTTP, the errors of `options`, and the
+    /// error of the group join. A join with an unknown outcome raises `PendingOperationError`:
+    /// the client keeps it, so a new `reader()` raises `lane_blocked` until
+    /// `resolve_operation()` or `abandon_operation()` settles it.
+    #[allow(clippy::too_many_arguments)]
+    #[pyo3(signature = (
+        stream,
+        topic,
+        *,
+        consumer,
+        partitions=None,
+        start=None,
+        options=None,
+        prefetch_bytes=None,
+        prefetch_messages=None,
+    ))]
+    #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[PartitionReader]", imports=("collections.abc")))]
+    fn reader<'a>(
+        &self,
+        py: Python<'a>,
+        stream: PyIdentifier,
+        topic: PyIdentifier,
+        consumer: &PyConsumer,
+        #[gen_stub(override_type(type_repr = "list[builtins.int] | None"))] partitions: Option<
+            Vec<u32>,
+        >,
+        #[gen_stub(override_type(type_repr = "PollingStrategy | None"))] start: Option<
+            PollingStrategy,
+        >,
+        #[gen_stub(override_type(type_repr = "PollOptions | None"))] options: Option<PollOptions>,
+        #[gen_stub(override_type(type_repr = "builtins.int | None"))] prefetch_bytes: Option<u32>,
+        #[gen_stub(override_type(type_repr = "builtins.int | None"))] prefetch_messages: Option<
+            u32,
         >,
     ) -> PyResult<Bound<'a, PyAny>> {
         let consumer = RustConsumer::try_from(consumer)?;
         let stream = Identifier::try_from(stream)?;
         let topic = Identifier::try_from(topic)?;
-        let strategy = RustPollingStrategy::from(polling_strategy);
-        let strategy = match context {
-            Some(context) => strategy.with_context((*context).into()),
-            None => strategy,
-        };
-
-        let inner = self.inner.clone();
+        let mut builder = self.inner.reader(&stream, &topic, &consumer);
+        if let Some(partitions) = partitions {
+            builder = builder.partitions(partitions);
+        }
+        if let Some(start) = start {
+            builder = builder.start(start.inner);
+        }
+        if let Some(options) = options {
+            builder = builder.options(options.inner);
+        }
+        if let Some(prefetch_bytes) = prefetch_bytes {
+            builder = builder.prefetch_bytes(prefetch_bytes);
+        }
+        if let Some(prefetch_messages) = prefetch_messages {
+            builder = builder.prefetch_messages(prefetch_messages);
+        }
 
         future_into_py(py, async move {
-            let polled_messages = inner
-                .poll_messages(
-                    &stream,
-                    &topic,
-                    partition_id,
-                    &consumer,
-                    &strategy,
-                    count,
-                    auto_commit,
-                )
-                .await
-                .map_err(to_runtime_error)?;
-            let partition_id = polled_messages.partition_id;
-            let context = PartitionContext::from(polled_messages.context);
-            let messages = polled_messages
-                .messages
-                .into_iter()
-                .map(|m| ReceiveMessage {
-                    inner: m,
-                    partition_id,
-                    context,
-                })
-                .collect::<Vec<_>>();
-            Ok(messages)
+            // The reader starts Tokio tasks, so it must be built on the Rust runtime.
+            let reader = builder.build().await.map_err(to_runtime_error)?;
+            Ok(PartitionReader::new(reader))
         })
     }
 
-    /// Creates a new consumer group consumer.
-    /// `partition_id` is ignored for a consumer group: the member reads the partitions
-    /// the server assigns to it.
-    /// `auto_commit` defaults to `AutoCommit.Disabled()`: the consumer stores no offset,
-    /// so store positions after processing, or each restart reads the same messages again.
-    /// Returns the consumer or a RuntimeError on failure. Raises `ValueError` if
-    /// `polling_retry_interval`, `init_retry_interval` or an `AutoCommit` interval
-    /// is negative or zero.
+    /// Creates a new consumer group consumer, which joins the group before it returns.
+    /// The member reads the partitions the server assigns to it.
     ///
-    /// Consumer groups are not available over HTTP. With `auto_join_consumer_group`
-    /// left on, this call fails at the join with `Feature is unavailable`.
-    /// Turning it off is not a workaround: the join is skipped, but a group
-    /// member always polls without a partition, so the first poll fails with
-    /// the same error. Use `Consumer.Single(...)` with `poll_messages(...)`
-    /// instead - a `Consumer.Group(...)` poll with an explicit `partition_id`
-    /// does reach the server, but is served as an ordinary consumer named
-    /// after the group.
+    /// `polling_strategy` is where a partition without a stored offset starts,
+    /// `PollingStrategy.Next()` by default. `poll_options` sets each request, and defaults to
+    /// `PollOptions(max_wait=timedelta(seconds=5))`: a consumer without a wait polls an idle
+    /// topic in a busy loop. Its `commit` says when offsets are stored: `CommitPolicy.Manual()`,
+    /// the default, stores none, so commit checkpoints after processing, or each restart reads
+    /// the same messages again. `CommitPolicy.OnRead()` makes the server store the offset of
+    /// each reply, and `CommitPolicy.Auto(...)` makes the consumer store counted positions.
+    /// `prefetch_bytes` (16 MiB by default) and `prefetch_messages` (the larger of 16,000 and
+    /// the poll count by default) bound the requests in flight and the queued batches.
+    /// Call `shutdown()` before dropping the consumer.
+    /// Returns the consumer or raises `IggyError` on failure. Raises `ValueError` if
+    /// `polling_retry_interval` or `init_retry_interval` is negative or zero.
+    ///
+    /// Consumer groups are not available over HTTP: this call raises `FeatureUnavailable`.
+    /// Use `Consumer.Single(...)` with `poll_messages(...)` or `reader(...)` there.
     #[allow(clippy::too_many_arguments)]
     #[pyo3(signature = (
         name,
         stream,
         topic,
-        partition_id=None,
-        polling_strategy=None,
-        batch_length=None,
-        auto_commit=None,
-        create_consumer_group_if_not_exists=true,
-        auto_join_consumer_group=true,
         *,
+        polling_strategy=None,
+        poll_options=None,
+        prefetch_bytes=None,
+        prefetch_messages=None,
+        create_consumer_group_if_not_exists=true,
         polling_retry_interval=None,
         init_retries=None,
         init_retry_interval=None,
@@ -1811,16 +2114,17 @@
         name: &str,
         stream: &str,
         topic: &str,
-        #[gen_stub(override_type(type_repr = "builtins.int | None"))] partition_id: Option<u32>,
         #[gen_stub(override_type(type_repr = "PollingStrategy | None"))] polling_strategy: Option<
             &PollingStrategy,
         >,
-        #[gen_stub(override_type(type_repr = "builtins.int | None"))] batch_length: Option<u32>,
-        #[gen_stub(override_type(type_repr = "AutoCommit | None"))] auto_commit: Option<
-            &AutoCommit,
+        #[gen_stub(override_type(type_repr = "PollOptions | None"))] poll_options: Option<
+            PollOptions,
+        >,
+        #[gen_stub(override_type(type_repr = "builtins.int | None"))] prefetch_bytes: Option<u32>,
+        #[gen_stub(override_type(type_repr = "builtins.int | None"))] prefetch_messages: Option<
+            u32,
         >,
         create_consumer_group_if_not_exists: bool,
-        auto_join_consumer_group: bool,
         #[gen_stub(override_type(type_repr = "datetime.timedelta | None", imports=("datetime")))]
         polling_retry_interval: Option<Py<PyDelta>>,
         #[gen_stub(override_type(type_repr = "builtins.int | None"))] init_retries: Option<u32>,
@@ -1832,73 +2136,90 @@
             .inner
             .consumer_group(name, stream, topic)
             .map_err(to_runtime_error)?
+            .without_encryptor();
+        builder = if create_consumer_group_if_not_exists {
+            builder.create_consumer_group_if_not_exists()
+        } else {
+            builder.do_not_create_consumer_group_if_not_exists()
+        };
+        let settings = ConsumerSettings {
+            polling_strategy: polling_strategy.map(|strategy| strategy.inner),
+            poll_options,
+            prefetch_bytes,
+            prefetch_messages,
+            polling_retry_interval,
+            init_retries,
+            init_retry_interval,
+            allow_replay,
+        };
+        initialized_consumer(py, settings.apply(builder)?)
+    }
+
+    /// Creates a standalone consumer of `partitions` of `topic`, identified by `name`: it
+    /// stores its offsets as `Consumer.Single(name)` and joins no group. A partition without a
+    /// stored offset starts at `polling_strategy`, `PollingStrategy.Next()` by default.
+    ///
+    /// The other arguments are those of `consumer_group()`. Call `shutdown()` before dropping
+    /// the consumer. Raises `IggyError` with `InvalidCommand` (3) without partitions, and the
+    /// errors of `poll_options`.
+    #[allow(clippy::too_many_arguments)]
+    #[pyo3(signature = (
+        name,
+        stream,
+        topic,
+        *,
+        partitions,
+        polling_strategy=None,
+        poll_options=None,
+        prefetch_bytes=None,
+        prefetch_messages=None,
+        polling_retry_interval=None,
+        init_retries=None,
+        init_retry_interval=None,
+        allow_replay=false,
+    ))]
+    #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[IggyConsumer]", imports=("collections.abc")))]
+    fn consumer<'a>(
+        &self,
+        py: Python<'a>,
+        name: &str,
+        stream: &str,
+        topic: &str,
+        partitions: Vec<u32>,
+        #[gen_stub(override_type(type_repr = "PollingStrategy | None"))] polling_strategy: Option<
+            &PollingStrategy,
+        >,
+        #[gen_stub(override_type(type_repr = "PollOptions | None"))] poll_options: Option<
+            PollOptions,
+        >,
+        #[gen_stub(override_type(type_repr = "builtins.int | None"))] prefetch_bytes: Option<u32>,
+        #[gen_stub(override_type(type_repr = "builtins.int | None"))] prefetch_messages: Option<
+            u32,
+        >,
+        #[gen_stub(override_type(type_repr = "datetime.timedelta | None", imports=("datetime")))]
+        polling_retry_interval: Option<Py<PyDelta>>,
+        #[gen_stub(override_type(type_repr = "builtins.int | None"))] init_retries: Option<u32>,
+        #[gen_stub(override_type(type_repr = "datetime.timedelta | None", imports=("datetime")))]
+        init_retry_interval: Option<Py<PyDelta>>,
+        allow_replay: bool,
+    ) -> PyResult<Bound<'a, PyAny>> {
+        let builder = self
+            .inner
+            .consumer(name, stream, topic)
+            .map_err(to_runtime_error)?
             .without_encryptor()
-            .partition(partition_id);
-
-        if create_consumer_group_if_not_exists {
-            builder = builder.create_consumer_group_if_not_exists()
-        } else {
-            builder = builder.do_not_create_consumer_group_if_not_exists()
+            .partitions(partitions);
+        let settings = ConsumerSettings {
+            polling_strategy: polling_strategy.map(|strategy| strategy.inner),
+            poll_options,
+            prefetch_bytes,
+            prefetch_messages,
+            polling_retry_interval,
+            init_retries,
+            init_retry_interval,
+            allow_replay,
         };
-        if auto_join_consumer_group {
-            builder = builder.auto_join_consumer_group()
-        } else {
-            builder = builder.do_not_auto_join_consumer_group()
-        };
-        if let Some(polling_strategy) = polling_strategy {
-            builder = builder.polling_strategy(polling_strategy.into())
-        };
-        if let Some(batch_length) = batch_length {
-            builder = builder.batch_length(batch_length)
-        };
-        if let Some(auto_commit) = auto_commit {
-            builder = builder.auto_commit(RustAutoCommit::try_from(auto_commit)?)
-        };
-        if let Some(polling_retry_interval) = polling_retry_interval {
-            builder = builder.polling_retry_interval(reject_zero(
-                py_delta_to_iggy_duration(&polling_retry_interval)?,
-                "polling_retry_interval",
-            )?)
-        }
-        if init_retries.is_some() && init_retry_interval.is_none() {
-            return Err(PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(
-                "'init_retry_interval' is required if 'init_retries' is set",
-            ));
-        }
-        if init_retries.is_none() && init_retry_interval.is_some() {
-            return Err(PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(
-                "'init_retries' is required if 'init_retry_interval' is set",
-            ));
-        }
-        if let (Some(init_retries), Some(init_retry_interval)) = (init_retries, init_retry_interval)
-        {
-            builder = builder.init_retries(
-                init_retries,
-                reject_zero(
-                    py_delta_to_iggy_duration(&init_retry_interval)?,
-                    "init_retry_interval",
-                )?,
-            );
-        }
-        if allow_replay {
-            builder = builder.allow_replay()
-        }
-        let mut consumer = builder.build();
-
-        future_into_py(py, async move {
-            consumer.init().await.map_err(to_runtime_error)?;
-            let state = consumer.state();
-            let name = consumer.name().to_string();
-            let stream = PyIdentifier::try_from(consumer.stream())?;
-            let topic = PyIdentifier::try_from(consumer.topic())?;
-            Ok(IggyConsumer {
-                inner: Arc::new(Mutex::new(consumer)),
-                state,
-                name,
-                stream,
-                topic,
-            })
-        })
+        initialized_consumer(py, settings.apply(builder)?)
     }
 
     /// Send a command code with a payload and return the raw response bytes.
@@ -1973,9 +2294,96 @@
 
 define_stub_info_gatherer!(stub_info);
 
+/// The settings that a standalone consumer and a consumer group member share.
+struct ConsumerSettings {
+    polling_strategy: Option<RustPollingStrategy>,
+    poll_options: Option<PollOptions>,
+    prefetch_bytes: Option<u32>,
+    prefetch_messages: Option<u32>,
+    polling_retry_interval: Option<Py<PyDelta>>,
+    init_retries: Option<u32>,
+    init_retry_interval: Option<Py<PyDelta>>,
+    allow_replay: bool,
+}
+
+impl ConsumerSettings {
+    fn apply(self, mut builder: IggyConsumerBuilder) -> PyResult<IggyConsumerBuilder> {
+        if let Some(polling_strategy) = self.polling_strategy {
+            builder = builder.polling_strategy(polling_strategy);
+        }
+        if let Some(poll_options) = self.poll_options {
+            builder = builder.poll_options(poll_options.inner);
+        }
+        if let Some(prefetch_bytes) = self.prefetch_bytes {
+            builder = builder.prefetch_bytes(prefetch_bytes);
+        }
+        if let Some(prefetch_messages) = self.prefetch_messages {
+            builder = builder.prefetch_messages(prefetch_messages);
+        }
+        if let Some(polling_retry_interval) = self.polling_retry_interval {
+            builder = builder.polling_retry_interval(reject_zero(
+                py_delta_to_iggy_duration(&polling_retry_interval)?,
+                "polling_retry_interval",
+            )?);
+        }
+        match (self.init_retries, self.init_retry_interval) {
+            (Some(init_retries), Some(init_retry_interval)) => {
+                builder = builder.init_retries(
+                    init_retries,
+                    reject_zero(
+                        py_delta_to_iggy_duration(&init_retry_interval)?,
+                        "init_retry_interval",
+                    )?,
+                );
+            }
+            (Some(_), None) => {
+                return Err(PyRuntimeError::new_err(
+                    "'init_retry_interval' is required if 'init_retries' is set",
+                ));
+            }
+            (None, Some(_)) => {
+                return Err(PyRuntimeError::new_err(
+                    "'init_retries' is required if 'init_retry_interval' is set",
+                ));
+            }
+            (None, None) => {}
+        }
+        if self.allow_replay {
+            builder = builder.allow_replay();
+        }
+        Ok(builder)
+    }
+}
+
+/// Builds the consumer and initializes it on the Rust runtime, which joins a group member.
+fn initialized_consumer(
+    py: Python<'_>,
+    builder: IggyConsumerBuilder,
+) -> PyResult<Bound<'_, PyAny>> {
+    let mut consumer = builder.build();
+    future_into_py(py, async move {
+        consumer.init().await.map_err(to_runtime_error)?;
+        let state = consumer.state();
+        let name = consumer.name().to_string();
+        let stream = PyIdentifier::try_from(consumer.stream())?;
+        let topic = PyIdentifier::try_from(consumer.topic())?;
+        Ok(IggyConsumer {
+            inner: Arc::new(Mutex::new(consumer)),
+            state,
+            name,
+            stream,
+            topic,
+            consuming: Arc::default(),
+            stopping: Arc::new(watch::Sender::new(false)),
+        })
+    })
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
+    use std::sync::mpsc;
+    use std::thread;
 
     #[test]
     fn given_root_policy_refusal_when_mapping_should_preserve_code_and_kind() {
@@ -2000,12 +2408,27 @@
         Python::initialize();
         Python::attach(|py| {
             for operation_id in [(1u64 << 53) + 17, u64::MAX] {
-                for (error, kind) in [
-                    (IggyError::OperationUncertain(operation_id), "uncertain"),
-                    (IggyError::LaneBlocked(operation_id), "lane_blocked"),
+                for (error, kind, partition_id) in [
+                    (
+                        IggyError::OperationUncertain(operation_id),
+                        "uncertain",
+                        None,
+                    ),
+                    (IggyError::LaneBlocked(operation_id), "lane_blocked", None),
+                    (
+                        IggyError::FetchProgressUncertain(operation_id),
+                        "fetch_progress_uncertain",
+                        None,
+                    ),
+                    (
+                        IggyError::PartitionLaneBlocked(u32::MAX, operation_id),
+                        "lane_blocked",
+                        Some(u32::MAX),
+                    ),
                     (
                         IggyError::OperationNotRetained(operation_id),
                         "not_retained",
+                        None,
                     ),
                 ] {
                     let code = error.as_code();
@@ -2030,13 +2453,182 @@
                         value.getattr("kind").unwrap().extract::<String>().unwrap(),
                         kind
                     );
+                    assert_eq!(
+                        value
+                            .getattr("partition_id")
+                            .unwrap()
+                            .extract::<Option<u32>>()
+                            .unwrap(),
+                        partition_id
+                    );
                     assert!(value.setattr("operation_id", 0).is_err());
+                    assert!(value.setattr("partition_id", 0).is_err());
                 }
             }
         });
     }
 
     #[test]
+    fn given_decode_diagnostics_when_mapping_read_error_should_preserve_original_bytes_and_offset()
+    {
+        Python::initialize();
+        Python::attach(|py| {
+            for offset in [None, Some(10)] {
+                for record in [None, Some(Bytes::from_static(&[0, 255, 17, 128]))] {
+                    let error = to_read_error(RustReadError {
+                        partition_id: Some(7),
+                        error: IggyError::CannotReadMessage,
+                        skip: Some(RustPollingStrategy::offset(11)),
+                        offset,
+                        record: record.clone(),
+                    });
+                    let value = error.value(py);
+                    assert_eq!(
+                        value
+                            .getattr("offset")
+                            .unwrap()
+                            .extract::<Option<u64>>()
+                            .unwrap(),
+                        offset
+                    );
+                    let bytes = value.getattr("record").unwrap();
+                    match &record {
+                        Some(record) => {
+                            assert!(bytes.is_instance_of::<PyBytes>());
+                            assert_eq!(bytes.extract::<Vec<u8>>().unwrap(), record.to_vec());
+                        }
+                        None => assert!(bytes.is_none()),
+                    }
+                    assert!(value.setattr("offset", 0).is_err());
+                    assert!(value.setattr("record", py.None()).is_err());
+                }
+            }
+        });
+    }
+
+    #[test]
+    fn given_read_error_when_mapping_should_keep_partition_skip_and_cause() {
+        const PARTITION_ID: u32 = 7;
+        const OPERATION_ID: u64 = 42;
+        const SKIP_OFFSET: u64 = 11;
+        Python::initialize();
+        Python::attach(|py| {
+            let cause = IggyError::FetchProgressUncertain(OPERATION_ID);
+            let code = cause.as_code();
+            let read_error = RustReadError {
+                partition_id: Some(PARTITION_ID),
+                error: cause,
+                skip: Some(RustPollingStrategy::offset(SKIP_OFFSET)),
+                offset: None,
+                record: None,
+            };
+            let message = read_error.to_string();
+
+            let error = to_read_error(read_error);
+
+            assert!(error.is_instance_of::<ReadError>(py));
+            assert!(error.is_instance_of::<PyIggyError>(py));
+            assert!(error.is_instance_of::<PyRuntimeError>(py));
+            let value = error.value(py);
+            assert_eq!(
+                value.getattr("code").unwrap().extract::<u32>().unwrap(),
+                code
+            );
+            assert_eq!(
+                value
+                    .getattr("partition_id")
+                    .unwrap()
+                    .extract::<Option<u32>>()
+                    .unwrap(),
+                Some(PARTITION_ID)
+            );
+            assert_eq!(value.str().unwrap().to_str().unwrap(), message);
+            let skip = value.getattr("skip").unwrap();
+            assert_eq!(
+                skip.extract::<PollingStrategy>().unwrap().inner,
+                RustPollingStrategy::offset(SKIP_OFFSET)
+            );
+            let cause = value.getattr("error").unwrap();
+            assert!(cause.is_instance_of::<PendingOperationError>());
+            assert_eq!(
+                cause
+                    .getattr("operation_id")
+                    .unwrap()
+                    .extract::<u64>()
+                    .unwrap(),
+                OPERATION_ID
+            );
+            assert_eq!(
+                cause.getattr("code").unwrap().extract::<u32>().unwrap(),
+                code
+            );
+        });
+    }
+
+    #[test]
+    fn given_read_error_of_the_whole_reader_when_mapping_should_have_no_partition_or_skip() {
+        Python::initialize();
+        Python::attach(|py| {
+            let error = to_read_error(RustReadError {
+                partition_id: None,
+                error: IggyError::Disconnected,
+                skip: None,
+                offset: None,
+                record: None,
+            });
+
+            let value = error.value(py);
+            assert!(value.getattr("partition_id").unwrap().is_none());
+            assert!(value.getattr("skip").unwrap().is_none());
+            let cause = value.getattr("error").unwrap();
+            assert!(cause.is_instance_of::<PyIggyError>());
+            assert!(!cause.is_instance_of::<PendingOperationError>());
+        });
+    }
+
+    #[test]
+    fn given_the_gil_held_by_another_thread_when_mapping_errors_should_not_wait_for_it() {
+        const BUILD_TIME_LIMIT: Duration = Duration::from_secs(5);
+        const OPERATION_ID: u64 = 42;
+        Python::initialize();
+        Python::attach(|py| {
+            let (built, received) = mpsc::channel();
+            // The GIL stays with this thread until the errors arrive, as on a busy event loop.
+            thread::spawn(move || {
+                let errors = (
+                    to_runtime_error(IggyError::FetchProgressUncertain(OPERATION_ID)),
+                    to_runtime_error(IggyError::InvalidOptionValue("count".to_owned())),
+                    to_read_error(RustReadError {
+                        partition_id: None,
+                        error: IggyError::Disconnected,
+                        skip: None,
+                        offset: None,
+                        record: None,
+                    }),
+                );
+                let _ = built.send(errors);
+            });
+
+            let (pending, option, read) = received
+                .recv_timeout(BUILD_TIME_LIMIT)
+                .expect("errors are built without the GIL");
+
+            assert!(pending.is_instance_of::<PendingOperationError>(py));
+            assert_eq!(
+                pending
+                    .value(py)
+                    .getattr("operation_id")
+                    .unwrap()
+                    .extract::<u64>()
+                    .unwrap(),
+                OPERATION_ID
+            );
+            assert!(option.is_instance_of::<OptionError>(py));
+            assert!(read.is_instance_of::<ReadError>(py));
+        });
+    }
+
+    #[test]
     fn business_rejection_keeps_original_error_without_retained_identity() {
         Python::initialize();
         Python::attach(|py| {
diff --git a/foreign/python/src/config.rs b/foreign/python/src/config.rs
index 19a264b..e697a09 100644
--- a/foreign/python/src/config.rs
+++ b/foreign/python/src/config.rs
@@ -618,7 +618,8 @@
     ///     pending_operations: Client-wide mutation limits; defaults to 256 operations and 64 MiB.
     ///     reconnection: Reconnection policy. Defaults to `QuicReconnectionConfig()`.
     ///     heartbeat_interval: Interval of heartbeats sent by the client. Defaults to 5 seconds.
-    ///     response_buffer_size: Size of the response buffer in bytes. Defaults to 10 MB.
+    ///     response_buffer_size: Size of the response buffer in bytes. Defaults to one reply
+    ///         that holds the largest message, about 64 MB.
     ///     max_concurrent_bidi_streams: Maximum number of concurrent bidirectional
     ///         streams. Defaults to 10,000.
     ///     datagram_send_buffer_size: Size of the datagram send buffer in bytes.
@@ -906,12 +907,8 @@
 /// There is no `AutoLogin` and no reconnection policy, and `connect()` does not
 /// dial: it only starts the heartbeat, so `login_user(...)` has to follow it.
 ///
-/// HTTP is single-consumer only. `consumer_group(...)` fails with
-/// `Feature is unavailable`, and so does a `Consumer.Group(...)` poll unless it
-/// names an explicit `partition_id`. With one, the consumer kind is not carried
-/// on the HTTP wire, so the poll is served as an ordinary consumer named after
-/// the group, with no membership, no partition assignment, and no rebalancing
-/// behind it. Pass `Consumer.Single(...)` explicitly.
+/// HTTP is single-consumer only. `consumer_group(...)`, and a `Consumer.Group(...)`
+/// poll or reader, fail with `Feature is unavailable`. Pass `Consumer.Single(...)`.
 #[gen_stub_pyclass]
 #[pyclass(from_py_object)]
 #[derive(Clone)]
@@ -1176,9 +1173,9 @@
     ///         Lifting the limit lets a peer queue an arbitrarily large message
     ///         in memory, so prefer a finite value.
     ///     max_frame_size: Maximum frame size in bytes, or an explicit `None` to
-    ///         lift the limit entirely. Omitting the argument keeps the
-    ///         underlying default of 16 MiB, with the same caveat as
-    ///         `max_message_size`.
+    ///         lift the limit entirely. Omitting the argument keeps the SDK
+    ///         default of 64,156,564 bytes, which holds the largest reply of one
+    ///         record, with the same caveat as `max_message_size`.
     ///     accept_unmasked_frames: Whether to accept unmasked frames. Defaults to
     ///         `False`; clients should typically keep this off for RFC compliance.
     ///
@@ -1197,7 +1194,7 @@
         write_buffer_size=None,
         max_write_buffer_size=None,
         max_message_size=64 << 20,
-        max_frame_size=16 << 20,
+        max_frame_size=64156564,
         accept_unmasked_frames=None,
     ))]
     fn new(
@@ -1610,7 +1607,7 @@
     const DEFAULT_MAX_MESSAGE_SIZE: usize = 64 << 20;
 
     /// Mirrors the literal in `WebSocketFramingConfig::new`'s signature.
-    const DEFAULT_MAX_FRAME_SIZE: usize = 16 << 20;
+    const DEFAULT_MAX_FRAME_SIZE: usize = 64_156_564;
 
     /// The signature defaults have to be literals for the generated stub to stay
     /// valid Python, so nothing but this test stops them drifting from the SDK
diff --git a/foreign/python/src/consumer.rs b/foreign/python/src/consumer.rs
index 0dd77e0..9739af5 100644
--- a/foreign/python/src/consumer.rs
+++ b/foreign/python/src/consumer.rs
@@ -15,34 +15,40 @@
 // specific language governing permissions and limitations
 // under the License.
 
-use std::sync::Arc;
+use std::pin::pin;
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::{Arc, Mutex as StdMutex, PoisonError};
 
 use futures::StreamExt;
+use futures::future::{Either, select};
 use iggy::consumer_ext::{IggyConsumerMessageExt, MessageConsumer};
 use iggy::prelude::{
-    AutoCommit as RustAutoCommit, AutoCommitAfter as RustAutoCommitAfter,
-    AutoCommitWhen as RustAutoCommitWhen, Consumer as RustConsumer,
-    ConsumerGroup as RustConsumerGroup, ConsumerGroupDetails as RustConsumerGroupDetails,
-    ConsumerGroupMember as RustConsumerGroupMember, ConsumerPosition as RustConsumerPosition,
-    Identifier, IggyConsumer as RustIggyConsumer, IggyConsumerState as RustIggyConsumerState,
-    IggyError, NonZeroIggyDuration, ReceivedMessage,
+    Consumer as RustConsumer, ConsumerGroup as RustConsumerGroup,
+    ConsumerGroupDetails as RustConsumerGroupDetails,
+    ConsumerGroupMember as RustConsumerGroupMember, Identifier, IggyConsumer as RustIggyConsumer,
+    IggyConsumerState as RustIggyConsumerState, IggyError, ReceivedMessage,
 };
-use pyo3::exceptions::PyStopAsyncIteration;
-use pyo3::types::PyDelta;
+use pyo3::exceptions::{PyRuntimeError, PyStopAsyncIteration};
+use pyo3::intern;
 
 use pyo3::prelude::*;
-use pyo3_async_runtimes::TaskLocals;
-use pyo3_async_runtimes::tokio::{future_into_py, get_runtime, into_future, scope};
+use pyo3::sync::PyOnceLock;
+use pyo3_async_runtimes::tokio::{
+    future_into_py, get_current_locals, get_runtime, into_future, scope,
+};
+use pyo3_async_runtimes::{TaskLocals, into_future_with_locals};
 use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pyclass_complex_enum, gen_stub_pymethods};
-use pyo3_stub_gen::{PyStubType, TypeInfo};
-use tokio::sync::Mutex;
-use tokio::sync::oneshot::Sender;
-use tokio::task::JoinHandle;
+use tokio::sync::{Mutex, oneshot, watch};
 
-use crate::client::to_runtime_error;
-use crate::duration::{py_delta_to_iggy_duration, reject_zero};
+use crate::client::{to_read_error, to_runtime_error};
+use crate::event_loop::on_event_loop;
 use crate::identifier::PyIdentifier;
-use crate::receive_message::{ConsumerPosition, ReceiveMessage};
+use crate::poll_options::CommitPolicy;
+use crate::receive_message::{Checkpoint, PollingStrategy, ReceiveMessage};
+
+/// Names the consumer whose `consume_messages()` callback runs in the current context, so a call
+/// that would wait for the run that waits for the callback raises instead.
+static CONSUMING_CALLBACK: PyOnceLock<Py<PyAny>> = PyOnceLock::new();
 
 /// A Python class representing the Iggy consumer.
 /// It provides asynchronous functionality through the contained runtime.
@@ -56,6 +62,11 @@
     pub(crate) name: String,
     pub(crate) stream: PyIdentifier,
     pub(crate) topic: PyIdentifier,
+    /// Set while `consume_messages()` holds `inner`, so a `seek()` from its callback fails
+    /// instead of waiting for the run that waits for the callback.
+    pub(crate) consuming: Arc<AtomicBool>,
+    /// Set by `shutdown()` before it waits for `inner`, which a parked iteration holds.
+    pub(crate) stopping: Arc<watch::Sender<bool>>,
 }
 
 #[gen_stub_pymethods]
@@ -69,7 +80,9 @@
     }
 
     /// Get the last offset successfully stored for this partition, or `None` until
-    /// this consumer has stored one. Polling under `AutoCommit.Disabled()` leaves it unset.
+    /// this consumer has stored one. `CommitPolicy.Manual()`, the default, leaves it unset
+    /// until a commit. Under `CommitPolicy.OnRead()` it is the end of the last batch that the
+    /// consumer took from its reader.
     #[gen_stub(override_return_type(type_repr = "builtins.int | None"))]
     fn get_last_stored_offset(&self, partition_id: u32) -> Option<u64> {
         self.state.get_last_stored_offset(partition_id)
@@ -97,15 +110,12 @@
 
     /// Stores the provided offset for the provided partition id or if none is specified
     /// uses the current partition id for the consumer group.
-    /// The offset is stored under the context of the latest message consumed from the
-    /// partition. To commit a message held across a newer poll or an ownership change,
-    /// pass `message.position()` to `store_position()` instead.
-    /// Before a message is consumed from the partition, the write takes the context its
-    /// route reports. After another client deleted and created the partition again, one
-    /// such write can fail with `HistoryUnavailable` (87), or
-    /// `ConsumerGroupPartitionNotOwned` (5009) for a consumer group member. The failed
-    /// route is dropped, and the next call routes again.
-    /// Raises `RuntimeError` if the operation fails.
+    /// It always sends, also an offset below the stored one. The offset is stored under the
+    /// context of the latest message consumed from the partition, or without a context when
+    /// nothing was consumed from it. To commit a message held across a newer read or an
+    /// ownership change, pass `message.checkpoint()` to `commit()` instead.
+    /// Raises `IggyError` if the operation fails, for example `HistoryUnavailable` (87)
+    /// after the partition was deleted and created again.
     #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))]
     fn store_offset<'a>(
         &self,
@@ -122,28 +132,73 @@
         })
     }
 
-    /// Stores the position of a received message, taken from `ReceiveMessage.position()`.
-    /// The write keeps the context that delivered the message, so it fails with
+    /// Stores the offset of a checkpoint that this consumer yielded, taken from
+    /// `ReceiveMessage.checkpoint()`, which covers every earlier message of its partition.
+    /// The write keeps the partition history that delivered the message, so it fails with
     /// `HistoryUnavailable` (87) after the partition was deleted and created again, and
     /// with `ConsumerGroupPartitionNotOwned` (5009) after its consumer group owner
     /// changed, instead of committing the offset to the new partition.
-    /// A position that is not ahead of the last one stored under the same partition
-    /// incarnation and owner is skipped without a request, unless the consumer was
-    /// created with `allow_replay=True`. The same holds for an offset that
-    /// `store_offset()` stored before a message was consumed from the partition,
-    /// but a position at offset 0 is never skipped in that case.
-    /// Raises `RuntimeError` if the operation fails.
+    /// A checkpoint of an older generation of its partition, after `seek()` or a new
+    /// assignment, or at or below the last commit of its generation, stores nothing.
+    /// Raises `IggyError` with `InvalidCommand` (3) under `CommitPolicy.OnRead()` and for a
+    /// checkpoint that this consumer did not yield, or if the operation fails.
     #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))]
-    fn store_position<'a>(
+    fn commit<'a>(&self, py: Python<'a>, checkpoint: &Checkpoint) -> PyResult<Bound<'a, PyAny>> {
+        let state = self.state.clone();
+        let checkpoint = checkpoint.inner;
+        future_into_py(py, async move {
+            state.commit(checkpoint).await.map_err(to_runtime_error)
+        })
+    }
+
+    /// Counts a checkpoint that this consumer yielded under `CommitPolicy.Auto` and stores
+    /// nothing now: the policy stores the latest counted position of its partition. With
+    /// `CommitAfter.Marked` only marks count, so messages processed concurrently count once
+    /// they are done. It works also in a `consume_messages()` callback.
+    /// Raises `IggyError` with `InvalidCommand` (3) without `CommitPolicy.Auto` and for a
+    /// checkpoint that this consumer did not yield.
+    fn mark(&self, checkpoint: &Checkpoint) -> PyResult<()> {
+        self.state.mark(checkpoint.inner).map_err(to_runtime_error)
+    }
+
+    /// When the offsets of this consumer are stored, from its `PollOptions`.
+    #[getter]
+    fn commit_policy(&self) -> CommitPolicy {
+        self.state.commit_policy().into()
+    }
+
+    /// Sets the next read position of a partition and drops the rest of its batch that the
+    /// consumer yields from. With `CommitPolicy.OnRead()`, a seek to an offset above 0 first
+    /// stores the offset before it, so a restart resumes at the new position. Every other seek
+    /// stores nothing. It resumes a stopped partition.
+    /// `start` is a new strategy or a continuation of the same partition, such as
+    /// `ReadError.skip`. After `HistoryUnavailable` (87), the partition reads nothing until
+    /// this call picks a start in its new history.
+    /// Raises `IggyError` with `InvalidCommand` (3) for a partition that the consumer does not
+    /// read and for a continuation of another partition, `PendingOperationError` under
+    /// `CommitPolicy.OnRead()` while a read of the partition has an unknown outcome
+    /// (abandon its operation first), the errors of the offset store with `OnRead`, and
+    /// `RuntimeError` while `consume_messages()` runs, also from its callback: seek after the
+    /// run ends.
+    #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))]
+    fn seek<'a>(
         &self,
         py: Python<'a>,
-        position: &ConsumerPosition,
+        partition_id: u32,
+        start: &PollingStrategy,
     ) -> PyResult<Bound<'a, PyAny>> {
-        let state = self.state.clone();
-        let position = RustConsumerPosition::from(*position);
+        if self.consuming.load(Ordering::SeqCst) {
+            return Err(PyRuntimeError::new_err(
+                "seek() cannot run while consume_messages() runs",
+            ));
+        }
+        let inner = self.inner.clone();
+        let start = start.inner;
         future_into_py(py, async move {
-            state
-                .store_position(position)
+            inner
+                .lock()
+                .await
+                .seek(partition_id, start)
                 .await
                 .map_err(to_runtime_error)
         })
@@ -151,11 +206,8 @@
 
     /// Deletes the offset for the provided partition id or if none is specified
     /// uses the current partition id for the consumer group.
-    /// The write takes the context its route reports. After another client deleted and
-    /// created the partition again, one such write can fail with `HistoryUnavailable` (87),
-    /// or `ConsumerGroupPartitionNotOwned` (5009) for a consumer group member. The failed
-    /// route is dropped, and the next call routes again.
-    /// Raises `RuntimeError` if the operation fails.
+    /// Raises `IggyError` if the operation fails, for example `ConsumerOffsetNotFound`
+    /// when nothing is stored for the partition.
     #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))]
     fn delete_offset<'a>(
         &self,
@@ -171,30 +223,79 @@
         })
     }
 
+    /// Shuts the consumer down. Under `CommitPolicy.Auto` it stores the latest counted position
+    /// of each partition first, then it closes the reader and leaves the consumer group. With
+    /// `CommitPolicy.OnRead()`, it moves the offset back to the last yielded message, which a
+    /// dropped consumer does not do.
+    /// It ends a parked iteration at once, which then raises `StopAsyncIteration`, and it waits
+    /// for a running `consume_messages()` to end. Repeated calls are safe.
+    /// Raises `IggyError` for the first failed store of the `OnRead` close, or else
+    /// `PendingOperationError` for a partition with an unknown outcome: abandon its operation
+    /// before another consumer reads the partition. Raises `RuntimeError` in a
+    /// `consume_messages()` callback of this consumer, because the run waits for the callback:
+    /// set the shutdown event or cancel the run instead.
+    #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))]
+    fn shutdown<'a>(&self, py: Python<'a>) -> PyResult<Bound<'a, PyAny>> {
+        future_into_py(py, self.shutting_down(py)?)
+    }
+
+    #[gen_stub(override_return_type(type_repr = "collections.abc.Awaitable[IggyConsumer]", imports=("collections.abc")))]
+    fn __aenter__<'py>(this: Py<Self>, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
+        future_into_py(py, async move { Ok(this) })
+    }
+
+    /// Shuts the consumer down as `shutdown()` does, also when the block raised.
+    #[gen_stub(override_return_type(type_repr = "collections.abc.Awaitable[builtins.bool]", imports=("collections.abc")))]
+    fn __aexit__<'py>(
+        &self,
+        py: Python<'py>,
+        _exc_type: &Bound<'_, PyAny>,
+        _exc_value: &Bound<'_, PyAny>,
+        _traceback: &Bound<'_, PyAny>,
+    ) -> PyResult<Bound<'py, PyAny>> {
+        let shutting_down = self.shutting_down(py)?;
+        future_into_py(py, async move {
+            shutting_down.await?;
+            Ok(false)
+        })
+    }
+
     /// Asynchronously iterate over `ReceiveMessage`s.
-    /// Returns an async iterator that raises `StopAsyncIteration` when no more messages are available
-    /// or a `RuntimeError` on failure.
-    /// Note: This method does not currently support `AutoCommit.After`.
-    /// For `AutoCommit.IntervalOrAfter(datetime.timedelta, AutoCommitAfter)`,
-    /// only the interval part is applied; the `after` mode is ignored.
-    /// Use `consume_messages()` if you need commit-after-processing semantics.
-    /// After another client deleted and created a partition again, or after the consumer
-    /// group moved a partition to another member, one iteration can raise `RuntimeError`
-    /// with `HistoryUnavailable` (87), or `ConsumerGroupPartitionNotOwned` (5009) for a
-    /// consumer group member. The consumer drops the failed route and the position it
-    /// continued from, and the next iteration routes again.
+    /// Returns an async iterator that raises `StopAsyncIteration` when no more messages are
+    /// available or a `ReadError` on a failed read. The consumer stays usable after it and
+    /// retries by itself.
+    /// Under `CommitPolicy.Auto`, a message counts when it is yielded with
+    /// `CommitAfter.Delivery`, and when the next iteration starts with `CommitAfter.Processing`.
+    /// After another client deleted and created a partition again, one iteration raises
+    /// `ReadError` with `HistoryUnavailable` (87), and the partition reads nothing until
+    /// `seek()` picks a start in its new history.
+    /// A cancelled iteration leaves its message for the next one. Iterating in a
+    /// `consume_messages()` callback of this consumer raises `RuntimeError`.
     #[gen_stub(override_return_type(type_repr="collections.abc.AsyncIterator[ReceiveMessage]", imports=("collections.abc")))]
     fn iter_messages(&self) -> ReceiveMessageIterator {
-        let inner = self.inner.clone();
-        ReceiveMessageIterator { inner }
+        ReceiveMessageIterator {
+            inner: self.inner.clone(),
+            stopping: self.stopping.clone(),
+            owner: self.owner(),
+        }
     }
 
     /// Consumes messages continuously using a callback function and an optional `asyncio.Event` for signaling shutdown.
-    /// Returns an awaitable that completes when shutdown is signaled or a RuntimeError on failure.
-    /// After another client deleted and created a partition again, one poll can fail with
-    /// `HistoryUnavailable` (87), or `ConsumerGroupPartitionNotOwned` (5009) for a consumer
-    /// group member. The error is logged, not raised: the failed route is dropped, and the
-    /// next poll routes again.
+    /// Returns an awaitable that completes when shutdown is signaled. A cancel of the awaitable
+    /// ends the run once the running callback returns, with or without an event.
+    /// Under `CommitPolicy.Auto`, a message counts before its callback with
+    /// `CommitAfter.Delivery`, and after its callback returns with `CommitAfter.Processing`.
+    /// With `CommitAfter.Processing`, a callback that raises ends the run with its exception,
+    /// and its message stays uncounted, so the next run reads it again. Otherwise a callback
+    /// that raises is logged and the run goes on.
+    /// A failed read is logged, and the consumer retries by itself. The run raises `ReadError`
+    /// if the client cannot go on without the caller, or if a read stops its partition until
+    /// `seek()`: `HistoryUnavailable` (87), or an unknown outcome under
+    /// `CommitPolicy.OnRead()`. For a stopped partition, `ReadError.partition_id` names it.
+    /// To resume it, abandon the operation of an unknown outcome, seek the partition and consume
+    /// again.
+    /// Raises `RuntimeError` in a callback of a run of this consumer, because the run waits for
+    /// the callback.
     #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))]
     fn consume_messages<'a>(
         &self,
@@ -204,70 +305,137 @@
         #[gen_stub(override_type(type_repr="asyncio.Event | None", imports=("asyncio")))]
         shutdown_event: Option<Bound<'a, PyAny>>,
     ) -> PyResult<Bound<'a, PyAny>> {
+        refuse_in_callback(py, self.owner(), "consume_messages()")?;
         let inner = self.inner.clone();
-        let callback: Py<PyAny> = callback.unbind();
-        let shutdown_event: Option<Py<PyAny>> = shutdown_event.map(|e| e.unbind());
+        let consuming = self.consuming.clone();
+        let locals = get_current_locals(py)?;
+        let callback_context = locals.context(py).call_method0("copy")?;
+        callback_context.call_method1(
+            "run",
+            (consuming_callback(py)?.getattr("set")?, self.owner()),
+        )?;
+        let failure = Arc::new(StdMutex::new(None));
+        let consumer = PyCallbackConsumer {
+            callback: Arc::new(callback.unbind()),
+            task_locals: Arc::new(Mutex::new(locals.clone().with_context(callback_context))),
+            failure: failure.clone(),
+        };
+        let shutdown_requested = shutdown_event
+            .map(|event| into_future_with_locals(&locals, event.call_method0("wait")?))
+            .transpose()?;
 
-        future_into_py(py, async {
-            let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
-
-            let task_locals = Python::attach(pyo3_async_runtimes::tokio::get_current_locals)?;
-            let handle_consume: JoinHandle<PyResult<Result<(), IggyError>>> =
-                get_runtime().spawn(scope(task_locals, async move {
-                    let task_locals =
-                        Python::attach(pyo3_async_runtimes::tokio::get_current_locals)?;
-                    let consumer = PyCallbackConsumer {
-                        callback: Arc::new(callback),
-                        task_locals: Arc::new(Mutex::new(task_locals)),
-                    };
-                    let mut inner = inner.lock().await;
-                    Ok(inner.consume_messages(&consumer, shutdown_rx).await)
-                }));
-            let handle_shutdown = shutdown_event
-                .map(|shutdown_event| -> PyResult<JoinHandle<PyResult<()>>> {
-                    let task_locals =
-                        Python::attach(pyo3_async_runtimes::tokio::get_current_locals)?;
-                    Ok(get_runtime().spawn(scope(
-                        task_locals,
-                        wait_for_shutdown(shutdown_event, shutdown_tx),
-                    )))
-                })
-                .transpose()?;
-
-            let consume_result = handle_consume.await;
-
-            if let Some(handle_shutdown) = handle_shutdown {
-                // Consuming can also end on its own, and the shutdown task would then park on
-                // `Event.wait()` forever.
-                handle_shutdown.abort();
-                match handle_shutdown.await {
-                    Ok(shutdown_result) => shutdown_result?,
-                    Err(error) if error.is_cancelled() => {}
-                    Err(error) => {
-                        return Err(PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(
-                            error.to_string(),
-                        ));
+        future_into_py(py, async move {
+            // A dropped sender ends the run, so dropping this future, as a cancel does, ends it
+            // too.
+            let (stop, stopped) = oneshot::channel::<()>();
+            let run = get_runtime().spawn(async move {
+                let mut inner = inner.lock().await;
+                let _consuming = Consuming::start(consuming);
+                inner.consume_messages(&consumer, stopped).await
+            });
+            let outcome = match shutdown_requested {
+                None => run.await,
+                Some(shutdown_requested) => match select(run, pin!(shutdown_requested)).await {
+                    Either::Left((outcome, _)) => outcome,
+                    Either::Right((requested, run)) => {
+                        // A closed receiver only means consuming has already stopped, so the
+                        // result of the run is the one worth reporting.
+                        let _ = stop.send(());
+                        let outcome = run.await;
+                        requested?;
+                        outcome
+                    }
+                },
+            };
+            match outcome.map_err(|error| PyRuntimeError::new_err(error.to_string()))? {
+                Ok(()) => Ok(()),
+                // A run that a callback ended raises the exception of that callback.
+                Err(error) if error.error == IggyError::CannotReadMessage => {
+                    match failure
+                        .lock()
+                        .unwrap_or_else(PoisonError::into_inner)
+                        .take()
+                    {
+                        Some(callback_error) => Err(callback_error),
+                        None => Err(to_read_error(error)),
                     }
                 }
+                Err(error) => Err(to_read_error(error)),
             }
-
-            consume_result
-                .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(e.to_string()))??
-                .map_err(to_runtime_error)?;
-            Ok(())
         })
     }
 }
 
-async fn wait_for_shutdown(shutdown_event: Py<PyAny>, shutdown_tx: Sender<()>) -> PyResult<()> {
-    Python::attach(|py| into_future(shutdown_event.bind(py).as_any().call_method0("wait")?))?
-        .await?;
-    // A closed receiver only means consuming has already stopped, so there is nothing left
-    // to signal and the result of the run is the one worth reporting.
-    let _ = shutdown_tx.send(());
+impl IggyConsumer {
+    /// Identifies this consumer to the guard of its callbacks.
+    fn owner(&self) -> usize {
+        Arc::as_ptr(&self.consuming) as usize
+    }
+
+    /// Ends a parked iteration and returns the shutdown, which waits for the consumer.
+    fn shutting_down(
+        &self,
+        py: Python<'_>,
+    ) -> PyResult<impl Future<Output = PyResult<()>> + use<>> {
+        refuse_in_callback(py, self.owner(), "shutdown()")?;
+        self.stopping.send_replace(true);
+        let inner = self.inner.clone();
+        Ok(async move {
+            inner
+                .lock()
+                .await
+                .shutdown()
+                .await
+                .map_err(to_runtime_error)
+        })
+    }
+}
+
+/// The context variable that names the consumer whose callback runs.
+fn consuming_callback(py: Python<'_>) -> PyResult<&Bound<'_, PyAny>> {
+    CONSUMING_CALLBACK
+        .get_or_try_init(py, || {
+            Ok::<_, PyErr>(
+                py.import("contextvars")?
+                    .getattr("ContextVar")?
+                    .call1(("apache_iggy_consuming_callback",))?
+                    .unbind(),
+            )
+        })
+        .map(|variable| variable.bind(py))
+}
+
+/// Refuses `call` in a `consume_messages()` callback of the consumer `owner`, which would wait
+/// for the run that waits for the callback.
+fn refuse_in_callback(py: Python<'_>, owner: usize, call: &str) -> PyResult<()> {
+    let running = consuming_callback(py)?
+        .call_method1("get", (py.None(),))?
+        .extract::<Option<usize>>()?;
+    if running == Some(owner) {
+        return Err(PyRuntimeError::new_err(format!(
+            "{call} cannot run in a consume_messages() callback"
+        )));
+    }
     Ok(())
 }
 
+/// Marks a `consume_messages()` run while it holds the consumer. The drop also clears the mark
+/// of a run that panics.
+struct Consuming(Arc<AtomicBool>);
+
+impl Consuming {
+    fn start(consuming: Arc<AtomicBool>) -> Self {
+        consuming.store(true, Ordering::SeqCst);
+        Self(consuming)
+    }
+}
+
+impl Drop for Consuming {
+    fn drop(&mut self) {
+        self.0.store(false, Ordering::SeqCst);
+    }
+}
+
 /// The consumer polling the messages. It selects both the consumer kind and the
 /// identifier the server keys the stored offset on.
 #[derive(Clone)]
@@ -291,6 +459,44 @@
     }
 }
 
+/// The offset that a consumer stored on a partition.
+#[gen_stub_pyclass]
+#[pyclass(frozen)]
+pub struct ConsumerOffsetInfo {
+    pub(crate) partition_id: u32,
+    pub(crate) current_offset: u64,
+    pub(crate) stored_offset: u64,
+}
+
+#[gen_stub_pymethods]
+#[pymethods]
+impl ConsumerOffsetInfo {
+    /// The partition of the offset.
+    #[getter]
+    fn partition_id(&self) -> u32 {
+        self.partition_id
+    }
+
+    /// The offset of the last message of the partition when the offset was read.
+    #[getter]
+    fn current_offset(&self) -> u64 {
+        self.current_offset
+    }
+
+    /// The stored offset.
+    #[getter]
+    fn stored_offset(&self) -> u64 {
+        self.stored_offset
+    }
+
+    fn __repr__(&self) -> String {
+        format!(
+            "ConsumerOffsetInfo(partition_id={}, current_offset={}, stored_offset={})",
+            self.partition_id, self.current_offset, self.stored_offset
+        )
+    }
+}
+
 #[gen_stub_pyclass]
 #[pyclass]
 pub struct ConsumerGroup {
@@ -424,22 +630,38 @@
 #[pyclass]
 pub struct ReceiveMessageIterator {
     pub(crate) inner: Arc<Mutex<RustIggyConsumer>>,
+    pub(crate) stopping: Arc<watch::Sender<bool>>,
+    pub(crate) owner: usize,
 }
 
 #[pymethods]
 impl ReceiveMessageIterator {
-    pub fn __anext__<'a>(&self, py: Python<'a>) -> PyResult<Bound<'a, PyAny>> {
-        let inner = self.inner.clone();
-        future_into_py(py, async move {
-            let mut inner = inner.lock().await;
-            if let Some(message) = inner.next().await {
-                Ok(message
-                    .map(ReceiveMessage::from)
-                    .map_err(to_runtime_error)?)
-            } else {
-                Err(PyStopAsyncIteration::new_err("No more messages"))
+    // pyo3 builds no slot from an `async fn`, so the slot returns the coroutine of `_next`.
+    pub fn __anext__<'a>(slf: &Bound<'a, Self>) -> PyResult<Bound<'a, PyAny>> {
+        refuse_in_callback(slf.py(), slf.borrow().owner, "Iterating the consumer")?;
+        slf.call_method0(intern!(slf.py(), "_next"))
+    }
+
+    #[pyo3(name = "_next")]
+    async fn next_message(&self) -> PyResult<ReceiveMessage> {
+        let mut stopping = self.stopping.subscribe();
+        on_event_loop(async {
+            let next = async {
+                let mut inner = self.inner.lock().await;
+                inner.next().await
+            };
+            // The iteration holds the consumer while it waits, so a shutdown ends it first.
+            let stopped = stopping.wait_for(|stopping| *stopping);
+            match select(pin!(next), pin!(stopped)).await {
+                Either::Left((Some(message), _)) => {
+                    message.map(ReceiveMessage::from).map_err(to_read_error)
+                }
+                Either::Left((None, _)) | Either::Right(_) => {
+                    Err(PyStopAsyncIteration::new_err("No more messages"))
+                }
             }
         })
+        .await
     }
 
     pub fn __aiter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> {
@@ -450,6 +672,8 @@
 struct PyCallbackConsumer {
     callback: Arc<Py<PyAny>>,
     task_locals: Arc<Mutex<TaskLocals>>,
+    /// The exception of the last callback, so a run that it ends raises it.
+    failure: Arc<StdMutex<Option<PyErr>>>,
 }
 
 impl MessageConsumer for PyCallbackConsumer {
@@ -457,127 +681,28 @@
         let callback = self.callback.clone();
         let task_locals = self.task_locals.lock().await.clone();
         let message = ReceiveMessage::from(received);
-        get_runtime()
+        let called = get_runtime()
             .spawn(scope(task_locals, async move {
                 Python::attach(|py| {
                     let callback = callback.bind(py);
                     let result = callback.as_any().call1((message,))?;
                     into_future(result)
-                })
+                })?
+                .await
             }))
             .await
-            .map_err(|_| IggyError::CannotReadMessage)?
-            .map_err(|_| IggyError::CannotReadMessage)?
-            .await
-            .map_err(|_| IggyError::CannotReadMessage)?;
-        Ok(())
-    }
-}
-
-/// The auto-commit configuration for storing the offset on the server.
-// #[derive(Debug, PartialEq, Copy, Clone)]
-#[gen_stub_pyclass_complex_enum]
-#[pyclass]
-pub enum AutoCommit {
-    /// The auto-commit is disabled and the offset must be stored manually by the consumer.
-    Disabled(),
-    /// The auto-commit is enabled and the offset is stored on the server after a certain interval.
-    Interval(Py<PyDelta>),
-    /// The auto-commit is enabled and the offset is stored on the server after a certain interval or depending on the mode when consuming the messages.
-    IntervalOrWhen(Py<PyDelta>, AutoCommitWhen),
-    /// The auto-commit is enabled and the offset is stored on the server after a certain interval or depending on the mode after consuming the messages.
-    IntervalOrAfter(Py<PyDelta>, AutoCommitAfter),
-    /// The auto-commit is enabled and the offset is stored on the server depending on the mode when consuming the messages.
-    When(AutoCommitWhen),
-    /// The auto-commit is enabled and the offset is stored on the server depending on the mode after consuming the messages.
-    After(AutoCommitAfter),
-}
-
-impl TryFrom<&AutoCommit> for RustAutoCommit {
-    type Error = PyErr;
-
-    fn try_from(val: &AutoCommit) -> PyResult<RustAutoCommit> {
-        Ok(match val {
-            AutoCommit::Disabled() => RustAutoCommit::Disabled,
-            AutoCommit::Interval(delta) => RustAutoCommit::Interval(auto_commit_interval(delta)?),
-            AutoCommit::IntervalOrWhen(delta, when) => {
-                RustAutoCommit::IntervalOrWhen(auto_commit_interval(delta)?, when.into())
+            .map_err(|error| PyRuntimeError::new_err(error.to_string()))
+            .and_then(|called| called);
+        let mut failure = self.failure.lock().unwrap_or_else(PoisonError::into_inner);
+        match called {
+            Ok(_) => {
+                *failure = None;
+                Ok(())
             }
-            AutoCommit::IntervalOrAfter(delta, after) => {
-                RustAutoCommit::IntervalOrAfter(auto_commit_interval(delta)?, after.into())
-            }
-            AutoCommit::When(when) => RustAutoCommit::When(when.into()),
-            AutoCommit::After(after) => RustAutoCommit::After(after.into()),
-        })
-    }
-}
-
-fn auto_commit_interval(delta: &Py<PyDelta>) -> PyResult<NonZeroIggyDuration> {
-    reject_zero(py_delta_to_iggy_duration(delta)?, "interval")
-}
-
-/// The auto-commit mode for storing the offset on the server.
-#[derive(Debug, PartialEq, Copy, Clone)]
-#[gen_stub_pyclass_complex_enum(skip_stub_type)]
-#[pyclass(from_py_object)]
-pub enum AutoCommitWhen {
-    /// The offset is stored on the server when the messages are received.
-    PollingMessages(),
-    /// The offset is stored on the server when all the messages are consumed.
-    ConsumingAllMessages(),
-    /// The offset is stored on the server when consuming each message.
-    ConsumingEachMessage(),
-    /// The offset is stored on the server when consuming every Nth message.
-    ConsumingEveryNthMessage(u32),
-}
-
-impl From<&AutoCommitWhen> for RustAutoCommitWhen {
-    fn from(val: &AutoCommitWhen) -> RustAutoCommitWhen {
-        match val {
-            AutoCommitWhen::PollingMessages() => RustAutoCommitWhen::PollingMessages,
-            AutoCommitWhen::ConsumingAllMessages() => RustAutoCommitWhen::ConsumingAllMessages,
-            AutoCommitWhen::ConsumingEachMessage() => RustAutoCommitWhen::ConsumingEachMessage,
-            AutoCommitWhen::ConsumingEveryNthMessage(n) => {
-                RustAutoCommitWhen::ConsumingEveryNthMessage(n.to_owned())
+            Err(error) => {
+                *failure = Some(error);
+                Err(IggyError::CannotReadMessage)
             }
         }
     }
 }
-
-impl PyStubType for AutoCommitWhen {
-    fn type_output() -> TypeInfo {
-        TypeInfo::unqualified("AutoCommitWhen")
-    }
-}
-
-/// The auto-commit mode for storing the offset on the server **after** receiving the messages.
-#[derive(Debug, PartialEq, Copy, Clone)]
-#[gen_stub_pyclass_complex_enum(skip_stub_type)]
-#[pyclass(from_py_object)]
-#[allow(clippy::enum_variant_names)]
-pub enum AutoCommitAfter {
-    /// The offset is stored on the server after all the messages are consumed.
-    ConsumingAllMessages(),
-    /// The offset is stored on the server after consuming each message.
-    ConsumingEachMessage(),
-    /// The offset is stored on the server after consuming every Nth message.
-    ConsumingEveryNthMessage(u32),
-}
-
-impl From<&AutoCommitAfter> for RustAutoCommitAfter {
-    fn from(val: &AutoCommitAfter) -> RustAutoCommitAfter {
-        match val {
-            AutoCommitAfter::ConsumingAllMessages() => RustAutoCommitAfter::ConsumingAllMessages,
-            AutoCommitAfter::ConsumingEachMessage() => RustAutoCommitAfter::ConsumingEachMessage,
-            AutoCommitAfter::ConsumingEveryNthMessage(n) => {
-                RustAutoCommitAfter::ConsumingEveryNthMessage(n.to_owned())
-            }
-        }
-    }
-}
-
-impl PyStubType for AutoCommitAfter {
-    fn type_output() -> TypeInfo {
-        TypeInfo::unqualified("AutoCommitAfter")
-    }
-}
diff --git a/foreign/python/src/event_loop.rs b/foreign/python/src/event_loop.rs
new file mode 100644
index 0000000..54c2e2c
--- /dev/null
+++ b/foreign/python/src/event_loop.rs
@@ -0,0 +1,48 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::future::poll_fn;
+use std::pin::pin;
+use std::sync::Arc;
+use std::task::{Context, Wake, Waker};
+
+use pyo3_async_runtimes::tokio::get_runtime;
+
+/// Runs an SDK future in the coroutine of an `async fn` pymethod, which pyo3 polls on the thread
+/// of the asyncio event loop. A poll that completes hands its value to Python in the same call,
+/// and a cancel drops the future, so a cancel-safe SDK future loses nothing.
+///
+/// Each poll enters the Tokio runtime, because the SDK spawns tasks and arms timers. The waker of
+/// the coroutine takes the GIL, so it runs on the blocking pool instead of the Tokio worker that
+/// wakes it.
+pub(crate) async fn on_event_loop<F: Future>(future: F) -> F::Output {
+    let mut future = pin!(future);
+    poll_fn(|cx| {
+        let waker = Waker::from(Arc::new(BlockingWaker(cx.waker().clone())));
+        let _runtime = get_runtime().enter();
+        future.as_mut().poll(&mut Context::from_waker(&waker))
+    })
+    .await
+}
+
+struct BlockingWaker(Waker);
+
+impl Wake for BlockingWaker {
+    fn wake(self: Arc<Self>) {
+        get_runtime().spawn_blocking(move || self.0.wake_by_ref());
+    }
+}
diff --git a/foreign/python/src/lib.rs b/foreign/python/src/lib.rs
index 9ed4a4f..a2ecb43 100644
--- a/foreign/python/src/lib.rs
+++ b/foreign/python/src/lib.rs
@@ -20,12 +20,15 @@
 mod consumer;
 mod durability;
 mod duration;
+mod event_loop;
 mod identifier;
 mod metadata_changes;
 mod options;
 mod partitioning;
 mod permissions;
+mod poll_options;
 mod producer;
+mod reader;
 mod receive_message;
 mod resource_options;
 mod send_message;
@@ -35,14 +38,14 @@
 mod user;
 mod user_headers;
 
-use client::{IggyClient, OptionError, PendingOperationError, PyIggyError};
+use client::{IggyClient, OptionError, PendingOperationError, PyIggyError, ReadError};
 use config::{
     AutoLogin, HttpConfig, PendingOperationsConfig, QuicConfig, QuicReconnectionConfig, TcpConfig,
     TcpReconnectionConfig, WebSocketConfig, WebSocketFramingConfig, WebSocketReconnectionConfig,
 };
 use consumer::{
-    AutoCommit, AutoCommitAfter, AutoCommitWhen, Consumer, ConsumerGroup, ConsumerGroupDetails,
-    ConsumerGroupMember, IggyConsumer, ReceiveMessageIterator,
+    Consumer, ConsumerGroup, ConsumerGroupDetails, ConsumerGroupMember, ConsumerOffsetInfo,
+    IggyConsumer, ReceiveMessageIterator,
 };
 use metadata_changes::{
     MetadataChangeCursor, MetadataChangeGroup, MetadataChangeKind, MetadataChangePhase,
@@ -59,12 +62,16 @@
 use options::OptionSpec;
 use partitioning::Partitioning;
 use permissions::{GlobalPermissions, Permissions, StreamPermissions, TopicPermissions};
+use poll_options::{AutoCommit, CommitAfter, CommitEvery, CommitPolicy, PollOptions};
 use producer::{
     BackgroundProducerConfig, BackpressureMode, DirectProducerConfig, IggyProducer,
     ProducerSendError, ProducerSharding,
 };
 use pyo3::prelude::*;
-use receive_message::{ConsumerPosition, PartitionContext, PollingStrategy, ReceiveMessage};
+use reader::PartitionReader;
+use receive_message::{
+    Checkpoint, PartitionContext, PolledMessages, PollingStrategy, ReceiveMessage,
+};
 use resource_options::{
     OptionKey, PartitionResizePolicy, StreamOptions, TopicOptions, UserOptions,
     encryption_key_fingerprint,
@@ -85,11 +92,18 @@
     m.add_class::<SendMessagesConfirmation>()?;
     m.add_class::<ReceiveMessage>()?;
     m.add_class::<PartitionContext>()?;
-    m.add_class::<ConsumerPosition>()?;
+    m.add_class::<Checkpoint>()?;
+    m.add_class::<PolledMessages>()?;
+    m.add_class::<PollOptions>()?;
+    m.add_class::<CommitPolicy>()?;
+    m.add_class::<AutoCommit>()?;
+    m.add_class::<CommitEvery>()?;
+    m.add_class::<CommitAfter>()?;
     m.add_class::<IggyClient>()?;
     m.add_class::<PyIggyError>()?;
     m.add_class::<PendingOperationError>()?;
     m.add_class::<OptionError>()?;
+    m.add_class::<ReadError>()?;
     m.add_class::<IggyProducer>()?;
     m.add_class::<ProducerSendError>()?;
     m.add_class::<DirectProducerConfig>()?;
@@ -125,14 +139,13 @@
     m.add_class::<Partitioning>()?;
     m.add_class::<Partition>()?;
     m.add_class::<Consumer>()?;
+    m.add_class::<ConsumerOffsetInfo>()?;
     m.add_class::<ConsumerGroup>()?;
     m.add_class::<ConsumerGroupDetails>()?;
     m.add_class::<ConsumerGroupMember>()?;
     m.add_class::<PollingStrategy>()?;
+    m.add_class::<PartitionReader>()?;
     m.add_class::<IggyConsumer>()?;
-    m.add_class::<AutoCommit>()?;
-    m.add_class::<AutoCommitAfter>()?;
-    m.add_class::<AutoCommitWhen>()?;
     m.add_class::<ReceiveMessageIterator>()?;
     m.add_class::<IdentityInfo>()?;
     m.add_class::<TokenInfo>()?;
diff --git a/foreign/python/src/poll_options.rs b/foreign/python/src/poll_options.rs
new file mode 100644
index 0000000..ff12a08
--- /dev/null
+++ b/foreign/python/src/poll_options.rs
@@ -0,0 +1,387 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::num::NonZeroU32;
+
+use iggy::prelude::{
+    AutoCommit as RustAutoCommit, CommitAfter as RustCommitAfter, CommitEvery as RustCommitEvery,
+    CommitPolicy as RustCommitPolicy, PollOptions as RustPollOptions,
+};
+use pyo3::exceptions::PyValueError;
+use pyo3::prelude::*;
+use pyo3::types::PyDelta;
+use pyo3_stub_gen::derive::{
+    gen_stub_pyclass, gen_stub_pyclass_complex_enum, gen_stub_pyclass_enum, gen_stub_pymethods,
+};
+
+use crate::duration::{
+    duration_repr, iggy_duration_to_py_delta, py_delta_to_iggy_duration, reject_zero,
+};
+
+/// When the offsets of the messages that a read returns are stored.
+#[derive(Clone, Copy, PartialEq)]
+#[gen_stub_pyclass_complex_enum]
+#[pyclass(eq, from_py_object)]
+pub enum CommitPolicy {
+    /// Nothing is stored. The application commits checkpoints. The default.
+    Manual(),
+    /// The server stores the offset of the last message of each read as part of the read, in one
+    /// round trip. While the primary stays up a message is delivered at most once. A failover
+    /// before the store replicates can deliver the last batch again.
+    OnRead(),
+    /// A `PartitionReader` or an `IggyConsumer` stores the latest counted position, at least
+    /// once with `CommitAfter.Processing`. `IggyClient.poll_messages()` refuses it.
+    Auto(AutoCommit),
+}
+
+/// When `CommitPolicy.Auto` stores. It also stores on close, on shutdown and before a rebalance
+/// takes a partition away. A store only moves forward.
+#[derive(Clone, Copy, PartialEq)]
+#[gen_stub_pyclass]
+#[pyclass(eq, frozen, from_py_object)]
+pub struct AutoCommit {
+    pub(crate) inner: RustAutoCommit,
+}
+
+/// The event on which `AutoCommit` also stores.
+#[derive(Clone, Copy, PartialEq)]
+#[gen_stub_pyclass_complex_enum]
+#[pyclass(eq, from_py_object)]
+pub enum CommitEvery {
+    /// Each counted message, or each counted batch of a `PartitionReader`.
+    Message(),
+    /// This many counted messages of a partition since its last store. Not zero.
+    Messages(u32),
+    /// The last message of each batch.
+    Batch(),
+}
+
+/// When a message counts for `CommitPolicy.Auto`.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+#[gen_stub_pyclass_enum]
+#[pyclass(eq, from_py_object)]
+pub enum CommitAfter {
+    /// When the application asks for the next message or batch, returns from its callback, or
+    /// closes the reader. A callback that raises leaves its message uncounted.
+    Processing,
+    /// When the message or the batch is handed over.
+    Delivery,
+    /// Only when the application calls `mark()` with its checkpoint.
+    Marked,
+}
+
+#[gen_stub_pymethods]
+#[pymethods]
+impl AutoCommit {
+    /// Constructs when `CommitPolicy.Auto` stores. `AutoCommit.default()` stores every 10,000
+    /// messages or every 5 seconds, whichever comes first, after processing.
+    ///
+    /// Args:
+    ///     interval: Store the latest counted position at this interval, when it moved.
+    ///     every: Also store it on this event.
+    ///     after: When a message counts. `None` counts after processing, as
+    ///         `CommitAfter.Processing`.
+    ///
+    /// Raises `ValueError` for a negative or zero `interval`, for `CommitEvery.Messages(0)`, and
+    /// without `interval` and `every`, which would store only on close.
+    #[new]
+    #[pyo3(signature = (*, interval=None, every=None, after=None))]
+    fn new(
+        #[gen_stub(override_type(type_repr = "datetime.timedelta | None", imports=("datetime")))]
+        interval: Option<Py<PyDelta>>,
+        #[gen_stub(override_type(type_repr = "CommitEvery | None"))] every: Option<CommitEvery>,
+        // A `CommitAfter` default makes the stub refer to the class before its definition.
+        #[gen_stub(override_type(type_repr = "CommitAfter | None"))] after: Option<CommitAfter>,
+    ) -> PyResult<Self> {
+        let interval = interval
+            .as_ref()
+            .map(|interval| reject_zero(py_delta_to_iggy_duration(interval)?, "interval"))
+            .transpose()?;
+        let every = every.map(RustCommitEvery::try_from).transpose()?;
+        if interval.is_none() && every.is_none() {
+            return Err(PyValueError::new_err(
+                "an AutoCommit needs an 'interval' or an 'every' event",
+            ));
+        }
+        Ok(Self {
+            inner: RustAutoCommit {
+                interval,
+                every,
+                after: after.map_or_else(RustCommitAfter::default, Into::into),
+            },
+        })
+    }
+
+    /// Every 10,000 messages or every 5 seconds, whichever comes first, after processing.
+    #[staticmethod]
+    fn default() -> Self {
+        Self {
+            inner: RustAutoCommit::default(),
+        }
+    }
+
+    /// The interval of the stores, or `None`.
+    #[gen_stub(override_return_type(type_repr = "datetime.timedelta | None", imports=("datetime")))]
+    #[getter]
+    fn interval<'py>(&self, py: Python<'py>) -> PyResult<Option<Bound<'py, PyDelta>>> {
+        self.inner
+            .interval
+            .map(|interval| iggy_duration_to_py_delta(py, interval.get()))
+            .transpose()
+    }
+
+    /// The event of the stores, or `None`.
+    #[gen_stub(override_return_type(type_repr = "CommitEvery | None"))]
+    #[getter]
+    fn every(&self) -> Option<CommitEvery> {
+        self.inner.every.map(CommitEvery::from)
+    }
+
+    /// When a message counts.
+    #[getter]
+    fn after(&self) -> CommitAfter {
+        self.inner.after.into()
+    }
+
+    fn __repr__(&self) -> String {
+        auto_commit_repr(&self.inner)
+    }
+}
+
+#[gen_stub_pymethods]
+#[pymethods]
+impl CommitPolicy {
+    fn __repr__(&self) -> String {
+        commit_policy_repr((*self).into())
+    }
+}
+
+#[gen_stub_pymethods]
+#[pymethods]
+impl CommitEvery {
+    fn __repr__(&self) -> String {
+        commit_every_repr(match self {
+            Self::Message() => Some(RustCommitEvery::Message),
+            Self::Messages(count) => NonZeroU32::new(*count).map(RustCommitEvery::Messages),
+            Self::Batch() => Some(RustCommitEvery::Batch),
+        })
+    }
+}
+
+impl From<CommitPolicy> for RustCommitPolicy {
+    fn from(policy: CommitPolicy) -> Self {
+        match policy {
+            CommitPolicy::Manual() => Self::Manual,
+            CommitPolicy::OnRead() => Self::OnRead,
+            CommitPolicy::Auto(auto) => Self::Auto(auto.inner),
+        }
+    }
+}
+
+impl From<RustCommitPolicy> for CommitPolicy {
+    fn from(policy: RustCommitPolicy) -> Self {
+        match policy {
+            RustCommitPolicy::Manual => Self::Manual(),
+            RustCommitPolicy::OnRead => Self::OnRead(),
+            RustCommitPolicy::Auto(inner) => Self::Auto(AutoCommit { inner }),
+        }
+    }
+}
+
+impl TryFrom<CommitEvery> for RustCommitEvery {
+    type Error = PyErr;
+
+    fn try_from(every: CommitEvery) -> PyResult<Self> {
+        Ok(match every {
+            CommitEvery::Message() => Self::Message,
+            CommitEvery::Messages(count) => {
+                Self::Messages(NonZeroU32::new(count).ok_or_else(|| {
+                    PyValueError::new_err("'CommitEvery.Messages' needs a count above zero")
+                })?)
+            }
+            CommitEvery::Batch() => Self::Batch,
+        })
+    }
+}
+
+impl From<RustCommitEvery> for CommitEvery {
+    fn from(every: RustCommitEvery) -> Self {
+        match every {
+            RustCommitEvery::Message => Self::Message(),
+            RustCommitEvery::Messages(count) => Self::Messages(count.get()),
+            RustCommitEvery::Batch => Self::Batch(),
+        }
+    }
+}
+
+impl From<CommitAfter> for RustCommitAfter {
+    fn from(after: CommitAfter) -> Self {
+        match after {
+            CommitAfter::Processing => Self::Processing,
+            CommitAfter::Delivery => Self::Delivery,
+            CommitAfter::Marked => Self::Marked,
+        }
+    }
+}
+
+impl From<RustCommitAfter> for CommitAfter {
+    fn from(after: RustCommitAfter) -> Self {
+        match after {
+            RustCommitAfter::Processing => Self::Processing,
+            RustCommitAfter::Delivery => Self::Delivery,
+            RustCommitAfter::Marked => Self::Marked,
+        }
+    }
+}
+
+/// The policy as Python writes it, so a `__repr__` built from it can be pasted back.
+fn commit_policy_repr(policy: RustCommitPolicy) -> String {
+    match policy {
+        RustCommitPolicy::Manual => "CommitPolicy.Manual()".to_owned(),
+        RustCommitPolicy::OnRead => "CommitPolicy.OnRead()".to_owned(),
+        RustCommitPolicy::Auto(auto) => format!("CommitPolicy.Auto({})", auto_commit_repr(&auto)),
+    }
+}
+
+fn auto_commit_repr(auto: &RustAutoCommit) -> String {
+    let interval = auto.interval.map_or_else(
+        || "None".to_owned(),
+        |interval| duration_repr(interval.get()),
+    );
+    format!(
+        "AutoCommit(interval={interval}, every={}, after=CommitAfter.{:?})",
+        commit_every_repr(auto.every),
+        CommitAfter::from(auto.after)
+    )
+}
+
+fn commit_every_repr(every: Option<RustCommitEvery>) -> String {
+    match every {
+        None => "None".to_owned(),
+        Some(RustCommitEvery::Message) => "CommitEvery.Message()".to_owned(),
+        Some(RustCommitEvery::Messages(count)) => format!("CommitEvery.Messages({count})"),
+        Some(RustCommitEvery::Batch) => "CommitEvery.Batch()".to_owned(),
+    }
+}
+
+/// The limits of each read and when its offsets are stored. A read is a poll of one partition,
+/// or a fetch that a partition reader sends for several partitions of one primary. A fetch
+/// applies `count`, `min_count` and `max_bytes` to all of its partitions together. A reader that
+/// commits nothing runs several fetches at once, each within these limits, for partitions with
+/// more messages waiting.
+#[derive(Clone, Copy)]
+#[gen_stub_pyclass]
+#[pyclass(frozen, from_py_object)]
+pub struct PollOptions {
+    pub(crate) inner: RustPollOptions,
+}
+
+#[gen_stub_pymethods]
+#[pymethods]
+impl PollOptions {
+    /// Constructs the options of a read.
+    ///
+    /// Args:
+    ///     count: The most messages in one read, over all partitions of a fetch.
+    ///     max_wait: How long the server waits for `min_count` messages, at most 600 seconds.
+    ///         `None` answers at once. A reader or a consumer with no wait on an idle topic
+    ///         polls in a busy loop.
+    ///     min_count: The messages to wait for, from 1 to `count`, over all partitions of a
+    ///         fetch.
+    ///     max_bytes: The most bytes in one read, over all partitions of a fetch. A message
+    ///         larger than that comes alone.
+    ///     commit: When offsets are stored. `None` stores none, as `CommitPolicy.Manual()`.
+    ///
+    /// The limits are checked when the options are used, and a fault raises `IggyError`.
+    #[new]
+    #[pyo3(signature = (
+        *,
+        count=RustPollOptions::default().count,
+        max_wait=None,
+        min_count=RustPollOptions::default().min_count,
+        max_bytes=RustPollOptions::default().max_bytes,
+        commit=None,
+    ))]
+    fn new(
+        count: u32,
+        #[gen_stub(override_type(type_repr = "datetime.timedelta | None", imports=("datetime")))]
+        max_wait: Option<Py<PyDelta>>,
+        min_count: u32,
+        max_bytes: u32,
+        // A `CommitPolicy` default makes the stub refer to the class before its definition,
+        // which the stub linters refuse.
+        #[gen_stub(override_type(type_repr = "CommitPolicy | None"))] commit: Option<CommitPolicy>,
+    ) -> PyResult<Self> {
+        let defaults = RustPollOptions::default();
+        let max_wait = max_wait
+            .as_ref()
+            .map(py_delta_to_iggy_duration)
+            .transpose()?
+            .unwrap_or(defaults.max_wait);
+        Ok(Self {
+            inner: RustPollOptions {
+                count,
+                max_wait,
+                min_count,
+                max_bytes,
+                commit: commit.map_or(defaults.commit, Into::into),
+            },
+        })
+    }
+
+    /// The most messages in one read, over all partitions of a fetch.
+    #[getter]
+    fn count(&self) -> u32 {
+        self.inner.count
+    }
+
+    /// How long the server waits for `min_count` messages. Zero answers at once.
+    #[gen_stub(override_return_type(type_repr = "datetime.timedelta", imports=("datetime")))]
+    #[getter]
+    fn max_wait<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyDelta>> {
+        iggy_duration_to_py_delta(py, self.inner.max_wait)
+    }
+
+    /// The messages to wait for.
+    #[getter]
+    fn min_count(&self) -> u32 {
+        self.inner.min_count
+    }
+
+    /// The most bytes in one read, over all partitions of a fetch.
+    #[getter]
+    fn max_bytes(&self) -> u32 {
+        self.inner.max_bytes
+    }
+
+    /// When the offsets of a read are stored.
+    #[getter]
+    fn commit(&self) -> CommitPolicy {
+        self.inner.commit.into()
+    }
+
+    fn __repr__(&self) -> String {
+        format!(
+            "PollOptions(count={}, max_wait={}, min_count={}, max_bytes={}, commit={})",
+            self.inner.count,
+            duration_repr(self.inner.max_wait),
+            self.inner.min_count,
+            self.inner.max_bytes,
+            commit_policy_repr(self.inner.commit)
+        )
+    }
+}
diff --git a/foreign/python/src/reader.rs b/foreign/python/src/reader.rs
new file mode 100644
index 0000000..62e3944
--- /dev/null
+++ b/foreign/python/src/reader.rs
@@ -0,0 +1,257 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::sync::Arc;
+
+use iggy::prelude::PartitionReader as RustPartitionReader;
+use pyo3::exceptions::{PyRuntimeError, PyStopAsyncIteration};
+use pyo3::intern;
+use pyo3::prelude::*;
+use pyo3_async_runtimes::tokio::future_into_py;
+use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods};
+use tokio::sync::Mutex;
+
+use crate::client::{to_read_error, to_runtime_error};
+use crate::event_loop::on_event_loop;
+use crate::receive_message::{Checkpoint, PolledBatch, PollingStrategy};
+
+/// Reads the partitions of a topic in batches, with routing, prefetch and the position of each
+/// partition inside. Build one with `IggyClient.reader()`.
+///
+/// - A `Consumer.Single` reads the partitions given to `reader()`. A `Consumer.Group` reads the
+///   assignment of its member: the reader joins the group, joins it again after a session loss
+///   and leaves it in `close()`.
+/// - `next()` returns the next batch of one partition, in offset order within one generation of
+///   the partition: from its first read, from each `seek()` and from each new assignment to
+///   this member.
+/// - The `commit` of its `PollOptions` says when offsets are stored. With `CommitPolicy.Manual()`
+///   and `CommitPolicy.Auto`, `commit()` stores the offset of a checkpoint. `CommitPolicy.Auto`
+///   also stores the latest counted position by itself, and `mark()` counts one. With
+///   `CommitPolicy.OnRead()`, the server stores the offset of each reply, the reader reads only
+///   while `next()` waits and then fills its budget (one fetch per primary that may wait for
+///   messages, the partitions with messages left behind spread over the other slots), a `seek()`
+///   to an offset stores the offset before it, and `close()` moves the offset back to the last
+///   delivered message.
+/// - The reader stays usable after a `ReadError` and retries by itself. `HistoryUnavailable`
+///   (87) stops its partition until `seek()`. With `OnRead`, an unknown outcome stops its
+///   partition until `IggyClient.abandon_operation()` and `seek()`.
+/// - The calls on one reader run one at a time, so a call waits for a pending `next()`.
+///   `next()` is cancel safe: cancel it before `close()`.
+/// - Dropping the reader stops its tasks without waiting, and it moves no offset back.
+/// - `async for` reads its batches until `close()`, and `async with` closes it on exit.
+#[gen_stub_pyclass]
+#[pyclass]
+pub struct PartitionReader {
+    inner: Arc<Mutex<Option<RustPartitionReader>>>,
+}
+
+impl PartitionReader {
+    pub(crate) fn new(reader: RustPartitionReader) -> Self {
+        Self {
+            inner: Arc::new(Mutex::new(Some(reader))),
+        }
+    }
+}
+
+#[gen_stub_pymethods]
+#[pymethods]
+impl PartitionReader {
+    /// Returns the next batch of one partition. It is never empty, and it has no time limit.
+    /// A cancelled call leaves its batch in the reader for the next call.
+    ///
+    /// Raises `ReadError` for a failed read, and `RuntimeError` after `close()`.
+    #[gen_stub(override_return_type(type_repr = "PolledMessages"))]
+    async fn next(&self) -> PyResult<PolledBatch> {
+        on_event_loop(async {
+            let mut reader = self.inner.lock().await;
+            let batch = reader
+                .as_mut()
+                .ok_or_else(closed)?
+                .next()
+                .await
+                .map_err(to_read_error)?;
+            Ok(PolledBatch(batch))
+        })
+        .await
+    }
+
+    /// Stores the offset of a checkpoint that this reader delivered, which covers every earlier
+    /// message of its partition. A checkpoint of an older generation of its partition, or at or
+    /// below the last commit of its generation, stores nothing.
+    ///
+    /// Raises `IggyError` with `InvalidCommand` (3) under `CommitPolicy.OnRead()` and for a
+    /// checkpoint that this reader did not deliver, the errors of the offset store, and
+    /// `RuntimeError` after `close()`.
+    #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))]
+    fn commit<'a>(&self, py: Python<'a>, checkpoint: &Checkpoint) -> PyResult<Bound<'a, PyAny>> {
+        let checkpoint = checkpoint.inner;
+        let inner = self.inner.clone();
+        future_into_py(py, async move {
+            let reader = inner.lock().await;
+            reader
+                .as_ref()
+                .ok_or_else(closed)?
+                .commit(checkpoint)
+                .await
+                .map_err(to_runtime_error)
+        })
+    }
+
+    /// Counts a checkpoint that this reader delivered under `CommitPolicy.Auto` and stores
+    /// nothing now: the policy stores the latest counted position of its partition. With
+    /// `CommitAfter.Marked` only marks count, so batches processed concurrently count once they
+    /// are done. Like every call of the reader, it waits for a pending `next()`.
+    ///
+    /// Raises `IggyError` with `InvalidCommand` (3) without `CommitPolicy.Auto` and for a
+    /// checkpoint that this reader did not deliver, and `RuntimeError` after `close()`.
+    #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))]
+    fn mark<'a>(&self, py: Python<'a>, checkpoint: &Checkpoint) -> PyResult<Bound<'a, PyAny>> {
+        let checkpoint = checkpoint.inner;
+        let inner = self.inner.clone();
+        future_into_py(py, async move {
+            let reader = inner.lock().await;
+            reader
+                .as_ref()
+                .ok_or_else(closed)?
+                .mark(checkpoint)
+                .map_err(to_runtime_error)
+        })
+    }
+
+    /// Sets the next read position of a partition and starts a new generation of it. It drops
+    /// the queued batches of the partition, ignores its late replies and resumes a stopped
+    /// partition. `start` is a new strategy or a continuation of the same partition, such as
+    /// `ReadError.skip`.
+    ///
+    /// With `CommitPolicy.OnRead()`, a seek to an offset above 0 first stores the offset before
+    /// it, under the history of a continuation or else of the last delivered message, so a
+    /// restart resumes at the new position. A failed store fails the seek and nothing moves.
+    /// Every other seek stores nothing. The new position waits for the end of the committing
+    /// read in flight, whose reply the new generation drops.
+    ///
+    /// Raises `IggyError` with `InvalidCommand` (3) for a partition that the reader does not read
+    /// and for a continuation of another partition, `PendingOperationError` with `OnRead` while
+    /// a read of the partition has an unknown outcome (abandon its operation first), the errors
+    /// of the offset store with `OnRead`, and `RuntimeError` after `close()`.
+    #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))]
+    fn seek<'a>(
+        &self,
+        py: Python<'a>,
+        partition_id: u32,
+        start: &PollingStrategy,
+    ) -> PyResult<Bound<'a, PyAny>> {
+        let start = start.inner;
+        let inner = self.inner.clone();
+        future_into_py(py, async move {
+            let mut reader = inner.lock().await;
+            reader
+                .as_mut()
+                .ok_or_else(closed)?
+                .seek(partition_id, start)
+                .await
+                .map_err(to_runtime_error)
+        })
+    }
+
+    /// Returns the partitions that the reader reads: those given to `reader()`, or the current
+    /// assignment of a consumer group member.
+    ///
+    /// Raises `RuntimeError` after `close()`.
+    #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[list[builtins.int]]", imports=("collections.abc")))]
+    fn partitions<'a>(&self, py: Python<'a>) -> PyResult<Bound<'a, PyAny>> {
+        let inner = self.inner.clone();
+        future_into_py(py, async move {
+            let reader = inner.lock().await;
+            Ok(reader.as_ref().ok_or_else(closed)?.partitions())
+        })
+    }
+
+    /// Stops the reader. It waits at most until the deadlines of the reads in flight. With
+    /// `CommitPolicy.Manual()` it commits nothing. With `CommitPolicy.Auto`, it counts the last
+    /// batch under `CommitAfter.Processing` and stores the latest counted position of each
+    /// partition. A group reader then leaves its group. Repeated calls are safe.
+    ///
+    /// With `CommitPolicy.OnRead()`, it moves the offset of each partition with undelivered
+    /// batches back to its last delivered message. A partition that delivered nothing and
+    /// started at `Next` gets the offset before its first undelivered message, or no offset at
+    /// offset 0.
+    ///
+    /// Raises `IggyError` for the first failed store of the `OnRead` or the `Auto` close, or else
+    /// `PendingOperationError` for a partition with an unknown outcome: abandon its operation
+    /// before a new reader reads the partition.
+    #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))]
+    fn close<'a>(&self, py: Python<'a>) -> PyResult<Bound<'a, PyAny>> {
+        future_into_py(py, closing(self.inner.clone()))
+    }
+
+    fn __aiter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> {
+        slf
+    }
+
+    // pyo3 builds no slot from an `async fn`, so the slot returns the coroutine of `_anext`.
+    #[gen_stub(override_return_type(type_repr = "collections.abc.Awaitable[PolledMessages]", imports=("collections.abc")))]
+    fn __anext__<'a>(slf: &Bound<'a, Self>) -> PyResult<Bound<'a, PyAny>> {
+        slf.call_method0(intern!(slf.py(), "_anext"))
+    }
+
+    /// `next()` for `async for`, which ends after `close()`.
+    #[gen_stub(override_return_type(type_repr = "PolledMessages"))]
+    #[pyo3(name = "_anext")]
+    async fn next_in_iteration(&self) -> PyResult<PolledBatch> {
+        on_event_loop(async {
+            let mut reader = self.inner.lock().await;
+            let Some(reader) = reader.as_mut() else {
+                return Err(PyStopAsyncIteration::new_err("reader has been closed"));
+            };
+            Ok(PolledBatch(reader.next().await.map_err(to_read_error)?))
+        })
+        .await
+    }
+
+    #[gen_stub(override_return_type(type_repr = "collections.abc.Awaitable[PartitionReader]", imports=("collections.abc")))]
+    fn __aenter__<'py>(this: Py<Self>, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
+        future_into_py(py, async move { Ok(this) })
+    }
+
+    /// Closes the reader as `close()` does, also when the block raised.
+    #[gen_stub(override_return_type(type_repr = "collections.abc.Awaitable[builtins.bool]", imports=("collections.abc")))]
+    fn __aexit__<'py>(
+        &self,
+        py: Python<'py>,
+        _exc_type: &Bound<'_, PyAny>,
+        _exc_value: &Bound<'_, PyAny>,
+        _traceback: &Bound<'_, PyAny>,
+    ) -> PyResult<Bound<'py, PyAny>> {
+        let closing = closing(self.inner.clone());
+        future_into_py(py, async move {
+            closing.await?;
+            Ok(false)
+        })
+    }
+}
+
+async fn closing(inner: Arc<Mutex<Option<RustPartitionReader>>>) -> PyResult<()> {
+    let reader = inner.lock().await.take();
+    match reader {
+        Some(reader) => reader.close().await.map_err(to_runtime_error),
+        None => Ok(()),
+    }
+}
+
+fn closed() -> PyErr {
+    PyRuntimeError::new_err("reader has been closed")
+}
diff --git a/foreign/python/src/receive_message.rs b/foreign/python/src/receive_message.rs
index 2493ec0..da5865f 100644
--- a/foreign/python/src/receive_message.rs
+++ b/foreign/python/src/receive_message.rs
@@ -15,23 +15,24 @@
 // specific language governing permissions and limitations
 // under the License.
 
+use std::hash::{DefaultHasher, Hash, Hasher};
+
 use iggy::prelude::{
-    ConsumerPosition as RustConsumerPosition, IggyMessage as RustReceiveMessage,
-    PartitionContext as RustPartitionContext, PollingStrategy as RustPollingStrategy,
-    ReceivedMessage,
+    Checkpoint as RustCheckpoint, IggyMessage as RustReceiveMessage,
+    PartitionContext as RustPartitionContext, PolledMessages as RustPolledMessages, PollingKind,
+    PollingStrategy as RustPollingStrategy, ReceivedMessage,
 };
 use pyo3::exceptions::PyValueError;
 use pyo3::prelude::*;
-use pyo3::types::PyBytes;
-use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pyclass_complex_enum, gen_stub_pymethods};
+use pyo3::types::{PyBytes, PyTuple};
+use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods};
 
 use crate::user_headers::{UserHeaders, rust_user_headers_to_py};
 
-/// The partition incarnation and consumer group owner a poll was served under.
+/// The partition incarnation and consumer group owner a request was served under.
 ///
-/// Keep it with an offset to continue or commit under the same partition later: the
-/// server refuses a context whose partition was deleted and created again, or whose
-/// consumer group owner changed, instead of applying the offset to the new partition.
+/// The server refuses a context whose partition was deleted and created again, or whose
+/// consumer group owner changed, instead of applying an offset to the new partition.
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
 #[gen_stub_pyclass]
 #[pyclass(eq, frozen, hash, skip_from_py_object)]
@@ -59,16 +60,6 @@
     }
 }
 
-impl From<PartitionContext> for RustPartitionContext {
-    fn from(context: PartitionContext) -> Self {
-        Self {
-            incarnation: context.incarnation,
-            owner_generation: context.owner_generation,
-            metadata_op: context.metadata_op,
-        }
-    }
-}
-
 #[gen_stub_pymethods]
 #[pymethods]
 impl PartitionContext {
@@ -89,77 +80,67 @@
     }
 }
 
-/// The position of a received message: its partition, its offset and the context of
-/// the poll that delivered it. Commit it with `IggyConsumer.store_position()`.
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+/// The position after one received message: its partition, its offset and the partition
+/// history that delivered it. A commit stores its offset, which marks every earlier message
+/// of the partition as consumed.
+///
+/// Get it from `ReceiveMessage.checkpoint()` or `PolledMessages.checkpoint()`, and commit it
+/// with `PartitionReader.commit()` or `IggyConsumer.commit()`. A commit after the partition was
+/// deleted and created again, or after its consumer group owner changed, is refused instead of
+/// landing on the new partition.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
 #[gen_stub_pyclass]
-#[pyclass(eq, frozen, hash, skip_from_py_object)]
-pub struct ConsumerPosition {
-    /// The partition the message was read from.
-    #[pyo3(get)]
-    pub partition_id: u32,
-    /// The offset of the message. Committing it marks the message as consumed.
-    #[pyo3(get)]
-    pub offset: u64,
-    pub context: PartitionContext,
-}
-
-impl From<ConsumerPosition> for RustConsumerPosition {
-    fn from(position: ConsumerPosition) -> Self {
-        Self {
-            partition_id: position.partition_id,
-            offset: position.offset,
-            context: position.context.into(),
-        }
-    }
+#[pyclass(eq, frozen, skip_from_py_object)]
+pub struct Checkpoint {
+    pub(crate) inner: RustCheckpoint,
 }
 
 #[gen_stub_pymethods]
 #[pymethods]
-impl ConsumerPosition {
-    #[new]
-    fn new(partition_id: u32, offset: u64, context: &PartitionContext) -> Self {
-        Self {
-            partition_id,
-            offset,
-            context: *context,
-        }
+impl Checkpoint {
+    /// The partition the message was read from.
+    #[getter]
+    fn partition_id(&self) -> u32 {
+        self.inner.partition_id()
     }
 
-    // A getter, not `#[pyo3(get)]`: pyo3-stub-gen writes an undefined type name for a
-    // pyclass-typed field.
-    /// The partition incarnation and owner the poll was served under.
+    /// The offset of the message. Committing it marks the message as consumed.
     #[getter]
-    fn context(&self) -> PartitionContext {
-        self.context
+    fn offset(&self) -> u64 {
+        self.inner.offset()
     }
 
     fn __repr__(&self) -> String {
         format!(
-            "ConsumerPosition(partition_id={}, offset={}, context={})",
-            self.partition_id,
-            self.offset,
-            self.context.__repr__()
+            "Checkpoint(partition_id={}, offset={})",
+            self.inner.partition_id(),
+            self.inner.offset()
         )
     }
+
+    // Equal checkpoints share their partition and offset, so a hash of the two agrees with
+    // equality.
+    fn __hash__(&self) -> u64 {
+        let mut hasher = DefaultHasher::new();
+        (self.inner.partition_id(), self.inner.offset()).hash(&mut hasher);
+        hasher.finish()
+    }
 }
 
 /// A Python class representing a received message.
 /// It provides access to the message payload and offset.
-#[pyclass]
+#[pyclass(frozen)]
 #[gen_stub_pyclass]
 pub struct ReceiveMessage {
     pub(crate) inner: RustReceiveMessage,
-    pub(crate) partition_id: u32,
-    pub(crate) context: PartitionContext,
+    pub(crate) checkpoint: RustCheckpoint,
 }
 
 impl From<ReceivedMessage> for ReceiveMessage {
     fn from(received: ReceivedMessage) -> Self {
         Self {
+            checkpoint: received.checkpoint(),
             inner: received.message,
-            partition_id: received.partition_id,
-            context: received.context.into(),
         }
     }
 }
@@ -211,24 +192,15 @@
 
     /// Retrieves the partition this message belongs to.
     pub fn partition_id(&self) -> u32 {
-        self.partition_id
+        self.checkpoint.partition_id()
     }
 
-    /// Retrieves the partition incarnation and owner the poll that delivered this message
-    /// was served under.
-    pub fn context(&self) -> PartitionContext {
-        self.context
-    }
-
-    /// The position to commit for this message with `IggyConsumer.store_position()`.
-    /// It keeps the context that delivered the message, so a commit after the partition
-    /// was deleted and created again, or after its consumer group owner changed, is
-    /// refused instead of landing on the new partition.
-    pub fn position(&self) -> ConsumerPosition {
-        ConsumerPosition {
-            partition_id: self.partition_id,
-            offset: self.inner.header.offset,
-            context: self.context,
+    /// The position after this message, to commit it and every earlier message of its
+    /// partition with `PartitionReader.commit()` or `IggyConsumer.commit()`. The reader or
+    /// consumer that delivered the message must commit it.
+    pub fn checkpoint(&self) -> Checkpoint {
+        Checkpoint {
+            inner: self.checkpoint,
         }
     }
 
@@ -250,27 +222,171 @@
     }
 }
 
-#[derive(Clone, Copy)]
-#[gen_stub_pyclass_complex_enum]
-#[pyclass(from_py_object)]
-pub enum PollingStrategy {
-    Offset { value: u64 },
-    Timestamp { value: u64 },
-    First {},
-    Last {},
-    Next {},
+/// One batch of messages of one partition, from `IggyClient.poll_messages()` or
+/// `PartitionReader.next()`.
+#[gen_stub_pyclass]
+#[pyclass(frozen)]
+pub struct PolledMessages {
+    partition_id: u32,
+    current_offset: u64,
+    messages: Py<PyTuple>,
+    checkpoints: Vec<RustCheckpoint>,
+    continuation: RustPollingStrategy,
 }
 
-impl From<&PollingStrategy> for RustPollingStrategy {
-    fn from(value: &PollingStrategy) -> Self {
-        match value {
-            PollingStrategy::Offset { value } => RustPollingStrategy::offset(value.to_owned()),
-            PollingStrategy::Timestamp { value } => {
-                RustPollingStrategy::timestamp(value.to_owned().into())
-            }
-            PollingStrategy::First {} => RustPollingStrategy::first(),
-            PollingStrategy::Last {} => RustPollingStrategy::last(),
-            PollingStrategy::Next {} => RustPollingStrategy::next(),
+impl PolledMessages {
+    fn new(py: Python<'_>, polled: RustPolledMessages) -> PyResult<Self> {
+        let continuation = polled.continuation();
+        let checkpoints: Vec<RustCheckpoint> = (0..polled.messages.len())
+            .map_while(|index| polled.checkpoint_at(index))
+            .collect();
+        let messages = polled
+            .messages
+            .into_iter()
+            .zip(checkpoints.iter().copied())
+            .map(|(inner, checkpoint)| ReceiveMessage { inner, checkpoint });
+        let messages = PyTuple::new(py, messages)?.unbind();
+        Ok(Self {
+            partition_id: polled.partition_id,
+            current_offset: polled.current_offset,
+            messages,
+            checkpoints,
+            continuation,
+        })
+    }
+}
+
+/// A batch that becomes a `PolledMessages` where pyo3 hands the result to Python, so the
+/// conversion holds the GIL on no Tokio worker.
+pub(crate) struct PolledBatch(pub(crate) RustPolledMessages);
+
+impl<'py> IntoPyObject<'py> for PolledBatch {
+    type Target = PolledMessages;
+    type Output = Bound<'py, PolledMessages>;
+    type Error = PyErr;
+
+    fn into_pyobject(self, py: Python<'py>) -> PyResult<Self::Output> {
+        Bound::new(py, PolledMessages::new(py, self.0)?)
+    }
+}
+
+#[gen_stub_pymethods]
+#[pymethods]
+impl PolledMessages {
+    /// The partition the batch was read from.
+    #[getter]
+    fn partition_id(&self) -> u32 {
+        self.partition_id
+    }
+
+    /// The offset of the newest message in the partition when the batch was read.
+    #[getter]
+    fn current_offset(&self) -> u64 {
+        self.current_offset
+    }
+
+    /// The messages of the batch, in offset order. Each read returns the same tuple.
+    #[gen_stub(override_return_type(type_repr = "builtins.tuple[ReceiveMessage, ...]"))]
+    #[getter]
+    fn messages(&self, py: Python<'_>) -> Py<PyTuple> {
+        self.messages.clone_ref(py)
+    }
+
+    /// The position after the last message, to commit the whole batch. `None` for an empty
+    /// batch.
+    #[gen_stub(override_return_type(type_repr = "Checkpoint | None"))]
+    fn checkpoint(&self) -> Option<Checkpoint> {
+        self.checkpoints.last().map(|&inner| Checkpoint { inner })
+    }
+
+    /// The position after `messages[index]`, to commit a processed part of the batch. `None`
+    /// for an index out of range.
+    #[gen_stub(override_return_type(type_repr = "Checkpoint | None"))]
+    fn checkpoint_at(&self, index: usize) -> Option<Checkpoint> {
+        self.checkpoints
+            .get(index)
+            .map(|&inner| Checkpoint { inner })
+    }
+
+    /// The position after the batch, for the next read of its partition.
+    ///
+    /// After the last message, it reads from the next offset. An empty batch keeps the start of
+    /// its request: `Offset` and `Timestamp` keep their value, `First` reads from offset 0,
+    /// `Last` stays `Last`, and `Next` reads after the stored offset again.
+    ///
+    /// The continuation belongs to the partition of the batch, so a read of another partition
+    /// refuses it with `InvalidCommand` (3). It keeps the partition history of the batch, so a
+    /// read after the partition was deleted and created again, or after its consumer group
+    /// owner changed, raises `HistoryUnavailable` (87) instead of reading the new partition.
+    fn continuation(&self) -> PollingStrategy {
+        PollingStrategy {
+            inner: self.continuation,
+        }
+    }
+}
+
+/// Where a read starts. A strategy from a constructor reads from its own start in any
+/// partition. A continuation from `PolledMessages.continuation()` belongs to the partition of
+/// its batch and keeps its partition history.
+#[derive(Clone, Copy)]
+#[gen_stub_pyclass]
+#[pyclass(frozen, from_py_object)]
+pub struct PollingStrategy {
+    pub(crate) inner: RustPollingStrategy,
+}
+
+impl From<RustPollingStrategy> for PollingStrategy {
+    fn from(inner: RustPollingStrategy) -> Self {
+        Self { inner }
+    }
+}
+
+#[gen_stub_pymethods]
+#[pymethods]
+impl PollingStrategy {
+    /// Reads from the offset `value`.
+    #[staticmethod]
+    #[pyo3(name = "Offset")]
+    fn offset(value: u64) -> Self {
+        RustPollingStrategy::offset(value).into()
+    }
+
+    /// Reads from the first message at or after the timestamp `value`, in microseconds.
+    #[staticmethod]
+    #[pyo3(name = "Timestamp")]
+    fn timestamp(value: u64) -> Self {
+        RustPollingStrategy::timestamp(value.into()).into()
+    }
+
+    /// Reads from the first message of the partition.
+    #[staticmethod]
+    #[pyo3(name = "First")]
+    fn first() -> Self {
+        RustPollingStrategy::first().into()
+    }
+
+    /// Reads the last messages of the partition.
+    #[staticmethod]
+    #[pyo3(name = "Last")]
+    fn last() -> Self {
+        RustPollingStrategy::last().into()
+    }
+
+    /// Reads after the offset stored for the consumer, or from offset 0 without one.
+    #[staticmethod]
+    #[pyo3(name = "Next")]
+    fn next() -> Self {
+        RustPollingStrategy::next().into()
+    }
+
+    fn __repr__(&self) -> String {
+        let value = self.inner.value;
+        match self.inner.kind {
+            PollingKind::Offset => format!("PollingStrategy.Offset(value={value})"),
+            PollingKind::Timestamp => format!("PollingStrategy.Timestamp(value={value})"),
+            PollingKind::First => "PollingStrategy.First()".to_owned(),
+            PollingKind::Last => "PollingStrategy.Last()".to_owned(),
+            PollingKind::Next => "PollingStrategy.Next()".to_owned(),
         }
     }
 }
@@ -281,33 +397,106 @@
     use std::str::FromStr;
 
     const PARTITION_ID: u32 = 3;
-    const OFFSET: u64 = 42;
+    const FIRST_OFFSET: u64 = 42;
+    const MESSAGES_COUNT: u64 = 3;
+    const CURRENT_OFFSET: u64 = 100;
     const CONTEXT: RustPartitionContext = RustPartitionContext {
         incarnation: 7,
         owner_generation: 11,
         metadata_op: 13,
     };
 
-    fn received_message() -> ReceivedMessage {
+    fn message(offset: u64) -> RustReceiveMessage {
         let mut message = RustReceiveMessage::from_str("payload").expect("build message");
-        message.header.offset = OFFSET;
-        ReceivedMessage::new(message, OFFSET, PARTITION_ID, CONTEXT)
+        message.header.offset = offset;
+        message
+    }
+
+    fn polled(
+        messages: Vec<RustReceiveMessage>,
+        start: &RustPollingStrategy,
+    ) -> RustPolledMessages {
+        let count = u32::try_from(messages.len()).expect("message count fits u32");
+        let mut polled =
+            RustPolledMessages::new(CONTEXT, PARTITION_ID, CURRENT_OFFSET, count, messages);
+        polled.set_request_start(start);
+        polled
     }
 
     #[test]
-    fn given_received_message_when_converted_should_keep_its_context() {
-        let message = ReceiveMessage::from(received_message());
-
-        assert_eq!(RustPartitionContext::from(message.context()), CONTEXT);
-    }
-
-    #[test]
-    fn given_received_message_when_reading_position_should_match_the_rust_position() {
-        let received = received_message();
-        let expected = received.position();
+    fn given_received_message_when_converted_should_keep_its_checkpoint() {
+        let received =
+            ReceivedMessage::new(message(FIRST_OFFSET), CURRENT_OFFSET, PARTITION_ID, CONTEXT);
+        let expected = received.checkpoint();
 
         let message = ReceiveMessage::from(received);
 
-        assert_eq!(RustConsumerPosition::from(message.position()), expected);
+        assert_eq!(message.checkpoint().inner, expected);
+        assert_eq!(message.partition_id(), PARTITION_ID);
+        assert_eq!(message.offset(), FIRST_OFFSET);
+    }
+
+    #[test]
+    fn given_polled_batch_when_converted_should_match_the_rust_checkpoints_and_continuation() {
+        Python::initialize();
+        Python::attach(|py| {
+            let offsets = FIRST_OFFSET..FIRST_OFFSET + MESSAGES_COUNT;
+            let rust = polled(
+                offsets.clone().map(message).collect(),
+                &RustPollingStrategy::offset(FIRST_OFFSET),
+            );
+            let expected_checkpoints: Vec<_> = (0..offsets.clone().count())
+                .map(|index| rust.checkpoint_at(index))
+                .collect();
+            let expected_last = rust.checkpoint();
+            let expected_continuation = rust.continuation();
+
+            let batch = PolledMessages::new(py, rust).expect("convert batch");
+
+            assert_eq!(batch.partition_id(), PARTITION_ID);
+            assert_eq!(batch.current_offset(), CURRENT_OFFSET);
+            assert!(batch.messages(py).is(batch.messages(py)));
+            let messages: Vec<Py<ReceiveMessage>> =
+                batch.messages(py).extract(py).expect("a tuple of messages");
+            assert_eq!(
+                messages
+                    .iter()
+                    .map(|message| message.get().offset())
+                    .collect::<Vec<_>>(),
+                offsets.collect::<Vec<_>>()
+            );
+            for (index, expected) in expected_checkpoints.into_iter().enumerate() {
+                assert_eq!(
+                    batch
+                        .checkpoint_at(index)
+                        .map(|checkpoint| checkpoint.inner),
+                    expected
+                );
+                assert_eq!(Some(messages[index].get().checkpoint().inner), expected);
+            }
+            assert_eq!(
+                batch.checkpoint().map(|checkpoint| checkpoint.inner),
+                expected_last
+            );
+            assert!(batch.checkpoint_at(messages.len()).is_none());
+            assert_eq!(batch.continuation().inner, expected_continuation);
+        });
+    }
+
+    #[test]
+    fn given_empty_batch_when_converted_should_keep_the_start_of_its_request() {
+        Python::initialize();
+        Python::attach(|py| {
+            let start = RustPollingStrategy::timestamp(CURRENT_OFFSET.into());
+            let rust = polled(Vec::new(), &start);
+            let expected_continuation = rust.continuation();
+
+            let batch = PolledMessages::new(py, rust).expect("convert batch");
+
+            assert!(batch.messages(py).bind(py).is_empty());
+            assert!(batch.checkpoint().is_none());
+            assert_eq!(batch.continuation().inner, expected_continuation);
+            assert_eq!(batch.continuation().inner.value, start.value);
+        });
     }
 }
diff --git a/foreign/python/tests/fixtures/deferred_polling.json b/foreign/python/tests/fixtures/deferred_polling.json
new file mode 100644
index 0000000..452b461
--- /dev/null
+++ b/foreign/python/tests/fixtures/deferred_polling.json
@@ -0,0 +1,610 @@
+{
+  "schema_version": 1,
+  "generated_by": "core/common/examples/deferred_polling_fixtures.rs",
+  "conventions": {
+    "body_hex": "Every *_hex field is lowercase hex without separators. body_hex is the command payload without framing.",
+    "header_hex": "header_hex is the whole request header of a cancellation, which has no body, before the transport stamps its checksum and checksum_body.",
+    "oversized": "A reply may carry a first record above max_bytes, up to max_record_size, so a client reply cap is at least max_record_reply_size.",
+    "refused": "Every refused fetch fails to decode or to validate. Every refused fetch reply fails to decode.",
+    "status": "A section status of status_ok was read. Any other status is the error code of that partition alone, and its section holds no records.",
+    "strategy": "strategy.kind is the wire kind: 1 offset, 2 timestamp, 3 first, 4 last, 5 next."
+  },
+  "constants": {
+    "codes": {
+      "cancel_deferred_request": 108,
+      "fetch_messages": 109,
+      "poll_messages": 100,
+      "poll_messages_deferred": 106
+    },
+    "status_ok": 0,
+    "errors": {
+      "deferred_poll_capacity_exceeded": 90,
+      "fetch_progress_uncertain": 14010,
+      "history_unavailable": 87,
+      "invalid_size_bytes": 80,
+      "not_primary": 89,
+      "partition_lane_blocked": 14009,
+      "too_many_consumer_offsets": 3024,
+      "transient_not_accepted": 58,
+      "transient_not_committed": 57
+    },
+    "min_deferred_poll_max_bytes": 344,
+    "max_deferred_poll_wait_us": 600000000,
+    "max_deferred_poll_timeout_us": 630000000,
+    "min_deferred_poll_reply_margin_us": 1000000,
+    "fetch_reply_margin_us": 200000,
+    "max_partitions_per_request": 1000,
+    "max_record_size": 64100304,
+    "max_record_reply_size": 64156564,
+    "request_header_size": 256,
+    "fetch_section_header_size": 56
+  },
+  "deferred_polls": [
+    {
+      "case": "consumer_offset_without_commit",
+      "request": {
+        "consumer": {
+          "kind": 1,
+          "id": {
+            "kind": "numeric",
+            "value": 1
+          }
+        },
+        "stream_id": {
+          "kind": "numeric",
+          "value": 1
+        },
+        "topic_id": {
+          "kind": "named",
+          "value": "orders"
+        },
+        "partition_id": 2,
+        "strategy": {
+          "kind": 1,
+          "value": 41
+        },
+        "count": 100,
+        "auto_commit": false,
+        "wait_us": 5000000,
+        "min_count": 1,
+        "max_bytes": 1048576,
+        "request_timeout_us": 6000000
+      },
+      "body_hex": "0101040100000001040100000002066f726465727301020000000129000000000000006400000000404b4c00000000000100000000001000808d5b0000000000"
+    },
+    {
+      "case": "group_next_with_commit",
+      "request": {
+        "consumer": {
+          "kind": 2,
+          "id": {
+            "kind": "named",
+            "value": "orders-group"
+          }
+        },
+        "stream_id": {
+          "kind": "numeric",
+          "value": 1
+        },
+        "topic_id": {
+          "kind": "named",
+          "value": "orders"
+        },
+        "partition_id": 2,
+        "strategy": {
+          "kind": 5,
+          "value": 0
+        },
+        "count": 100,
+        "auto_commit": true,
+        "wait_us": 5000000,
+        "min_count": 1,
+        "max_bytes": 1048576,
+        "request_timeout_us": 6000000
+      },
+      "body_hex": "02020c6f72646572732d67726f757001040100000002066f726465727301020000000500000000000000006400000001404b4c00000000000100000000001000808d5b0000000000"
+    }
+  ],
+  "fetches": [
+    {
+      "case": "consumer_two_targets_without_commit",
+      "request": {
+        "consumer": {
+          "kind": 1,
+          "id": {
+            "kind": "numeric",
+            "value": 1
+          }
+        },
+        "stream_id": {
+          "kind": "numeric",
+          "value": 1
+        },
+        "topic_id": {
+          "kind": "numeric",
+          "value": 2
+        },
+        "wait_us": 5000000,
+        "request_timeout_us": 6000000,
+        "min_count": 1,
+        "count": 1000,
+        "max_bytes": 1048576,
+        "auto_commit": false,
+        "targets": [
+          {
+            "partition_id": 1,
+            "strategy": {
+              "kind": 1,
+              "value": 41
+            },
+            "max_bytes": 524288,
+            "incarnation": 11,
+            "owner_generation": 0
+          },
+          {
+            "partition_id": 3,
+            "strategy": {
+              "kind": 5,
+              "value": 0
+            },
+            "max_bytes": 524288,
+            "incarnation": 11,
+            "owner_generation": 0
+          }
+        ]
+      },
+      "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b000000000001000000e803000000001000000200000001000000012900000000000000000008000b00000000000000000000000000000003000000050000000000000000000008000b000000000000000000000000000000"
+    },
+    {
+      "case": "group_three_targets_with_commit",
+      "request": {
+        "consumer": {
+          "kind": 2,
+          "id": {
+            "kind": "numeric",
+            "value": 9
+          }
+        },
+        "stream_id": {
+          "kind": "numeric",
+          "value": 1
+        },
+        "topic_id": {
+          "kind": "numeric",
+          "value": 2
+        },
+        "wait_us": 5000000,
+        "request_timeout_us": 6000000,
+        "min_count": 1,
+        "count": 1000,
+        "max_bytes": 1048576,
+        "auto_commit": true,
+        "targets": [
+          {
+            "partition_id": 1,
+            "strategy": {
+              "kind": 5,
+              "value": 0
+            },
+            "max_bytes": 524288,
+            "incarnation": 11,
+            "owner_generation": 4
+          },
+          {
+            "partition_id": 2,
+            "strategy": {
+              "kind": 5,
+              "value": 0
+            },
+            "max_bytes": 524288,
+            "incarnation": 11,
+            "owner_generation": 4
+          },
+          {
+            "partition_id": 4,
+            "strategy": {
+              "kind": 1,
+              "value": 7
+            },
+            "max_bytes": 524288,
+            "incarnation": 11,
+            "owner_generation": 4
+          }
+        ]
+      },
+      "body_hex": "02010409000000010401000000010402000000404b4c0000000000808d5b000000000001000000e803000000001000010300000001000000050000000000000000000008000b00000000000000040000000000000002000000050000000000000000000008000b00000000000000040000000000000004000000010700000000000000000008000b000000000000000400000000000000"
+    }
+  ],
+  "cancellations": [
+    {
+      "case": "cancel_deferred_poll",
+      "cancels_code": 106,
+      "client": 7,
+      "request": 21,
+      "session": 3,
+      "header_hex": "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000050000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007000000000000000000000000000000000000000000000000000000000000000000000000000000150000000000000002000000000000000300000000000000000000006c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
+    },
+    {
+      "case": "cancel_fetch",
+      "cancels_code": 109,
+      "client": 7,
+      "request": 22,
+      "session": 3,
+      "header_hex": "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000050000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007000000000000000000000000000000000000000000000000000000000000000000000000000000160000000000000002000000000000000300000000000000000000006c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
+    }
+  ],
+  "fetch_replies": [
+    {
+      "case": "ok_section_and_empty_start_section",
+      "sections": [
+        {
+          "partition_id": 1,
+          "status": 0,
+          "current_offset": 44,
+          "messages_count": 3,
+          "context": {
+            "incarnation": 11,
+            "owner_generation": 4,
+            "metadata_op": 42
+          },
+          "next_offset": 44,
+          "records_len": 416,
+          "messages": [
+            {
+              "offset": 41,
+              "id": "258",
+              "timestamp": 1700000000000500,
+              "origin_timestamp": 1700000000000000,
+              "payload": "first"
+            },
+            {
+              "offset": 42,
+              "id": "259",
+              "timestamp": 1700000000000500,
+              "origin_timestamp": 1700000000000000,
+              "payload": "second"
+            },
+            {
+              "offset": 43,
+              "id": "260",
+              "timestamp": 1700000000000500,
+              "origin_timestamp": 1700000000000000,
+              "payload": "third"
+            }
+          ]
+        },
+        {
+          "partition_id": 3,
+          "status": 0,
+          "current_offset": 9,
+          "messages_count": 0,
+          "context": {
+            "incarnation": 11,
+            "owner_generation": 4,
+            "metadata_op": 42
+          },
+          "next_offset": 10,
+          "records_len": 0,
+          "messages": []
+        }
+      ],
+      "body_hex": "0200000001000000000000002c00000000000000030000000b0000000000000004000000000000002a000000000000002c00000000000000a001000003000000000000000900000000000000000000000b0000000000000004000000000000002a000000000000000a000000000000000000000001000000000000002900000000000000f4411e18240a060000401e18240a0600a00100000000000088fc3268f2c0e02103000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000bb5b1cd43b71761a02010000000000000000000000000000000000000000000000000000050000000000000000000000666972737453c25c376f98c265030100000000000000000000000000000100000000000000000000000600000000000000000000007365636f6e64a761c18f632fb022040100000000000000000000000000000200000000000000000000000500000000000000000000007468697264"
+    },
+    {
+      "case": "ok_section_beside_error_sections",
+      "sections": [
+        {
+          "partition_id": 1,
+          "status": 89,
+          "current_offset": 0,
+          "messages_count": 0,
+          "context": {
+            "incarnation": 0,
+            "owner_generation": 0,
+            "metadata_op": 0
+          },
+          "next_offset": 0,
+          "records_len": 0,
+          "messages": []
+        },
+        {
+          "partition_id": 2,
+          "status": 0,
+          "current_offset": 7,
+          "messages_count": 1,
+          "context": {
+            "incarnation": 11,
+            "owner_generation": 4,
+            "metadata_op": 42
+          },
+          "next_offset": 7,
+          "records_len": 308,
+          "messages": [
+            {
+              "offset": 6,
+              "id": "340282366920938463463374607431768211455",
+              "timestamp": 1700000000000500,
+              "origin_timestamp": 1700000000000000,
+              "payload": "only"
+            }
+          ]
+        },
+        {
+          "partition_id": 4,
+          "status": 58,
+          "current_offset": 0,
+          "messages_count": 0,
+          "context": {
+            "incarnation": 0,
+            "owner_generation": 0,
+            "metadata_op": 0
+          },
+          "next_offset": 0,
+          "records_len": 0,
+          "messages": []
+        }
+      ],
+      "body_hex": "03000000010000005900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000700000000000000010000000b0000000000000004000000000000002a00000000000000070000000000000034010000040000003a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000600000000000000f4411e18240a060000401e18240a060034010000000000003e89bfefc17b805301000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000fbb4aeb35a85bdaeffffffffffffffffffffffffffffffff0000000000000000000000000400000000000000000000006f6e6c79"
+    },
+    {
+      "case": "no_sections",
+      "sections": [],
+      "body_hex": "00000000"
+    }
+  ],
+  "refused": {
+    "fetches": [
+      {
+        "case": "no_targets",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b000000000001000000e8030000000010000000000000"
+      },
+      {
+        "case": "duplicate_partition",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b000000000001000000e803000000001000000200000001000000010000000000000000000008000b00000000000000000000000000000001000000050000000000000000000008000b000000000000000000000000000000"
+      },
+      {
+        "case": "first_start_point",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b000000000001000000e803000000001000000100000001000000030000000000000000000008000b000000000000000000000000000000"
+      },
+      {
+        "case": "min_count_above_count",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b0000000000e9030000e803000000001000000100000001000000010000000000000000000008000b000000000000000000000000000000"
+      },
+      {
+        "case": "target_bytes_above_request_bytes",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b000000000001000000e803000000001000000100000001000000010000000000000000010010000b000000000000000000000000000000"
+      },
+      {
+        "case": "timeout_without_reply_margin",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000404b4c000000000001000000e803000000001000000100000001000000010000000000000000000008000b000000000000000000000000000000"
+      }
+    ],
+    "fetch_replies": [
+      {
+        "case": "error_section_with_records",
+        "body_hex": "01000000010000005900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000"
+      },
+      {
+        "case": "truncated_table",
+        "body_hex": "02000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
+      }
+    ]
+  },
+  "commit_outcomes": {
+    "rule": "A committing deferred poll (106) or fetch (109) that fails with a certain status stored nothing. Every other status, a status not listed here, a lost reply and a transport failure after the send leave the outcome unknown.",
+    "request": [
+      {
+        "status": 5,
+        "error": "feature_unavailable",
+        "certain": true
+      },
+      {
+        "status": 6,
+        "error": "invalid_identifier",
+        "certain": true
+      },
+      {
+        "status": 30,
+        "error": "stale_client",
+        "certain": false
+      },
+      {
+        "status": 40,
+        "error": "unauthenticated",
+        "certain": false
+      },
+      {
+        "status": 41,
+        "error": "unauthorized",
+        "certain": false
+      },
+      {
+        "status": 57,
+        "error": "transient_not_committed",
+        "certain": false
+      },
+      {
+        "status": 58,
+        "error": "transient_not_accepted",
+        "certain": true
+      },
+      {
+        "status": 72,
+        "error": "cannot_decrypt_data",
+        "certain": false
+      },
+      {
+        "status": 80,
+        "error": "invalid_size_bytes",
+        "certain": true
+      },
+      {
+        "status": 87,
+        "error": "history_unavailable",
+        "certain": true
+      },
+      {
+        "status": 89,
+        "error": "not_primary",
+        "certain": true
+      },
+      {
+        "status": 90,
+        "error": "deferred_poll_capacity_exceeded",
+        "certain": true
+      },
+      {
+        "status": 1009,
+        "error": "stream_id_not_found",
+        "certain": true
+      },
+      {
+        "status": 1010,
+        "error": "stream_name_not_found",
+        "certain": true
+      },
+      {
+        "status": 2010,
+        "error": "topic_id_not_found",
+        "certain": true
+      },
+      {
+        "status": 2011,
+        "error": "topic_name_not_found",
+        "certain": true
+      },
+      {
+        "status": 3007,
+        "error": "partition_not_found",
+        "certain": true
+      },
+      {
+        "status": 3024,
+        "error": "too_many_consumer_offsets",
+        "certain": true
+      },
+      {
+        "status": 4100,
+        "error": "invalid_offset",
+        "certain": true
+      },
+      {
+        "status": 5009,
+        "error": "consumer_group_partition_not_owned",
+        "certain": true
+      },
+      {
+        "status": 11001,
+        "error": "shard_communication_error",
+        "certain": false
+      }
+    ],
+    "section": [
+      {
+        "status": 5,
+        "error": "feature_unavailable",
+        "certain": false
+      },
+      {
+        "status": 6,
+        "error": "invalid_identifier",
+        "certain": false
+      },
+      {
+        "status": 30,
+        "error": "stale_client",
+        "certain": false
+      },
+      {
+        "status": 40,
+        "error": "unauthenticated",
+        "certain": false
+      },
+      {
+        "status": 41,
+        "error": "unauthorized",
+        "certain": false
+      },
+      {
+        "status": 57,
+        "error": "transient_not_committed",
+        "certain": false
+      },
+      {
+        "status": 58,
+        "error": "transient_not_accepted",
+        "certain": true
+      },
+      {
+        "status": 72,
+        "error": "cannot_decrypt_data",
+        "certain": false
+      },
+      {
+        "status": 80,
+        "error": "invalid_size_bytes",
+        "certain": true
+      },
+      {
+        "status": 87,
+        "error": "history_unavailable",
+        "certain": true
+      },
+      {
+        "status": 89,
+        "error": "not_primary",
+        "certain": true
+      },
+      {
+        "status": 90,
+        "error": "deferred_poll_capacity_exceeded",
+        "certain": false
+      },
+      {
+        "status": 1009,
+        "error": "stream_id_not_found",
+        "certain": false
+      },
+      {
+        "status": 1010,
+        "error": "stream_name_not_found",
+        "certain": false
+      },
+      {
+        "status": 2010,
+        "error": "topic_id_not_found",
+        "certain": false
+      },
+      {
+        "status": 2011,
+        "error": "topic_name_not_found",
+        "certain": false
+      },
+      {
+        "status": 3007,
+        "error": "partition_not_found",
+        "certain": true
+      },
+      {
+        "status": 3024,
+        "error": "too_many_consumer_offsets",
+        "certain": true
+      },
+      {
+        "status": 4100,
+        "error": "invalid_offset",
+        "certain": false
+      },
+      {
+        "status": 5009,
+        "error": "consumer_group_partition_not_owned",
+        "certain": true
+      },
+      {
+        "status": 11001,
+        "error": "shard_communication_error",
+        "certain": false
+      }
+    ]
+  }
+}
diff --git a/foreign/python/tests/fixtures/resource_options.json b/foreign/python/tests/fixtures/resource_options.json
index c5cc2ac..202888f 100644
--- a/foreign/python/tests/fixtures/resource_options.json
+++ b/foreign/python/tests/fixtures/resource_options.json
@@ -16,18 +16,18 @@
   },
   "constants": {
     "protocol_version": {
-      "packed": 12288,
-      "text": "0.12.0",
+      "packed": 12289,
+      "text": "0.12.1",
       "major": 0,
       "minor": 12,
-      "patch": 0
+      "patch": 1
     },
     "protocol_version_min": {
-      "packed": 12288,
-      "text": "0.12.0",
+      "packed": 12289,
+      "text": "0.12.1",
       "major": 0,
       "minor": 12,
-      "patch": 0
+      "patch": 1
     },
     "command_codes": {
       "create_stream": {
diff --git a/foreign/python/tests/test_client_config.py b/foreign/python/tests/test_client_config.py
index e04794d..815df47 100644
--- a/foreign/python/tests/test_client_config.py
+++ b/foreign/python/tests/test_client_config.py
@@ -33,12 +33,14 @@
 
 from apache_iggy import (
     AutoCommit,
-    AutoCommitAfter,
-    AutoCommitWhen,
     AutoLogin,
+    CommitAfter,
+    CommitEvery,
+    CommitPolicy,
     IggyClient,
     IggyExpiry,
     PendingOperationsConfig,
+    PollOptions,
     QuicConfig,
     TcpConfig,
     TcpReconnectionConfig,
@@ -344,38 +346,11 @@
                 **interval_kwargs,
             )
 
-    @pytest.mark.parametrize(
-        "auto_commit",
-        [
-            AutoCommit.Interval(timedelta(0)),
-            AutoCommit.IntervalOrWhen(timedelta(0), AutoCommitWhen.PollingMessages()),
-            AutoCommit.IntervalOrAfter(
-                timedelta(0), AutoCommitAfter.ConsumingEachMessage()
-            ),
-        ],
-        ids=["interval", "interval_or_when", "interval_or_after"],
-    )
-    def test_zero_auto_commit_interval_is_rejected(self, auto_commit: AutoCommit):
-        """Test that a zero auto-commit interval fails at the call.
+    def test_arguments_after_topic_are_keyword_only(self):
+        """Test that a positional argument after topic fails.
 
-        Zero there stores the offsets in a busy loop. Committing on every poll
-        is spelled AutoCommit.When or AutoCommit.After instead.
-        """
-        client = IggyClient()
-
-        with pytest.raises(ValueError, match="zero"):
-            client.consumer_group(
-                name="group",
-                stream="stream",
-                topic="topic",
-                auto_commit=auto_commit,
-            )
-
-    def test_arguments_after_auto_join_are_keyword_only(self):
-        """Test that a positional argument after auto_join_consumer_group fails.
-
-        The removed poll_interval was the tenth argument. A positional call
-        written for it would otherwise set polling_retry_interval.
+        The removed partition_id was the fourth argument. A positional None
+        written for it would otherwise pass as polling_strategy.
         """
         client = IggyClient()
 
@@ -384,17 +359,53 @@
                 "group",
                 "stream",
                 "topic",
-                None,
-                None,
-                None,
-                None,
-                True,
-                True,
                 # pyrefly: ignore  # bad-argument-count
-                timedelta(seconds=1),
+                None,
             )
 
 
+@pytest.mark.unit
+class TestCommitPolicy:
+    """Test when reads store their offsets."""
+
+    def test_default_options_store_nothing(self):
+        """Test that the default commit policy leaves every store to the caller."""
+        assert PollOptions().commit == CommitPolicy.Manual()
+
+    def test_auto_commit_default_stores_every_10000_messages_or_5_seconds(self):
+        """Test the default of an automatic commit and that options keep it."""
+        auto = AutoCommit.default()
+        options = PollOptions(commit=CommitPolicy.Auto(auto))
+
+        assert auto == AutoCommit(
+            interval=timedelta(seconds=5),
+            every=CommitEvery.Messages(10_000),
+            after=CommitAfter.Processing,
+        )
+        assert options.commit == CommitPolicy.Auto(auto)
+        assert repr(options.commit) == repr(CommitPolicy.Auto(auto))
+        assert (
+            "CommitPolicy.Auto(AutoCommit(interval=datetime.timedelta(seconds=5)"
+            in (repr(options))
+        )
+
+    @pytest.mark.parametrize(
+        ("interval", "every", "message"),
+        [
+            (timedelta(0), None, "zero"),
+            (None, CommitEvery.Messages(0), "above zero"),
+            (None, None, "'interval' or an 'every'"),
+        ],
+        ids=["zero_interval", "zero_messages", "neither"],
+    )
+    def test_auto_commit_that_never_stores_is_rejected(
+        self, interval: timedelta | None, every: CommitEvery | None, message: str
+    ):
+        """Test that an automatic commit storing only on close fails at once."""
+        with pytest.raises(ValueError, match=message):
+            AutoCommit(interval=interval, every=every)
+
+
 @pytest.mark.integration
 class TestAutoLoginAgainstServer:
     """Test that configured credentials are actually replayed on connect."""
diff --git a/foreign/python/tests/test_consumer_group.py b/foreign/python/tests/test_consumer_group.py
index 3ab2e33..b2abd7c 100644
--- a/foreign/python/tests/test_consumer_group.py
+++ b/foreign/python/tests/test_consumer_group.py
@@ -19,14 +19,21 @@
 # + consumer_group setup once test fixture is in place.
 
 import asyncio
+from collections.abc import AsyncIterator
 from datetime import timedelta
 
 import pytest
 
 from apache_iggy import (
     AutoCommit,
+    CommitAfter,
+    CommitEvery,
+    CommitPolicy,
+    Consumer,
     IggyClient,
+    IggyError,
     PollingStrategy,
+    PollOptions,
     ReceiveMessage,
     TopicOptions,
 )
@@ -34,11 +41,34 @@
 
 from .utils import (
     get_server_config,
+    read_offsets_with_cancels,
     wait_for_consumer_group_assignment,
     wait_for_ping,
     wait_for_server,
 )
 
+# A consumer without a wait polls an idle topic in a busy loop.
+POLL_WAIT = timedelta(seconds=1)
+POLL_OPTIONS = PollOptions(count=10, max_wait=POLL_WAIT)
+# Stores the delivered position every 5 seconds and on shutdown.
+INTERVAL_COMMIT_OPTIONS = PollOptions(
+    count=10,
+    max_wait=POLL_WAIT,
+    commit=CommitPolicy.Auto(
+        AutoCommit(interval=timedelta(seconds=5), after=CommitAfter.Delivery)
+    ),
+)
+# One message per batch and one batch of prefetch, so most iterations wait for a reply.
+ONE_MESSAGE = 1
+CANCELLED_ITERATIONS_MESSAGES = 100
+RUN_TIMEOUT_SECONDS = 10
+INVALID_COMMAND = 3
+NO_EVENT_RUN_MESSAGES = 5
+
+
+async def _message_offset(iterator: AsyncIterator[ReceiveMessage]) -> list[int]:
+    return [(await anext(iterator)).offset()]
+
 
 async def _create_consumer_group_with_numeric_ids(
     iggy_client: IggyClient,
@@ -273,9 +303,8 @@
             consumer_name,
             stream_name,
             topic_name,
-            partition_id,
-            PollingStrategy.Next(),
-            10,
+            polling_strategy=PollingStrategy.Next(),
+            poll_options=POLL_OPTIONS,
         )
         await wait_for_consumer_group_assignment(
             iggy_client, stream_name, topic_name, consumer_name, members_count=1
@@ -785,10 +814,8 @@
             consumer_name,
             stream_name,
             topic_name,
-            partition_id,
-            PollingStrategy.Next(),
-            10,
-            auto_commit=AutoCommit.Interval(timedelta(seconds=5)),
+            polling_strategy=PollingStrategy.Next(),
+            poll_options=INTERVAL_COMMIT_OPTIONS,
         )
 
         assert consumer.name() == consumer_name
@@ -819,10 +846,8 @@
             consumer_name,
             stream_name,
             topic_name,
-            partition_id,
-            PollingStrategy.First(),
-            10,
-            auto_commit=AutoCommit.Disabled(),
+            polling_strategy=PollingStrategy.First(),
+            poll_options=POLL_OPTIONS,
         )
 
         async def take(received: ReceiveMessage) -> None:
@@ -875,10 +900,8 @@
             consumer_name,
             stream_name,
             topic_name,
-            partition_id,
-            PollingStrategy.First(),
-            10,
-            auto_commit=AutoCommit.Disabled(),
+            polling_strategy=PollingStrategy.First(),
+            poll_options=POLL_OPTIONS,
         )
 
         async def take(received: ReceiveMessage) -> None:
@@ -928,10 +951,8 @@
             consumer_name,
             stream_name,
             topic_name,
-            partition_id,
-            PollingStrategy.First(),
-            10,
-            auto_commit=AutoCommit.Disabled(),
+            polling_strategy=PollingStrategy.First(),
+            poll_options=POLL_OPTIONS,
         )
 
         assert consumer.get_last_consumed_offset(partition_id) is None
@@ -999,10 +1020,8 @@
             consumer_name,
             stream_name,
             topic_name,
-            partition_id,
-            PollingStrategy.First(),
-            10,
-            auto_commit=AutoCommit.Disabled(),
+            polling_strategy=PollingStrategy.First(),
+            poll_options=POLL_OPTIONS,
         )
 
         assert consumer.get_last_stored_offset(partition_id) is None
@@ -1065,10 +1084,8 @@
             consumer_name,
             stream_name,
             topic_name,
-            partition_id,
-            PollingStrategy.First(),
-            10,
-            auto_commit=AutoCommit.Disabled(),
+            polling_strategy=PollingStrategy.First(),
+            poll_options=POLL_OPTIONS,
         )
         await wait_for_consumer_group_assignment(
             iggy_client, stream_name, topic_name, consumer_name, members_count=1
@@ -1120,10 +1137,8 @@
             consumer_name,
             stream_name,
             topic_name,
-            partition_id,
-            PollingStrategy.Next(),
-            10,
-            auto_commit=AutoCommit.Interval(timedelta(seconds=5)),
+            polling_strategy=PollingStrategy.Next(),
+            poll_options=INTERVAL_COMMIT_OPTIONS,
         )
 
         async def take(message: ReceiveMessage) -> None:
@@ -1166,10 +1181,8 @@
             consumer_name,
             stream_name,
             topic_name,
-            partition_id,
-            PollingStrategy.Next(),
-            10,
-            auto_commit=AutoCommit.Disabled(),
+            polling_strategy=PollingStrategy.Next(),
+            poll_options=POLL_OPTIONS,
         )
 
         async def take(message: ReceiveMessage) -> None:
@@ -1207,10 +1220,8 @@
             consumer_name,
             stream_name,
             topic_name,
-            partition_id,
-            PollingStrategy.Next(),
-            10,
-            auto_commit=AutoCommit.Interval(timedelta(seconds=5)),
+            polling_strategy=PollingStrategy.Next(),
+            poll_options=INTERVAL_COMMIT_OPTIONS,
         )
 
         await iggy_client.send_messages(
@@ -1248,10 +1259,8 @@
             consumer_name,
             stream_name,
             topic_name,
-            partition_id,
-            PollingStrategy.Next(),
-            10,
-            auto_commit=AutoCommit.Disabled(),
+            polling_strategy=PollingStrategy.Next(),
+            poll_options=POLL_OPTIONS,
         )
 
         await iggy_client.send_messages(
@@ -1296,10 +1305,8 @@
             consumer_name,
             stream_name,
             topic_name,
-            partition_id,
-            PollingStrategy.First(),
-            10,
-            auto_commit=AutoCommit.Disabled(),
+            polling_strategy=PollingStrategy.First(),
+            poll_options=POLL_OPTIONS,
         )
 
         iterator = consumer.iter_messages()
@@ -1335,10 +1342,8 @@
             consumer_name,
             stream_name,
             topic_name,
-            partition_id,
-            PollingStrategy.Last(),
-            1,
-            auto_commit=AutoCommit.Disabled(),
+            polling_strategy=PollingStrategy.Last(),
+            poll_options=PollOptions(count=1, max_wait=POLL_WAIT),
         )
 
         iterator = consumer.iter_messages()
@@ -1354,10 +1359,10 @@
         )
 
     @pytest.mark.asyncio
-    async def test_iter_messages_with_last_and_batch_length_two_reads_last_two_messages(
+    async def test_iter_messages_with_last_and_count_two_reads_last_two_messages(
         self, iggy_client: IggyClient, unique_name
     ):
-        """Test iter_messages with Last and batch_length=2 reads the last two."""
+        """Test iter_messages with Last and count=2 reads the last two."""
         consumer_name = unique_name()
         stream_name = unique_name()
         topic_name = unique_name()
@@ -1379,10 +1384,8 @@
             consumer_name,
             stream_name,
             topic_name,
-            partition_id,
-            PollingStrategy.Last(),
-            2,
-            auto_commit=AutoCommit.Disabled(),
+            polling_strategy=PollingStrategy.Last(),
+            poll_options=PollOptions(count=2, max_wait=POLL_WAIT),
         )
 
         iterator = consumer.iter_messages()
@@ -1419,10 +1422,8 @@
             consumer_name,
             stream_name,
             topic_name,
-            partition_id,
-            PollingStrategy.Last(),
-            1,
-            auto_commit=AutoCommit.Disabled(),
+            polling_strategy=PollingStrategy.Last(),
+            poll_options=PollOptions(count=1, max_wait=POLL_WAIT),
         )
 
         async def take(message: ReceiveMessage) -> None:
@@ -1465,10 +1466,8 @@
             unique_name(),
             stream_name,
             topic_name,
-            partition_id,
-            PollingStrategy.First(),
-            10,
-            auto_commit=AutoCommit.Disabled(),
+            polling_strategy=PollingStrategy.First(),
+            poll_options=POLL_OPTIONS,
         )
 
         iterator = first_consumer.iter_messages()
@@ -1482,10 +1481,8 @@
             unique_name(),
             stream_name,
             topic_name,
-            partition_id,
-            PollingStrategy.Offset(value=start_offset),
-            10,
-            auto_commit=AutoCommit.Disabled(),
+            polling_strategy=PollingStrategy.Offset(value=start_offset),
+            poll_options=POLL_OPTIONS,
         )
 
         iterator = offset_consumer.iter_messages()
@@ -1529,10 +1526,8 @@
             unique_name(),
             stream_name,
             topic_name,
-            partition_id,
-            PollingStrategy.First(),
-            10,
-            auto_commit=AutoCommit.Disabled(),
+            polling_strategy=PollingStrategy.First(),
+            poll_options=POLL_OPTIONS,
         )
 
         iterator = first_consumer.iter_messages()
@@ -1546,10 +1541,8 @@
             unique_name(),
             stream_name,
             topic_name,
-            partition_id,
-            PollingStrategy.Offset(value=start_offset),
-            10,
-            auto_commit=AutoCommit.Disabled(),
+            polling_strategy=PollingStrategy.Offset(value=start_offset),
+            poll_options=POLL_OPTIONS,
         )
 
         async def take(message: ReceiveMessage) -> None:
@@ -1598,10 +1591,8 @@
             unique_name(),
             stream_name,
             topic_name,
-            partition_id,
-            PollingStrategy.First(),
-            10,
-            auto_commit=AutoCommit.Disabled(),
+            polling_strategy=PollingStrategy.First(),
+            poll_options=POLL_OPTIONS,
         )
 
         iterator = first_consumer.iter_messages()
@@ -1615,10 +1606,8 @@
             unique_name(),
             stream_name,
             topic_name,
-            partition_id,
-            PollingStrategy.Timestamp(value=start_timestamp),
-            10,
-            auto_commit=AutoCommit.Disabled(),
+            polling_strategy=PollingStrategy.Timestamp(value=start_timestamp),
+            poll_options=POLL_OPTIONS,
         )
 
         iterator = timestamp_consumer.iter_messages()
@@ -1666,10 +1655,8 @@
             unique_name(),
             stream_name,
             topic_name,
-            partition_id,
-            PollingStrategy.First(),
-            10,
-            auto_commit=AutoCommit.Disabled(),
+            polling_strategy=PollingStrategy.First(),
+            poll_options=POLL_OPTIONS,
         )
 
         iterator = first_consumer.iter_messages()
@@ -1683,10 +1670,8 @@
             unique_name(),
             stream_name,
             topic_name,
-            partition_id,
-            PollingStrategy.Timestamp(value=start_timestamp),
-            10,
-            auto_commit=AutoCommit.Disabled(),
+            polling_strategy=PollingStrategy.Timestamp(value=start_timestamp),
+            poll_options=POLL_OPTIONS,
         )
 
         async def take(message: ReceiveMessage) -> None:
@@ -1724,10 +1709,8 @@
             consumer_name,
             stream_name,
             topic_name,
-            partition_id,
-            PollingStrategy.First(),
-            10,
-            auto_commit=AutoCommit.Disabled(),
+            polling_strategy=PollingStrategy.First(),
+            poll_options=POLL_OPTIONS,
         )
 
         assert consumer.get_last_consumed_offset(partition_id) is None
@@ -1762,7 +1745,7 @@
     async def test_iter_messages_reads_across_multiple_batches(
         self, iggy_client: IggyClient, unique_name
     ):
-        """Test iter_messages continues across polls when batch_length is small."""
+        """Test iter_messages continues across polls when count is small."""
         consumer_name = unique_name()
         stream_name = unique_name()
         topic_name = unique_name()
@@ -1784,10 +1767,8 @@
             consumer_name,
             stream_name,
             topic_name,
-            partition_id,
-            PollingStrategy.First(),
-            2,
-            auto_commit=AutoCommit.Disabled(),
+            polling_strategy=PollingStrategy.First(),
+            poll_options=PollOptions(count=2, max_wait=POLL_WAIT),
         )
 
         iterator = consumer.iter_messages()
@@ -1799,18 +1780,18 @@
 
     @pytest.mark.asyncio
     @pytest.mark.parametrize("allow_replay", [False, True])
-    async def test_store_offset_respects_allow_replay(
+    async def test_store_offset_moves_the_stored_offset_back(
         self,
         iggy_client: IggyClient,
         unique_name,
         allow_replay: bool,
     ):
-        """Test rewinding offsets only re-consumes when replay is enabled."""
+        """Test storing a lower offset rewinds the group, with or without replay."""
         consumer_name = unique_name()
         stream_name = unique_name()
         topic_name = unique_name()
         partition_id = 0
-        test_messages = [f"Replay test {i} - {unique_name()}" for i in range(3)]
+        test_messages = [f"Rewind test {i} - {unique_name()}" for i in range(3)]
         received_messages = []
         replayed_messages = []
 
@@ -1821,10 +1802,8 @@
             consumer_name,
             stream_name,
             topic_name,
-            partition_id,
-            PollingStrategy.First(),
-            10,
-            auto_commit=AutoCommit.Disabled(),
+            polling_strategy=PollingStrategy.First(),
+            poll_options=POLL_OPTIONS,
             allow_replay=allow_replay,
         )
 
@@ -1846,30 +1825,78 @@
         assert consumer.get_last_stored_offset(partition_id) == offsets[-1]
 
         await consumer.store_offset(offsets[-2], partition_id)
-        expected_stored_offset = offsets[-2] if allow_replay else offsets[-1]
-        assert consumer.get_last_stored_offset(partition_id) == expected_stored_offset
+        assert consumer.get_last_stored_offset(partition_id) == offsets[-2]
 
         replay_consumer = await iggy_client.consumer_group(
             consumer_name,
             stream_name,
             topic_name,
-            partition_id,
-            PollingStrategy.Next(),
-            1,
-            auto_commit=AutoCommit.Disabled(),
+            polling_strategy=PollingStrategy.Next(),
+            poll_options=PollOptions(count=1, max_wait=POLL_WAIT),
             allow_replay=allow_replay,
         )
 
-        if allow_replay:
-            iterator = replay_consumer.iter_messages()
+        iterator = replay_consumer.iter_messages()
+        message = await asyncio.wait_for(iterator.__anext__(), timeout=5)
+        replayed_messages.append(message.payload().decode())
+        assert replayed_messages == [test_messages[-1]]
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("allow_replay", [False, True])
+    async def test_seek_back_reads_again_with_and_without_allow_replay(
+        self,
+        iggy_client: IggyClient,
+        unique_name,
+        allow_replay: bool,
+    ):
+        """Test a seek back reads the sought messages again with any allow_replay."""
+        consumer_name = unique_name()
+        stream_name = unique_name()
+        topic_name = unique_name()
+        partition_id = 0
+        test_messages = [f"Replay test {i} - {unique_name()}" for i in range(3)]
+        final_message = f"Replay test 3 - {unique_name()}"
+        received_messages = []
+
+        await iggy_client.create_stream(stream_name)
+        await iggy_client.create_topic(stream=stream_name, name=topic_name)
+
+        consumer = await iggy_client.consumer_group(
+            consumer_name,
+            stream_name,
+            topic_name,
+            polling_strategy=PollingStrategy.First(),
+            poll_options=POLL_OPTIONS,
+            allow_replay=allow_replay,
+        )
+
+        await iggy_client.send_messages(
+            stream_name,
+            topic_name,
+            partition_id,
+            [Message(message) for message in test_messages],
+        )
+
+        iterator = consumer.iter_messages()
+        for _ in range(len(test_messages)):
             message = await asyncio.wait_for(iterator.__anext__(), timeout=5)
-            replayed_messages.append(message.payload().decode())
-            assert replayed_messages == [test_messages[-1]]
-        else:
-            with pytest.raises(asyncio.TimeoutError):
-                await asyncio.wait_for(
-                    replay_consumer.iter_messages().__anext__(), timeout=0.2
-                )
+            received_messages.append(message)
+
+        await consumer.seek(
+            partition_id,
+            PollingStrategy.Offset(value=received_messages[-1].offset()),
+        )
+        # The replay filter skips only deliveries of the generation that the seek
+        # ended, so both cases yield the sought message before the later one.
+        await iggy_client.send_messages(
+            stream_name,
+            topic_name,
+            partition_id,
+            [Message(final_message)],
+        )
+
+        message = await asyncio.wait_for(iterator.__anext__(), timeout=5)
+        assert message.payload().decode() == test_messages[-1]
 
     @pytest.mark.asyncio
     async def test_consumer_group_retries_initialization_until_stream_and_topic_exist(
@@ -1885,10 +1912,8 @@
                 consumer_name,
                 stream_name,
                 topic_name,
-                0,
-                PollingStrategy.Next(),
-                10,
-                auto_commit=AutoCommit.Disabled(),
+                polling_strategy=PollingStrategy.Next(),
+                poll_options=POLL_OPTIONS,
                 init_retries=10,
                 init_retry_interval=timedelta(milliseconds=50),
             )
@@ -1906,10 +1931,10 @@
         assert consumer.topic() == topic_name
 
     @pytest.mark.asyncio
-    async def test_consumer_group_can_disable_auto_join(
+    async def test_consumer_group_joins_the_group_on_initialization(
         self, iggy_client: IggyClient, unique_name
     ):
-        """Test initialization can skip automatically joining the group."""
+        """Test initialization joins the group before the consumer is returned."""
         consumer_name = unique_name()
         stream_name = unique_name()
         topic_name = unique_name()
@@ -1922,17 +1947,19 @@
             consumer_name,
             stream_name,
             topic_name,
-            partition_id,
-            PollingStrategy.Next(),
-            10,
-            auto_commit=AutoCommit.Disabled(),
-            auto_join_consumer_group=False,
+            polling_strategy=PollingStrategy.Next(),
+            poll_options=POLL_OPTIONS,
         )
 
         assert consumer.name() == consumer_name
         assert consumer.stream() == stream_name
         assert consumer.topic() == topic_name
         assert consumer.partition_id() == partition_id
+        group = await iggy_client.get_consumer_group(
+            stream_name, topic_name, consumer_name
+        )
+        assert group is not None
+        assert group.members_count == 1
 
     @pytest.mark.asyncio
     async def test_consumer_group_can_disable_auto_create_when_group_exists(
@@ -1950,20 +1977,16 @@
             consumer_name,
             stream_name,
             topic_name,
-            0,
-            PollingStrategy.Next(),
-            10,
-            auto_commit=AutoCommit.Disabled(),
+            polling_strategy=PollingStrategy.Next(),
+            poll_options=POLL_OPTIONS,
         )
 
         consumer = await iggy_client.consumer_group(
             consumer_name,
             stream_name,
             topic_name,
-            0,
-            PollingStrategy.Next(),
-            10,
-            auto_commit=AutoCommit.Disabled(),
+            polling_strategy=PollingStrategy.Next(),
+            poll_options=POLL_OPTIONS,
             create_consumer_group_if_not_exists=False,
         )
 
@@ -1997,10 +2020,8 @@
                 consumer_name,
                 stream_name,
                 topic_name,
-                0,
-                PollingStrategy.Next(),
-                10,
-                auto_commit=AutoCommit.Disabled(),
+                polling_strategy=PollingStrategy.Next(),
+                poll_options=POLL_OPTIONS,
             )
 
     @pytest.mark.asyncio
@@ -2019,10 +2040,8 @@
                 unique_name(),
                 stream_name,
                 topic_name,
-                0,
-                PollingStrategy.Next(),
-                10,
-                auto_commit=AutoCommit.Disabled(),
+                polling_strategy=PollingStrategy.Next(),
+                poll_options=POLL_OPTIONS,
                 create_consumer_group_if_not_exists=False,
             )
 
@@ -2055,10 +2074,8 @@
                 unique_name(),
                 stream_name,
                 topic_name,
-                0,
-                PollingStrategy.Next(),
-                10,
-                auto_commit=AutoCommit.Disabled(),
+                polling_strategy=PollingStrategy.Next(),
+                poll_options=POLL_OPTIONS,
                 **kwargs,
             )
 
@@ -2073,10 +2090,8 @@
                 unique_name(),
                 unique_name(),
                 unique_name(),
-                0,
-                PollingStrategy.Next(),
-                10,
-                auto_commit=AutoCommit.Disabled(),
+                polling_strategy=PollingStrategy.Next(),
+                poll_options=POLL_OPTIONS,
             )
 
     @pytest.mark.asyncio
@@ -2094,10 +2109,8 @@
                 unique_name(),
                 unique_name(),
                 unique_name(),
-                0,
-                PollingStrategy.Next(),
-                10,
-                auto_commit=AutoCommit.Disabled(),
+                polling_strategy=PollingStrategy.Next(),
+                poll_options=POLL_OPTIONS,
             )
 
     @pytest.mark.asyncio
@@ -2106,7 +2119,6 @@
         consumer_name = unique_name()
         stream_name = unique_name()
         topic_name = unique_name()
-        partition_id = 0
         shutdown_event = asyncio.Event()
 
         await iggy_client.create_stream(stream_name)
@@ -2116,10 +2128,8 @@
             consumer_name,
             stream_name,
             topic_name,
-            partition_id,
-            PollingStrategy.Next(),
-            10,
-            auto_commit=AutoCommit.Interval(timedelta(seconds=5)),
+            polling_strategy=PollingStrategy.Next(),
+            poll_options=INTERVAL_COMMIT_OPTIONS,
         )
 
         async def take(_: ReceiveMessage) -> None:
@@ -2128,3 +2138,531 @@
         shutdown_event.set()
 
         await consumer.consume_messages(take, shutdown_event)
+
+    @pytest.mark.asyncio
+    async def test_shutdown_with_on_read_moves_the_offset_back(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test OnRead shutdown moves the offset back to the last yielded message."""
+        consumer_name = unique_name()
+        stream_name = unique_name()
+        topic_name = unique_name()
+        partition_id = 0
+        test_messages = [f"OnRead test {i} - {unique_name()}" for i in range(5)]
+        yielded_count = 2
+        yielded_messages = []
+        resumed_messages = []
+
+        await iggy_client.create_stream(stream_name)
+        await iggy_client.create_topic(stream=stream_name, name=topic_name)
+
+        await iggy_client.send_messages(
+            stream_name,
+            topic_name,
+            partition_id,
+            [Message(message) for message in test_messages],
+        )
+
+        consumer = await iggy_client.consumer_group(
+            consumer_name,
+            stream_name,
+            topic_name,
+            polling_strategy=PollingStrategy.Next(),
+            poll_options=PollOptions(
+                count=len(test_messages),
+                max_wait=POLL_WAIT,
+                commit=CommitPolicy.OnRead(),
+            ),
+        )
+
+        iterator = consumer.iter_messages()
+        for _ in range(yielded_count):
+            message = await asyncio.wait_for(iterator.__anext__(), timeout=5)
+            yielded_messages.append(message)
+
+        # OnRead stored the batch end, so the shutdown has an offset to move back.
+        stored_offset = consumer.get_last_stored_offset(partition_id)
+        assert stored_offset is not None
+        assert stored_offset > yielded_messages[-1].offset()
+
+        await consumer.shutdown()
+
+        assert (
+            consumer.get_last_stored_offset(partition_id)
+            == yielded_messages[-1].offset()
+        )
+
+        resuming_consumer = await iggy_client.consumer_group(
+            consumer_name,
+            stream_name,
+            topic_name,
+            polling_strategy=PollingStrategy.Next(),
+            poll_options=POLL_OPTIONS,
+        )
+
+        iterator = resuming_consumer.iter_messages()
+        for _ in range(len(test_messages) - yielded_count):
+            message = await asyncio.wait_for(iterator.__anext__(), timeout=5)
+            resumed_messages.append(message.payload().decode())
+
+        assert resumed_messages == test_messages[yielded_count:]
+
+    @pytest.mark.asyncio
+    async def test_cancelled_iterations_lose_and_repeat_no_message(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test iterations cancelled at short deadlines yield each offset once."""
+        consumer_name = unique_name()
+        stream_name = unique_name()
+        topic_name = unique_name()
+        partition_id = 0
+
+        await iggy_client.create_stream(stream_name)
+        await iggy_client.create_topic(stream=stream_name, name=topic_name)
+        await iggy_client.send_messages(
+            stream_name,
+            topic_name,
+            partition_id,
+            [Message(f"Cancel test {i}") for i in range(CANCELLED_ITERATIONS_MESSAGES)],
+        )
+
+        consumer = await iggy_client.consumer_group(
+            consumer_name,
+            stream_name,
+            topic_name,
+            polling_strategy=PollingStrategy.First(),
+            poll_options=PollOptions(count=ONE_MESSAGE, max_wait=POLL_WAIT),
+            prefetch_messages=ONE_MESSAGE,
+        )
+        iterator = consumer.iter_messages()
+        try:
+            offsets = await read_offsets_with_cancels(
+                lambda: _message_offset(iterator), CANCELLED_ITERATIONS_MESSAGES - 1
+            )
+        finally:
+            await consumer.shutdown()
+
+        assert offsets == list(range(CANCELLED_ITERATIONS_MESSAGES))
+
+    @pytest.mark.asyncio
+    async def test_seek_from_the_consume_messages_callback_raises(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test seek() raises RuntimeError in consume_messages() and works after it."""
+        consumer_name = unique_name()
+        stream_name = unique_name()
+        topic_name = unique_name()
+        partition_id = 0
+        shutdown_event = asyncio.Event()
+        refusals: list[str] = []
+
+        await iggy_client.create_stream(stream_name)
+        await iggy_client.create_topic(stream=stream_name, name=topic_name)
+        await iggy_client.send_messages(
+            stream_name, topic_name, partition_id, [Message("Seek test")]
+        )
+
+        consumer = await iggy_client.consumer_group(
+            consumer_name,
+            stream_name,
+            topic_name,
+            polling_strategy=PollingStrategy.First(),
+            poll_options=POLL_OPTIONS,
+        )
+
+        async def take(_: ReceiveMessage) -> None:
+            try:
+                await consumer.seek(partition_id, PollingStrategy.First())
+            except RuntimeError as error:
+                refusals.append(str(error))
+            shutdown_event.set()
+
+        try:
+            await asyncio.wait_for(
+                consumer.consume_messages(take, shutdown_event),
+                timeout=RUN_TIMEOUT_SECONDS,
+            )
+            await consumer.seek(partition_id, PollingStrategy.First())
+        finally:
+            await consumer.shutdown()
+
+        assert refusals == ["seek() cannot run while consume_messages() runs"]
+
+    @pytest.mark.asyncio
+    async def test_consume_messages_without_an_event_runs_until_cancelled(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test consume_messages() without an event consumes until a cancel ends it."""
+        consumer_name = unique_name()
+        stream_name = unique_name()
+        topic_name = unique_name()
+        partition_id = 0
+        all_taken = asyncio.Event()
+        offsets: list[int] = []
+
+        await iggy_client.create_stream(stream_name)
+        await iggy_client.create_topic(stream=stream_name, name=topic_name)
+        await iggy_client.send_messages(
+            stream_name,
+            topic_name,
+            partition_id,
+            [Message(f"No event test {i}") for i in range(NO_EVENT_RUN_MESSAGES)],
+        )
+
+        consumer = await iggy_client.consumer_group(
+            consumer_name,
+            stream_name,
+            topic_name,
+            polling_strategy=PollingStrategy.First(),
+            poll_options=POLL_OPTIONS,
+        )
+
+        async def take(message: ReceiveMessage) -> None:
+            offsets.append(message.offset())
+            if len(offsets) == NO_EVENT_RUN_MESSAGES:
+                all_taken.set()
+
+        run = asyncio.ensure_future(consumer.consume_messages(take, None))
+        try:
+            await asyncio.wait_for(all_taken.wait(), timeout=RUN_TIMEOUT_SECONDS)
+            assert not run.done(), "The run ended without a cancel"
+        finally:
+            run.cancel()
+            # The run holds the consumer until the cancel ends it.
+            await asyncio.wait_for(consumer.shutdown(), timeout=RUN_TIMEOUT_SECONDS)
+
+        assert offsets == list(range(NO_EVENT_RUN_MESSAGES))
+
+    @pytest.mark.asyncio
+    async def test_auto_commit_after_delivery_stores_the_yielded_position_on_shutdown(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test CommitAfter.Delivery counts a message when it is yielded."""
+        stream_name, topic_name = unique_name(), unique_name()
+        await iggy_client.create_stream(stream_name)
+        await iggy_client.create_topic(stream=stream_name, name=topic_name)
+        await iggy_client.send_messages(
+            stream_name, topic_name, 0, [Message(f"Delivery {i}") for i in range(3)]
+        )
+        consumer = await iggy_client.consumer_group(
+            unique_name(),
+            stream_name,
+            topic_name,
+            polling_strategy=PollingStrategy.First(),
+            poll_options=PollOptions(
+                count=10,
+                max_wait=POLL_WAIT,
+                commit=CommitPolicy.Auto(
+                    AutoCommit(every=CommitEvery.Message(), after=CommitAfter.Delivery)
+                ),
+            ),
+        )
+        iterator = consumer.iter_messages()
+        first = await asyncio.wait_for(anext(iterator), timeout=RUN_TIMEOUT_SECONDS)
+        second = await asyncio.wait_for(anext(iterator), timeout=RUN_TIMEOUT_SECONDS)
+
+        await consumer.shutdown()
+
+        assert consumer.commit_policy == CommitPolicy.Auto(
+            AutoCommit(every=CommitEvery.Message(), after=CommitAfter.Delivery)
+        )
+        assert [first.offset(), second.offset()] == [0, 1]
+        assert consumer.get_last_stored_offset(0) == second.offset()
+
+    @pytest.mark.asyncio
+    async def test_auto_commit_after_processing_ends_the_run_on_a_raising_callback(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test a raising callback ends the run and leaves its message uncounted."""
+        stream_name, topic_name = unique_name(), unique_name()
+        await iggy_client.create_stream(stream_name)
+        await iggy_client.create_topic(stream=stream_name, name=topic_name)
+        await iggy_client.send_messages(
+            stream_name, topic_name, 0, [Message(f"Processing {i}") for i in range(3)]
+        )
+        consumer = await iggy_client.consumer_group(
+            unique_name(),
+            stream_name,
+            topic_name,
+            polling_strategy=PollingStrategy.First(),
+            poll_options=PollOptions(
+                count=10,
+                max_wait=POLL_WAIT,
+                commit=CommitPolicy.Auto(AutoCommit(every=CommitEvery.Message())),
+            ),
+        )
+        handled: list[int] = []
+
+        async def take(message: ReceiveMessage) -> None:
+            if message.offset() == 1:
+                raise ValueError("handler failed at offset 1")
+            handled.append(message.offset())
+
+        try:
+            with pytest.raises(ValueError, match="handler failed at offset 1"):
+                await asyncio.wait_for(
+                    consumer.consume_messages(take, None), timeout=RUN_TIMEOUT_SECONDS
+                )
+        finally:
+            await consumer.shutdown()
+
+        assert handled == [0]
+        assert consumer.get_last_stored_offset(0) == 0
+
+    @pytest.mark.asyncio
+    async def test_auto_commit_after_marked_stores_only_the_marked_position(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test mark() counts under CommitAfter.Marked and is refused without Auto."""
+        stream_name, topic_name = unique_name(), unique_name()
+        await iggy_client.create_stream(stream_name)
+        await iggy_client.create_topic(stream=stream_name, name=topic_name)
+        await iggy_client.send_messages(
+            stream_name, topic_name, 0, [Message(f"Marked {i}") for i in range(3)]
+        )
+        marked = await iggy_client.consumer_group(
+            unique_name(),
+            stream_name,
+            topic_name,
+            polling_strategy=PollingStrategy.First(),
+            poll_options=PollOptions(
+                count=10,
+                max_wait=POLL_WAIT,
+                commit=CommitPolicy.Auto(
+                    AutoCommit(every=CommitEvery.Message(), after=CommitAfter.Marked)
+                ),
+            ),
+        )
+        manual = await iggy_client.consumer_group(
+            unique_name(),
+            stream_name,
+            topic_name,
+            polling_strategy=PollingStrategy.First(),
+            poll_options=POLL_OPTIONS,
+        )
+        try:
+            iterator = marked.iter_messages()
+            first = await asyncio.wait_for(anext(iterator), timeout=RUN_TIMEOUT_SECONDS)
+            await asyncio.wait_for(anext(iterator), timeout=RUN_TIMEOUT_SECONDS)
+            marked.mark(first.checkpoint())
+            unmarked = await asyncio.wait_for(
+                anext(manual.iter_messages()), timeout=RUN_TIMEOUT_SECONDS
+            )
+            with pytest.raises(IggyError) as refused:
+                manual.mark(unmarked.checkpoint())
+        finally:
+            await marked.shutdown()
+            await manual.shutdown()
+
+        assert marked.get_last_stored_offset(0) == first.offset()
+        assert refused.value.code == INVALID_COMMAND
+
+    @pytest.mark.asyncio
+    async def test_calls_that_wait_for_the_run_raise_in_its_callback(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test shutdown(), iteration and a nested run raise in a callback, not hang."""
+        consumer_name = unique_name()
+        stream_name = unique_name()
+        topic_name = unique_name()
+        partition_id = 0
+        shutdown_event = asyncio.Event()
+        refusals: list[str] = []
+
+        await iggy_client.create_stream(stream_name)
+        await iggy_client.create_topic(stream=stream_name, name=topic_name)
+        await iggy_client.send_messages(
+            stream_name, topic_name, partition_id, [Message("Callback test")]
+        )
+
+        consumer = await iggy_client.consumer_group(
+            consumer_name,
+            stream_name,
+            topic_name,
+            polling_strategy=PollingStrategy.First(),
+            poll_options=POLL_OPTIONS,
+        )
+
+        async def take(_: ReceiveMessage) -> None:
+            calls = [
+                consumer.shutdown,
+                lambda: anext(consumer.iter_messages()),
+                lambda: consumer.consume_messages(take, None),
+            ]
+            for call in calls:
+                try:
+                    await call()
+                except RuntimeError as error:
+                    refusals.append(str(error))
+            shutdown_event.set()
+
+        try:
+            await asyncio.wait_for(
+                consumer.consume_messages(take, shutdown_event),
+                timeout=RUN_TIMEOUT_SECONDS,
+            )
+        finally:
+            await asyncio.wait_for(consumer.shutdown(), timeout=RUN_TIMEOUT_SECONDS)
+
+        assert refusals == [
+            "shutdown() cannot run in a consume_messages() callback",
+            "Iterating the consumer cannot run in a consume_messages() callback",
+            "consume_messages() cannot run in a consume_messages() callback",
+        ]
+
+    @pytest.mark.asyncio
+    async def test_shutdown_ends_a_parked_iteration(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test shutdown() from another task ends an iteration parked while idle."""
+        consumer_name = unique_name()
+        stream_name = unique_name()
+        topic_name = unique_name()
+
+        await iggy_client.create_stream(stream_name)
+        await iggy_client.create_topic(stream=stream_name, name=topic_name)
+
+        consumer = await iggy_client.consumer_group(
+            consumer_name,
+            stream_name,
+            topic_name,
+            polling_strategy=PollingStrategy.First(),
+            poll_options=POLL_OPTIONS,
+        )
+        parked = asyncio.ensure_future(anext(consumer.iter_messages()))
+        # Long enough for the iteration to take the consumer and wait for a message.
+        await asyncio.sleep(POLL_WAIT.total_seconds())
+        assert not parked.done()
+
+        await asyncio.wait_for(consumer.shutdown(), timeout=RUN_TIMEOUT_SECONDS)
+
+        with pytest.raises(StopAsyncIteration):
+            await asyncio.wait_for(parked, timeout=RUN_TIMEOUT_SECONDS)
+
+    @pytest.mark.asyncio
+    async def test_cancel_of_a_run_with_an_event_ends_the_run(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test a cancel ends a run whose shutdown event is never set."""
+        consumer_name = unique_name()
+        stream_name = unique_name()
+        topic_name = unique_name()
+        partition_id = 0
+        taken = asyncio.Event()
+
+        await iggy_client.create_stream(stream_name)
+        await iggy_client.create_topic(stream=stream_name, name=topic_name)
+        await iggy_client.send_messages(
+            stream_name, topic_name, partition_id, [Message("Cancel run test")]
+        )
+
+        consumer = await iggy_client.consumer_group(
+            consumer_name,
+            stream_name,
+            topic_name,
+            polling_strategy=PollingStrategy.First(),
+            poll_options=POLL_OPTIONS,
+        )
+
+        async def take(_: ReceiveMessage) -> None:
+            taken.set()
+
+        run = asyncio.ensure_future(consumer.consume_messages(take, asyncio.Event()))
+        await asyncio.wait_for(taken.wait(), timeout=RUN_TIMEOUT_SECONDS)
+        run.cancel()
+
+        # The run holds the consumer, so the shutdown waits for the cancel to end it.
+        await asyncio.wait_for(consumer.shutdown(), timeout=RUN_TIMEOUT_SECONDS)
+
+    @pytest.mark.asyncio
+    async def test_async_with_shuts_the_consumer_down_on_exit(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test async with yields the consumer and shuts it down when the block ends."""
+        consumer_name = unique_name()
+        stream_name = unique_name()
+        topic_name = unique_name()
+        partition_id = 0
+
+        await iggy_client.create_stream(stream_name)
+        await iggy_client.create_topic(stream=stream_name, name=topic_name)
+        await iggy_client.send_messages(
+            stream_name, topic_name, partition_id, [Message("Context test")]
+        )
+
+        consumer = await iggy_client.consumer_group(
+            consumer_name,
+            stream_name,
+            topic_name,
+            polling_strategy=PollingStrategy.First(),
+            poll_options=POLL_OPTIONS,
+        )
+        async with consumer as entered:
+            iterator = entered.iter_messages()
+            message = await asyncio.wait_for(anext(iterator), timeout=5)
+
+        assert entered is consumer
+        assert message.payload() == b"Context test"
+        with pytest.raises(StopAsyncIteration):
+            await asyncio.wait_for(anext(iterator), timeout=RUN_TIMEOUT_SECONDS)
+
+
+class TestStandaloneConsumer:
+    """Test the consumer of chosen partitions built by IggyClient.consumer()."""
+
+    @pytest.mark.asyncio
+    async def test_standalone_consumer_reads_its_partitions_and_stores_on_shutdown(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test a consumer of two partitions reads both and stores each on shutdown."""
+        consumer_name = unique_name()
+        stream_name = unique_name()
+        topic_name = unique_name()
+        partitions = [0, 1]
+        await iggy_client.create_stream(stream_name)
+        await iggy_client.create_topic(
+            stream=stream_name,
+            name=topic_name,
+            options=TopicOptions(partitions_count=len(partitions)),
+        )
+        for partition_id in partitions:
+            await iggy_client.send_messages(
+                stream_name,
+                topic_name,
+                partition_id,
+                [Message(f"Standalone {partition_id} {i}") for i in range(2)],
+            )
+
+        consumer = await iggy_client.consumer(
+            consumer_name,
+            stream_name,
+            topic_name,
+            partitions=partitions,
+            polling_strategy=PollingStrategy.First(),
+            poll_options=PollOptions(
+                count=10,
+                max_wait=POLL_WAIT,
+                commit=CommitPolicy.Auto(
+                    AutoCommit(every=CommitEvery.Message(), after=CommitAfter.Delivery)
+                ),
+            ),
+        )
+        async with consumer:
+            iterator = consumer.iter_messages()
+            received = [
+                await asyncio.wait_for(anext(iterator), timeout=RUN_TIMEOUT_SECONDS)
+                for _ in range(4)
+            ]
+
+        assert sorted(
+            (message.partition_id(), message.payload().decode()) for message in received
+        ) == [(p, f"Standalone {p} {i}") for p in partitions for i in range(2)]
+        for partition_id in partitions:
+            stored = await iggy_client.get_consumer_offset(
+                stream_name,
+                topic_name,
+                consumer=Consumer.Single(consumer_name),
+                partition_id=partition_id,
+            )
+            assert stored is not None
+            assert stored.stored_offset == 1
diff --git a/foreign/python/tests/test_http_config.py b/foreign/python/tests/test_http_config.py
index aefe11d..a2250e4 100644
--- a/foreign/python/tests/test_http_config.py
+++ b/foreign/python/tests/test_http_config.py
@@ -32,11 +32,21 @@
 
 import pytest
 
-from apache_iggy import Consumer, HttpConfig, IggyClient, PollingStrategy
+from apache_iggy import (
+    CommitPolicy,
+    Consumer,
+    HttpConfig,
+    IggyClient,
+    IggyError,
+    PollingStrategy,
+    PollOptions,
+)
 from apache_iggy import SendMessage as Message
 
 from .utils import get_http_server_config, wait_for_ping
 
+FEATURE_UNAVAILABLE = 5
+
 
 @pytest.mark.unit
 class TestHttpConfig:
@@ -259,17 +269,16 @@
             messages=[Message(payload)],
         )
 
-        polled_messages = await client.poll_messages(
-            stream=stream_name,
-            topic=topic_name,
+        polled = await client.poll_messages(
+            stream_name,
+            topic_name,
             consumer=Consumer.Single("http-round-trip"),
             partition_id=0,
             polling_strategy=PollingStrategy.First(),
-            count=1,
-            auto_commit=True,
+            options=PollOptions(count=1, commit=CommitPolicy.OnRead()),
         )
 
-        assert [message.payload().decode() for message in polled_messages] == [payload]
+        assert [message.payload().decode() for message in polled.messages] == [payload]
 
     @pytest.mark.asyncio
     async def test_jwt_config_actually_authenticates(self, unique_name):
@@ -333,13 +342,9 @@
     async def test_consumer_group_is_rejected(self, unique_name):
         """Test that a consumer group fails loudly over HTTP, not silently.
 
-        `join_consumer_group` answers `Feature is unavailable` over HTTP, and
-        `consumer_group(...)` awaits that join before returning, so the
-        failure surfaces at construction. `auto_join_consumer_group=False`
-        is not a way around it: the join is skipped, but the first poll
-        raises the same error, because a group member is pinned to no
-        partition and a consumer-group poll without one is rejected
-        client-side.
+        The HTTP client refuses `join_consumer_group` with
+        `FeatureUnavailable`, and `consumer_group(...)` always joins before
+        it returns, so the failure surfaces at construction.
         """
         host, port = get_http_server_config()
         stream_name = unique_name()
@@ -353,7 +358,8 @@
         await client.create_stream(stream_name)
         await client.create_topic(stream=stream_name, name=topic_name)
 
-        with pytest.raises(RuntimeError, match="Feature is unavailable"):
+        with pytest.raises(IggyError, match="Feature is unavailable") as refusal:
             await client.consumer_group(
                 name=unique_name(), stream=stream_name, topic=topic_name
             )
+        assert refusal.value.code == FEATURE_UNAVAILABLE
diff --git a/foreign/python/tests/test_message_operations.py b/foreign/python/tests/test_message_operations.py
index 8817e16..c15c349 100644
--- a/foreign/python/tests/test_message_operations.py
+++ b/foreign/python/tests/test_message_operations.py
@@ -16,19 +16,24 @@
 # under the License.
 
 import asyncio
+import contextlib
+import time
 import uuid
 
 import pytest
 
 from apache_iggy import (
+    AutoCommit,
+    CommitPolicy,
     Consumer,
-    ConsumerPosition,
     HeaderKey,
     HeaderValue,
     IggyClient,
+    IggyError,
     PartitionContext,
     Partitioning,
     PollingStrategy,
+    PollOptions,
     TopicOptions,
     UserHeaders,
 )
@@ -36,6 +41,14 @@
 
 from .utils import wait_for_consumer_group_assignment
 
+INVALID_CONFIGURATION = 2
+INVALID_COMMAND = 3
+CONSUMER_OFFSET_NOT_FOUND = 3021
+HISTORY_UNAVAILABLE = 87
+CONSUMER_GROUP_PARTITION_NOT_OWNED = 5009
+# Far longer than a local reply takes.
+REPLY_WAIT_SECONDS = 1
+
 
 class TestPartitioning:
     """Test message partitioning strategy construction."""
@@ -79,46 +92,311 @@
 
 
 class TestPartitionContext:
-    """Test the context and position values that fence polls and commits."""
+    """Test the context, checkpoint and continuation values that fence polls."""
 
     @pytest.mark.unit
-    def test_context_and_position_are_comparable_values(self):
+    def test_context_is_a_comparable_value(self):
         context = PartitionContext(incarnation=7, owner_generation=11, metadata_op=13)
-        position = ConsumerPosition(partition_id=3, offset=42, context=context)
 
         assert context.incarnation == 7
         assert context.owner_generation == 11
         assert context.metadata_op == 13
-        assert position.partition_id == 3
-        assert position.offset == 42
-        assert position.context == context
-        assert repr(position) == (
-            "ConsumerPosition(partition_id=3, offset=42, context="
-            "PartitionContext(incarnation=7, owner_generation=11, metadata_op=13))"
+        assert repr(context) == (
+            "PartitionContext(incarnation=7, owner_generation=11, metadata_op=13)"
         )
 
-        equal_position = ConsumerPosition(3, 42, PartitionContext(7, 11, 13))
-        assert position == equal_position
-        assert hash(position) == hash(equal_position)
-        assert position != ConsumerPosition(3, 42, PartitionContext(8, 11, 13))
+        equal_context = PartitionContext(7, 11, 13)
+        assert context == equal_context
+        assert hash(context) == hash(equal_context)
+        assert context != PartitionContext(8, 11, 13)
 
-    @pytest.mark.unit
-    async def test_poll_messages_accepts_a_caller_context(self):
-        client = IggyClient()
-        poll = client.poll_messages(
-            "stream",
-            "topic",
+    @pytest.mark.asyncio
+    async def test_checkpoint_is_a_comparable_value(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test a checkpoint names its message and hashes as it compares."""
+        stream_name = unique_name()
+        topic_name = unique_name()
+        partition_id = 0
+
+        await iggy_client.create_stream(stream_name)
+        await iggy_client.create_topic(stream=stream_name, name=topic_name)
+        await iggy_client.send_messages(
+            stream=stream_name,
+            topic=topic_name,
+            partitioning=partition_id,
+            messages=[Message("first"), Message("second")],
+        )
+
+        polled_messages = await iggy_client.poll_messages(
+            stream=stream_name,
+            topic=topic_name,
             consumer=Consumer.Single(1),
-            polling_strategy=PollingStrategy.Offset(value=42),
-            count=1,
-            auto_commit=False,
-            partition_id=3,
-            context=PartitionContext(7, 11, 13),
+            partition_id=partition_id,
+            polling_strategy=PollingStrategy.First(),
+            options=PollOptions(count=10),
         )
 
-        # The context was accepted; the poll fails only because nothing is connected.
-        with pytest.raises(RuntimeError, match="Disconnected"):
-            await poll
+        first, second = polled_messages.messages
+        checkpoint = first.checkpoint()
+        assert checkpoint.partition_id == partition_id
+        assert checkpoint.offset == first.offset()
+        assert repr(checkpoint) == (
+            f"Checkpoint(partition_id={partition_id}, offset={first.offset()})"
+        )
+        assert checkpoint == polled_messages.checkpoint_at(0)
+        assert hash(checkpoint) == hash(polled_messages.checkpoint_at(0))
+        assert checkpoint != second.checkpoint()
+        assert {checkpoint, polled_messages.checkpoint_at(0), second.checkpoint()} == {
+            checkpoint,
+            second.checkpoint(),
+        }
+        assert polled_messages.checkpoint() == second.checkpoint()
+        assert polled_messages.checkpoint_at(len(polled_messages.messages)) is None
+
+    @pytest.mark.asyncio
+    async def test_client_stores_reads_and_deletes_consumer_offsets(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test the client-level offset calls by partition and by checkpoint."""
+        stream_name = unique_name()
+        topic_name = unique_name()
+        partition_id = 0
+        consumer = Consumer.Single(7)
+        await iggy_client.create_stream(stream_name)
+        await iggy_client.create_topic(stream=stream_name, name=topic_name)
+        await iggy_client.send_messages(
+            stream=stream_name,
+            topic=topic_name,
+            partitioning=partition_id,
+            messages=[Message(f"offset {i}") for i in range(3)],
+        )
+        polled = await iggy_client.poll_messages(
+            stream=stream_name,
+            topic=topic_name,
+            consumer=consumer,
+            partition_id=partition_id,
+            polling_strategy=PollingStrategy.First(),
+            options=PollOptions(count=10),
+        )
+
+        def offset():
+            return iggy_client.get_consumer_offset(
+                stream_name, topic_name, consumer=consumer, partition_id=partition_id
+            )
+
+        assert await offset() is None
+        await iggy_client.store_consumer_offset(
+            stream_name,
+            topic_name,
+            consumer=consumer,
+            partition_id=partition_id,
+            offset=1,
+        )
+        stored_by_partition = await offset()
+        checkpoint = polled.checkpoint()
+        assert checkpoint is not None
+        await iggy_client.store_consumer_position(
+            stream_name, topic_name, consumer=consumer, checkpoint=checkpoint
+        )
+        stored_by_checkpoint = await offset()
+        await iggy_client.delete_consumer_offset(
+            stream_name, topic_name, consumer=consumer, partition_id=partition_id
+        )
+        deleted = await offset()
+        with pytest.raises(IggyError) as missing:
+            await iggy_client.delete_consumer_offset(
+                stream_name, topic_name, consumer=consumer, partition_id=partition_id
+            )
+
+        assert stored_by_partition is not None
+        assert (
+            stored_by_partition.partition_id,
+            stored_by_partition.current_offset,
+            stored_by_partition.stored_offset,
+        ) == (partition_id, 2, 1)
+        assert stored_by_checkpoint is not None
+        assert stored_by_checkpoint.stored_offset == 2
+        assert deleted is None
+        assert missing.value.code == CONSUMER_OFFSET_NOT_FOUND
+
+    @pytest.mark.asyncio
+    async def test_continuation_reads_on_after_the_batch(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test a continuation reads from the message after its batch."""
+        stream_name = unique_name()
+        topic_name = unique_name()
+        partition_id = 0
+        test_messages = [f"Continuation test {i} - {unique_name()}" for i in range(5)]
+
+        await iggy_client.create_stream(stream_name)
+        await iggy_client.create_topic(stream=stream_name, name=topic_name)
+        await iggy_client.send_messages(
+            stream=stream_name,
+            topic=topic_name,
+            partitioning=partition_id,
+            messages=[Message(message) for message in test_messages],
+        )
+
+        first_batch = await iggy_client.poll_messages(
+            stream=stream_name,
+            topic=topic_name,
+            consumer=Consumer.Single(1),
+            partition_id=partition_id,
+            polling_strategy=PollingStrategy.First(),
+            options=PollOptions(count=2),
+        )
+        next_batch = await iggy_client.poll_messages(
+            stream=stream_name,
+            topic=topic_name,
+            consumer=Consumer.Single(1),
+            partition_id=partition_id,
+            polling_strategy=first_batch.continuation(),
+            options=PollOptions(count=10),
+        )
+
+        assert [
+            message.payload().decode("utf-8") for message in first_batch.messages
+        ] == test_messages[:2]
+        assert [
+            message.payload().decode("utf-8") for message in next_batch.messages
+        ] == test_messages[2:]
+
+    @pytest.mark.asyncio
+    async def test_continuation_of_an_empty_batch_keeps_its_start(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test an empty batch continues from the offset its poll started at."""
+        stream_name = unique_name()
+        topic_name = unique_name()
+        partition_id = 0
+        existing_messages = [
+            f"Existing continuation test {i} - {unique_name()}" for i in range(3)
+        ]
+        new_messages = [f"Continuation test {i} - {unique_name()}" for i in range(2)]
+        # A new partition starts at offset 0.
+        start_offset = len(existing_messages)
+
+        await iggy_client.create_stream(stream_name)
+        await iggy_client.create_topic(stream=stream_name, name=topic_name)
+        await iggy_client.send_messages(
+            stream=stream_name,
+            topic=topic_name,
+            partitioning=partition_id,
+            messages=[Message(message) for message in existing_messages],
+        )
+
+        empty_batch = await iggy_client.poll_messages(
+            stream=stream_name,
+            topic=topic_name,
+            consumer=Consumer.Single(1),
+            partition_id=partition_id,
+            polling_strategy=PollingStrategy.Offset(value=start_offset),
+            options=PollOptions(count=10),
+        )
+        assert empty_batch.messages == ()
+        assert empty_batch.partition_id == partition_id
+        assert empty_batch.checkpoint() is None
+
+        await iggy_client.send_messages(
+            stream=stream_name,
+            topic=topic_name,
+            partitioning=partition_id,
+            messages=[Message(message) for message in new_messages],
+        )
+
+        resumed_batch = await iggy_client.poll_messages(
+            stream=stream_name,
+            topic=topic_name,
+            consumer=Consumer.Single(1),
+            partition_id=partition_id,
+            polling_strategy=empty_batch.continuation(),
+            options=PollOptions(count=10),
+        )
+        assert [
+            message.payload().decode("utf-8") for message in resumed_batch.messages
+        ] == new_messages
+
+    @pytest.mark.asyncio
+    async def test_continuation_of_another_partition_is_refused(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test a continuation reads only the partition of its batch."""
+        stream_name = unique_name()
+        topic_name = unique_name()
+
+        await iggy_client.create_stream(stream_name)
+        await iggy_client.create_topic(
+            stream=stream_name,
+            name=topic_name,
+            options=TopicOptions(partitions_count=2),
+        )
+        await iggy_client.send_messages(
+            stream=stream_name,
+            topic=topic_name,
+            partitioning=0,
+            messages=[Message("Partition 0")],
+        )
+        polled_messages = await iggy_client.poll_messages(
+            stream=stream_name,
+            topic=topic_name,
+            consumer=Consumer.Single(1),
+            partition_id=0,
+            polling_strategy=PollingStrategy.First(),
+            options=PollOptions(count=10),
+        )
+
+        with pytest.raises(IggyError) as refusal:
+            await iggy_client.poll_messages(
+                stream=stream_name,
+                topic=topic_name,
+                consumer=Consumer.Single(1),
+                partition_id=1,
+                polling_strategy=polled_messages.continuation(),
+                options=PollOptions(count=10),
+            )
+        assert refusal.value.code == INVALID_COMMAND
+
+    @pytest.mark.asyncio
+    async def test_continuation_after_the_partition_was_created_again_is_refused(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test a continuation does not read a partition created again at its id."""
+        stream_name = unique_name()
+        topic_name = unique_name()
+        partition_id = 0
+
+        await iggy_client.create_stream(stream_name)
+        await iggy_client.create_topic(stream=stream_name, name=topic_name)
+        await iggy_client.send_messages(
+            stream=stream_name,
+            topic=topic_name,
+            partitioning=partition_id,
+            messages=[Message("Before the partition was created again")],
+        )
+        polled_messages = await iggy_client.poll_messages(
+            stream=stream_name,
+            topic=topic_name,
+            consumer=Consumer.Single(1),
+            partition_id=partition_id,
+            polling_strategy=PollingStrategy.First(),
+            options=PollOptions(count=10),
+        )
+
+        await iggy_client.delete_partitions(stream_name, topic_name, 1)
+        await iggy_client.create_partitions(stream_name, topic_name, 1)
+
+        with pytest.raises(IggyError) as refusal:
+            await iggy_client.poll_messages(
+                stream=stream_name,
+                topic=topic_name,
+                consumer=Consumer.Single(1),
+                partition_id=partition_id,
+                polling_strategy=polled_messages.continuation(),
+                options=PollOptions(count=10),
+            )
+        assert refusal.value.code == HISTORY_UNAVAILABLE
 
 
 class TestMessageOperations:
@@ -150,15 +428,81 @@
             consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.First(),
-            count=10,
-            auto_commit=True,
+            options=PollOptions(count=10, commit=CommitPolicy.OnRead()),
         )
 
-        assert [message.payload().decode() for message in polled_messages] == (
+        assert [message.payload().decode() for message in polled_messages.messages] == (
             test_messages
         )
 
     @pytest.mark.asyncio
+    async def test_poll_refuses_auto_commit(self, iggy_client: IggyClient, unique_name):
+        """Test a one-shot poll refuses CommitPolicy.Auto, which it cannot count."""
+        stream_name = unique_name()
+        topic_name = unique_name()
+        await iggy_client.create_stream(stream_name)
+        await iggy_client.create_topic(stream=stream_name, name=topic_name)
+
+        with pytest.raises(IggyError) as refused:
+            await iggy_client.poll_messages(
+                stream=stream_name,
+                topic=topic_name,
+                consumer=Consumer.Single(1),
+                partition_id=0,
+                polling_strategy=PollingStrategy.First(),
+                options=PollOptions(commit=CommitPolicy.Auto(AutoCommit.default())),
+            )
+
+        assert refused.value.code == INVALID_CONFIGURATION
+
+    @pytest.mark.asyncio
+    async def test_cancelled_on_read_poll_settles_through_its_one_reply(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test a cancelled OnRead poll settles its partition through its one reply."""
+        stream_name = unique_name()
+        topic_name = unique_name()
+        partition_id = 0
+        consumer = Consumer.Single(1)
+        on_read = PollOptions(count=1, commit=CommitPolicy.OnRead())
+
+        await iggy_client.create_stream(stream_name)
+        await iggy_client.create_topic(stream=stream_name, name=topic_name)
+        await iggy_client.send_messages(
+            stream=stream_name,
+            topic=topic_name,
+            partitioning=partition_id,
+            messages=[Message("first"), Message("second")],
+        )
+
+        def poll():
+            return iggy_client.poll_messages(
+                stream=stream_name,
+                topic=topic_name,
+                consumer=consumer,
+                partition_id=partition_id,
+                polling_strategy=PollingStrategy.Next(),
+                options=on_read,
+            )
+
+        cancelled = asyncio.ensure_future(poll())
+        await asyncio.sleep(0)
+        # Blocks the event loop while the reply arrives, so the cancel lands after it.
+        time.sleep(REPLY_WAIT_SECONDS)
+        cancelled.cancel()
+        with contextlib.suppress(asyncio.CancelledError):
+            await cancelled
+
+        # The reply of the cancelled poll settles it, so the next poll is not blocked.
+        # The cancel wins and nothing is stored, or the store happened and the batch
+        # went with the cancelled call.
+        polled = await poll()
+        assert [message.payload() for message in polled.messages] in (
+            [b"first"],
+            [b"second"],
+        )
+
+    @pytest.mark.asyncio
     async def test_send_messages_reports_committed_confirmation(
         self, iggy_client: IggyClient, unique_name
     ):
@@ -184,11 +528,12 @@
             consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.First(),
-            count=10,
-            auto_commit=True,
+            options=PollOptions(count=10, commit=CommitPolicy.OnRead()),
         )
 
-        assert [message.payload().decode() for message in polled_messages] == payloads
+        assert [
+            message.payload().decode() for message in polled_messages.messages
+        ] == payloads
 
         # The server confirms committed sends with the partition and the base
         # offset of the batch; the whole send targeted one partition, so a
@@ -196,7 +541,7 @@
         assert len(response.confirmations) == 1
         confirmation = response.confirmations[0]
         assert confirmation.partition_id == partition_id
-        assert confirmation.base_offset == polled_messages[0].offset()
+        assert confirmation.base_offset == polled_messages.messages[0].offset()
 
     @pytest.mark.asyncio
     async def test_send_messages_with_partition_id_strategy(
@@ -325,11 +670,10 @@
             consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.First(),
-            count=10,
-            auto_commit=True,
+            options=PollOptions(count=10, commit=CommitPolicy.OnRead()),
         )
 
-        assert [message.payload().decode() for message in polled_messages] == (
+        assert [message.payload().decode() for message in polled_messages.messages] == (
             test_messages
         )
 
@@ -358,12 +702,11 @@
             consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.Last(),
-            count=1,
-            auto_commit=True,
+            options=PollOptions(count=1, commit=CommitPolicy.OnRead()),
         )
 
-        assert len(polled_messages) >= 1
-        msg = polled_messages[0]
+        assert len(polled_messages.messages) >= 1
+        msg = polled_messages.messages[0]
 
         assert msg.payload().decode("utf-8") == test_payload
         assert isinstance(msg.offset(), int) and msg.offset() >= 0
@@ -413,12 +756,11 @@
             consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.Last(),
-            count=1,
-            auto_commit=True,
+            options=PollOptions(count=1, commit=CommitPolicy.OnRead()),
         )
 
-        assert len(polled_messages) == 1
-        message = polled_messages[0]
+        assert len(polled_messages.messages) == 1
+        message = polled_messages.messages[0]
         assert message.id() == message_id
         typed_headers = message.user_headers()
         assert typed_headers is not None
@@ -462,12 +804,11 @@
             consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.Last(),
-            count=1,
-            auto_commit=True,
+            options=PollOptions(count=1, commit=CommitPolicy.OnRead()),
         )
 
-        assert len(polled_messages) == 1
-        headers = polled_messages[0].user_headers()
+        assert len(polled_messages.messages) == 1
+        headers = polled_messages.messages[0].user_headers()
         assert headers == user_headers
 
     @pytest.mark.asyncio
@@ -503,11 +844,10 @@
             consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.Last(),
-            count=1,
-            auto_commit=True,
+            options=PollOptions(count=1, commit=CommitPolicy.OnRead()),
         )
 
-        headers = polled_messages[0].user_headers()
+        headers = polled_messages.messages[0].user_headers()
         assert headers is not None
         assert headers == {
             HeaderKey.String("small"): HeaderValue.UnsignedInt8(200),
@@ -803,12 +1143,11 @@
             consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.Last(),
-            count=1,
-            auto_commit=True,
+            options=PollOptions(count=1, commit=CommitPolicy.OnRead()),
         )
 
-        assert len(polled_messages) == 1
-        message = polled_messages[0]
+        assert len(polled_messages.messages) == 1
+        message = polled_messages.messages[0]
         assert message.payload() == expected_payload
         assert message.payload().decode("utf-8") == payload
         assert message.length() == len(expected_payload)
@@ -843,12 +1182,11 @@
             consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.Last(),
-            count=1,
-            auto_commit=True,
+            options=PollOptions(count=1, commit=CommitPolicy.OnRead()),
         )
 
-        assert len(polled_messages) == 1
-        message = polled_messages[0]
+        assert len(polled_messages.messages) == 1
+        message = polled_messages.messages[0]
         assert message.payload() == expected_payload
         assert message.length() == len(expected_payload)
 
@@ -881,12 +1219,11 @@
             consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.Last(),
-            count=1,
-            auto_commit=True,
+            options=PollOptions(count=1, commit=CommitPolicy.OnRead()),
         )
 
-        assert len(polled_messages) == 1
-        message = polled_messages[0]
+        assert len(polled_messages.messages) == 1
+        message = polled_messages.messages[0]
         assert message.payload() == payload
         assert message.length() == len(payload)
 
@@ -916,12 +1253,11 @@
             consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.First(),
-            count=1,
-            auto_commit=False,
+            options=PollOptions(count=1),
         )
 
-        assert len(polled_messages) == 1
-        assert polled_messages[0].payload().decode("utf-8") == test_messages[0]
+        assert len(polled_messages.messages) == 1
+        assert polled_messages.messages[0].payload().decode("utf-8") == test_messages[0]
 
     @pytest.mark.asyncio
     async def test_poll_messages_with_large_count_returns_all_available_messages(
@@ -949,13 +1285,12 @@
             consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.First(),
-            count=10,
-            auto_commit=False,
+            options=PollOptions(count=10),
         )
 
-        assert len(polled_messages) == len(test_messages)
+        assert len(polled_messages.messages) == len(test_messages)
         assert [
-            message.payload().decode("utf-8") for message in polled_messages
+            message.payload().decode("utf-8") for message in polled_messages.messages
         ] == test_messages
 
     @pytest.mark.asyncio
@@ -985,8 +1320,7 @@
                 consumer=Consumer.Single(1),
                 partition_id=partition_id,
                 polling_strategy=PollingStrategy.First(),
-                count=0,
-                auto_commit=False,
+                options=PollOptions(count=0),
             )
 
     @pytest.mark.asyncio
@@ -1021,8 +1355,7 @@
                 consumer=Consumer.Single(1),
                 partition_id=1,
                 polling_strategy=PollingStrategy.First(),
-                count=1,
-                auto_commit=False,
+                options=PollOptions(count=1),
             )
 
     @pytest.mark.asyncio
@@ -1052,11 +1385,10 @@
             consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.Last(),
-            count=1,
-            auto_commit=False,
+            options=PollOptions(count=1),
         )
-        assert len(last_messages) == 1
-        assert last_messages[0].payload().decode("utf-8") == test_messages[-1]
+        assert len(last_messages.messages) == 1
+        assert last_messages.messages[0].payload().decode("utf-8") == test_messages[-1]
 
     @pytest.mark.asyncio
     async def test_polling_strategy_last_with_count_two_returns_last_two_messages(
@@ -1085,12 +1417,11 @@
             consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.Last(),
-            count=2,
-            auto_commit=False,
+            options=PollOptions(count=2),
         )
-        assert len(last_messages) == 2
+        assert len(last_messages.messages) == 2
         assert [
-            message.payload().decode("utf-8") for message in last_messages
+            message.payload().decode("utf-8") for message in last_messages.messages
         ] == test_messages[-2:]
 
     @pytest.mark.asyncio
@@ -1120,10 +1451,9 @@
             consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.First(),
-            count=10,
-            auto_commit=False,
+            options=PollOptions(count=10),
         )
-        start_offset = first_messages[2].offset()
+        start_offset = first_messages.messages[2].offset()
 
         offset_messages = await iggy_client.poll_messages(
             stream=stream_name,
@@ -1131,13 +1461,12 @@
             consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.Offset(value=start_offset),
-            count=10,
-            auto_commit=False,
+            options=PollOptions(count=10),
         )
-        assert len(offset_messages) == len(test_messages[2:])
-        assert offset_messages[0].offset() == start_offset
+        assert len(offset_messages.messages) == len(test_messages[2:])
+        assert offset_messages.messages[0].offset() == start_offset
         assert [
-            message.payload().decode("utf-8") for message in offset_messages
+            message.payload().decode("utf-8") for message in offset_messages.messages
         ] == test_messages[2:]
 
     @pytest.mark.asyncio
@@ -1166,10 +1495,9 @@
             consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.First(),
-            count=10,
-            auto_commit=False,
+            options=PollOptions(count=10),
         )
-        offset_beyond_newest = first_messages[-1].offset() + 1
+        offset_beyond_newest = first_messages.messages[-1].offset() + 1
 
         offset_messages = await iggy_client.poll_messages(
             stream=stream_name,
@@ -1177,11 +1505,10 @@
             consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.Offset(value=offset_beyond_newest),
-            count=10,
-            auto_commit=False,
+            options=PollOptions(count=10),
         )
 
-        assert offset_messages == []
+        assert offset_messages.messages == ()
 
     @pytest.mark.asyncio
     async def test_polling_strategy_timestamp_starts_at_or_after_timestamp(
@@ -1211,10 +1538,9 @@
             consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.First(),
-            count=10,
-            auto_commit=False,
+            options=PollOptions(count=10),
         )
-        start_timestamp = first_messages[2].timestamp()
+        start_timestamp = first_messages.messages[2].timestamp()
 
         timestamp_messages = await iggy_client.poll_messages(
             stream=stream_name,
@@ -1222,13 +1548,12 @@
             consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.Timestamp(value=start_timestamp),
-            count=10,
-            auto_commit=False,
+            options=PollOptions(count=10),
         )
-        assert len(timestamp_messages) == len(test_messages[2:])
-        assert timestamp_messages[0].timestamp() >= start_timestamp
+        assert len(timestamp_messages.messages) == len(test_messages[2:])
+        assert timestamp_messages.messages[0].timestamp() >= start_timestamp
         assert [
-            message.payload().decode("utf-8") for message in timestamp_messages
+            message.payload().decode("utf-8") for message in timestamp_messages.messages
         ] == test_messages[2:]
 
     @pytest.mark.asyncio
@@ -1259,10 +1584,9 @@
             consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.First(),
-            count=10,
-            auto_commit=False,
+            options=PollOptions(count=10),
         )
-        timestamp_after_newest = first_messages[-1].timestamp() + 1
+        timestamp_after_newest = first_messages.messages[-1].timestamp() + 1
 
         timestamp_messages = await iggy_client.poll_messages(
             stream=stream_name,
@@ -1270,17 +1594,16 @@
             consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.Timestamp(value=timestamp_after_newest),
-            count=10,
-            auto_commit=False,
+            options=PollOptions(count=10),
         )
 
-        assert timestamp_messages == []
+        assert timestamp_messages.messages == ()
 
     @pytest.mark.asyncio
-    async def test_poll_messages_with_auto_commit_true_advances_next(
+    async def test_poll_messages_committing_on_read_advances_next(
         self, iggy_client: IggyClient, unique_name
     ):
-        """Test auto_commit=True stores progress so Next resumes after it."""
+        """Test CommitPolicy.OnRead() stores progress so Next resumes after it."""
         stream_name = unique_name()
         topic_name = unique_name()
         partition_id = 0
@@ -1305,12 +1628,11 @@
             consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.First(),
-            count=10,
-            auto_commit=True,
+            options=PollOptions(count=10, commit=CommitPolicy.OnRead()),
         )
         assert [
             message.payload().decode("utf-8")
-            for message in first_messages[: len(existing_messages)]
+            for message in first_messages.messages[: len(existing_messages)]
         ] == existing_messages
 
         await iggy_client.send_messages(
@@ -1326,19 +1648,18 @@
             consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.Next(),
-            count=10,
-            auto_commit=True,
+            options=PollOptions(count=10, commit=CommitPolicy.OnRead()),
         )
-        assert len(next_messages) == len(new_messages)
+        assert len(next_messages.messages) == len(new_messages)
         assert [
-            message.payload().decode("utf-8") for message in next_messages
+            message.payload().decode("utf-8") for message in next_messages.messages
         ] == new_messages
 
     @pytest.mark.asyncio
-    async def test_poll_messages_with_auto_commit_false_does_not_advance_next(
+    async def test_poll_messages_without_a_commit_does_not_advance_next(
         self, iggy_client: IggyClient, unique_name
     ):
-        """Test auto_commit=False leaves no stored offset, so Next starts over."""
+        """Test a poll without a server commit stores no offset, so Next starts over."""
         stream_name = unique_name()
         topic_name = unique_name()
         partition_id = 0
@@ -1363,12 +1684,11 @@
             consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.First(),
-            count=10,
-            auto_commit=False,
+            options=PollOptions(count=10),
         )
         assert [
             message.payload().decode("utf-8")
-            for message in first_messages[: len(existing_messages)]
+            for message in first_messages.messages[: len(existing_messages)]
         ] == existing_messages
 
         await iggy_client.send_messages(
@@ -1384,12 +1704,11 @@
             consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.Next(),
-            count=10,
-            auto_commit=False,
+            options=PollOptions(count=10),
         )
-        assert len(next_messages) == len(existing_messages) + len(new_messages)
+        assert len(next_messages.messages) == len(existing_messages) + len(new_messages)
         assert [
-            message.payload().decode("utf-8") for message in next_messages
+            message.payload().decode("utf-8") for message in next_messages.messages
         ] == existing_messages + new_messages
 
     @pytest.mark.asyncio
@@ -1418,11 +1737,11 @@
                 consumer=Consumer.Single(consumer_name),
                 partition_id=partition_id,
                 polling_strategy=PollingStrategy.Next(),
-                count=10,
-                auto_commit=True,
+                options=PollOptions(count=10, commit=CommitPolicy.OnRead()),
             )
             assert [
-                message.payload().decode("utf-8") for message in polled_messages
+                message.payload().decode("utf-8")
+                for message in polled_messages.messages
             ] == test_messages
 
     @pytest.mark.asyncio
@@ -1450,11 +1769,10 @@
             consumer=Consumer.Single("shared-consumer"),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.Next(),
-            count=10,
-            auto_commit=True,
+            options=PollOptions(count=10, commit=CommitPolicy.OnRead()),
         )
         assert [
-            message.payload().decode("utf-8") for message in first_poll
+            message.payload().decode("utf-8") for message in first_poll.messages
         ] == test_messages
 
         second_poll = await iggy_client.poll_messages(
@@ -1463,10 +1781,9 @@
             consumer=Consumer.Single("shared-consumer"),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.Next(),
-            count=10,
-            auto_commit=True,
+            options=PollOptions(count=10, commit=CommitPolicy.OnRead()),
         )
-        assert second_poll == []
+        assert second_poll.messages == ()
 
     @pytest.mark.asyncio
     async def test_poll_messages_without_a_consumer_raises_type_error(
@@ -1482,8 +1799,7 @@
                 topic=unique_name(),
                 partition_id=0,
                 polling_strategy=PollingStrategy.Next(),
-                count=10,
-                auto_commit=True,
+                options=PollOptions(count=10, commit=CommitPolicy.OnRead()),
             )
 
     @pytest.mark.asyncio
@@ -1504,8 +1820,7 @@
                 consumer=consumer,
                 partition_id=0,
                 polling_strategy=PollingStrategy.Next(),
-                count=10,
-                auto_commit=True,
+                options=PollOptions(count=10, commit=CommitPolicy.OnRead()),
             )
 
     @pytest.mark.parametrize("identifier", [-1, 2**32], ids=["negative", "above-u32"])
@@ -1543,15 +1858,14 @@
                 consumer=consumer_kind(identifier),
                 partition_id=0,
                 polling_strategy=PollingStrategy.Next(),
-                count=10,
-                auto_commit=True,
+                options=PollOptions(count=10, commit=CommitPolicy.OnRead()),
             )
 
     @pytest.mark.asyncio
-    async def test_poll_messages_without_partition_id_reads_partition_zero(
+    async def test_poll_messages_with_partition_id_reads_only_that_partition(
         self, iggy_client: IggyClient, unique_name
     ):
-        """Test a regular consumer falls back to partition 0, not the whole topic."""
+        """Test a regular consumer reads the given partition, not the whole topic."""
         stream_name = unique_name()
         topic_name = unique_name()
         partitions_count = 3
@@ -1574,21 +1888,21 @@
             stream=stream_name,
             topic=topic_name,
             consumer=Consumer.Single("partition-zero-consumer"),
+            partition_id=0,
             polling_strategy=PollingStrategy.Next(),
-            count=10,
-            auto_commit=True,
+            options=PollOptions(count=10, commit=CommitPolicy.OnRead()),
         )
 
         assert [
             (message.partition_id(), message.payload().decode("utf-8"))
-            for message in polled_messages
+            for message in polled_messages.messages
         ] == [(0, "Partition 0")]
 
     @pytest.mark.asyncio
-    async def test_poll_messages_with_consumer_group_rotates_assigned_partitions(
+    async def test_poll_messages_with_consumer_group_reads_every_assigned_partition(
         self, iggy_client: IggyClient, unique_name
     ):
-        """Test a sole group member reaches every partition over repeated polls."""
+        """Test a sole group member is assigned every partition and reads each one."""
         stream_name = unique_name()
         topic_name = unique_name()
         group_name = unique_name()
@@ -1614,18 +1928,18 @@
             )
 
         polled = []
-        for _ in range(partitions_count):
+        for partition_id in range(partitions_count):
             polled_messages = await iggy_client.poll_messages(
                 stream=stream_name,
                 topic=topic_name,
                 consumer=Consumer.Group(group_name),
+                partition_id=partition_id,
                 polling_strategy=PollingStrategy.Next(),
-                count=10,
-                auto_commit=True,
+                options=PollOptions(count=10, commit=CommitPolicy.OnRead()),
             )
             polled.extend(
                 (message.partition_id(), message.payload().decode("utf-8"))
-                for message in polled_messages
+                for message in polled_messages.messages
             )
 
         assert polled == [
@@ -1653,67 +1967,22 @@
         )
 
         # The group exists; only the join is missing.
-        with pytest.raises(RuntimeError, match="was not found"):
+        with pytest.raises(IggyError) as refusal:
             await iggy_client.poll_messages(
                 stream=stream_name,
                 topic=topic_name,
                 consumer=Consumer.Group(group_name),
+                partition_id=0,
                 polling_strategy=PollingStrategy.Next(),
-                count=10,
-                auto_commit=True,
+                options=PollOptions(count=10, commit=CommitPolicy.OnRead()),
             )
+        assert refusal.value.code == CONSUMER_GROUP_PARTITION_NOT_OWNED
 
     @pytest.mark.asyncio
-    async def test_poll_messages_with_consumer_group_honours_an_explicit_partition(
+    async def test_poll_messages_with_consumer_group_reads_its_assigned_partition(
         self, iggy_client: IggyClient, unique_name
     ):
-        """Test an explicit partition overrides the rotation for a group member."""
-        stream_name = unique_name()
-        topic_name = unique_name()
-        group_name = unique_name()
-        partitions_count = 3
-
-        await iggy_client.create_stream(stream_name)
-        await iggy_client.create_topic(
-            stream=stream_name,
-            name=topic_name,
-            options=TopicOptions(partitions_count=partitions_count),
-        )
-        await iggy_client.create_consumer_group(stream_name, topic_name, group_name)
-        await iggy_client.join_consumer_group(stream_name, topic_name, group_name)
-        await wait_for_consumer_group_assignment(
-            iggy_client, stream_name, topic_name, group_name, members_count=1
-        )
-        for partition_id in range(partitions_count):
-            await iggy_client.send_messages(
-                stream=stream_name,
-                topic=topic_name,
-                partitioning=partition_id,
-                messages=[Message(f"Partition {partition_id}")],
-            )
-
-        # Partition 0 is the fallback, so asking for the others is what proves
-        # the explicit id is honoured.
-        for requested_partition in (1, 2):
-            polled_messages = await iggy_client.poll_messages(
-                stream=stream_name,
-                topic=topic_name,
-                consumer=Consumer.Group(group_name),
-                partition_id=requested_partition,
-                polling_strategy=PollingStrategy.Next(),
-                count=10,
-                auto_commit=True,
-            )
-            assert [
-                (message.partition_id(), message.payload().decode("utf-8"))
-                for message in polled_messages
-            ] == [(requested_partition, f"Partition {requested_partition}")]
-
-    @pytest.mark.asyncio
-    async def test_poll_messages_with_consumer_group_reads_assigned_partitions(
-        self, iggy_client: IggyClient, unique_name
-    ):
-        """Test a group member polls its assignment when no partition is given."""
+        """Test a group member reads the messages of the partition assigned to it."""
         stream_name = unique_name()
         topic_name = unique_name()
         group_name = unique_name()
@@ -1737,10 +2006,10 @@
             stream=stream_name,
             topic=topic_name,
             consumer=Consumer.Group(group_name),
+            partition_id=0,
             polling_strategy=PollingStrategy.Next(),
-            count=10,
-            auto_commit=True,
+            options=PollOptions(count=10, commit=CommitPolicy.OnRead()),
         )
         assert [
-            message.payload().decode("utf-8") for message in polled_messages
+            message.payload().decode("utf-8") for message in polled_messages.messages
         ] == test_messages
diff --git a/foreign/python/tests/test_partition_reader.py b/foreign/python/tests/test_partition_reader.py
new file mode 100644
index 0000000..814e8d7
--- /dev/null
+++ b/foreign/python/tests/test_partition_reader.py
@@ -0,0 +1,644 @@
+# 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.
+
+import asyncio
+from datetime import timedelta
+
+import pytest
+
+from apache_iggy import (
+    AutoCommit,
+    CommitAfter,
+    CommitEvery,
+    CommitPolicy,
+    Consumer,
+    IggyClient,
+    IggyError,
+    PartitionReader,
+    PolledMessages,
+    PollingStrategy,
+    PollOptions,
+    ReadError,
+    ReceiveMessage,
+    TopicOptions,
+)
+from apache_iggy import SendMessage as Message
+
+from .utils import read_offsets_with_cancels, wait_for_consumer_group_assignment
+
+INVALID_COMMAND = 3
+FEATURE_UNAVAILABLE = 5
+HISTORY_UNAVAILABLE = 87
+CONSUMER_OFFSET_NOT_FOUND = 3021
+READ_TIMEOUT_SECONDS = 10
+CONSUMER_ID = 1
+PARTITION_ID = 0
+PARTITIONS = [0, 1, 2]
+MESSAGES_PER_PARTITION = 5
+# Below MESSAGES_PER_PARTITION, so each partition arrives in several batches.
+SMALL_BATCH = 2
+# A reader without a wait polls an idle topic in a busy loop.
+POLL_WAIT = timedelta(seconds=1)
+# One message per batch and one batch of prefetch, so most reads wait for the server.
+ONE_MESSAGE = 1
+CANCELLED_READS_MESSAGES = 100
+LAST_CANCELLED_READS_OFFSET = CANCELLED_READS_MESSAGES - 1
+
+
+async def _create_topic(
+    iggy_client: IggyClient, unique_name, partitions_count: int = 1
+) -> tuple[str, str]:
+    stream_name = unique_name()
+    topic_name = unique_name()
+    await iggy_client.create_stream(stream_name)
+    await iggy_client.create_topic(
+        stream=stream_name,
+        name=topic_name,
+        options=TopicOptions(partitions_count=partitions_count),
+    )
+    return stream_name, topic_name
+
+
+async def _send(
+    iggy_client: IggyClient,
+    stream_name: str,
+    topic_name: str,
+    partitions: list[int],
+    label: str = "message",
+    count: int = MESSAGES_PER_PARTITION,
+) -> dict[int, list[tuple[int, str]]]:
+    """Sends messages to empty partitions and returns them as `_by_partition` does."""
+    sent: dict[int, list[tuple[int, str]]] = {}
+    for partition_id in partitions:
+        payloads = [
+            f"{label} {index} of partition {partition_id}" for index in range(count)
+        ]
+        await iggy_client.send_messages(
+            stream_name,
+            topic_name,
+            partition_id,
+            [Message(payload) for payload in payloads],
+        )
+        sent[partition_id] = list(enumerate(payloads))
+    return sent
+
+
+async def _next(reader: PartitionReader) -> PolledMessages:
+    """`next()` has no time limit, so each read is bounded here."""
+    return await asyncio.wait_for(reader.next(), READ_TIMEOUT_SECONDS)
+
+
+async def _read(reader: PartitionReader, count: int) -> list[ReceiveMessage]:
+    """Reads batches until they hold `count` messages, and returns the messages."""
+    messages: list[ReceiveMessage] = []
+    while len(messages) < count:
+        batch = await _next(reader)
+        assert batch.messages, "A reader never returns an empty batch"
+        messages.extend(batch.messages)
+    return messages
+
+
+async def _batch_offsets(reader: PartitionReader) -> list[int]:
+    return [message.offset() for message in (await reader.next()).messages]
+
+
+def _by_partition(messages: list[ReceiveMessage]) -> dict[int, list[tuple[int, str]]]:
+    """The offset and payload of each message by partition, in the order read."""
+    received: dict[int, list[tuple[int, str]]] = {}
+    for message in messages:
+        received.setdefault(message.partition_id(), []).append(
+            (message.offset(), message.payload().decode())
+        )
+    return received
+
+
+class TestPartitionReader:
+    """Test the partition reader built by IggyClient.reader()."""
+
+    @pytest.mark.asyncio
+    async def test_reader_reads_every_message_of_its_partitions(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test a reader reads each of its partitions whole and in offset order."""
+        stream_name, topic_name = await _create_topic(
+            iggy_client, unique_name, partitions_count=len(PARTITIONS)
+        )
+        sent = await _send(iggy_client, stream_name, topic_name, PARTITIONS)
+
+        reader = await iggy_client.reader(
+            stream_name,
+            topic_name,
+            consumer=Consumer.Single(CONSUMER_ID),
+            partitions=PARTITIONS,
+            options=PollOptions(count=SMALL_BATCH, max_wait=POLL_WAIT),
+        )
+        try:
+            partitions = await reader.partitions()
+            messages = await _read(reader, len(PARTITIONS) * MESSAGES_PER_PARTITION)
+        finally:
+            await reader.close()
+
+        assert partitions == PARTITIONS
+        assert _by_partition(messages) == sent
+
+    @pytest.mark.asyncio
+    async def test_reader_resumes_after_the_committed_checkpoint(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test a new reader at Next reads on after the committed checkpoint."""
+        committed_offset = 2
+        stream_name, topic_name = await _create_topic(iggy_client, unique_name)
+        sent = await _send(iggy_client, stream_name, topic_name, [PARTITION_ID])
+
+        reader = await iggy_client.reader(
+            stream_name,
+            topic_name,
+            consumer=Consumer.Single(CONSUMER_ID),
+            partitions=[PARTITION_ID],
+        )
+        try:
+            delivered = await _read(reader, MESSAGES_PER_PARTITION)
+            await reader.commit(delivered[committed_offset].checkpoint())
+        finally:
+            await reader.close()
+        resumed = await iggy_client.reader(
+            stream_name,
+            topic_name,
+            consumer=Consumer.Single(CONSUMER_ID),
+            partitions=[PARTITION_ID],
+            start=PollingStrategy.Next(),
+        )
+        try:
+            messages = await _read(
+                resumed, MESSAGES_PER_PARTITION - committed_offset - 1
+            )
+        finally:
+            await resumed.close()
+
+        assert _by_partition(messages) == {
+            PARTITION_ID: sent[PARTITION_ID][committed_offset + 1 :]
+        }
+
+    @pytest.mark.asyncio
+    async def test_async_with_and_async_for_read_until_the_reader_closes(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test async for reads batches, and async with closes the reader on exit."""
+        stream_name, topic_name = await _create_topic(iggy_client, unique_name)
+        sent = await _send(iggy_client, stream_name, topic_name, [PARTITION_ID])
+
+        reader = await iggy_client.reader(
+            stream_name,
+            topic_name,
+            consumer=Consumer.Single(CONSUMER_ID),
+            partitions=[PARTITION_ID],
+        )
+        messages: list[ReceiveMessage] = []
+        async with reader as entered:
+            async with asyncio.timeout(READ_TIMEOUT_SECONDS):
+                async for batch in entered:
+                    messages.extend(batch.messages)
+                    if len(messages) == MESSAGES_PER_PARTITION:
+                        break
+
+        assert entered is reader
+        assert _by_partition(messages) == sent
+        assert [batch async for batch in reader] == []
+        with pytest.raises(RuntimeError, match="reader has been closed"):
+            await reader.next()
+
+    @pytest.mark.asyncio
+    async def test_auto_commit_stores_the_marked_checkpoint_when_the_reader_closes(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test a reader with CommitAfter.Marked stores what mark() counted on close."""
+        marked_index = 1
+        stream_name, topic_name = await _create_topic(iggy_client, unique_name)
+        sent = await _send(iggy_client, stream_name, topic_name, [PARTITION_ID])
+        consumer = Consumer.Single(CONSUMER_ID)
+
+        reader = await iggy_client.reader(
+            stream_name,
+            topic_name,
+            consumer=consumer,
+            partitions=[PARTITION_ID],
+            start=PollingStrategy.First(),
+            options=PollOptions(
+                max_wait=POLL_WAIT,
+                commit=CommitPolicy.Auto(
+                    AutoCommit(every=CommitEvery.Batch(), after=CommitAfter.Marked)
+                ),
+            ),
+        )
+        async with reader:
+            batch = await _next(reader)
+            checkpoint = batch.checkpoint_at(marked_index)
+            assert checkpoint is not None
+            await reader.mark(checkpoint)
+        resumed = await iggy_client.reader(
+            stream_name,
+            topic_name,
+            consumer=consumer,
+            partitions=[PARTITION_ID],
+            start=PollingStrategy.Next(),
+        )
+        async with resumed:
+            messages = await _read(resumed, MESSAGES_PER_PARTITION - marked_index - 1)
+
+        assert _by_partition(messages) == {
+            PARTITION_ID: sent[PARTITION_ID][marked_index + 1 :]
+        }
+
+    @pytest.mark.asyncio
+    async def test_deleted_position_lets_a_new_reader_start_over(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test delete_consumer_position removes the offset a reader committed."""
+        stream_name, topic_name = await _create_topic(iggy_client, unique_name)
+        sent = await _send(iggy_client, stream_name, topic_name, [PARTITION_ID])
+        consumer = Consumer.Single(CONSUMER_ID)
+
+        reader = await iggy_client.reader(
+            stream_name, topic_name, consumer=consumer, partitions=[PARTITION_ID]
+        )
+        try:
+            checkpoint = (await _read(reader, MESSAGES_PER_PARTITION))[-1].checkpoint()
+            await reader.commit(checkpoint)
+        finally:
+            await reader.close()
+        await iggy_client.delete_consumer_position(
+            stream_name, topic_name, consumer=consumer, checkpoint=checkpoint
+        )
+        with pytest.raises(IggyError) as missing:
+            await iggy_client.delete_consumer_position(
+                stream_name, topic_name, consumer=consumer, checkpoint=checkpoint
+            )
+        resumed = await iggy_client.reader(
+            stream_name,
+            topic_name,
+            consumer=consumer,
+            partitions=[PARTITION_ID],
+            start=PollingStrategy.Next(),
+        )
+        try:
+            messages = await _read(resumed, MESSAGES_PER_PARTITION)
+        finally:
+            await resumed.close()
+
+        assert missing.value.code == CONSUMER_OFFSET_NOT_FOUND
+        assert _by_partition(messages) == sent
+
+    @pytest.mark.asyncio
+    async def test_seek_reads_the_partition_again_from_the_new_start(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test seek moves the next read of a partition back to the new start."""
+        seek_offset = 1
+        stream_name, topic_name = await _create_topic(iggy_client, unique_name)
+        sent = await _send(iggy_client, stream_name, topic_name, [PARTITION_ID])
+
+        reader = await iggy_client.reader(
+            stream_name,
+            topic_name,
+            consumer=Consumer.Single(CONSUMER_ID),
+            partitions=[PARTITION_ID],
+        )
+        try:
+            await _read(reader, MESSAGES_PER_PARTITION)
+            await reader.seek(PARTITION_ID, PollingStrategy.Offset(value=seek_offset))
+            messages = await _read(reader, MESSAGES_PER_PARTITION - seek_offset)
+        finally:
+            await reader.close()
+
+        assert _by_partition(messages) == {
+            PARTITION_ID: sent[PARTITION_ID][seek_offset:]
+        }
+
+    @pytest.mark.asyncio
+    async def test_seek_refuses_a_partition_the_reader_does_not_read(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test seek of another partition of the topic raises InvalidCommand."""
+        other_partition_id = 1
+        stream_name, topic_name = await _create_topic(
+            iggy_client, unique_name, partitions_count=2
+        )
+        reader = await iggy_client.reader(
+            stream_name,
+            topic_name,
+            consumer=Consumer.Single(CONSUMER_ID),
+            partitions=[PARTITION_ID],
+        )
+        try:
+            with pytest.raises(IggyError) as refusal:
+                await reader.seek(other_partition_id, PollingStrategy.First())
+        finally:
+            await reader.close()
+
+        assert refusal.value.code == INVALID_COMMAND
+
+    @pytest.mark.asyncio
+    async def test_close_is_safe_twice_and_ends_the_reader(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test a second close does nothing and a read after close fails."""
+        stream_name, topic_name = await _create_topic(iggy_client, unique_name)
+        reader = await iggy_client.reader(
+            stream_name,
+            topic_name,
+            consumer=Consumer.Single(CONSUMER_ID),
+            partitions=[PARTITION_ID],
+        )
+
+        await reader.close()
+        await reader.close()
+
+        with pytest.raises(RuntimeError, match="reader has been closed"):
+            await _next(reader)
+
+    @pytest.mark.asyncio
+    async def test_reader_refuses_an_invalid_configuration(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test reader() refuses wrong partitions and a continuation as start."""
+        consumer = Consumer.Single(CONSUMER_ID)
+        stream_name, topic_name = await _create_topic(iggy_client, unique_name)
+        await _send(iggy_client, stream_name, topic_name, [PARTITION_ID])
+        polled = await iggy_client.poll_messages(
+            stream_name,
+            topic_name,
+            partition_id=PARTITION_ID,
+            consumer=consumer,
+            polling_strategy=PollingStrategy.First(),
+        )
+
+        with pytest.raises(IggyError) as without_partitions:
+            await iggy_client.reader(stream_name, topic_name, consumer=consumer)
+        with pytest.raises(IggyError) as empty_partitions:
+            await iggy_client.reader(
+                stream_name, topic_name, consumer=consumer, partitions=[]
+            )
+        with pytest.raises(IggyError) as group_partitions:
+            await iggy_client.reader(
+                stream_name,
+                topic_name,
+                consumer=Consumer.Group(unique_name()),
+                partitions=[PARTITION_ID],
+            )
+        with pytest.raises(IggyError) as continuation_start:
+            await iggy_client.reader(
+                stream_name,
+                topic_name,
+                consumer=consumer,
+                partitions=[PARTITION_ID],
+                start=polled.continuation(),
+            )
+
+        assert without_partitions.value.code == INVALID_COMMAND
+        assert empty_partitions.value.code == INVALID_COMMAND
+        assert group_partitions.value.code == INVALID_COMMAND
+        assert continuation_start.value.code == INVALID_COMMAND
+
+    @pytest.mark.asyncio
+    async def test_commit_refuses_a_checkpoint_it_cannot_store(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test commit refuses a polled or foreign checkpoint, and any under OnRead."""
+        stream_name, topic_name = await _create_topic(iggy_client, unique_name)
+        await _send(iggy_client, stream_name, topic_name, [PARTITION_ID])
+        polled = await iggy_client.poll_messages(
+            stream_name,
+            topic_name,
+            partition_id=PARTITION_ID,
+            consumer=Consumer.Single(CONSUMER_ID),
+            polling_strategy=PollingStrategy.First(),
+        )
+        reader = await iggy_client.reader(
+            stream_name,
+            topic_name,
+            consumer=Consumer.Single(CONSUMER_ID),
+            partitions=[PARTITION_ID],
+        )
+        on_read_reader = await iggy_client.reader(
+            stream_name,
+            topic_name,
+            consumer=Consumer.Single(CONSUMER_ID),
+            partitions=[PARTITION_ID],
+            options=PollOptions(max_wait=POLL_WAIT, commit=CommitPolicy.OnRead()),
+        )
+        try:
+            delivered = (await _read(on_read_reader, 1))[0].checkpoint()
+            with pytest.raises(IggyError) as polled_refusal:
+                await reader.commit(polled.messages[0].checkpoint())
+            with pytest.raises(IggyError) as other_reader_refusal:
+                await reader.commit(delivered)
+            with pytest.raises(IggyError) as on_read_refusal:
+                await on_read_reader.commit(delivered)
+        finally:
+            await reader.close()
+            await on_read_reader.close()
+
+        assert polled_refusal.value.code == INVALID_COMMAND
+        assert other_reader_refusal.value.code == INVALID_COMMAND
+        assert on_read_refusal.value.code == INVALID_COMMAND
+
+    @pytest.mark.asyncio
+    async def test_group_reader_reads_its_assignment_and_leaves_in_close(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test a group reader owns every partition alone and leaves in close."""
+        group_name = unique_name()
+        stream_name, topic_name = await _create_topic(
+            iggy_client, unique_name, partitions_count=len(PARTITIONS)
+        )
+        await iggy_client.create_consumer_group(stream_name, topic_name, group_name)
+        sent = await _send(iggy_client, stream_name, topic_name, PARTITIONS)
+
+        reader = await iggy_client.reader(
+            stream_name, topic_name, consumer=Consumer.Group(group_name)
+        )
+        try:
+            await wait_for_consumer_group_assignment(
+                iggy_client, stream_name, topic_name, group_name, members_count=1
+            )
+            joined = await iggy_client.get_consumer_group(
+                stream_name, topic_name, group_name
+            )
+            messages = await _read(reader, len(PARTITIONS) * MESSAGES_PER_PARTITION)
+            assigned = await reader.partitions()
+        finally:
+            await reader.close()
+        left = await iggy_client.get_consumer_group(stream_name, topic_name, group_name)
+
+        assert joined is not None
+        assert [sorted(member.partitions) for member in joined.members] == [PARTITIONS]
+        assert assigned == PARTITIONS
+        assert _by_partition(messages) == sent
+        assert left is not None
+        assert left.members_count == 0
+        assert left.members == []
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("transport_client", ["http"], indirect=True)
+    async def test_group_reader_is_unavailable_over_http(
+        self, iggy_client: IggyClient, transport_client: IggyClient, unique_name
+    ):
+        """Test a group reader over HTTP raises FeatureUnavailable."""
+        group_name = unique_name()
+        stream_name, topic_name = await _create_topic(iggy_client, unique_name)
+        await iggy_client.create_consumer_group(stream_name, topic_name, group_name)
+
+        with pytest.raises(IggyError) as refusal:
+            await transport_client.reader(
+                stream_name, topic_name, consumer=Consumer.Group(group_name)
+            )
+
+        assert refusal.value.code == FEATURE_UNAVAILABLE
+
+    @pytest.mark.asyncio
+    async def test_partition_created_again_is_refused_until_seek(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test a partition created again raises HistoryUnavailable until seek."""
+        stream_name, topic_name = await _create_topic(iggy_client, unique_name)
+        await _send(iggy_client, stream_name, topic_name, [PARTITION_ID], "old")
+        reader = await iggy_client.reader(
+            stream_name,
+            topic_name,
+            consumer=Consumer.Single(CONSUMER_ID),
+            partitions=[PARTITION_ID],
+        )
+        refusals: list[ReadError] = []
+        messages: list[ReceiveMessage] = []
+        try:
+            await _read(reader, MESSAGES_PER_PARTITION)
+            await iggy_client.delete_partitions(
+                stream_name, topic_name, partitions_count=1
+            )
+            await iggy_client.create_partitions(
+                stream_name, topic_name, partitions_count=1
+            )
+            sent = await _send(
+                iggy_client, stream_name, topic_name, [PARTITION_ID], "new"
+            )
+            while len(messages) < MESSAGES_PER_PARTITION:
+                try:
+                    batch = await _next(reader)
+                except ReadError as error:
+                    # A read while the partition did not exist fails with another
+                    # error, and the reader retries it.
+                    if error.code == HISTORY_UNAVAILABLE:
+                        refusals.append(error)
+                        await reader.seek(PARTITION_ID, PollingStrategy.First())
+                    continue
+                assert refusals, "A batch came before the refusal of the old history"
+                messages.extend(batch.messages)
+        finally:
+            await reader.close()
+
+        assert [(refusal.partition_id, refusal.error.code) for refusal in refusals] == [
+            (PARTITION_ID, HISTORY_UNAVAILABLE)
+        ]
+        assert _by_partition(messages) == sent
+
+    @pytest.mark.asyncio
+    async def test_cancelled_reads_lose_and_repeat_no_batch(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test reads cancelled at short deadlines return each offset once, in order."""
+        stream_name, topic_name = await _create_topic(iggy_client, unique_name)
+        await _send(
+            iggy_client,
+            stream_name,
+            topic_name,
+            [PARTITION_ID],
+            count=CANCELLED_READS_MESSAGES,
+        )
+        reader = await iggy_client.reader(
+            stream_name,
+            topic_name,
+            consumer=Consumer.Single(CONSUMER_ID),
+            partitions=[PARTITION_ID],
+            options=PollOptions(count=ONE_MESSAGE, max_wait=POLL_WAIT),
+            prefetch_messages=ONE_MESSAGE,
+        )
+        try:
+            offsets = await read_offsets_with_cancels(
+                lambda: _batch_offsets(reader), LAST_CANCELLED_READS_OFFSET
+            )
+        finally:
+            await reader.close()
+
+        assert offsets == list(range(CANCELLED_READS_MESSAGES))
+
+    @pytest.mark.asyncio
+    async def test_on_read_close_after_a_cancelled_read_resumes_after_the_last_batch(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test a new reader starts after the last batch that an OnRead reader returned
+        before a cancelled read and close."""
+        stream_name, topic_name = await _create_topic(iggy_client, unique_name)
+        await _send(
+            iggy_client,
+            stream_name,
+            topic_name,
+            [PARTITION_ID],
+            count=CANCELLED_READS_MESSAGES,
+        )
+        reader = await iggy_client.reader(
+            stream_name,
+            topic_name,
+            consumer=Consumer.Single(CONSUMER_ID),
+            partitions=[PARTITION_ID],
+            options=PollOptions(
+                count=ONE_MESSAGE,
+                max_wait=POLL_WAIT,
+                commit=CommitPolicy.OnRead(),
+            ),
+            prefetch_messages=ONE_MESSAGE,
+        )
+        try:
+            offsets = await read_offsets_with_cancels(
+                lambda: _batch_offsets(reader), LAST_CANCELLED_READS_OFFSET // 2
+            )
+            pending = asyncio.ensure_future(reader.next())
+            # One step polls the read: it returns a queued batch or waits for a reply.
+            await asyncio.sleep(0)
+            pending.cancel()
+            await asyncio.wait([pending])
+            if not pending.cancelled():
+                offsets.extend(
+                    message.offset() for message in pending.result().messages
+                )
+        finally:
+            await reader.close()
+        resumed = await iggy_client.reader(
+            stream_name,
+            topic_name,
+            consumer=Consumer.Single(CONSUMER_ID),
+            partitions=[PARTITION_ID],
+        )
+        try:
+            offsets.extend(
+                await read_offsets_with_cancels(
+                    lambda: _batch_offsets(resumed), LAST_CANCELLED_READS_OFFSET
+                )
+            )
+        finally:
+            await resumed.close()
+
+        assert offsets == list(range(CANCELLED_READS_MESSAGES))
diff --git a/foreign/python/tests/test_producer.py b/foreign/python/tests/test_producer.py
index 9205f0c..99a854e 100644
--- a/foreign/python/tests/test_producer.py
+++ b/foreign/python/tests/test_producer.py
@@ -40,6 +40,7 @@
     MaxTopicSize,
     Partitioning,
     PollingStrategy,
+    PollOptions,
     ProducerSendError,
     ProducerSharding,
     SendMessage,
@@ -137,16 +138,15 @@
     *,
     partition_id: int = 0,
 ) -> list[str]:
-    messages = await client.poll_messages(
-        stream=stream,
-        topic=topic,
+    polled = await client.poll_messages(
+        stream,
+        topic,
         consumer=Consumer.Single(1),
         partition_id=partition_id,
         polling_strategy=PollingStrategy.First(),
-        count=100,
-        auto_commit=False,
+        options=PollOptions(count=100),
     )
-    return [message.payload().decode("utf-8") for message in messages]
+    return [message.payload().decode("utf-8") for message in polled.messages]
 
 
 async def wait_for_payloads(
diff --git a/foreign/python/tests/test_quic_config.py b/foreign/python/tests/test_quic_config.py
index d36a17e..97bd918 100644
--- a/foreign/python/tests/test_quic_config.py
+++ b/foreign/python/tests/test_quic_config.py
@@ -26,9 +26,11 @@
 """
 
 import ast
+import json
 import socket
 from collections.abc import Callable
 from datetime import timedelta
+from pathlib import Path
 
 import pytest
 
@@ -36,6 +38,11 @@
 
 from .utils import get_quic_server_config, wait_for_ping
 
+# One reply that holds the largest message, as the Rust SDK sizes the QUIC buffer.
+MAX_RECORD_REPLY_SIZE = json.loads(
+    (Path(__file__).parent / "fixtures" / "deferred_polling.json").read_text()
+)["constants"]["max_record_reply_size"]
+
 
 @pytest.mark.unit
 class TestQuicReconnectionConfig:
@@ -163,7 +170,7 @@
         assert config.auto_login.enabled is False
         assert config.reconnection.enabled is True
         assert config.heartbeat_interval == timedelta(seconds=5)
-        assert config.response_buffer_size == 10_000_000
+        assert config.response_buffer_size == MAX_RECORD_REPLY_SIZE
         assert config.max_concurrent_bidi_streams == 10_000
         assert config.datagram_send_buffer_size == 100_000
         assert config.initial_mtu == 1200
diff --git a/foreign/python/tests/test_resource_options.py b/foreign/python/tests/test_resource_options.py
index f670b2a..0b886c4 100644
--- a/foreign/python/tests/test_resource_options.py
+++ b/foreign/python/tests/test_resource_options.py
@@ -49,6 +49,7 @@
     PartitionResizePolicy,
     Permissions,
     PollingStrategy,
+    PollOptions,
     QuicConfig,
     QuicReconnectionConfig,
     Stream,
@@ -186,15 +187,14 @@
             renamed.encryption_kek_fingerprints == created.encryption_kek_fingerprints
         )
         polled = await transport_client.poll_messages(
-            stream=new_name,
-            topic=topic_name,
+            new_name,
+            topic_name,
             consumer=Consumer.Single("stream-rename"),
             partition_id=0,
             polling_strategy=PollingStrategy.First(),
-            count=1,
-            auto_commit=False,
+            options=PollOptions(count=1),
         )
-        assert [message.payload().decode() for message in polled] == [payload]
+        assert [message.payload().decode() for message in polled.messages] == [payload]
 
     @pytest.mark.asyncio
     async def test_given_stream_key_reset_when_updating_should_reject_without_changing_state(  # noqa: E501
diff --git a/foreign/python/tests/test_segment.py b/foreign/python/tests/test_segment.py
index 7e7e1c0..41af2fa 100644
--- a/foreign/python/tests/test_segment.py
+++ b/foreign/python/tests/test_segment.py
@@ -20,11 +20,13 @@
 import pytest
 
 from apache_iggy import (
+    CommitPolicy,
     Consumer,
     Durability,
     IggyClient,
     Permissions,
     PollingStrategy,
+    PollOptions,
     SendMessage,
     StreamPermissions,
     TopicOptions,
@@ -42,6 +44,9 @@
 SEGMENT_SIZE = 1024 * 1024
 PAYLOAD_SIZE = 220_000
 MESSAGES_PER_SEALED_SEGMENT = 5
+# Each poll reads up to twelve PAYLOAD_SIZE messages in one reply, which the
+# default 1 MiB reply limit would split.
+POLL_MAX_BYTES = 4 * 1024 * 1024
 
 
 class TestSegmentManagement:
@@ -80,15 +85,14 @@
         # afterwards, so poll until the surviving offsets settle.
         for _ in range(100):
             polled = await iggy_client.poll_messages(
-                stream=stream_id,
-                topic=topic_id,
+                stream_id,
+                topic_id,
                 partition_id=PARTITION_ID,
                 consumer=Consumer.Single(99),
                 polling_strategy=PollingStrategy.First(),
-                count=100,
-                auto_commit=False,
+                options=PollOptions(count=100, max_bytes=POLL_MAX_BYTES),
             )
-            offsets = [message.offset() for message in polled]
+            offsets = [message.offset() for message in polled.messages]
             if offsets == list(range(5, 10)):
                 break
             await asyncio.sleep(0.1)
@@ -124,15 +128,14 @@
 
         for _ in range(100):
             polled = await iggy_client.poll_messages(
-                stream=stream_name,
-                topic=topic_name,
+                stream_name,
+                topic_name,
                 partition_id=PARTITION_ID,
                 consumer=Consumer.Single(99),
                 polling_strategy=PollingStrategy.First(),
-                count=100,
-                auto_commit=False,
+                options=PollOptions(count=100, max_bytes=POLL_MAX_BYTES),
             )
-            offsets = [message.offset() for message in polled]
+            offsets = [message.offset() for message in polled.messages]
             if offsets == [10, 11]:
                 break
             await asyncio.sleep(0.1)
@@ -164,29 +167,31 @@
         # Commit offset 5: the first sealed segment (offsets 0-4) is behind the
         # consumer and deletable, the second (offsets 5-9) is not.
         polled = await iggy_client.poll_messages(
-            stream=stream_name,
-            topic=topic_name,
+            stream_name,
+            topic_name,
             partition_id=PARTITION_ID,
             consumer=Consumer.Single(1),
             polling_strategy=PollingStrategy.First(),
-            count=MESSAGES_PER_SEALED_SEGMENT + 1,
-            auto_commit=True,
+            options=PollOptions(
+                count=MESSAGES_PER_SEALED_SEGMENT + 1,
+                max_bytes=POLL_MAX_BYTES,
+                commit=CommitPolicy.OnRead(),
+            ),
         )
-        assert [message.offset() for message in polled] == list(range(6))
+        assert [message.offset() for message in polled.messages] == list(range(6))
 
         await iggy_client.delete_segments(stream_name, topic_name, PARTITION_ID, 2)
 
         for _ in range(100):
             polled = await iggy_client.poll_messages(
-                stream=stream_name,
-                topic=topic_name,
+                stream_name,
+                topic_name,
                 partition_id=PARTITION_ID,
                 consumer=Consumer.Single(99),
                 polling_strategy=PollingStrategy.First(),
-                count=100,
-                auto_commit=False,
+                options=PollOptions(count=100, max_bytes=POLL_MAX_BYTES),
             )
-            offsets = [message.offset() for message in polled]
+            offsets = [message.offset() for message in polled.messages]
             if offsets == list(range(5, 12)):
                 break
             await asyncio.sleep(0.1)
@@ -222,15 +227,14 @@
         # act before checking that nothing was trimmed.
         await asyncio.sleep(1.5)
         polled = await iggy_client.poll_messages(
-            stream=stream_name,
-            topic=topic_name,
+            stream_name,
+            topic_name,
             partition_id=PARTITION_ID,
             consumer=Consumer.Single(99),
             polling_strategy=PollingStrategy.First(),
-            count=100,
-            auto_commit=False,
+            options=PollOptions(count=100, max_bytes=POLL_MAX_BYTES),
         )
-        assert [message.offset() for message in polled] == list(range(6))
+        assert [message.offset() for message in polled.messages] == list(range(6))
 
     @pytest.mark.asyncio
     @pytest.mark.parametrize("missing", ["stream", "topic", "partition"])
diff --git a/foreign/python/tests/test_tls.py b/foreign/python/tests/test_tls.py
index eadb2f5..a2d7e02 100644
--- a/foreign/python/tests/test_tls.py
+++ b/foreign/python/tests/test_tls.py
@@ -41,11 +41,13 @@
 from testcontainers.core.waiting_utils import wait_for_logs
 
 from apache_iggy import (
+    CommitPolicy,
     Consumer,
     IggyClient,
     MetadataChangeGroup,
     MetadataChangeKind,
     PollingStrategy,
+    PollOptions,
 )
 from apache_iggy import SendMessage as Message
 
@@ -154,20 +156,19 @@
 
         # Consume messages
         polled = await tls_client.poll_messages(
-            stream=stream_name,
-            topic=topic_name,
+            stream_name,
+            topic_name,
             consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.First(),
-            count=10,
-            auto_commit=True,
+            options=PollOptions(count=10, commit=CommitPolicy.OnRead()),
         )
-        assert len(polled) >= len(test_messages)
+        assert len(polled.messages) >= len(test_messages)
 
         # Verify message payloads
         for i, expected_msg in enumerate(test_messages):
-            if i < len(polled):
-                assert polled[i].payload().decode("utf-8") == expected_msg
+            if i < len(polled.messages):
+                assert polled.messages[i].payload().decode("utf-8") == expected_msg
 
     @pytest.mark.asyncio
     async def test_follow_metadata_changes_over_tls(
diff --git a/foreign/python/tests/test_websocket_config.py b/foreign/python/tests/test_websocket_config.py
index 555e758..4b97181 100644
--- a/foreign/python/tests/test_websocket_config.py
+++ b/foreign/python/tests/test_websocket_config.py
@@ -29,11 +29,13 @@
 """
 
 import ast
+import json
 import socket
 import sys
 import threading
 from collections.abc import Callable
 from datetime import timedelta
+from pathlib import Path
 
 import pytest
 
@@ -55,7 +57,10 @@
 # `usize::MAX`, tungstenite's "unbounded" write buffer.
 TUNGSTENITE_MAX_WRITE_BUFFER_SIZE = sys.maxsize * 2 + 1
 TUNGSTENITE_MAX_MESSAGE_SIZE = 64 << 20
-TUNGSTENITE_MAX_FRAME_SIZE = 16 << 20
+# The SDK raises the frame limit to one reply that holds the largest message.
+SDK_MAX_FRAME_SIZE = json.loads(
+    (Path(__file__).parent / "fixtures" / "deferred_polling.json").read_text()
+)["constants"]["max_record_reply_size"]
 
 # The accept thread only has to come back from a connection the client already
 # dialed, so anything beyond this is a hang, not slowness.
@@ -189,7 +194,7 @@
         assert framing.write_buffer_size == TUNGSTENITE_WRITE_BUFFER_SIZE
         assert framing.max_write_buffer_size == TUNGSTENITE_MAX_WRITE_BUFFER_SIZE
         assert framing.max_message_size == TUNGSTENITE_MAX_MESSAGE_SIZE
-        assert framing.max_frame_size == TUNGSTENITE_MAX_FRAME_SIZE
+        assert framing.max_frame_size == SDK_MAX_FRAME_SIZE
         assert framing.accept_unmasked_frames is False
 
     def test_every_field_round_trips(self):
diff --git a/foreign/python/tests/utils.py b/foreign/python/tests/utils.py
index 9c0a56e..ba8d032 100644
--- a/foreign/python/tests/utils.py
+++ b/foreign/python/tests/utils.py
@@ -20,9 +20,12 @@
 """
 
 import asyncio
+import contextlib
+import itertools
 import os
 import socket
 import time
+from collections.abc import Awaitable, Callable
 
 from apache_iggy import (
     IggyClient,
@@ -49,6 +52,11 @@
 METADATA_FOLLOW_ATTEMPTS = 100
 METADATA_FOLLOW_INTERVAL_SECONDS = 0.1
 
+# Deadlines around a local round trip, so a cancel lands before, at or after a reply.
+CANCEL_DEADLINES_SECONDS = (0.0001, 0.0005, 0.001, 0.002)
+# Bounds a whole read loop, so a lost last message fails the test instead of hanging it.
+CANCELLED_READS_TIMEOUT_SECONDS = 10
+
 
 def get_transport_config(port_env_var: str, default_port: int) -> tuple[str, int]:
     """
@@ -231,6 +239,28 @@
         await asyncio.sleep(interval)
 
 
+async def read_offsets_with_cancels(
+    read_offsets: Callable[[], Awaitable[list[int]]], last_offset: int
+) -> list[int]:
+    """
+    Read offsets up to `last_offset` with reads that time out at short deadlines.
+
+    Args:
+        read_offsets: Reads the offsets of the next batch or message
+        last_offset: The offset that ends the reading
+
+    Returns:
+        The offsets in the order read
+    """
+    offsets: list[int] = []
+    deadlines = itertools.cycle(CANCEL_DEADLINES_SECONDS)
+    async with asyncio.timeout(CANCELLED_READS_TIMEOUT_SECONDS):
+        while not offsets or offsets[-1] < last_offset:
+            with contextlib.suppress(TimeoutError):
+                offsets.extend(await asyncio.wait_for(read_offsets(), next(deadlines)))
+    return offsets
+
+
 def unique_credentials(unique_name) -> tuple[str, str]:
     """Return a unique (username, password) pair within the server limits."""
     username = unique_name(max_bytes=MAX_USERNAME_BYTES)
diff --git a/foreign/python/uv.lock b/foreign/python/uv.lock
index 8149edb..702163a 100644
--- a/foreign/python/uv.lock
+++ b/foreign/python/uv.lock
@@ -11,7 +11,7 @@
 
 [[package]]
 name = "apache-iggy"
-version = "0.9.1.dev5"
+version = "0.9.1.dev6"
 source = { editable = "." }
 
 [package.optional-dependencies]
diff --git a/foreign/swift/Examples/Reader/main.swift b/foreign/swift/Examples/Reader/main.swift
new file mode 100644
index 0000000..c15fa0a
--- /dev/null
+++ b/foreign/swift/Examples/Reader/main.swift
@@ -0,0 +1,79 @@
+// 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.
+
+import Foundation
+import Iggy
+
+// Sends messages to a topic of three partitions, reads them back with a
+// single poll, a partition reader and a group consumer, and commits with each
+// of the three commit policies. Run it against a local server:
+// `swift run reader-example` with IGGY_TCP_ADDRESS set, 127.0.0.1:8090 by
+// default.
+
+let address = ProcessInfo.processInfo.environment["IGGY_TCP_ADDRESS"] ?? "127.0.0.1:8090"
+let client = IggyClient(configuration: ClientConfiguration(address: address, autoLogin: .usernamePassword(username: "iggy", password: "iggy")))
+try await client.connect()
+
+let stream: Identifier = "reader-example"
+let topic: Identifier = "orders"
+if try await client.getStream(stream) == nil {
+    _ = try await client.createStream(name: "reader-example")
+}
+if try await client.getTopic(streamID: stream, topicID: topic) == nil {
+    _ = try await client.createTopic(streamID: stream, name: "orders", options: TopicCreateOptions(partitionsCount: 3))
+}
+for partition: UInt32 in 0..<3 {
+    var messages = try (0..<5).map { try IggyMessage("order \(partition)-\($0)") }
+    try await client.sendMessages(streamID: stream, topicID: topic, partitioning: .partition(partition), messages: &messages)
+}
+
+// One partition, one request. The application stores the checkpoint.
+let consumer = Consumer.consumer(try Identifier(named: "single-poll"))
+let polled = try await client.pollMessages(
+    streamID: stream, topicID: topic, partitionID: 0, consumer: consumer, strategy: .first, options: PollOptions(count: 2, maxWait: .seconds(1)))
+print("single poll:", polled.messages.map(\.payloadString))
+if let checkpoint = polled.checkpoint {
+    try await client.storeConsumerPosition(consumer: consumer, streamID: stream, topicID: topic, checkpoint: checkpoint)
+}
+
+// Every partition, one request per primary. The server stores each read.
+let reader = try await client.reader(
+    streamID: stream, topicID: topic, consumer: .consumer(try Identifier(named: "reader")),
+    configuration: ReaderConfiguration(partitions: [0, 1, 2], start: .first, options: PollOptions(maxWait: .seconds(1), commit: .onRead)))
+var read = 0
+while read < 15, let batch = try await reader.next() {
+    print("reader, partition \(batch.partitionID):", batch.messages.map(\.payloadString))
+    read += batch.messages.count
+}
+try await reader.close()
+
+// A group member that counts a message once its handler returned and stores
+// the counted positions every second and on shutdown.
+let member = try client.consumerGroup(
+    name: "reader-example", streamID: stream, topicID: topic,
+    configuration: ConsumerConfiguration(
+        pollingStrategy: .first, pollOptions: PollOptions(maxWait: .seconds(1), commit: .auto(AutoCommit(interval: .seconds(1), after: .processing)))))
+try await member.initialize()
+let handled = Task {
+    try await member.consumeMessages { received in
+        print("group member, partition \(received.partitionID):", received.message.payloadString)
+    }
+}
+try await Task.sleep(for: .seconds(3))
+await member.shutdown()
+_ = await handled.result
+await client.shutdown()
diff --git a/foreign/swift/Package.swift b/foreign/swift/Package.swift
index de70d68..413814b 100644
--- a/foreign/swift/Package.swift
+++ b/foreign/swift/Package.swift
@@ -63,6 +63,11 @@
             name: "IggyE2ETests",
             dependencies: ["Iggy"]
         ),
+        .executableTarget(
+            name: "reader-example",
+            dependencies: ["Iggy"],
+            path: "Examples/Reader"
+        ),
     ],
     swiftLanguageModes: [.v6]
 )
diff --git a/foreign/swift/README.md b/foreign/swift/README.md
index a7f2705..15d7433 100644
--- a/foreign/swift/README.md
+++ b/foreign/swift/README.md
@@ -77,8 +77,10 @@
 ```
 
 One client owns one connection and is safe to share between tasks: requests are
-serialized on the connection. `disconnect()` closes the connection until the next
-`connect()`, `shutdown()` is final.
+serialized on the connection. A deferred poll or fetch is the
+exception. The server can hold it for its whole wait, so it goes over a connection of its
+own, bound to the same session. `disconnect()` closes the connection until the next `connect()`,
+`shutdown()` is final.
 
 ### Configuration
 
@@ -231,52 +233,86 @@
 
 ### Messages
 
-`sendMessages` writes a batch to one partition. The server routes explicit partitions
-only, so `balanced` (the default) and `messagesKey` are resolved on the client:
-balanced round-robins over the topic's partitions, and a key is hashed with XXH32 the
-way every other SDK hashes it. A message without an id gets a random one, written back
-into the batch, so sending the same batch again after `transientNotCommitted` carries
-the same ids. `pollMessages` reads from a partition, starting where the strategy says:
+`sendMessages` writes a batch to one partition. The server routes explicit partitions only, so `balanced` (the default) and `messagesKey` are resolved on the client: balanced round-robins over the topic's partitions, and a key is hashed with XXH32 the way every other SDK hashes it. A message without an id gets a random one, written back into the batch, so sending the same batch again after `transientNotCommitted` carries the same ids.
+
+`pollMessages` reads one partition with one request, starting where the strategy says, within the limits of `PollOptions`:
 
 ```swift
 var batch: [IggyMessage] = ["placed"]
 try await client.sendMessages(streamID: "orders", topicID: "created", partitioning: .messagesKey("customer-42"), messages: &batch)
 let polled = try await client.pollMessages(
-    streamID: "orders", topicID: "created", partitionID: 0, consumer: .consumer(1), strategy: .next, count: 100, autoCommit: true)
+    streamID: "orders", topicID: "created", partitionID: 0, consumer: .consumer(1), strategy: .next,
+    options: PollOptions(count: 100, maxWait: .seconds(5), commit: .onRead))
 for message in polled.messages {
     print(message.offset, message.payloadString)
 }
+let more = try await client.pollMessages(
+    streamID: "orders", topicID: "created", partitionID: 0, consumer: .consumer(1), strategy: polled.continuation)
 ```
 
-Every partition request carries the partition context the server requires: the
-partition's incarnation, which changes when a deleted partition id is reused. The
-client reads it from the partition's route and caches it. A poll reply carries the
-context it was served under; hand it back with `.offset(_:context:)` to continue the
-same partition, or in a `ConsumerPosition` to store a checkpoint. The server refuses
-either with `historyUnavailable` once the partition was recreated, and a consumer-group
-checkpoint with `consumerGroupPartitionNotOwned` once the partition moved to another
-member.
+Every poll is deferred (binary command 106). The server answers once `minCount` messages are readable, the next message would pass `maxBytes`, or `maxWait` ran out, and then returns what the partition holds, which can be nothing. `PollOptions()` reads up to 1000 messages and 1 MiB with no wait and `minCount` 1.
+
+The call takes at most its request timeout, `maxWait` plus ten seconds: the route lookup, a reconnect, the wait, the reply and every retry. A first message larger than `maxBytes` comes alone in its own reply. The client refuses options out of range before it sends anything: a `minCount` outside `1...count` with `invalidMessagesCount`, a wait above 600 seconds with `invalidCommand`, a `maxBytes` below 344 with `invalidSizeBytes`.
+
+`commit` is a `CommitPolicy`. `.manual`, the default, stores nothing. `.onRead` makes the server store the offset of the last message of each read as part of the read, in one round trip. While the primary stays up a message is delivered at most once. A failover before the store replicates can deliver the last batch again.
+
+`.auto(_)` stores counted positions on the client and needs a `PartitionReader` or an `IggyConsumer`: a single poll refuses it with `invalidConfiguration`, as it has nothing to count.
+
+Positions are opaque. `polled.continuation` is where the next poll of the same partition starts, and a poll of another partition refuses it with `invalidCommand`. `polled.checkpoint` and `polled.checkpoint(at:)` are the positions after a message, which `storeConsumerPosition(consumer:streamID:topicID:checkpoint:)` stores.
+
+Both carry the partition context the read was served under: the partition's incarnation, which changes when a deleted partition id is reused. The server refuses either with `historyUnavailable` once the partition was recreated, and a consumer-group checkpoint with `consumerGroupPartitionNotOwned` once the partition moved to another member.
+
+To cancel a poll, cancel its task. The client then sends command 108 on the connection of the request. A poll that commits nothing gets no reply, and a reply that still arrives is dropped.
+
+A poll with `.onRead` gets exactly one reply, which the cancelled caller waits for: `CancellationError` when the cancellation won and the server stored nothing, or the messages the server stored before the cancellation arrived. A poll with `.onRead` that lost its reply, to a broken connection or to `disconnect()`, or whose reply arrived after the caller signed in or out, may have stored its offset.
+
+It fails with `fetchProgressUncertain` (14010), whose `operationID` names it, and is not sent again. Every later poll with `.onRead` or `.next` and every offset store or delete of that partition on the same client then fails with `partitionLaneBlocked` (14009) naming the same operation, also after a reconnect and a new session, until `abandonOperation(_:)` takes that operation.
+
+Offset reads still work, so the caller can look at what the server stored before it abandons.
+
+A client keeps at most 64 deferred requests in flight. A request waits for a free slot within its request timeout, and fails with `transientNotAccepted` when none frees up in time. If the server refuses to admit a request with `deferredPollCapacityExceeded`, the client pauses and sends it again within the same timeout.
+
+### Partition reader
+
+`PartitionReader` reads the partitions of one topic in batches. A standalone consumer reads the partitions it names, and a consumer group member reads the partitions the server assigns to it: the reader joins the group, creating it when it is missing and named by name, joins again after a lost membership, and leaves it in `close()`.
+
+```swift
+let reader = try await client.reader(
+    streamID: "orders", topicID: "created", consumer: .group("billing"),
+    configuration: ReaderConfiguration(options: PollOptions(count: 100, maxWait: .seconds(5), commit: .auto(.default))))
+for try await batch in reader {
+    print(batch.partitionID, batch.messages.count)
+}
+```
+
+The reader groups its partitions by their primary and reads each group with one request, which the server holds until a message arrives or the wait runs out. A moved primary, a rebalance and a lost connection are repaired inside, so a caller never groups partitions or handles `notPrimary`.
+
+The partitions that a read served go last in the next read of their group, so a backlog does not starve the other partitions. The partitions whose last read ended before their head are spread over the free requests of the reader, each share in a fetch of its own with the full `count` and `maxBytes`, while the quiet partitions of a group share one fetch that may wait.
+
+`next()` returns a batch of one partition, never empty, in offset order within one generation of the partition: from its first read, from each `seek(partitionID:to:)`, and from each new assignment. The default wait of a reader is five seconds. With a zero `maxWait`, a partition that a read found empty waits `retryInterval` before it is read again.
+
+- `.manual`: `commit(_:)` stores a checkpoint that the reader delivered.
+- `.onRead`: the server stores each read, and the reader reads only when `next()` waits with nothing queued, so no batch is stored before it was asked for. It then fills its free requests: the quiet partitions of a group share one fetch that may wait, and the backlogged ones take a committing fetch each beside it, with the full `count` and `maxBytes`. A partition that leaves the assignment still hands over the reads that the server stored for it.
+
+  `close()` moves the offset of each partition back to the last batch delivered. It cancels the committing reads in flight first, and each waits for its one reply, so a parked read ends the close in about a round trip.
+
+  A read that lost its reply, or a section whose error leaves the outcome unknown, may have stored messages that never arrived. The reader abandons that read and reads its partitions again after `retryInterval`: the next reply then starts past those messages, and the reader reads that gap again without a commit and delivers it first.
+
+  A forward jump without such a lost read is a store of the application or of another session, and the reader delivers from there. When the partition moved to another member before the gap was read, the reader reports `fetchProgressUncertain` with the lost range.
+
+  A seek to an offset above zero stores the offset before it first, once the committing read of the partition in flight ended, so a restart resumes at the sought offset. A refused store fails the seek and moves nothing. A seek to offset zero or to another strategy stores nothing. A partition that a lost read of another caller on the same client blocks reports `partitionLaneBlocked` once and reads again after that operation was abandoned.
+
+- `.auto(AutoCommit(interval:every:after:))`: the reader counts a batch when `next()` returns it (`.delivery`), when the next one is asked for or the reader closes (`.processing`), or when the application calls `mark(_:)` (`.marked`), and stores the latest counted position of each partition at the interval, on the event of `every` (`.message`, `.messages(n)` counted per partition, `.batch`), before a rebalance takes a partition away, and in `close()`.
+
+  `AutoCommit.default` stores every 10,000 messages or 5 seconds after `.processing`. A store only moves forward, and a position of an older generation is dropped.
+
+A failed read is thrown as a `ReadError` with the partition, the `IggyError` and, for a message that does not decode, its offset, its record and `skip`, the continuation after it. The reader reads its partitions again after `retryInterval`. A message that does not decode, and `historyUnavailable` for a position read in a history that is gone, stop the partition until a seek.
 
 ### Consumer groups
 
-A member polls without a partition id and the client takes the next partition of its
-assignment, round-robin, syncing the assignment from the server when it changes. The
-heartbeat syncs every joined group, so a member picks up new partitions on its own:
+`joinConsumerGroup`, `leaveConsumerGroup`, `createConsumerGroup` and `getConsumerGroup` manage groups directly, but reading a group goes through a `PartitionReader` or an `IggyConsumer` that names `.group(id)`, which joins on its own. Membership belongs to the session. A reconnect resumes the session and keeps it.
 
-```swift
-try await client.joinConsumerGroup(streamID: "orders", topicID: "created", groupID: "billing")
-let batch = try await client.pollMessages(
-    streamID: "orders", topicID: "created", partitionID: nil, consumer: .group("billing"), strategy: .next, count: 100, autoCommit: true)
-```
-
-A member holding no partitions gets an empty poll whose partition id is
-`PolledMessages.noAssignedPartition`, and a client that is not a member gets
-`consumerGroupMemberNotFound`. Membership belongs to the session. A reconnect resumes
-the session and keeps it, but when the server no longer holds the session the client
-signs in under a new one and the caller must call `joinConsumerGroup` again: the next
-group poll fails with `consumerGroupMemberNotFound` until it does. In a cluster, polls
-and offset writes go to the partition's primary over a second connection bound to the
-same session.
+When the server no longer holds the session, the client signs in under a new one and the reader joins again. In a cluster, reads and offset writes go to the partition's primary over a second connection bound to the same session, and the heartbeat syncs every joined group.
 
 ### Producer
 
@@ -325,49 +361,41 @@
 
 ### Consumer
 
-`IggyConsumer` reads one topic and hands its messages over one at a time. A standalone
-consumer reads one partition; a member of a consumer group reads the partitions the
-server assigns, joining the group in `initialize()`:
+`IggyConsumer` reads one topic through one `PartitionReader` and hands its messages over one at a time. A standalone consumer reads the partitions it names, and a member of a consumer group reads the partitions the server assigns, joining the group in `initialize()`:
 
 ```swift
-let consumer = try client.consumerGroup(name: "billing", streamID: "orders", topicID: "created")
+let consumer = try client.consumerGroup(
+    name: "billing", streamID: "orders", topicID: "created",
+    configuration: ConsumerConfiguration(pollOptions: PollOptions(maxWait: .seconds(5), commit: .auto(.default))))
 try await consumer.initialize()
 for try await received in consumer {
     print(received.partitionID, received.message.offset, received.message.payloadString)
 }
 ```
 
-A poll refills the buffer once it runs empty. `pollingStrategy` says where reading a
-partition starts, and each later poll of it continues after the last message handed over
-from it. The exception is `.next` with an `autoCommit` that commits with every poll, as in
-the default configuration. That poll starts after the offset that the previous poll
-stored. A failed poll is thrown after the retry
-interval and leaves the consumer usable, so a caller of `next()` can call it again, and
-`consumeMessages(_:)` logs it and keeps reading until the client cannot come back on its
-own. Without `allowReplay` a message at or below the last one handed over from the same
-partition history is skipped.
+`pollOptions` sets the limits of each read and the `CommitPolicy`, `.manual` by default. Under `.manual`, `commit(received.checkpoint)` stores a message. Under `.auto`, a message counts when it is handed over (`.delivery`), once the next one is asked for or the consumer shuts down (`.processing`), or once it is marked with `mark(_:)` (`.marked`). `consumeMessages(_:)` counts a message under `.processing` after its handler returned.
 
-`autoCommit` decides when the position is stored. The default commits with every poll,
-before the batch is handed over, and on a one-second interval; `.disabled` leaves it to
-`storePosition(_:)` with the position of a `ReceivedMessage`, the only setting under which a
-message whose handling failed comes back on the next run. `shutdown()` sends the queued
-commits, stores the reading position of every partition read unless auto-commit is
-disabled, and leaves the group.
+A handler that throws leaves its message uncounted and ends `consumeMessages(_:)` with its error, so the message is read again by the next run. Under the other policies a handler that throws is logged and reading goes on.
 
-Unlike the Rust consumer this one has no encryptor, and it does not resolve a group create
-or join whose reply was lost, since the Swift client has no operation lanes yet.
+`pollingStrategy` says where a partition without a position starts. A failed read is thrown as a `ReadError` and leaves the consumer usable: a caller of `next()` can call it again, and `consumeMessages(_:)` logs it and keeps reading until the client cannot come back on its own. Without `allowReplay` a message at or below the last one handed over from the same partition history is skipped, unless `seek(partitionID:to:)` moved the partition back.
+
+`shutdown()` counts the last message under `.processing` and closes the reader within `offsetDrainTimeout`: it stores the counted positions under `.auto`, moves the offset of each partition back to the last message handed over under `.onRead`, and leaves the group.
+
+Unlike the Rust consumer this one has no encryptor, and it does not resolve a group create or join whose reply was lost, since the Swift client has no operation lanes. For the same reason a store of a partition waits for a read in flight that reads or moves the stored offset of that partition, and no such read starts while the store waits.
 
 ### Raw requests
 
 `sendRawRequest(code:payload:)` sends a command code with an encoded payload and returns the raw reply body. `sendRawRequest(code:payload:replica:)` sends it to one node of a cluster, named by the replica id that cluster metadata returns.
 
-Each node answers non-replicated commands such as `GetStats` from its own state, so this reads the state of that node. Replicated commands, polls, consumer-offset reads and session-control codes fail with `invalidCommand` before anything is sent. An id that cluster metadata does not name fails with `invalidIdentifier`. An unreachable node is retried until the request timeout, and `notPrimary` is returned rather than sent to another node.
+A raw deferred poll (106) takes the route and the limits of a typed poll. A raw fetch (109) goes to the primary of its first target and keeps the fences and the target limits of its payload. If its payload does not decode or its options are out of range, it fails with `invalidCommand`. A raw cancellation (108) fails with `invalidCommand`: cancel the task of the deferred request instead.
+
+Each node answers non-replicated commands such as `GetStats` from its own state, so this reads the state of that node. Replicated commands, polls, deferred polls, fetches, cancellations, consumer-offset reads and session-control codes fail with `invalidCommand` before anything is sent. An id that cluster metadata does not name fails with `invalidIdentifier`. An unreachable node is retried until the request timeout, and `notPrimary` is returned rather than sent to another node.
 
 ### Errors
 
 Every client operation throws `IggyError`, a value carrying the typed `code`
 (`IggyErrorCode`, one case per server error code), the raw wire code, and optional
-context. A cancelled task gets `CancellationError`. A direct producer send is the
+context. An error of a read carries the `partitionID` it names. A cancelled task gets `CancellationError`. A direct producer send is the
 exception: a failure while sending, cancellation included, throws `ProducerSendError`,
 carrying the `IggyError` as `cause`, the confirmations so far, and the unconfirmed
 `failed` tail. A send refused before it starts, such as with `producerClosed` after
@@ -390,6 +418,9 @@
   safe.
 - `transientNotCommitted` (57): the outcome is unknown, and the server possibly applied
   the write. Read the state before you write it again.
+- `fetchProgressUncertain` (14010): a poll or fetch with `.onRead` lost its reply, so the
+  server may have stored its offset. Its partition is blocked with
+  `partitionLaneBlocked` (14009) until `abandonOperation(_:)` takes the operation.
 - `unauthenticated` (40): the request needs a session. Sign in, then retry.
 
 The Rust SDK and the other native SDKs keep a write with an unknown outcome in a
diff --git a/foreign/swift/Sources/Iggy/Client/ClientCore.swift b/foreign/swift/Sources/Iggy/Client/ClientCore.swift
index f3dce54..e719cb6 100644
--- a/foreign/swift/Sources/Iggy/Client/ClientCore.swift
+++ b/foreign/swift/Sources/Iggy/Client/ClientCore.swift
@@ -424,21 +424,22 @@
     /// cancelled request, or an outage longer than the request timeout leaves
     /// the client disconnected, and with credentials to sign in again the
     /// next typed call reconnects instead of failing until ``connect()``. A
-    /// reconnect that outlasts the request timeout refuses the call as not
-    /// accepted, since nothing was sent, and one whose sign-in the server
-    /// refused ends it as unauthenticated.
+    /// reconnect that outlasts the request timeout, or `limit` of a caller
+    /// with a budget of its own, refuses the call as not accepted, since
+    /// nothing was sent, and one whose sign-in the server refused ends it as
+    /// unauthenticated.
     ///
     /// Returns the lineage the call started under, read before the reconnect
     /// can wait for a sign-in of the caller, so that no request of the call
     /// is written under the session that sign-in opens.
-    func requireAuthenticated() async throws -> UInt64 {
+    func requireAuthenticated(within limit: ContinuousClock.Instant? = nil) async throws -> UInt64 {
         let started = lineage
         switch state {
         case .disconnected, .connecting, .authenticating:
             if !disconnectedByCaller, configuration.reconnection.enabled, signInCredentials() != nil {
                 let reconnected: Void?
                 do {
-                    reconnected = try await withDeadline(ContinuousClock.now + configuration.requestTimeout) { [self] in
+                    reconnected = try await withDeadline(requestDeadline(within: limit)) { [self] in
                         try await self.connect()
                     }
                 } catch {
@@ -781,6 +782,12 @@
                 // of the same request no longer proves it never committed.
                 let error = outcome.observe(answer, committed: VSRFrame.hasCommittedResult(header))
                 refusal = error
+                logger.debug(
+                    "The server refused the request",
+                    metadata: [
+                        "code": "\(code)", "request_id": "\(stamped.requestID)", "error": "\(answer)", "server_address": "\(currentAddress)",
+                        "metadata_watermark": "\(metadataWatermark)",
+                    ])
                 if answer.code == .notPrimary, !VSRFrame.hasCommittedResult(header) {
                     return .failure(answer)
                 }
@@ -1198,6 +1205,9 @@
         lineage += 1
         rememberedSignIn = nil
         stopHeartbeat()
+        // A committing read parked on its own connection ends now, with an
+        // unknown outcome, before the server can answer it for the session.
+        await pollRouter.closeDeferredConnections()
         await disconnectTransport()
         resetSession()
     }
diff --git a/foreign/swift/Sources/Iggy/Client/ConsumerGroupState.swift b/foreign/swift/Sources/Iggy/Client/ConsumerGroupState.swift
index 15c35e4..b92e33e 100644
--- a/foreign/swift/Sources/Iggy/Client/ConsumerGroupState.swift
+++ b/foreign/swift/Sources/Iggy/Client/ConsumerGroupState.swift
@@ -123,6 +123,11 @@
         }
     }
 
+    /// The assigned partitions, or nil when nothing is cached for the group.
+    func assignedPartitions(_ key: GroupKey) -> [UInt32]? {
+        storage.withLockedValue { $0.assignments[key]?.partitions }
+    }
+
     // MARK: Membership
 
     /// Forgets a group after leaving it.
diff --git a/foreign/swift/Sources/Iggy/Client/IggyClient+Messages.swift b/foreign/swift/Sources/Iggy/Client/IggyClient+Messages.swift
index bcc4008..471e83e 100644
--- a/foreign/swift/Sources/Iggy/Client/IggyClient+Messages.swift
+++ b/foreign/swift/Sources/Iggy/Client/IggyClient+Messages.swift
@@ -69,44 +69,306 @@
         return (try? Responses.sendConfirmations(body[...])) ?? SendMessagesResponse()
     }
 
-    /// Polls up to `count` messages.
+    /// Polls one partition, as `poll_messages` in the Rust SDK. The request
+    /// is deferred: the server answers once `minCount` messages are
+    /// readable, the next one would pass `maxBytes`, or `maxWait` ran out,
+    /// and then returns what there is, possibly nothing. A first message
+    /// larger than `maxBytes` comes alone in its own reply. The call takes
+    /// at most the request timeout of `options`, `maxWait` plus ten seconds.
     ///
-    /// A consumer-group poll with `partitionID` nil takes the next of the
-    /// member's assigned partitions, round-robin, syncing the assignment from
-    /// the coordinator when needed. A member holding no partitions gets an
-    /// empty result whose partition id is ``PolledMessages/noAssignedPartition``,
-    /// and a client that is not a member gets
-    /// ``IggyErrorCode/consumerGroupMemberNotFound``, so it can join again.
+    /// With ``CommitPolicy/onRead`` the server stores the offset of the last
+    /// message it returns, as part of the read. A cancelled caller of such a
+    /// poll that was sent waits for its one reply: `CancellationError` when
+    /// the server stored nothing, or the messages it stored as consumed. Such
+    /// a poll whose outcome is unknown, after a lost reply, a
+    /// ``disconnect()`` or a sign-in or sign-out while it waits, fails with
+    /// ``IggyErrorCode/fetchProgressUncertain`` and is never sent again. Every later poll with `onRead` or `next` and
+    /// every offset store or delete of its partition then fails with
+    /// ``IggyErrorCode/partitionLaneBlocked`` until ``abandonOperation(_:)``.
+    /// ``CommitPolicy/auto(_:)`` is refused with
+    /// ``IggyErrorCode/invalidConfiguration``: a single poll has nothing to
+    /// count. Use a ``PartitionReader`` or an ``IggyConsumer`` for it.
     ///
-    /// With `autoCommit` the server stores the offset of the last polled
-    /// message as consumed. A `strategy` with a ``PollingStrategy/context``
-    /// is refused once its partition was recreated.
+    /// Continue with ``PolledMessages/continuation``, which this call refuses
+    /// for another partition with ``IggyErrorCode/invalidCommand``, and
+    /// commit with ``storeConsumerPosition(consumer:streamID:topicID:checkpoint:)``.
+    /// Invalid options fail before anything is sent.
     public func pollMessages(
-        streamID: Identifier, topicID: Identifier, partitionID: UInt32?, consumer: Consumer = .default, strategy: PollingStrategy, count: UInt32,
-        autoCommit: Bool
+        streamID: Identifier, topicID: Identifier, partitionID: UInt32, consumer: Consumer = .default, strategy: PollingStrategy,
+        options: PollOptions = PollOptions()
     ) async throws -> PolledMessages {
-        try await pollMessages(streamID: streamID, topicID: topicID, partitionID: partitionID, consumer: consumer, count: count, autoCommit: autoCommit) {
-            _ in strategy
+        if case .auto = options.commit {
+            throw IggyError(.invalidConfiguration, context: "a single poll has nothing to count, a PartitionReader commits automatically")
         }
+        try strategy.validate(partitionID: partitionID)
+        try options.validate()
+        var polled = try await pollMessages(
+            streamID: streamID, topicID: topicID, partitionID: partitionID, consumer: consumer, strategy: strategy, count: options.count,
+            autoCommit: options.commitsOnRead, options: options.deferred)
+        polled.start = strategy
+        return polled
     }
 
-    /// ``pollMessages(streamID:topicID:partitionID:consumer:strategy:count:autoCommit:)``
+    /// One poll with the wire limits of the caller: command 100 without
+    /// `options`, else command 106. A consumer-group poll with `partitionID`
+    /// nil takes the next of the member's assigned partitions, round-robin,
+    /// syncing the assignment from the coordinator when needed. A member
+    /// holding no partitions gets an empty result whose partition id is
+    /// ``PolledMessages/noAssignedPartition``, and a client that is not a
+    /// member gets ``IggyErrorCode/consumerGroupMemberNotFound``. A deferred
+    /// consumer-group poll names its partition.
+    func pollMessages(
+        streamID: Identifier, topicID: Identifier, partitionID: UInt32?, consumer: Consumer = .default, strategy: PollingStrategy, count: UInt32,
+        autoCommit: Bool, options: DeferredPollOptions? = nil
+    ) async throws -> PolledMessages {
+        try await pollMessages(
+            streamID: streamID, topicID: topicID, partitionID: partitionID, consumer: consumer, count: count, autoCommit: autoCommit, options: options
+        ) { _ in strategy }
+    }
+
+    /// ``pollMessages(streamID:topicID:partitionID:consumer:strategy:count:autoCommit:options:)``
     /// with the strategy chosen once the partition is known, so a caller can
     /// continue each partition from its own position.
-    public func pollMessages(
+    func pollMessages(
         streamID: Identifier, topicID: Identifier, partitionID: UInt32?, consumer: Consumer = .default, count: UInt32, autoCommit: Bool,
-        strategyFor: @Sendable (UInt32) -> PollingStrategy
+        options: DeferredPollOptions? = nil, strategyFor: @Sendable (UInt32) -> PollingStrategy
     ) async throws -> PolledMessages {
-        let lineage = try await requireAuthenticated()
+        let started = ContinuousClock.now
+        try options?.validate(count: count)
         if consumer.kind == .consumerGroup, partitionID == nil {
+            guard options == nil else {
+                throw IggyError(
+                    .invalidCommand, context: "a deferred consumer-group poll names its partition; use fetchMessages for a readiness group instead")
+            }
+            let lineage = try await requireAuthenticated()
             return try await pollGroup(
                 streamID: streamID, topicID: topicID, consumer: consumer, count: count, autoCommit: autoCommit, lineage: lineage, strategyFor: strategyFor)
         }
-        let strategy = strategyFor(partitionID ?? 0)
+        let lineage = try await requireAuthenticated(within: options.map { started + $0.requestTimeout })
+        return try await poll(
+            consumer: consumer, streamID: streamID, topicID: topicID, partitionID: partitionID, strategy: strategyFor(partitionID ?? 0), count: count,
+            autoCommit: autoCommit, options: options, started: started, lineage: lineage)
+    }
+
+    /// Fetches up to `count` messages from the partitions of one readiness
+    /// group, in one request to the primary of the group, as
+    /// `fetch_messages` in the Rust SDK. Only a ``PartitionReader`` builds
+    /// its targets: every target must share the primary of the first one.
+    ///
+    /// Each start point is an offset or `next`, and each partition appears
+    /// once, at most 1000 of them. A target without a context is fenced with
+    /// the incarnation of its route. The reply holds a section per target
+    /// that was read or failed. A `notPrimary` section or refusal marks the
+    /// routes of its partitions moved, a `historyUnavailable` or
+    /// `consumerGroupPartitionNotOwned` one drops them. A fetch with
+    /// `autoCommit` or a `next` target orders progress polls and offset
+    /// stores of its targets. A cancelled caller of a fetch with
+    /// `autoCommit` that was sent waits for its one reply. A lost reply of
+    /// such a fetch fails with ``IggyErrorCode/fetchProgressUncertain`` and
+    /// blocks its targets, a section whose error leaves the outcome unknown
+    /// carries that error and blocks its partition, and the fetch is never
+    /// sent again.
+    func fetchMessages(
+        streamID: Identifier, topicID: Identifier, targets: [(partitionID: UInt32, strategy: PollingStrategy)], consumer: Consumer = .default,
+        count: UInt32, autoCommit: Bool, options: DeferredPollOptions = DeferredPollOptions()
+    ) async throws -> [FetchSection] {
+        let started = ContinuousClock.now
+        try Self.validateFetch(targets, count: count, options: options)
+        let lineage = try await requireAuthenticated(within: started + options.requestTimeout)
+        var options = options
+        options.maxBytes = Self.fetchMaxBytes(options.maxBytes, targets: targets.count)
+        // Each target may fill the whole reply: the server sizes the reads by
+        // the bytes each target holds, and a smaller cap would slice batches.
+        let wire = targets.map { target in
+            Requests.FetchTarget(
+                partitionID: target.partitionID, strategy: target.strategy, maxBytes: options.maxBytes, fence: target.strategy.context ?? .empty)
+        }
+        return try await fetch(
+            streamID: streamID, topicID: topicID, consumer: consumer, targets: wire, count: count, autoCommit: autoCommit, options: options,
+            started: started, lineage: lineage
+        ).sections
+    }
+
+    /// `partitions` grouped by the replica that is their primary, as
+    /// `readiness_groups` in the Rust SDK: one group per primary, in replica
+    /// order, split into groups of at most 1000 partitions, each in the
+    /// order of `partitions`.
+    func readinessGroups(streamID: Identifier, topicID: Identifier, partitions: [UInt32]) async throws -> [[UInt32]] {
+        let lineage = try await requireAuthenticated()
+        return try await core.pollRouter.primaryGroups(
+            stream: streamID, topic: topicID, partitions: partitions, lineage: lineage, coordinator: core,
+            deadline: ContinuousClock.now + PollRouter.pollTimeout)
+    }
+
+    /// A wait and a fetch name 1 to 1000 partitions, each once.
+    fileprivate static func validateTargets(_ partitions: [(partitionID: UInt32, strategy: PollingStrategy)], of request: String) throws {
+        guard (1...Requests.maxPartitionsPerRequest).contains(partitions.count), Set(partitions.map(\.partitionID)).count == partitions.count else {
+            throw IggyError(.invalidCommand, context: "\(request) names 1 to \(Requests.maxPartitionsPerRequest) partitions, each once")
+        }
+    }
+
+    fileprivate static func validateFetch(_ targets: [(partitionID: UInt32, strategy: PollingStrategy)], count: UInt32, options: DeferredPollOptions) throws {
+        try validateTargets(targets, of: "a fetch")
+        guard targets.allSatisfy({ fetchTakes($0.strategy) }) else {
+            throw IggyError(.invalidCommand, context: "a fetch starts each partition at an offset or after its stored offset")
+        }
+        try options.validateFetch(count: count)
+    }
+
+    /// A fetch takes only these start points. The others would loop or
+    /// misread under a count that the reply splits between its targets.
+    static func fetchTakes(_ strategy: PollingStrategy) -> Bool {
+        strategy.kind == .next || strategy.kind == .offset
+    }
+
+    /// A reply larger than a frame fails the connection that reads it, so the
+    /// records of a fetch leave room for its section table.
+    fileprivate static func fetchMaxBytes(_ maxBytes: UInt32, targets: Int) -> UInt32 {
+        min(maxBytes, DeferredPollOptions.maxReplyBytes - UInt32(Responses.fetchHeadSize(targets)))
+    }
+
+    /// One fetch on the route of its first target, the primary of its group.
+    /// Each target carries its own fence, so the header carries no partition
+    /// context. A fetch with `autoCommit` runs as a task of its own, which
+    /// the cancellation of its caller does not reach.
+    private func fetch(
+        streamID: Identifier, topicID: Identifier, consumer: Consumer, targets: [Requests.FetchTarget], count: UInt32, autoCommit: Bool,
+        options: DeferredPollOptions, started: ContinuousClock.Instant, lineage: UInt64
+    ) async throws -> (body: [UInt8], sections: [FetchSection]) {
+        let core = self.core
+        let router = core.pollRouter
+        let keys = targets.map { PollRouter.RouteKey(stream: streamID, topic: topicID, partitionID: $0.partitionID) }
+        guard let routing = keys.first else {
+            throw IggyError(.invalidCommand, context: "a fetch names at least one partition")
+        }
+        let fences = try await router.fences(
+            zip(keys, targets).map { ($0, $1.fence) }, lineage: lineage, coordinator: core, deadline: started + options.requestTimeout)
+        let targets = zip(targets, fences).map { target, fence in
+            Requests.FetchTarget(partitionID: target.partitionID, strategy: target.strategy, maxBytes: target.maxBytes, fence: fence)
+        }
+        // A fetch that commits nothing guards only the stored offsets that its
+        // `next` targets read.
+        let progressKeys = targets.filter { autoCommit || $0.strategy.kind == .next }.map {
+            PollRouter.ProgressKey(partitionID: $0.partitionID, incarnation: $0.fence.incarnation)
+        }
+        let result: (body: [UInt8], sections: [FetchSection])
+        do {
+            result = try await router.withProgress(progressKeys, deadline: started + options.requestTimeout) {
+                let attempt = autoCommit ? try router.reserveFetch(progressKeys) : nil
+                defer {
+                    if let attempt { router.releaseFetch(attempt) }
+                }
+                var deferred = PollRouter.Deferred(options: options, started: started, commits: autoCommit) { remaining in
+                    try Requests.fetchMessages(
+                        consumer: consumer, streamID: streamID, topicID: topicID, targets: targets, count: count, autoCommit: autoCommit, options: remaining)
+                }
+                deferred.fetchAttempt = attempt
+                do {
+                    let body = try await router.send(
+                        .fetchMessages, body: .deferred(deferred), key: routing, context: .empty, lineage: lineage, coordinator: core,
+                        deadline: deferred.deadline)
+                    var sections = try Responses.fetchSections(body[...])
+                    let requested = Set(targets.map(\.partitionID))
+                    let returned = Set(sections.map(\.partitionID))
+                    guard returned.count == sections.count, returned.isSubset(of: requested) else {
+                        throw IggyError(.invalidCommand, context: "a fetch reply repeats or adds a partition")
+                    }
+                    if let attempt {
+                        let uncertain = Set(
+                            sections.compactMap { section in
+                                section.error.flatMap { CommitErrorScope.section.provesNothingStored($0.code) ? nil : section.partitionID }
+                            })
+                        if !uncertain.isEmpty {
+                            _ = router.retainFetch(attempt, keys: progressKeys.filter { uncertain.contains($0.partitionID) })
+                            for index in sections.indices where uncertain.contains(sections[index].partitionID) {
+                                sections[index].uncertainty = IggyError(
+                                    .fetchProgressUncertain, context: sections[index].error?.description, operationID: attempt.id,
+                                    partitionID: sections[index].partitionID)
+                            }
+                        }
+                    }
+                    return (body, sections)
+                } catch {
+                    if let attempt, attempt.mayHaveCommitted, !Self.provesNothingStored(error) {
+                        throw router.retainFetch(attempt, keys: progressKeys, cause: "\(error)")
+                    }
+                    throw error
+                }
+            }
+        } catch let error as IggyError where error.code == .notPrimary {
+            router.mayHaveMoved(keys)
+            throw error
+        } catch let error as IggyError where Self.staleRoute(error.code) {
+            router.dropRoutes(keys)
+            throw error
+        }
+        let sections = result.sections
+        let failed = { (matches: (IggyErrorCode) -> Bool) in
+            sections.filter { $0.error.map { matches($0.code) } ?? false }.map {
+                PollRouter.RouteKey(stream: streamID, topic: topicID, partitionID: $0.partitionID)
+            }
+        }
+        router.mayHaveMoved(failed { $0 == .notPrimary })
+        router.dropRoutes(failed(Self.staleRoute))
+        return result
+    }
+
+    /// The server refuses a fence this way once the partition was recreated
+    /// or its owner changed, so the route is looked up again.
+    private static func staleRoute(_ code: IggyErrorCode) -> Bool {
+        code == .historyUnavailable || code == .consumerGroupPartitionNotOwned
+    }
+
+    /// Whether a committing poll or fetch that failed with `error` after it
+    /// may have been sent stored nothing. A cancellation is answered only
+    /// once the server stored nothing.
+    private static func provesNothingStored(_ error: any Error) -> Bool {
+        if error is CancellationError {
+            return true
+        }
+        return (error as? IggyError).map { CommitErrorScope.request.provesNothingStored($0.code) } ?? false
+    }
+
+    /// One deferred poll of one partition, its reply body undecoded, so a
+    /// reader can name the bytes of a message that does not decode.
+    func pollBody(
+        streamID: Identifier, topicID: Identifier, partitionID: UInt32, consumer: Consumer, strategy: PollingStrategy, count: UInt32,
+        autoCommit: Bool, options: DeferredPollOptions
+    ) async throws -> [UInt8] {
+        let started = ContinuousClock.now
+        try options.validate(count: count)
+        let lineage = try await requireAuthenticated(within: started + options.requestTimeout)
         let payload = try Requests.pollMessages(
             consumer: consumer, streamID: streamID, topicID: topicID, partitionID: partitionID, strategy: strategy, count: count, autoCommit: autoCommit)
-        return try Responses.polledMessages(
-            try await routedPartitionRequest(.pollMessages, payload: payload, context: strategy.context, lineage: lineage)[...])
+        let deferred = PollRouter.Deferred(options: options, started: started, commits: autoCommit) { remaining in
+            try Requests.pollMessages(
+                consumer: consumer, streamID: streamID, topicID: topicID, partitionID: partitionID, strategy: strategy, count: count,
+                autoCommit: autoCommit, options: remaining)
+        }
+        return try await routedPartitionRequest(
+            .pollMessagesDeferred, payload: payload, deferred: deferred, context: strategy.context, lineage: lineage)
+    }
+
+    /// One poll of one partition. With `options` it is deferred: command 106,
+    /// the body of a poll followed by the options left for each attempt.
+    private func poll(
+        consumer: Consumer, streamID: Identifier, topicID: Identifier, partitionID: UInt32?, strategy: PollingStrategy, count: UInt32,
+        autoCommit: Bool, options: DeferredPollOptions?, started: ContinuousClock.Instant, lineage: UInt64
+    ) async throws -> PolledMessages {
+        let payload = try Requests.pollMessages(
+            consumer: consumer, streamID: streamID, topicID: topicID, partitionID: partitionID, strategy: strategy, count: count, autoCommit: autoCommit)
+        guard let options else {
+            return try Responses.polledMessages(
+                try await routedPartitionRequest(.pollMessages, payload: payload, context: strategy.context, lineage: lineage)[...])
+        }
+        let deferred = PollRouter.Deferred(options: options, started: started, commits: autoCommit) { remaining in
+            try Requests.pollMessages(
+                consumer: consumer, streamID: streamID, topicID: topicID, partitionID: partitionID, strategy: strategy, count: count,
+                autoCommit: autoCommit, options: remaining)
+        }
+        let body = try await routedPartitionRequest(
+            .pollMessagesDeferred, payload: payload, deferred: deferred, context: strategy.context, lineage: lineage)
+        return try Responses.polledMessages(body[...])
     }
 
     // MARK: Partition contexts
@@ -153,21 +415,61 @@
     /// A poll or a consumer-offset request on the route of the partition its
     /// payload names, as `OperationDriver::send_raw` in the Rust SDK picks
     /// it. A plain consumer's poll and an offset read without a partition go
-    /// to partition 0. A group poll, a store, and a delete must name one.
-    func routedPartitionRequest(_ code: CommandCode, payload: [UInt8], context: PartitionContext?, lineage: UInt64) async throws -> [UInt8] {
+    /// to partition 0. A group poll, a store, and a delete must name one. A
+    /// `deferred` poll sends its own body, `payload` only routes it, and its
+    /// request timeout is its deadline.
+    func routedPartitionRequest(
+        _ code: CommandCode, payload: [UInt8], deferred: PollRouter.Deferred? = nil, context: PartitionContext?, lineage: UInt64
+    ) async throws -> [UInt8] {
         let target = try RawRequest.consumerTarget(payload)
+        let poll = code == .pollMessages || code == .pollMessagesDeferred
         let partitionID: UInt32
         if let named = target.partitionID {
             partitionID = named
-        } else if code == .getConsumerOffset || (code == .pollMessages && target.consumerKind == Consumer.Kind.consumer.rawValue) {
+        } else if code == .getConsumerOffset || (poll && target.consumerKind == Consumer.Kind.consumer.rawValue) {
             partitionID = 0
         } else {
             throw IggyError(.invalidIdentifier, context: "command \(code.rawValue) needs a partition id")
         }
         let key = PollRouter.RouteKey(stream: target.stream, topic: target.topic, partitionID: partitionID)
-        return try await core.pollRouter.send(
-            code, payload: payload, key: key, context: context, lineage: lineage, coordinator: core,
-            deadline: ContinuousClock.now + PollRouter.pollTimeout)
+        let deadline = deferred?.deadline ?? ContinuousClock.now + PollRouter.pollTimeout
+        let router = core.pollRouter
+        let progress = try code == .storeConsumerOffset || code == .deleteConsumerOffset || (poll && RawRequest.pollChangesProgress(payload))
+        let captured: PartitionContext?
+        let keys: [PollRouter.ProgressKey]
+        if progress {
+            let resolved = try await router.progressContext(key, context: context, lineage: lineage, coordinator: core, deadline: deadline)
+            captured = resolved
+            keys = [PollRouter.ProgressKey(partitionID: partitionID, incarnation: resolved.incarnation)]
+        } else {
+            captured = context
+            keys = []
+        }
+        return try await router.withProgress(keys, deadline: deadline) {
+            let attempt = deferred?.commits == true ? try router.reserveFetch(keys) : nil
+            defer {
+                if let attempt { router.releaseFetch(attempt) }
+            }
+            var deferred = deferred
+            deferred?.fetchAttempt = attempt
+            do {
+                let body = try await router.send(
+                    code, body: deferred.map { .deferred($0) } ?? .fixed(payload), key: key, context: captured, lineage: lineage, coordinator: core,
+                    deadline: deadline)
+                if let attempt, let ended = await core.lineageRefusal(lineage) {
+                    // The caller signed in or out meanwhile, so it never gets the batch the server stored.
+                    throw router.retainFetch(attempt, keys: keys, cause: "\(ended)")
+                }
+                return body
+            } catch let uncertain as IggyError where uncertain.code == .fetchProgressUncertain && uncertain.operationID == attempt?.id {
+                throw uncertain
+            } catch {
+                if let attempt, attempt.mayHaveCommitted, !Self.provesNothingStored(error) {
+                    throw router.retainFetch(attempt, keys: keys, cause: "\(error)")
+                }
+                throw error
+            }
+        }
     }
 
     // MARK: Client-side partitioning
@@ -206,7 +508,12 @@
     func syncGroupAssignment(streamID: Identifier, topicID: Identifier, groupID: Identifier, lineage: UInt64?) async throws {
         let state = core.groupState
         let scope = state.sessionScope
-        let body = try await send(.syncConsumerGroup, Requests.consumerGroup(streamID: streamID, topicID: topicID, groupID: groupID), lineage: lineage)
+        let payload = try Requests.consumerGroup(streamID: streamID, topicID: topicID, groupID: groupID)
+        // A cancelled exchange takes the coordinator connection with it, so a
+        // caller cancelled meanwhile lets the sync finish.
+        let body = try await Task {
+            try await self.core.exchange(code: CommandCode.syncConsumerGroup.rawValue, payload: payload, lineage: lineage)
+        }.value
         let key = ConsumerGroupState.GroupKey(stream: streamID, topic: topicID, group: groupID)
         if body.isEmpty {
             // The coordinator answers any member, even one holding no
@@ -217,6 +524,9 @@
             return
         }
         let assignment = try Responses.consumerGroupAssignment(body[...])
+        logger.debug(
+            "Synced the consumer group assignment",
+            metadata: ["group": "\(groupID)", "generation": "\(assignment.generation)", "partitions": "\(assignment.partitions)"])
         state.applySync(key, member: (assignment.generation, assignment.partitions), scope: scope)
     }
 
@@ -285,6 +595,7 @@
         // a normal rebalance look like a failure.
         return .empty
     }
+
 }
 
 // MARK: - Consumer offsets
@@ -306,16 +617,28 @@
             context: nil, lineage: lineage)
     }
 
-    /// Stores a checkpoint under the partition context of the poll that
-    /// delivered it. It is refused once the partition was recreated or, for a
-    /// consumer group, reassigned since.
-    public func storeConsumerPosition(consumer: Consumer = .default, streamID: Identifier, topicID: Identifier, position: ConsumerPosition) async throws {
+    /// Stores the offset of a checkpoint under the partition context of the
+    /// read that delivered it, as `store_consumer_position` in the Rust SDK.
+    /// It is refused with ``IggyErrorCode/historyUnavailable`` once the
+    /// partition was recreated and, for a consumer group, with
+    /// ``IggyErrorCode/consumerGroupPartitionNotOwned`` once it was
+    /// reassigned.
+    public func storeConsumerPosition(consumer: Consumer = .default, streamID: Identifier, topicID: Identifier, checkpoint: Checkpoint) async throws {
         let lineage = try await requireAuthenticated()
         _ = try await routedPartitionRequest(
             .storeConsumerOffset,
             payload: Requests.storeConsumerOffset(
-                consumer: consumer, streamID: streamID, topicID: topicID, partitionID: position.partitionID, offset: position.offset, ack: .quorum),
-            context: position.context, lineage: lineage)
+                consumer: consumer, streamID: streamID, topicID: topicID, partitionID: checkpoint.partitionID, offset: checkpoint.offset, ack: .quorum),
+            context: checkpoint.context, lineage: lineage)
+    }
+
+    /// Deletes the stored offset of the partition of a checkpoint under the
+    /// partition context of the read that delivered it, refused as
+    /// ``storeConsumerPosition(consumer:streamID:topicID:checkpoint:)`` is.
+    /// The offset of the checkpoint is not used.
+    public func deleteConsumerPosition(consumer: Consumer = .default, streamID: Identifier, topicID: Identifier, checkpoint: Checkpoint) async throws {
+        try await deleteConsumerOffset(
+            consumer: consumer, streamID: streamID, topicID: topicID, partitionID: checkpoint.partitionID, context: checkpoint.context)
     }
 
     /// The stored offset of a consumer on a partition, or nil when none is
@@ -332,15 +655,33 @@
         return body.isEmpty ? nil : try Responses.consumerOffset(body[...])
     }
 
+    func consumerOffsetNext(
+        consumer: Consumer, streamID: Identifier, topicID: Identifier, partitionID: UInt32, context: PartitionContext
+    ) async throws -> UInt64 {
+        let lineage = try await requireAuthenticated()
+        let body = try await routedPartitionRequest(
+            .getConsumerOffset, payload: Requests.getConsumerOffset(consumer: consumer, streamID: streamID, topicID: topicID, partitionID: partitionID),
+            context: context, lineage: lineage)
+        let offset = body.isEmpty ? nil : try Responses.consumerOffset(body[...])
+        return offset.map { $0.storedOffset == .max ? .max : $0.storedOffset + 1 } ?? 0
+    }
+
     /// Deletes the stored offset of a consumer on a partition. A nil
     /// `partitionID` fails with ``IggyErrorCode/invalidIdentifier`` before
     /// anything is sent.
     public func deleteConsumerOffset(consumer: Consumer = .default, streamID: Identifier, topicID: Identifier, partitionID: UInt32?) async throws {
+        try await deleteConsumerOffset(consumer: consumer, streamID: streamID, topicID: topicID, partitionID: partitionID, context: nil)
+    }
+
+    /// Deletes the offset under the context of a poll, so the server refuses
+    /// it once the partition was recreated or, for a consumer group,
+    /// reassigned since. A nil context takes the one of the route.
+    func deleteConsumerOffset(consumer: Consumer, streamID: Identifier, topicID: Identifier, partitionID: UInt32?, context: PartitionContext?) async throws {
         let lineage = try await requireAuthenticated()
         _ = try await routedPartitionRequest(
             .deleteConsumerOffset,
             payload: Requests.deleteConsumerOffset(consumer: consumer, streamID: streamID, topicID: topicID, partitionID: partitionID, ack: .quorum),
-            context: nil, lineage: lineage)
+            context: context, lineage: lineage)
     }
 }
 
@@ -362,6 +703,31 @@
         case .pollMessages:
             try RawRequest.validatePoll(payload)
             return try await routedPartitionRequest(.pollMessages, payload: payload, context: nil, lineage: lineage)
+        case .pollMessagesDeferred:
+            let started = ContinuousClock.now
+            let request = try RawRequest.deferredPoll(payload)
+            let deferred = PollRouter.Deferred(options: request.options, started: started, commits: request.autoCommit) { remaining in
+                var writer = ByteWriter(capacity: payload.count)
+                writer.write(request.poll)
+                try remaining.encode(into: &writer)
+                return writer.bytes
+            }
+            return try await routedPartitionRequest(.pollMessagesDeferred, payload: request.poll, deferred: deferred, context: nil, lineage: lineage)
+        case .fetchMessages:
+            // The fences and limits of the targets are the caller's, as the
+            // payload carries them.
+            let started = ContinuousClock.now
+            let request = try RawRequest.fetch(payload)
+            var options = request.options
+            options.maxBytes = Self.fetchMaxBytes(options.maxBytes, targets: request.targets.count)
+            let targets = request.targets.map { target in
+                Requests.FetchTarget(
+                    partitionID: target.partitionID, strategy: target.strategy, maxBytes: min(target.maxBytes, options.maxBytes), fence: target.fence)
+            }
+            return try await fetch(
+                streamID: request.stream, topicID: request.topic, consumer: request.consumer, targets: targets, count: request.count,
+                autoCommit: request.autoCommit, options: options, started: started, lineage: lineage
+            ).body
         case .getConsumerOffset, .storeConsumerOffset, .deleteConsumerOffset:
             return try await routedPartitionRequest(command, payload: payload, context: nil, lineage: lineage)
         default:
@@ -372,9 +738,9 @@
 
 /// Reads the partition a raw request names. A payload that does not decode
 /// fails with ``IggyErrorCode/invalidCommand`` before anything is sent.
-private enum RawRequest {
-    /// Strategy kind and value, count, and the auto-commit flag.
-    static let pollParametersSize = 9 + 4 + 1
+enum RawRequest {
+    /// `[kind:1][value:8]`.
+    static let strategySize = 9
 
     struct SendTarget {
         let stream: Identifier
@@ -390,6 +756,23 @@
         let partitionID: UInt32?
     }
 
+    struct DeferredPoll {
+        /// The poll without its options.
+        let poll: [UInt8]
+        let options: DeferredPollOptions
+        let autoCommit: Bool
+    }
+
+    struct Fetch {
+        let consumer: Consumer
+        let stream: Identifier
+        let topic: Identifier
+        let options: DeferredPollOptions
+        let count: UInt32
+        let autoCommit: Bool
+        let targets: [Requests.FetchTarget]
+    }
+
     /// `[metadata_len:u32][stream][topic][partitioning][messages_count:u32]`
     /// leads the batch, and the fields must fit the declared length. Balanced
     /// and key partitioning are resolved by the typed send from its cache and
@@ -417,12 +800,75 @@
     }
 
     static func validatePoll(_ payload: [UInt8]) throws {
+        try decode(payload) { reader in _ = try readPollCount(&reader) }
+    }
+
+    static func pollChangesProgress(_ payload: [UInt8]) throws -> Bool {
         try decode(payload) { reader in
             _ = try readConsumerTarget(&reader)
-            try reader.skip(pollParametersSize)
+            let strategy = try readStrategy(&reader)
+            _ = try reader.readUInt32()
+            let autoCommit = try reader.readBool()
+            return autoCommit || strategy.kind == .next
         }
     }
 
+    /// A poll followed by `[wait_us:8][min_count:4][max_bytes:4][request_timeout_us:8]`.
+    /// Options the wire refuses fail as a payload that does not decode, as in
+    /// the Rust SDK.
+    static func deferredPoll(_ payload: [UInt8]) throws -> DeferredPoll {
+        try decode(payload) { reader in
+            let (count, autoCommit) = try readPollCount(&reader)
+            let poll = Array(payload[..<reader.position])
+            let options = DeferredPollOptions(
+                maxWait: .microseconds(try reader.readUInt64()), minCount: try reader.readUInt32(), maxBytes: try reader.readUInt32(),
+                requestTimeout: .microseconds(try reader.readUInt64()))
+            try requireEnd(reader)
+            try options.validate(count: count)
+            return DeferredPoll(poll: poll, options: options, autoCommit: autoCommit)
+        }
+    }
+
+    /// `[consumer][stream][topic][wait_us:8][request_timeout_us:8][min_count:4][count:4][max_bytes:4][auto_commit:1][targets_count:4]`,
+    /// then `targets_count` x `[partition_id:4][strategy:9][max_bytes:4][incarnation:8][owner_generation:8]`.
+    static func fetch(_ payload: [UInt8]) throws -> Fetch {
+        try decode(payload) { reader in
+            let consumer = try readConsumer(&reader)
+            let stream = try Identifier.decode(from: &reader)
+            let topic = try Identifier.decode(from: &reader)
+            let wait = Duration.microseconds(try reader.readUInt64())
+            let timeout = Duration.microseconds(try reader.readUInt64())
+            let minCount = try reader.readUInt32()
+            let count = try reader.readUInt32()
+            let options = DeferredPollOptions(maxWait: wait, minCount: minCount, maxBytes: try reader.readUInt32(), requestTimeout: timeout)
+            let autoCommit = try reader.readBool()
+            let targetsCount = try reader.readUInt32()
+            // Compared as `UInt32` so a hostile count never traps a 32-bit `Int`.
+            guard targetsCount <= UInt32(Requests.maxPartitionsPerRequest) else {
+                throw WireError.validation("a fetch cannot name \(targetsCount) partitions")
+            }
+            let targets = try (0..<targetsCount).map { _ in
+                Requests.FetchTarget(
+                    partitionID: try reader.readUInt32(), strategy: try readStrategy(&reader), maxBytes: try reader.readUInt32(),
+                    fence: PartitionContext(incarnation: try reader.readUInt64(), ownerGeneration: try reader.readUInt64(), metadataOp: 0))
+            }
+            try requireEnd(reader)
+            try IggyClient.validateFetch(targets.map { ($0.partitionID, $0.strategy) }, count: count, options: options)
+            guard targets.allSatisfy({ $0.maxBytes >= DeferredPollOptions.minMaxBytes && $0.maxBytes <= options.maxBytes }) else {
+                throw WireError.validation("a fetch target fills \(DeferredPollOptions.minMaxBytes) bytes up to the limit of the fetch")
+            }
+            return Fetch(consumer: consumer, stream: stream, topic: topic, options: options, count: count, autoCommit: autoCommit, targets: targets)
+        }
+    }
+
+    /// Reads a poll to its end and returns the count it asks for and its
+    /// auto-commit flag.
+    private static func readPollCount(_ reader: inout ByteReader) throws -> (count: UInt32, autoCommit: Bool) {
+        _ = try readConsumerTarget(&reader)
+        try reader.skip(strategySize)
+        return (try reader.readUInt32(), try reader.readBool())
+    }
+
     private static func readConsumerTarget(_ reader: inout ByteReader) throws -> ConsumerTarget {
         let consumerKind = try reader.readUInt8()
         _ = try Identifier.decode(from: &reader)
@@ -433,6 +879,30 @@
         return ConsumerTarget(consumerKind: consumerKind, stream: stream, topic: topic, partitionID: hasPartition ? partitionID : nil)
     }
 
+    private static func readConsumer(_ reader: inout ByteReader) throws -> Consumer {
+        let kind = try reader.readUInt8()
+        guard let consumerKind = Consumer.Kind(rawValue: kind) else {
+            throw WireError.validation("unknown consumer kind \(kind)")
+        }
+        return Consumer(kind: consumerKind, id: try Identifier.decode(from: &reader))
+    }
+
+    private static func readStrategy(_ reader: inout ByteReader) throws -> PollingStrategy {
+        let kind = try reader.readUInt8()
+        guard let strategyKind = PollingStrategy.Kind(rawValue: kind) else {
+            throw WireError.validation("unknown polling strategy kind \(kind)")
+        }
+        return PollingStrategy(kind: strategyKind, value: try reader.readUInt64())
+    }
+
+    /// A request the client sends again with fresh options must hold nothing
+    /// that the next attempt would drop.
+    private static func requireEnd(_ reader: ByteReader) throws {
+        guard reader.isAtEnd else {
+            throw WireError.validation("\(reader.remaining) bytes follow the request")
+        }
+    }
+
     private static func decode<T>(_ payload: [UInt8], _ body: (inout ByteReader) throws -> T) throws -> T {
         do {
             return try Responses.decode(payload[...], body)
diff --git a/foreign/swift/Sources/Iggy/Client/IggyClient.swift b/foreign/swift/Sources/Iggy/Client/IggyClient.swift
index f820a7c..3353a67 100644
--- a/foreign/swift/Sources/Iggy/Client/IggyClient.swift
+++ b/foreign/swift/Sources/Iggy/Client/IggyClient.swift
@@ -47,8 +47,12 @@
 /// replicated write whose outcome is unknown fails with
 /// ``IggyErrorCode/transientNotCommitted``. The other SDKs keep such
 /// a write in a retained-operation registry and fail it with
-/// ``IggyErrorCode/operationUncertain``. This SDK has no such registry, so
-/// it keeps `transientNotCommitted`.
+/// ``IggyErrorCode/operationUncertain``. This SDK does not retain replicated
+/// writes, so it keeps `transientNotCommitted` for them. A read with
+/// ``CommitPolicy/onRead`` that lost its reply fails with
+/// ``IggyErrorCode/fetchProgressUncertain`` and blocks the progress of its
+/// partitions until ``abandonOperation(_:)``. A ``PartitionReader`` repairs
+/// such a read by itself.
 ///
 /// A request that was not sent before its deadline fails with
 /// ``IggyErrorCode/transientNotAccepted``, and a retry can succeed. A call
@@ -179,6 +183,17 @@
         try await core.logout()
     }
 
+    /// Lifts the block that a committing poll or fetch with an unknown
+    /// outcome left on its partitions, and accepts that outcome as unknown.
+    /// It sends nothing and does not read the poll again. Pass the
+    /// `operationID` of ``IggyErrorCode/fetchProgressUncertain`` or
+    /// ``IggyErrorCode/partitionLaneBlocked``. A poll with `next` then reads
+    /// on from whatever the server stored. An operation the client does not
+    /// block under fails with ``IggyErrorCode/operationNotRetained``.
+    public func abandonOperation(_ operationID: UInt128Value) throws {
+        try core.pollRouter.abandonFetch(operationID)
+    }
+
     // MARK: Raw requests
 
     /// Sends a command code with an already-encoded payload and returns the
@@ -193,17 +208,27 @@
     /// `GetConsumerOffset`, `StoreConsumerOffset`, and `DeleteConsumerOffset`
     /// take the route of
     /// ``sendMessages(streamID:topicID:partitioning:messages:)``,
-    /// ``pollMessages(streamID:topicID:partitionID:consumer:strategy:count:autoCommit:)``,
+    /// ``pollMessages(streamID:topicID:partitionID:consumer:strategy:options:)``,
     /// and the offset calls. A raw send with balanced or key partitioning
     /// fails with ``IggyErrorCode/featureUnavailable``. A group poll, a store,
     /// or a delete that names no partition fails with
     /// ``IggyErrorCode/invalidIdentifier``, and a partition payload that does
     /// not decode fails with ``IggyErrorCode/invalidCommand``. Each fails
-    /// before anything is sent.
+    /// before anything is sent. A deferred poll (106) takes the route of a
+    /// poll, and a fetch (109) the route of its first target, with the fences
+    /// and target limits of its payload.
+    /// Options or targets the wire refuses fail with
+    /// ``IggyErrorCode/invalidCommand``, and so does a cancellation (108):
+    /// cancel the task of the deferred request instead.
     public func sendRawRequest(code: UInt32, payload: [UInt8]) async throws -> [UInt8] {
         guard !CommandCode.isSessionControl(code) else {
             throw IggyError(.invalidCommand, context: "code \(code) controls the session; use login and logout")
         }
+        // A cancellation names a parked request by its id, and the server
+        // answers none, so a raw one could only wait for its deadline.
+        guard code != CommandCode.cancelDeferredRequest.rawValue else {
+            throw IggyError(.invalidCommand, context: "command \(code) cancels a deferred request; cancel the task of that request instead")
+        }
         let lineage = await core.lineage
         if let routed = try await sendRawPartitionRequest(code: code, payload: payload, lineage: lineage) {
             return routed
@@ -218,9 +243,9 @@
     /// node. The request carries the session's metadata watermark, so the
     /// replica answers once it applied the metadata this client saw.
     ///
-    /// Replicated commands, `PollMessages`, `GetConsumerOffset`, and
-    /// session-control codes fail with ``IggyErrorCode/invalidCommand``
-    /// before anything is sent. A
+    /// Replicated commands, `PollMessages`, `GetConsumerOffset`, the deferred
+    /// commands 106 to 108, and session-control codes fail with
+    /// ``IggyErrorCode/invalidCommand`` before anything is sent. A
     /// replica the roster does not list after one more roster read fails with
     /// ``IggyErrorCode/invalidIdentifier``. A replica that cannot be reached,
     /// or that answers ``IggyErrorCode/transientNotAccepted``, is asked again
@@ -228,8 +253,8 @@
     /// ``IggyErrorCode/notPrimary`` included, is returned and never sent to
     /// another replica.
     public func sendRawRequest(code: UInt32, payload: [UInt8], replica: UInt8) async throws -> [UInt8] {
-        guard !CommandCode.isSessionControl(code), code != CommandCode.pollMessages.rawValue,
-            code != CommandCode.getConsumerOffset.rawValue, ConsensusOperation.forCode(code) == .nonReplicated
+        guard !CommandCode.isSessionControl(code), !CommandCode.isDeferred(code), code != CommandCode.cancelDeferredRequest.rawValue,
+            code != CommandCode.pollMessages.rawValue, code != CommandCode.getConsumerOffset.rawValue, ConsensusOperation.forCode(code) == .nonReplicated
         else {
             throw IggyError(.invalidCommand, context: "command \(code) cannot be sent to a chosen replica")
         }
@@ -248,14 +273,17 @@
     }
 
     /// Fails unless the client is signed in, and returns the lineage the
-    /// call started under, which every request of the call carries.
-    func requireAuthenticated() async throws -> UInt64 {
-        try await core.requireAuthenticated()
+    /// call started under, which every request of the call carries. A
+    /// reconnect on the way ends by `limit` if it comes before the request
+    /// timeout.
+    func requireAuthenticated(within limit: ContinuousClock.Instant? = nil) async throws -> UInt64 {
+        try await core.requireAuthenticated(within: limit)
     }
 
-    /// As ``requireAuthenticated()`` for a call pinned to `captured`, such as
-    /// a producer send: once the caller signed in or out since, the call is
-    /// refused as unauthenticated before anything of it is written.
+    /// As ``requireAuthenticated(within:)`` for a call pinned to
+    /// `captured`, such as a producer send: once the caller signed in or out
+    /// since, the call is refused as unauthenticated before anything of it is
+    /// written.
     func requireAuthenticated(under captured: UInt64?) async throws -> UInt64 {
         let started = try await requireAuthenticated()
         guard let captured else {
diff --git a/foreign/swift/Sources/Iggy/Client/PollRouter.swift b/foreign/swift/Sources/Iggy/Client/PollRouter.swift
index 600cdf5..8f86c2b 100644
--- a/foreign/swift/Sources/Iggy/Client/PollRouter.swift
+++ b/foreign/swift/Sources/Iggy/Client/PollRouter.swift
@@ -45,6 +45,11 @@
     static let rosterReadTimeout: Duration = .seconds(5)
     static let maxCachedRoutes = 4096
     static let maxDataConnections = 256
+    /// `MAX_DEFERRED_REQUESTS` in the Rust SDK: the deferred requests one
+    /// client keeps in flight. The server refuses more than it parks for one
+    /// client with `deferredPollCapacityExceeded`.
+    static let maxDeferredRequests = 64
+    static let maxProgressLanes = maxDeferredRequests * Requests.maxPartitionsPerRequest + maxDataConnections
 
     /// A partition as its caller spelled it. A stream or topic named by
     /// number and the same one named by name are two keys.
@@ -54,6 +59,84 @@
         let partitionID: UInt32
     }
 
+    struct ProgressKey: Hashable, Sendable {
+        let partitionID: UInt32
+        let incarnation: UInt64
+    }
+
+    private struct ProgressLane {
+        let semaphore = AsyncSemaphore(permits: 1)
+        var users = 0
+    }
+
+    /// A committing poll or fetch, as a fetch block in the Rust SDK. Once its
+    /// frame may have reached the server, an unknown outcome blocks the
+    /// progress of its partitions under `id` until the caller abandons it.
+    final class FetchAttempt: Sendable {
+        let id: UInt128Value
+        private let possibleSend = NIOLockedValueBox(false)
+
+        init(id: UInt128Value) {
+            self.id = id
+        }
+
+        var mayHaveCommitted: Bool { possibleSend.withLockedValue { $0 } }
+
+        func sent() { possibleSend.withLockedValue { $0 = true } }
+        func refused() { possibleSend.withLockedValue { $0 = false } }
+    }
+
+    private struct FetchRetention {
+        var keys: [ProgressKey]
+        var blocked = false
+    }
+
+    /// What a routed request carries.
+    enum Body: Sendable {
+        case fixed([UInt8])
+        case deferred(Deferred)
+
+        var isDeferred: Bool {
+            if case .deferred = self { true } else { false }
+        }
+
+        var commits: Bool {
+            if case .deferred(let deferred) = self { deferred.commits } else { false }
+        }
+
+        var fetchAttempt: FetchAttempt? {
+            if case .deferred(let deferred) = self { deferred.fetchAttempt } else { nil }
+        }
+
+        /// The body of one attempt. A deferred request carries what is left
+        /// of its wait and its timeout, as `deferred_payload` in the Rust SDK,
+        /// so no attempt waits past the deadline of its call.
+        func payload() throws -> [UInt8] {
+            switch self {
+            case .fixed(let payload):
+                payload
+            case .deferred(let deferred):
+                try deferred.encode(deferred.options.remaining(after: .now - deferred.started))
+            }
+        }
+    }
+
+    /// A deferred poll or fetch: its options, when its call started,
+    /// and its body under the options left for an attempt.
+    struct Deferred: Sendable {
+        let options: DeferredPollOptions
+        let started: ContinuousClock.Instant
+        /// The server stores the offsets of what it reads, so a cancellation
+        /// waits for the one reply that says whether it did.
+        var commits = false
+        var fetchAttempt: FetchAttempt? = nil
+        let encode: @Sendable (DeferredPollOptions) throws -> [UInt8]
+
+        /// The request timeout bounds the whole call, routing and retries
+        /// included, not each attempt.
+        var deadline: ContinuousClock.Instant { started + options.requestTimeout }
+    }
+
     private struct Route: Sendable {
         /// The fence of the session the route was read under.
         let fence: UInt128Value
@@ -93,10 +176,23 @@
         var metadataPrimary: UInt8?
         var routes: [RouteKey: Route] = [:]
         var slots: [String: Slot] = [:]
+        /// The connections of deferred requests by endpoint, one per request
+        /// in flight there, as a parked request holds its connection for its
+        /// whole wait. At most ``PollRouter/maxDeferredRequests`` across all
+        /// endpoints.
+        var deferredSlots: [String: [Slot]] = [:]
+        // A session reset must not release progress still held by an older call.
+        var progressLanes: [ProgressKey: ProgressLane] = [:]
+        /// The committing polls and fetches in flight, and those whose
+        /// outcome is unknown. A session reset keeps both, so a block
+        /// survives a reconnect and a new session.
+        var fetches: [UInt128Value: FetchRetention] = [:]
+        var blockedProgress: [ProgressKey: UInt128Value] = [:]
         var nextHeartbeat: ContinuousClock.Instant?
     }
 
     private let state: NIOLockedValueBox<State>
+    private let deferredLeases = AsyncSemaphore(permits: PollRouter.maxDeferredRequests)
 
     init(fence: UInt128Value) {
         state = NIOLockedValueBox(State(fence: fence))
@@ -129,10 +225,171 @@
             state.fence = fence
             state.routes.removeAll()
             state.slots.removeAll()
+            state.deferredSlots.removeAll()
             state.nextHeartbeat = nil
         }
     }
 
+    /// Orders progress across connections and aliases of the same partition
+    /// history. Sorted acquisition prevents overlapping fetches from deadlocking.
+    /// Entries exist only while a holder or waiter references them. A
+    /// partition whose committing read has an unknown outcome fails with
+    /// ``IggyErrorCode/partitionLaneBlocked`` until that read is abandoned.
+    func withProgress<Value: Sendable>(
+        _ keys: [ProgressKey], deadline: ContinuousClock.Instant, _ operation: () async throws -> Value
+    ) async throws -> Value {
+        guard !keys.isEmpty else {
+            return try await operation()
+        }
+        let keys = Set(keys).sorted {
+            ($0.partitionID, $0.incarnation) < ($1.partitionID, $1.incarnation)
+        }
+        guard keys.allSatisfy({ $0.incarnation != 0 }) else {
+            throw IggyError(.transientNotAccepted, context: "progress requires a resolved partition incarnation")
+        }
+        let semaphores = try state.withLockedValue { state in
+            if let blocked = Self.blockedProgress(keys, in: state) {
+                throw blocked
+            }
+            let added = keys.reduce(0) { $0 + (state.progressLanes[$1] == nil ? 1 : 0) }
+            guard added <= Self.maxProgressLanes - state.progressLanes.count else {
+                throw IggyError(.transientNotAccepted, context: "the concurrent progress partition limit was reached")
+            }
+            return keys.map { key in
+                var lane = state.progressLanes[key] ?? ProgressLane()
+                lane.users += 1
+                state.progressLanes[key] = lane
+                return lane.semaphore
+            }
+        }
+        var acquired = 0
+        defer {
+            for semaphore in semaphores.prefix(acquired) {
+                semaphore.release(1)
+            }
+            state.withLockedValue { state in
+                for key in keys {
+                    state.progressLanes[key]?.users -= 1
+                    if state.progressLanes[key]?.users == 0 {
+                        state.progressLanes.removeValue(forKey: key)
+                    }
+                }
+            }
+        }
+        for semaphore in semaphores {
+            guard try await semaphore.acquire(1, deadline: deadline) else {
+                throw IggyError(.transientNotAccepted, context: "the request deadline passed waiting for partition progress")
+            }
+            acquired += 1
+        }
+        // The call that held the lane can have left its outcome unknown.
+        if let blocked = state.withLockedValue({ Self.blockedProgress(keys, in: $0) }) {
+            throw blocked
+        }
+        try Task.checkCancellation()
+        guard ContinuousClock.now < deadline else {
+            throw IggyError(.transientNotAccepted, context: "the request deadline passed before partition progress")
+        }
+        return try await operation()
+    }
+
+    /// Registers a committing poll or fetch before it is sent. At most
+    /// ``maxDeferredRequests`` are in flight or blocked at once.
+    func reserveFetch(_ keys: [ProgressKey]) throws -> FetchAttempt {
+        try state.withLockedValue { state in
+            guard state.fetches.count < Self.maxDeferredRequests else {
+                throw IggyError(.transientNotAccepted, context: "\(Self.maxDeferredRequests) committing reads are in flight or blocked")
+            }
+            var id = UInt128Value.random()
+            while state.fetches[id] != nil {
+                id = .random()
+            }
+            state.fetches[id] = FetchRetention(keys: keys)
+            return FetchAttempt(id: id)
+        }
+    }
+
+    /// Forgets an attempt that ended with a known outcome.
+    func releaseFetch(_ attempt: FetchAttempt) {
+        state.withLockedValue { state in
+            if state.fetches[attempt.id]?.blocked == false {
+                state.fetches.removeValue(forKey: attempt.id)
+            }
+        }
+    }
+
+    /// Blocks the progress of `keys` under the attempt and returns
+    /// ``IggyErrorCode/fetchProgressUncertain`` naming it.
+    func retainFetch(_ attempt: FetchAttempt, keys: [ProgressKey], cause: String? = nil) -> IggyError {
+        state.withLockedValue { state in
+            state.fetches[attempt.id] = FetchRetention(keys: keys, blocked: true)
+            for key in keys {
+                state.blockedProgress[key] = attempt.id
+            }
+        }
+        return IggyError(.fetchProgressUncertain, context: cause, operationID: attempt.id)
+    }
+
+    /// Whether the client still blocks progress under `id`.
+    func retainsFetch(_ id: UInt128Value) -> Bool {
+        state.withLockedValue { $0.fetches[id]?.blocked == true }
+    }
+
+    /// Lifts the blocks of an attempt whose outcome stays unknown, or throws
+    /// ``IggyErrorCode/operationNotRetained``.
+    func abandonFetch(_ id: UInt128Value) throws {
+        try state.withLockedValue { state in
+            guard let retained = state.fetches[id], retained.blocked else {
+                throw IggyError(.operationNotRetained, operationID: id)
+            }
+            for key in retained.keys where state.blockedProgress[key] == id {
+                state.blockedProgress.removeValue(forKey: key)
+            }
+            state.fetches.removeValue(forKey: id)
+        }
+    }
+
+    private static func blockedProgress(_ keys: [ProgressKey], in state: State) -> IggyError? {
+        for key in keys {
+            if let operationID = state.blockedProgress[key] {
+                return IggyError(
+                    .partitionLaneBlocked, context: "a committing read of partition \(key.partitionID) has an unknown outcome",
+                    operationID: operationID, partitionID: key.partitionID)
+            }
+        }
+        return nil
+    }
+
+    /// Closes the connections of the deferred requests in flight, so each
+    /// one ends at once instead of waiting on a session the caller ended.
+    func closeDeferredConnections() async {
+        let connections = state.withLockedValue { state in
+            state.deferredSlots.values.joined().compactMap { slot in slot.connection.withLockedValue { $0?.connection } }
+        }
+        for connection in connections {
+            await connection.close()
+        }
+    }
+
+    func progressContext(
+        _ key: RouteKey, context: PartitionContext?, lineage: UInt64, coordinator: ClientCore, deadline: ContinuousClock.Instant
+    ) async throws -> PartitionContext {
+        if let context, context.incarnation != 0 {
+            return context
+        }
+        return try await retryingLookups(lineage: lineage, coordinator: coordinator, deadline: deadline) { clustered in
+            let route = try await route(key, clustered: clustered, lineage: lineage, coordinator: coordinator, deadline: deadline)
+            guard route.context.incarnation != 0 else {
+                throw IggyError(.transientNotAccepted, context: "the route has no partition incarnation")
+            }
+            guard let context else {
+                return route.context
+            }
+            return PartitionContext(
+                incarnation: route.context.incarnation, ownerGeneration: context.ownerGeneration, metadataOp: context.metadataOp)
+        }
+    }
+
     /// Sends a poll or an offset request on its route. `context` is the
     /// partition context the caller captured, else the route's is taken and
     /// kept for every retry.
@@ -145,10 +402,28 @@
     /// once the caller signed in or out since, the request fails with
     /// `unauthenticated`, as nothing of the call goes out under a session the
     /// caller opened after the call started.
+    ///
+    /// A deferred request goes over a data connection of its own, on a single
+    /// node too, as its wait would hold a lockstep connection. It holds one of
+    /// ``maxDeferredRequests`` leases across its attempts, waited for until
+    /// `deadline`. A cancelled caller sends command 108 after a request that
+    /// is parked, so the server drops it, and the connection stays usable. A
+    /// committing request answers the cancellation once, so its caller waits
+    /// for that reply.
     func send(
-        _ code: CommandCode, payload: [UInt8], key: RouteKey, context: PartitionContext?, lineage: UInt64, coordinator: ClientCore,
+        _ code: CommandCode, body: Body, key: RouteKey, context: PartitionContext?, lineage: UInt64, coordinator: ClientCore,
         deadline: ContinuousClock.Instant
     ) async throws -> [UInt8] {
+        if body.isDeferred {
+            guard try await deferredLeases.acquire(1, deadline: deadline) else {
+                throw IggyError(.transientNotAccepted, context: "\(Self.maxDeferredRequests) deferred requests were in flight until the deadline")
+            }
+        }
+        defer {
+            if body.isDeferred {
+                deferredLeases.release(1)
+            }
+        }
         let clock = ContinuousClock()
         let now = clock.now
         let clustered = try await isClustered(coordinator, lineage: lineage, deadline: deadline)
@@ -157,7 +432,7 @@
             // pinged here once per heartbeat interval too.
             _ = try await control(.ping, [], lineage: lineage, coordinator: coordinator, deadline: deadline)
         }
-        let request = Request(code: code, payload: payload, key: key, lineage: lineage, deadline: deadline)
+        let request = Request(code: code, body: body, key: key, lineage: lineage, deadline: deadline)
         var captured = context
         var backoff = Backoff()
         var replay = Replay()
@@ -170,12 +445,16 @@
                 if clustered {
                     return try await sendOnPrimary(request, context: &captured, replay: &replay, coordinator: coordinator)
                 }
-                return try await sendOnCoordinator(request, context: &captured, coordinator: coordinator)
+                return try await sendOnCoordinator(request, context: &captured, replay: &replay, coordinator: coordinator)
             } catch let error as IggyError {
+                if body.fetchAttempt?.mayHaveCommitted == true {
+                    // A committing read that may have stored is never sent again.
+                    throw error
+                }
                 let reconnects =
                     replay.outcome.isUncertain && error.isConnectionLoss && error.code != .staleClient
                     && coordinator.configuration.reconnection.enabled
-                guard error.code == .transientNotAccepted || reconnects else {
+                guard error.code == .transientNotAccepted || error.code == .deferredPollCapacityExceeded || reconnects else {
                     throw replay.outcome.observe(error)
                 }
                 refusal = replay.outcome.observe(error)
@@ -282,6 +561,12 @@
                 throw refusal
             }
             try await Task.sleep(for: interval)
+            lengthen()
+        }
+
+        /// Doubles the next pause without waiting, for a caller that hands a
+        /// copy to an attempt that runs beside others.
+        mutating func lengthen() {
             interval = min(interval * 2, PollRouter.maxRetryInterval)
         }
     }
@@ -295,7 +580,7 @@
 
     private struct Request {
         let code: CommandCode
-        let payload: [UInt8]
+        let body: Body
         let key: RouteKey
         let lineage: UInt64
         let deadline: ContinuousClock.Instant
@@ -375,27 +660,44 @@
         _ code: CommandCode, _ payload: [UInt8], context: PartitionContext = .empty, lineage: UInt64, coordinator: ClientCore,
         deadline: ContinuousClock.Instant
     ) async throws -> [UInt8] {
-        do {
-            return try await coordinator.exchangeOnce(code: code.rawValue, payload: payload, context: context, deadline: deadline, lineage: lineage)
-        } catch let error as IggyError where error.isReconnectable && error.code != .staleClient && ContinuousClock.now < deadline {
-            _ = try await coordinator.exchange(code: CommandCode.ping.rawValue, payload: [], deadline: deadline, lineage: lineage)
-            return try await coordinator.exchangeOnce(code: code.rawValue, payload: payload, context: context, deadline: deadline, lineage: lineage)
-        }
+        // A cancelled exchange aborts the coordinator connection, so a caller
+        // cancelled meanwhile lets the read or the ping finish instead.
+        try await Task {
+            do {
+                return try await coordinator.exchangeOnce(code: code.rawValue, payload: payload, context: context, deadline: deadline, lineage: lineage)
+            } catch let error as IggyError where error.isReconnectable && error.code != .staleClient && ContinuousClock.now < deadline {
+                _ = try await coordinator.exchange(code: CommandCode.ping.rawValue, payload: [], deadline: deadline, lineage: lineage)
+                return try await coordinator.exchangeOnce(code: code.rawValue, payload: payload, context: context, deadline: deadline, lineage: lineage)
+            }
+        }.value
     }
 
     /// The single-node path: the request goes over the coordinator connection
-    /// with the context of its route.
-    private func sendOnCoordinator(_ request: Request, context: inout PartitionContext?, coordinator: ClientCore) async throws -> [UInt8] {
+    /// with the context of its route, a deferred one over a data connection to
+    /// the same node.
+    private func sendOnCoordinator(
+        _ request: Request, context: inout PartitionContext?, replay: inout Replay, coordinator: ClientCore
+    ) async throws -> [UInt8] {
         let route = try await route(request.key, clustered: false, lineage: request.lineage, coordinator: coordinator, deadline: request.deadline)
         let captured = context ?? route.context
         context = captured
         do {
+            if request.body.isDeferred {
+                return try await exchange(
+                    at: await coordinator.serverAddress, code: request.code.rawValue, body: request.body, context: captured,
+                    identity: route.consumerSession, lineage: request.lineage, fence: route.fence, coordinator: coordinator, deadline: request.deadline,
+                    dialDeadline: request.deadline, replay: &replay)
+            }
+            let payload = try request.body.payload()
             if request.code == .pollMessages {
                 return try await coordinator.exchangeOnce(
-                    code: request.code.rawValue, payload: request.payload, context: captured, deadline: request.deadline, lineage: request.lineage)
+                    code: request.code.rawValue, payload: payload, context: captured, deadline: request.deadline, lineage: request.lineage)
             }
             return try await coordinator.exchange(
-                code: request.code.rawValue, payload: request.payload, context: captured, deadline: request.deadline, lineage: request.lineage)
+                code: request.code.rawValue, payload: payload, context: captured, deadline: request.deadline, lineage: request.lineage)
+        } catch let unsent as UnsentRequest {
+            removeRoute(request.key)
+            throw unsent.refusal
         } catch let error as IggyError {
             removeRoute(request.key)
             throw Self.gradedLoss(error, on: "the coordinator")
@@ -412,7 +714,7 @@
         context = captured
         do {
             return try await exchange(
-                onReplica: route.primaryReplica, code: request.code.rawValue, payload: request.payload, context: captured,
+                onReplica: route.primaryReplica, code: request.code.rawValue, body: request.body, context: captured,
                 identity: route.consumerSession, lineage: request.lineage, fence: route.fence, coordinator: coordinator, deadline: request.deadline,
                 dialDeadline: request.deadline, replay: &replay)
         } catch let unsent as UnsentRequest {
@@ -420,6 +722,12 @@
             throw unsent.refusal
         } catch let error as IggyError {
             if error.code == .notPrimary {
+                // A fetch names several partitions and the refusal
+                // does not name the one that moved, so its caller looks up
+                // every route again.
+                guard request.code != .fetchMessages else {
+                    throw error
+                }
                 markNotPrimary(route, partitionID: request.key.partitionID)
                 throw ClientCore.graded(error)
             }
@@ -443,17 +751,28 @@
     ) async throws -> [UInt8] {
         var replay = Replay()
         return try await exchange(
-            onReplica: replica, code: code, payload: payload, context: context, identity: identity, lineage: lineage, fence: fence,
+            onReplica: replica, code: code, body: .fixed(payload), context: context, identity: identity, lineage: lineage, fence: fence,
             coordinator: coordinator, deadline: deadline, dialDeadline: dialDeadline, replay: &replay)
     }
 
     private func exchange(
-        onReplica replica: UInt8, code: UInt32, payload: [UInt8], context: PartitionContext, identity: Responses.SessionIdentity, lineage: UInt64,
+        onReplica replica: UInt8, code: UInt32, body: Body, context: PartitionContext, identity: Responses.SessionIdentity, lineage: UInt64,
         fence: UInt128Value, coordinator: ClientCore, deadline: ContinuousClock.Instant, dialDeadline: ContinuousClock.Instant, replay: inout Replay
     ) async throws -> [UInt8] {
         let endpoint = try await endpoint(of: replica, lineage: lineage, coordinator: coordinator, deadline: deadline)
-        let slot = try slot(fence: fence, at: endpoint)
-        try await slot.lock.lock()
+        return try await exchange(
+            at: endpoint, code: code, body: body, context: context, identity: identity, lineage: lineage, fence: fence, coordinator: coordinator,
+            deadline: deadline, dialDeadline: dialDeadline, replay: &replay)
+    }
+
+    private func exchange(
+        at endpoint: String, code: UInt32, body: Body, context: PartitionContext, identity: Responses.SessionIdentity, lineage: UInt64,
+        fence: UInt128Value, coordinator: ClientCore, deadline: ContinuousClock.Instant, dialDeadline: ContinuousClock.Instant, replay: inout Replay
+    ) async throws -> [UInt8] {
+        let slot = try slot(fence: fence, at: endpoint, deferred: body.isDeferred)
+        if !body.isDeferred {
+            try await slot.lock.lock()
+        }
         defer { slot.lock.unlock() }
 
         var data = slot.connection.withLockedValue { $0 }
@@ -465,7 +784,7 @@
             do {
                 dialed = try await coordinator.dial(endpoint, deadline: dialDeadline)
             } catch let error as IggyError where error.isReconnectable {
-                throw IggyError(.transientNotAccepted, context: "cannot reach replica \(replica) at \(endpoint): \(error)")
+                throw IggyError(.transientNotAccepted, context: "cannot reach \(endpoint): \(error)")
             }
             guard let dialed else {
                 throw UnsentRequest(refusal: IggyError(.transientNotAccepted, context: "the budget ran out before \(endpoint) was dialed"))
@@ -495,16 +814,22 @@
         }
 
         do {
-            let body = try await exchange(
-                on: data.connection, code: code, payload: payload, context: context, identity: identity, lineage: lineage, coordinator: coordinator,
+            let reply = try await exchange(
+                on: data.connection, code: code, body: body, context: context, identity: identity, lineage: lineage, coordinator: coordinator,
                 deadline: deadline, replay: &replay)
             data.usable = true
             slot.connection.withLockedValue { $0 = data }
-            return body
+            return reply
         } catch let unsent as UnsentRequest {
             data.usable = true
             slot.connection.withLockedValue { $0 = data }
             throw unsent
+        } catch let cancelled as CancellationError where body.isDeferred {
+            // The cancellation followed the request, and the connection drops
+            // a reply that still arrives.
+            data.usable = true
+            slot.connection.withLockedValue { $0 = data }
+            throw cancelled
         } catch let error as IggyError {
             data.usable = !error.isReconnectable
             if error.code == .transientNotAccepted {
@@ -554,7 +879,7 @@
             bindSecret: await coordinator.bindSecret)
         var replay = Replay()
         let body = try await exchange(
-            on: connection, code: CommandCode.bindSession.rawValue, payload: payload, context: .empty, identity: identity, lineage: lineage,
+            on: connection, code: CommandCode.bindSession.rawValue, body: .fixed(payload), context: .empty, identity: identity, lineage: lineage,
             coordinator: coordinator, deadline: deadline, replay: &replay)
         let bound = try Responses.loginRegister(body[...])
         if bound.session == 0 {
@@ -574,11 +899,13 @@
     /// attempt or `deadline` passes: the server answers the replay from its
     /// reply cache if the write committed. The retained frame also survives a
     /// route refresh or a lost connection, while the same session is bound.
+    /// A deferred request waits until `deadline`, which its own request
+    /// timeout set.
     private func exchange(
-        on connection: any IggyConnection, code: UInt32, payload: [UInt8], context: PartitionContext, identity: Responses.SessionIdentity,
+        on connection: any IggyConnection, code: UInt32, body: Body, context: PartitionContext, identity: Responses.SessionIdentity,
         lineage: UInt64, coordinator: ClientCore, deadline: ContinuousClock.Instant, replay: inout Replay
     ) async throws -> [UInt8] {
-        let replayDeadline = min(deadline, .now + coordinator.configuration.requestTimeout)
+        let replayDeadline = body.isDeferred ? deadline : min(deadline, .now + coordinator.configuration.requestTimeout)
         guard replayDeadline > .now else {
             throw UnsentRequest(refusal: IggyError(.transientNotAccepted, context: "the budget ran out before command \(code) was sent"))
         }
@@ -590,7 +917,7 @@
             request = retained
         } else {
             do {
-                request = try await coordinator.stampRequest(code: code, payload: payload, context: context, for: identity, lineage: lineage)
+                request = try await coordinator.stampRequest(code: code, payload: try body.payload(), context: context, for: identity, lineage: lineage)
             } catch let refusal as IggyError where refusal.code == .transientNotAccepted || refusal.code == .unauthenticated {
                 throw UnsentRequest(refusal: refusal)
             }
@@ -602,8 +929,17 @@
         while true {
             let raw: RawFrame
             do {
-                raw = try await connection.exchange(request.bytes, timeout: replayDeadline - .now)
-            } catch let lost as IggyError where replay.outcome.isUncertain {
+                body.fetchAttempt?.sent()
+                raw = try await connection.exchange(
+                    request.bytes, timeout: replayDeadline - .now,
+                    cancellation: request.deferredCancellation.map { DeferredCancellation(frame: $0, isAnswered: body.commits) })
+            } catch let lost as IggyError {
+                if lost.code == .notConnected || lost.code == .cannotEstablishConnection {
+                    body.fetchAttempt?.refused()
+                }
+                guard replay.outcome.isUncertain else {
+                    throw lost
+                }
                 // A replay that found the connection closed wrote nothing, but
                 // `notConnected` would route the write again under a new id.
                 throw IggyError(.disconnected, context: "an earlier attempt may have committed, then: \(lost)")
@@ -621,6 +957,15 @@
                 }
                 answer = IggyError(.transientNotCommitted, context: "the reply does not prove the earlier attempt committed")
             } catch let refusal as IggyError {
+                if VSRFrame.peekCommand(header) == .reply, VSRFrame.readReplyStatus(header) == refusal.rawCode,
+                    CommitErrorScope.request.provesNothingStored(refusal.code)
+                {
+                    body.fetchAttempt?.refused()
+                }
+                if body.commits, refusal.code == .transientNotAccepted, Task.isCancelled {
+                    // The cancellation won, so the server stored nothing.
+                    throw CancellationError()
+                }
                 if VSRFrame.peekCommand(header) == .eviction, refusal.code == .staleClient {
                     // As on the coordinator, a stale client eviction ends only
                     // the binding of the connection, so the request is lost with
@@ -635,6 +980,11 @@
                 }
                 answer = refusal
             }
+            coordinator.logger.debug(
+                "The data connection refused the request",
+                metadata: [
+                    "code": "\(code)", "request_id": "\(request.requestID)", "error": "\(answer)", "metadata_watermark": "\(identity.metadataWatermark)",
+                ])
             if answer.code == .notPrimary, !VSRFrame.hasCommittedResult(header) {
                 throw answer
             }
@@ -867,15 +1217,32 @@
         }
     }
 
-    /// The slot of the data connection to `endpoint`. A session replaced
+    /// The slot of the data connection to `endpoint`. A deferred request gets
+    /// a free one of its own, already locked for it. A session replaced
     /// since `fence` throws ``UnsentRequest``, since nothing was written.
-    private func slot(fence: UInt128Value, at endpoint: String) throws -> Slot {
+    private func slot(fence: UInt128Value, at endpoint: String, deferred: Bool) throws -> Slot {
         try state.withLockedValue { state in
             do {
                 try Self.checkFence(fence, in: state)
             } catch let refusal as IggyError {
                 throw UnsentRequest(refusal: refusal)
             }
+            if deferred {
+                if let free = state.deferredSlots[endpoint]?.first(where: { $0.lock.tryLock() }) {
+                    return free
+                }
+                // The leases keep at most `maxDeferredRequests` in flight, so
+                // full pools hold an idle slot at another endpoint. Dropping
+                // it closes its connection, which keeps the open connections
+                // within the same bound.
+                if state.deferredSlots.values.reduce(0, { $0 + $1.count }) >= Self.maxDeferredRequests {
+                    Self.dropIdleDeferredSlot(in: &state)
+                }
+                let slot = Slot()
+                _ = slot.lock.tryLock()
+                state.deferredSlots[endpoint, default: []].append(slot)
+                return slot
+            }
             if let slot = state.slots[endpoint] {
                 return slot
             }
@@ -888,17 +1255,123 @@
         }
     }
 
+    private static func dropIdleDeferredSlot(in state: inout State) {
+        for (endpoint, pool) in state.deferredSlots {
+            if let idle = pool.firstIndex(where: { $0.lock.tryLock() }) {
+                state.deferredSlots[endpoint]?.remove(at: idle)
+                return
+            }
+        }
+    }
+
     private func removeRoute(_ key: RouteKey) {
         _ = state.withLockedValue { $0.routes.removeValue(forKey: key) }
     }
 
     private func markNotPrimary(_ route: Route, partitionID: UInt32) {
         state.withLockedValue { state in
-            for key in state.routes.keys where key.partitionID == partitionID && state.routes[key]?.context.incarnation == route.context.incarnation {
+            for key in Self.aliases(of: route, partitionID: partitionID, in: state) {
                 let floor = max(state.routes[key]?.minimumView ?? 0, route.view == .max ? .max : route.view + 1)
                 state.routes[key]?.minimumView = floor
                 state.routes[key]?.askFirst = route.primaryReplica
             }
         }
     }
+
+    /// Drops the routes of `keys`, whose contexts a fetch found out of date,
+    /// so the next request looks each of them up again.
+    func dropRoutes(_ keys: [RouteKey]) {
+        keys.forEach(removeRoute)
+    }
+
+    /// Marks the routes of `keys` stale after a primary answered a wait or a
+    /// fetch that it is not the primary of one of them, as `may_have_moved`
+    /// in the Rust SDK. A refusal of a whole request does not name that
+    /// partition, so every view floor stays, and each next lookup asks the
+    /// primary of its route first.
+    func mayHaveMoved(_ keys: [RouteKey]) {
+        state.withLockedValue { state in
+            for key in keys {
+                guard let route = state.routes[key] else {
+                    continue
+                }
+                for alias in Self.aliases(of: route, partitionID: key.partitionID, in: state) {
+                    state.routes[alias]?.askFirst = route.primaryReplica
+                }
+            }
+        }
+    }
+
+    /// Every spelling of the partition history that `route` belongs to.
+    private static func aliases(of route: Route, partitionID: UInt32, in state: State) -> [RouteKey] {
+        state.routes.keys.filter { $0.partitionID == partitionID && state.routes[$0]?.context.incarnation == route.context.incarnation }
+    }
+
+    /// `partitions` grouped by the replica that is their primary, in replica
+    /// order, as `readiness_groups` in the Rust SDK: each group keeps the
+    /// order of `partitions` and is split into groups of at most the
+    /// partitions one request names. On a single node they form one group.
+    func primaryGroups(
+        stream: Identifier, topic: Identifier, partitions: [UInt32], lineage: UInt64, coordinator: ClientCore, deadline: ContinuousClock.Instant
+    ) async throws -> [[UInt32]] {
+        let groups = try await retryingLookups(lineage: lineage, coordinator: coordinator, deadline: deadline) { clustered in
+            var groups: [UInt8: [UInt32]] = [:]
+            for partition in partitions {
+                let key = RouteKey(stream: stream, topic: topic, partitionID: partition)
+                let route = try await route(key, clustered: clustered, lineage: lineage, coordinator: coordinator, deadline: deadline)
+                groups[route.primaryReplica, default: []].append(partition)
+            }
+            return groups
+        }
+        return groups.sorted { $0.key < $1.key }.flatMap { group in
+            stride(from: 0, to: group.value.count, by: Requests.maxPartitionsPerRequest).map {
+                Array(group.value[$0..<min($0 + Requests.maxPartitionsPerRequest, group.value.count)])
+            }
+        }
+    }
+
+    /// The fence of each fetch target: the context it carries, where one
+    /// without an incarnation takes the incarnation of its route, as
+    /// `fence_targets` in the Rust SDK fills it.
+    func fences(
+        _ targets: [(key: RouteKey, context: PartitionContext?)], lineage: UInt64, coordinator: ClientCore, deadline: ContinuousClock.Instant
+    ) async throws -> [PartitionContext] {
+        try await retryingLookups(lineage: lineage, coordinator: coordinator, deadline: deadline) { clustered in
+            var fences: [PartitionContext] = []
+            fences.reserveCapacity(targets.count)
+            for target in targets {
+                let fence = target.context ?? .empty
+                guard fence.incarnation == 0 else {
+                    fences.append(fence)
+                    continue
+                }
+                let route = try await route(target.key, clustered: clustered, lineage: lineage, coordinator: coordinator, deadline: deadline)
+                guard route.context.incarnation != 0 else {
+                    throw IggyError(.transientNotAccepted, context: "the route has no partition incarnation")
+                }
+                fences.append(PartitionContext(incarnation: route.context.incarnation, ownerGeneration: fence.ownerGeneration, metadataOp: fence.metadataOp))
+            }
+            return fences
+        }
+    }
+
+    /// Runs route lookups, one by one, as the lockstep connections they take
+    /// would queue them anyway. A lookup refused, answered "not primary" or
+    /// lost with its connection runs them again until `deadline`, as
+    /// `retries_lookup` in the Rust SDK allows.
+    private func retryingLookups<Value>(
+        lineage: UInt64, coordinator: ClientCore, deadline: ContinuousClock.Instant, _ lookUps: (_ clustered: Bool) async throws -> Value
+    ) async throws -> Value {
+        let clustered = try await isClustered(coordinator, lineage: lineage, deadline: deadline)
+        var backoff = Backoff()
+        while true {
+            do {
+                return try await lookUps(clustered)
+            } catch let error as IggyError
+                where [.transientNotAccepted, .notPrimary, .disconnected, .emptyResponse, .notConnected].contains(error.code)
+            {
+                try await backoff.pause(after: IggyError(.transientNotAccepted, context: "\(error)"), before: deadline)
+            }
+        }
+    }
 }
diff --git a/foreign/swift/Sources/Iggy/Consumer/ConsumerConfiguration.swift b/foreign/swift/Sources/Iggy/Consumer/ConsumerConfiguration.swift
index 16a8096..66719e1 100644
--- a/foreign/swift/Sources/Iggy/Consumer/ConsumerConfiguration.swift
+++ b/foreign/swift/Sources/Iggy/Consumer/ConsumerConfiguration.swift
@@ -15,120 +15,43 @@
 // specific language governing permissions and limitations
 // under the License.
 
-/// When a consumer stores its offsets on the server.
-public enum AutoCommit: Sendable, Hashable {
-    /// Never, not even on ``IggyConsumer/shutdown()``; store with
-    /// ``IggyConsumer/storePosition(_:)``.
-    case disabled
-    /// On every tick, the reading position of every partition read so far.
-    case interval(Duration)
-    /// On every tick, and as `when` says while messages are handed over.
-    case intervalOrWhen(Duration, AutoCommitWhen)
-    /// On every tick, and as `after` says once a handler of
-    /// ``IggyConsumer/consumeMessages(_:)`` returned.
-    case intervalOrAfter(Duration, AutoCommitAfter)
-    /// As `when` says while messages are handed over.
-    case when(AutoCommitWhen)
-    /// As `after` says once a handler of ``IggyConsumer/consumeMessages(_:)``
-    /// returned. Reading with ``IggyConsumer/next()`` never triggers it.
-    case after(AutoCommitAfter)
-
-    var interval: Duration? {
-        switch self {
-        case .interval(let interval), .intervalOrWhen(let interval, _), .intervalOrAfter(let interval, _): interval
-        case .disabled, .when, .after: nil
-        }
-    }
-
-    var whenTrigger: AutoCommitWhen? {
-        switch self {
-        case .intervalOrWhen(_, let when), .when(let when): when
-        case .disabled, .interval, .intervalOrAfter, .after: nil
-        }
-    }
-
-    var afterTrigger: AutoCommitAfter? {
-        switch self {
-        case .intervalOrAfter(_, let after), .after(let after): after
-        case .disabled, .interval, .intervalOrWhen, .when: nil
-        }
-    }
-}
-
-/// A commit triggered while messages are handed over, before the code that
-/// reads them has seen them.
-public enum AutoCommitWhen: Sendable, Hashable {
-    /// With the poll request itself: the server stores the offset of the
-    /// last message it returns, before any of the batch is handed over.
-    case pollingMessages
-    /// Queued when the buffer of the current batch runs empty.
-    case consumingAllMessages
-    /// Queued just before each message is handed over. Commits queued faster
-    /// than they are sent collapse into the latest one per partition.
-    case consumingEachMessage
-    /// Queued just before a message whose offset divides by `n` is handed
-    /// over. It tests the offset, not a count of messages. `n` must not be
-    /// zero.
-    case consumingEveryNthMessage(UInt32)
-}
-
-/// A commit triggered once a handler of ``IggyConsumer/consumeMessages(_:)``
-/// returned, whether it succeeded or threw.
-public enum AutoCommitAfter: Sendable, Hashable {
-    /// After the message whose offset is the partition's newest at poll
-    /// time.
-    case consumingAllMessages
-    /// After each message.
-    case consumingEachMessage
-    /// After a message whose offset divides by `n`. `n` must not be zero.
-    case consumingEveryNthMessage(UInt32)
-}
-
 /// Options of an ``IggyConsumer``, with the Rust SDK's defaults.
 public struct ConsumerConfiguration: Sendable {
-    /// Where reading a partition starts. After the first batch each poll
-    /// continues after the last message handed over from that partition,
-    /// except a ``PollingStrategy/next`` poll that commits with
-    /// ``AutoCommitWhen/pollingMessages``: it starts after the offset the
-    /// previous poll stored.
+    /// Where reading a partition without a position starts.
     public var pollingStrategy: PollingStrategy
-    /// Messages asked for per poll.
-    public var batchLength: UInt32
-    /// Minimum gap between the starts of two polls. Nil polls as soon as the
-    /// buffer is empty.
-    public var pollInterval: Duration?
-    public var autoCommit: AutoCommit
+    /// The limits of each read and the commit policy: a five-second wait,
+    /// 1000 messages, 1 MiB and ``CommitPolicy/manual`` by default. With
+    /// ``CommitPolicy/auto(_:)``, ``IggyConsumer/next()`` counts a message
+    /// under ``CommitAfter/processing`` once the next one is asked for, and
+    /// ``IggyConsumer/consumeMessages(_:)`` once its handler returned.
+    public var pollOptions: PollOptions
     /// Joins the group during ``IggyConsumer/initialize()`` and again before
-    /// a poll once the membership was lost. Off, a poll without a membership
+    /// a read once the membership was lost. Off, a read without a membership
     /// fails with ``IggyErrorCode/consumerGroupMemberNotFound``.
     public var autoJoinConsumerGroup: Bool
     /// Creates the group when it is missing before joining it.
     public var createConsumerGroupIfNotExists: Bool
-    /// The pause after a failed poll or join, and between checks while the
-    /// client is signed out.
+    /// The pause after a failed read before its partitions are read again.
     public var pollingRetryInterval: Duration
     /// Lookups of a missing stream or topic during
     /// ``IggyConsumer/initialize()`` before it fails. Nil or zero looks once.
     public var initRetries: UInt32?
     public var initRetryInterval: Duration
-    /// Hands over a message again when a poll returns it again, as after a
-    /// rebalance or a store that moved back. Off, a message at or below the
-    /// last one handed over from the same partition history is skipped.
+    /// Hands over a message again when a read returns it again, as after a
+    /// rebalance. Off, a message at or below the last one handed over from
+    /// the same partition history is skipped, unless a seek moved back.
     public var allowReplay: Bool
-    /// How long ``IggyConsumer/shutdown()`` waits for each background commit
-    /// task before it stops it.
+    /// How long ``IggyConsumer/shutdown()`` waits for the reader to store
+    /// its offsets and leave the group before it returns.
     public var offsetDrainTimeout: Duration
 
     public init(
-        pollingStrategy: PollingStrategy = .next, batchLength: UInt32 = 1000, pollInterval: Duration? = nil,
-        autoCommit: AutoCommit = .intervalOrWhen(.seconds(1), .pollingMessages), autoJoinConsumerGroup: Bool = true,
-        createConsumerGroupIfNotExists: Bool = true, pollingRetryInterval: Duration = .seconds(1), initRetries: UInt32? = nil,
-        initRetryInterval: Duration = .seconds(1), allowReplay: Bool = false, offsetDrainTimeout: Duration = .seconds(5)
+        pollingStrategy: PollingStrategy = .next, pollOptions: PollOptions = .wait(ReaderConfiguration.defaultMaxWait),
+        autoJoinConsumerGroup: Bool = true, createConsumerGroupIfNotExists: Bool = true, pollingRetryInterval: Duration = .seconds(1),
+        initRetries: UInt32? = nil, initRetryInterval: Duration = .seconds(1), allowReplay: Bool = false, offsetDrainTimeout: Duration = .seconds(5)
     ) {
         self.pollingStrategy = pollingStrategy
-        self.batchLength = batchLength
-        self.pollInterval = pollInterval
-        self.autoCommit = autoCommit
+        self.pollOptions = pollOptions
         self.autoJoinConsumerGroup = autoJoinConsumerGroup
         self.createConsumerGroupIfNotExists = createConsumerGroupIfNotExists
         self.pollingRetryInterval = pollingRetryInterval
@@ -138,29 +61,20 @@
         self.offsetDrainTimeout = offsetDrainTimeout
     }
 
-    /// Refuses what the Rust SDK's types cannot hold: a zero batch, a zero
-    /// or negative retry or commit interval (`NonZeroIggyDuration`), a zero
-    /// `n`, and any duration past `UInt64.max` microseconds, which would
-    /// also trap in `Task.sleep`.
+    /// Refuses what the Rust SDK's types cannot hold: a zero or negative
+    /// interval and any duration past `UInt64.max` microseconds, which would
+    /// also trap in `Task.sleep`, and poll options the server refuses.
     func validate() throws {
-        if batchLength == 0 {
-            throw IggyError(.invalidConfiguration, context: "batchLength must be at least 1")
-        }
+        try pollOptions.validate()
         try Self.validatePositive(pollingRetryInterval, named: "pollingRetryInterval")
         try Self.validatePositive(initRetryInterval, named: "initRetryInterval")
-        if let interval = autoCommit.interval {
-            try Self.validatePositive(interval, named: "the auto-commit interval")
-        }
-        if let pollInterval {
-            try ProducerConfiguration.validate(pollInterval, named: "pollInterval")
-        }
         try ProducerConfiguration.validate(offsetDrainTimeout, named: "offsetDrainTimeout")
-        if case .consumingEveryNthMessage(0)? = autoCommit.whenTrigger {
-            throw IggyError(.invalidConfiguration, context: "consumingEveryNthMessage needs n of at least 1")
-        }
-        if case .consumingEveryNthMessage(0)? = autoCommit.afterTrigger {
-            throw IggyError(.invalidConfiguration, context: "consumingEveryNthMessage needs n of at least 1")
-        }
+    }
+
+    func reader(partitions: [UInt32]?) -> ReaderConfiguration {
+        ReaderConfiguration(
+            partitions: partitions, start: pollingStrategy, options: pollOptions, retryInterval: pollingRetryInterval,
+            autoJoinConsumerGroup: autoJoinConsumerGroup, createConsumerGroupIfNotExists: createConsumerGroupIfNotExists)
     }
 
     private static func validatePositive(_ duration: Duration, named name: String) throws {
@@ -174,28 +88,13 @@
 /// One message handed over by an ``IggyConsumer``.
 public struct ReceivedMessage: Sendable {
     public var message: IggyMessage
-    /// The newest offset the partition had when the batch was polled, the
-    /// same for every message of the batch. Compared with the message's
-    /// offset it shows how far the consumer lags.
+    /// The newest offset the partition had when the batch was read, the same
+    /// for every message of the batch. Compared with the message's offset it
+    /// shows how far the consumer lags.
     public var currentOffset: UInt64
-    /// The partition the message was read from. A group member reads
-    /// several.
+    /// The partition the message was read from.
     public var partitionID: UInt32
-    /// The partition incarnation and owner the poll ran under.
-    public var context: PartitionContext
-
-    public init(message: IggyMessage, currentOffset: UInt64, partitionID: UInt32, context: PartitionContext) {
-        self.message = message
-        self.currentOffset = currentOffset
-        self.partitionID = partitionID
-        self.context = context
-    }
-
-    /// The checkpoint to store for this message with
-    /// ``IggyConsumer/storePosition(_:)``. It keeps the context that
-    /// delivered the message, so a store after a recreation or a reassignment
-    /// is refused instead of landing on the newer partition.
-    public var position: ConsumerPosition {
-        ConsumerPosition(partitionID: partitionID, offset: message.offset, context: context)
-    }
+    /// The position after this message, to commit with
+    /// ``IggyConsumer/commit(_:)`` or to count with ``IggyConsumer/mark(_:)``.
+    public let checkpoint: Checkpoint
 }
diff --git a/foreign/swift/Sources/Iggy/Consumer/IggyConsumer.swift b/foreign/swift/Sources/Iggy/Consumer/IggyConsumer.swift
index 2d42bee..724c00f 100644
--- a/foreign/swift/Sources/Iggy/Consumer/IggyConsumer.swift
+++ b/foreign/swift/Sources/Iggy/Consumer/IggyConsumer.swift
@@ -16,13 +16,13 @@
 // under the License.
 
 import Logging
-import NIOConcurrencyHelpers
 
 /// Reads the partitions of one topic and hands their messages over one at a
-/// time, a port of `IggyConsumer` in the Rust SDK.
+/// time, a port of `IggyConsumer` in the Rust SDK, on one
+/// ``PartitionReader``.
 ///
-/// Build a standalone consumer of one partition with
-/// ``IggyClient/consumer(name:streamID:topicID:partitionID:configuration:)``,
+/// Build a standalone consumer of chosen partitions with
+/// ``IggyClient/consumer(name:streamID:topicID:partitions:configuration:)``,
 /// or a member of a consumer group with
 /// ``IggyClient/consumerGroup(name:streamID:topicID:configuration:)``, whose
 /// partitions the server assigns. Call ``initialize()`` once, then read with
@@ -36,21 +36,19 @@
 /// }
 /// ```
 ///
-/// Messages come from a buffer that a poll refills once it runs empty. A
-/// failed poll is thrown after ``ConsumerConfiguration/pollingRetryInterval``
-/// and leaves the consumer usable, so a caller of ``next()`` can call it again;
-/// a `for try await` loop ends at the first error instead. ``next()`` returns
-/// nil only after ``shutdown()``. One reader consumes a consumer, as in the
-/// Rust SDK: two loops over the same consumer split its messages between them.
+/// The ``CommitPolicy`` of ``ConsumerConfiguration/pollOptions`` decides
+/// who stores offsets: the application with ``commit(_:)`` under `manual`,
+/// the server with each read under `onRead`, the consumer under `auto`,
+/// which counts a message when it is handed over (``CommitAfter/delivery``),
+/// once the application asked for the next one or its handler returned
+/// (``CommitAfter/processing``), or once it is marked
+/// (``CommitAfter/marked``). ``shutdown()`` stores the counted positions,
+/// moves an `onRead` offset back to the last message handed over, and
+/// leaves the group.
 ///
-/// The consumer remembers the last message handed over from each partition.
-/// Without ``ConsumerConfiguration/allowReplay`` a message at or below it is
-/// not handed over again, and ``ConsumerConfiguration/autoCommit`` decides
-/// when the reading position is stored on the server.
-///
-/// Unlike the Rust consumer, this one has no encryptor, since the Swift
-/// client has none, and does not resolve a group create or join whose outcome
-/// was lost: the Swift client has no operation lanes to resolve them through.
+/// A failed read is thrown as a ``ReadError`` and the consumer can be read
+/// on. ``next()`` returns nil only after ``shutdown()``. One reader consumes
+/// a consumer: two loops over the same consumer split its messages.
 public actor IggyConsumer: AsyncSequence {
     public typealias Element = ReceivedMessage
 
@@ -58,77 +56,42 @@
     public nonisolated let streamID: Identifier
     public nonisolated let topicID: Identifier
     public nonisolated let consumer: Consumer
-    /// The partition a standalone consumer reads; nil for a group member.
-    public nonisolated let partitionID: UInt32?
-    /// The partition of the last batch handed over. Before the first one it
-    /// is the partition of a standalone consumer and zero for a group member.
-    public private(set) var currentPartitionID: UInt32
+    /// The partitions a standalone consumer reads, nil for a group member.
+    public nonisolated let partitions: [UInt32]?
+    /// The partition of the last message handed over, zero before the first.
+    public private(set) var currentPartitionID: UInt32 = 0
 
     private let client: IggyClient
     private let configuration: ConsumerConfiguration
     private let logger: Logger
-    private let isConsumerGroup: Bool
-    /// One poll at a time: a reader that finds the buffer empty waits here
-    /// while another one polls.
     private let reading = AsyncMutex()
-    /// Held by a join and by the final leave, so a join in flight ends
-    /// before the leave and none starts after it.
-    private let membership = AsyncMutex()
-    /// Held by every store and delete, so a coverage check, its request, and
-    /// the cache update see no other store in between.
-    private let offsetWrites = AsyncMutex()
-    private let commitWakeups: AsyncStream<Void>
-    private let commitWakeup: AsyncStream<Void>.Continuation
-
-    private var isInitialized = false
-    /// Shared by concurrent calls of ``initialize()``.
+    private(set) var reader: PartitionReader?
     private var initialization: Task<Void, any Error>?
     private var isShutDown = false
     private var shutdownTask: Task<Void, Never>?
-    /// Follows the client's events: polling waits while the client is signed
-    /// out and can come back on its own.
-    private var canPoll = true
-    private var joinedGroup = false
-    private var buffer: [IggyMessage] = []
+    private var buffer: PolledMessages?
     private var bufferIndex = 0
-    private var bufferedContext = PartitionContext.empty
-    private var bufferedCurrentOffset: UInt64 = 0
-    private var lastPolledAt: ContinuousClock.Instant?
-    /// Where each partition continues after its last message handed over,
-    /// unless a `next` poll commits, which leaves that to the offset it stores.
-    private var nextPositions: [UInt32: ConsumerPosition] = [:]
-    private var lastConsumed: [UInt32: ConsumerPosition] = [:]
-    private var lastStored: [UInt32: ConsumerPosition] = [:]
-    /// Commits queued by the message triggers, the latest per partition.
-    private var pendingCommits: [UInt32: ConsumerPosition] = [:]
-    private var eventsTask: Task<Void, Never>?
-    private var intervalTask: Task<Void, Never>?
-    private var intervalIsStoring = false
-    private var commitTask: Task<Void, Never>?
+    /// With ``CommitAfter/processing``: the message ``next()`` handed over
+    /// last, which counts once the next one is asked for.
+    private var unprocessed: (checkpoint: Checkpoint, endsBatch: Bool)?
+    /// The last message handed over from each partition, which the replay
+    /// filter skips up to.
+    private var lastConsumed: [UInt32: Checkpoint] = [:]
 
     init(
-        client: IggyClient, name: String, consumer: Consumer, streamID: Identifier, topicID: Identifier, partitionID: UInt32?,
+        client: IggyClient, name: String, consumer: Consumer, streamID: Identifier, topicID: Identifier, partitions: [UInt32]?,
         configuration: ConsumerConfiguration
     ) throws {
         try configuration.validate()
+        try configuration.reader(partitions: partitions).validate(group: consumer.kind == .consumerGroup)
         self.client = client
         self.name = name
         self.consumer = consumer
         self.streamID = streamID
         self.topicID = topicID
-        self.partitionID = partitionID
-        currentPartitionID = partitionID ?? 0
+        self.partitions = partitions
         self.configuration = configuration
         logger = client.logger
-        isConsumerGroup = consumer.kind == .consumerGroup
-        (commitWakeups, commitWakeup) = AsyncStream<Void>.makeStream(bufferingPolicy: .bufferingNewest(1))
-    }
-
-    deinit {
-        eventsTask?.cancel()
-        intervalTask?.cancel()
-        commitTask?.cancel()
-        commitWakeup.finish()
     }
 
     public nonisolated func makeAsyncIterator() -> AsyncIterator {
@@ -160,12 +123,12 @@
     // MARK: Lifecycle
 
     /// Looks the stream and topic up, retrying a missing one as
-    /// ``ConsumerConfiguration/initRetries`` says, joins the group of a member
-    /// that joins on its own, and starts the commit tasks. Concurrent calls
-    /// share one run, and calling it again after it succeeded, or after
+    /// ``ConsumerConfiguration/initRetries`` says, and builds the reader,
+    /// which joins the group of a member that joins on its own. Concurrent
+    /// calls share one run, and calling it again after it succeeded, or after
     /// ``shutdown()``, does nothing.
     public func initialize() async throws {
-        if isInitialized || isShutDown {
+        if reader != nil || isShutDown {
             return
         }
         if let initialization {
@@ -180,50 +143,22 @@
     private func start() async throws {
         logger.info("Initializing the consumer", metadata: metadata)
         try await waitForStreamAndTopic()
-        let events = await client.events
-        // A retried initialize after a failed join replaces the subscription
-        // with nothing suspending in between, so none is left behind.
-        eventsTask?.cancel()
-        eventsTask = Task { [weak self] in
-            for await event in events {
-                guard let self, await self.observe(event) else {
-                    return
-                }
-            }
-        }
-        if isConsumerGroup, configuration.autoJoinConsumerGroup {
-            try await joinGroup()
-        }
+        let built = try await client.reader(
+            streamID: streamID, topicID: topicID, consumer: consumer, configuration: configuration.reader(partitions: partitions))
         guard !isShutDown else {
-            // A shutdown came in meanwhile and already stopped what it found.
-            eventsTask?.cancel()
-            eventsTask = nil
+            try? await built.close()
             return
         }
-        if let interval = configuration.autoCommit.interval {
-            intervalTask = Task { [weak self] in
-                while (try? await Task.sleep(for: interval)) != nil {
-                    guard let self, await self.storeOnTick() else {
-                        return
-                    }
-                }
-            }
-        }
-        commitTask = Task { [weak self, commitWakeups] in
-            for await _ in commitWakeups {
-                guard let self, await self.sendPendingCommits() else {
-                    return
-                }
-            }
-        }
-        isInitialized = true
+        reader = built
         logger.info("The consumer has been initialized", metadata: metadata)
     }
 
-    /// Stops reading, sends the queued commits, stores the reading position
-    /// of every partition read unless auto-commit is disabled, and leaves the
-    /// group. Every caller waits for the same shutdown. Failures are logged:
-    /// nothing is left for the caller to undo.
+    /// Stops reading, counts the last message under
+    /// ``CommitAfter/processing``, and closes the reader, which stores the
+    /// counted positions, moves an `onRead` offset back to the last message
+    /// handed over, and leaves the group, within
+    /// ``ConsumerConfiguration/offsetDrainTimeout``. Every caller waits for
+    /// the same shutdown. Failures are logged.
     public func shutdown() async {
         if let shutdownTask {
             await shutdownTask.value
@@ -237,159 +172,116 @@
 
     private func drain() async {
         logger.info("Shutting down the consumer", metadata: metadata)
-        let timeout = configuration.offsetDrainTimeout
-        if let intervalTask {
-            // A sleeping tick is cancelled. One that is storing ends after its
-            // store.
-            if intervalIsStoring {
-                await Self.wait(for: intervalTask, upTo: timeout)
-            } else {
-                intervalTask.cancel()
+        if let initialization {
+            _ = try? await initialization.value
+        }
+        guard let reader else {
+            return
+        }
+        await countUnprocessed()
+        do {
+            let closed = try await withDeadline(.now + configuration.offsetDrainTimeout) {
+                try await reader.close()
+                return true
             }
-        }
-        if let commitTask {
-            commitWakeup.yield()
-            await Self.wait(for: commitTask, upTo: timeout)
-        }
-        if configuration.autoCommit != .disabled {
-            let positions = Array(lastConsumed.values)
-            // One deadline for every final store, as in the Rust SDK.
-            _ = try? await withDeadline(.now + client.configuration.requestTimeout) {
-                await self.storeFinalPositions(positions)
+            if closed == nil {
+                logger.warning("The reader did not close within the offset drain timeout", metadata: metadata)
             }
+        } catch {
+            logger.warning("The reader closed with an error", metadata: metadata.merging(["error": "\(error)"]) { $1 })
         }
-        // Every join checks the shutdown under this lock, so none comes after
-        // the leave.
-        if isConsumerGroup, (try? await membership.lock()) != nil {
-            defer { membership.unlock() }
-            if joinedGroup {
-                joinedGroup = false
-                do {
-                    try await client.leaveConsumerGroup(streamID: streamID, topicID: topicID, groupID: consumer.id)
-                } catch {
-                    // Expected after an explicit leave or a deleted group.
-                    logger.debug("Failed to leave the consumer group", metadata: metadata.merging(["error": "\(error)"]) { $1 })
-                }
-            }
-        }
-        eventsTask?.cancel()
-        eventsTask = nil
         logger.info("The consumer has been shut down", metadata: metadata)
     }
 
     // MARK: Reading
 
-    /// The next message: from the buffer, or from a poll once it is empty.
-    /// Throws what a poll or join ran into, after the retry interval, and can
-    /// be called again after it threw. Nil once the consumer is shut down.
+    /// The next message, or nil once the consumer is shut down. Throws a
+    /// ``ReadError`` and can be called again after it threw.
     public func next() async throws -> ReceivedMessage? {
-        if isShutDown {
+        await countUnprocessed()
+        guard let received = try await take() else {
             return nil
         }
-        // Nothing suspends between the check and the handover, so a
-        // buffered message needs no lock.
-        if bufferIndex < buffer.count {
-            return handOverBuffered()
+        if configuration.pollOptions.autoCommit?.after == .processing {
+            unprocessed = (received.checkpoint, isLastOfBatch)
         }
-        try await reading.lock()
-        defer { reading.unlock() }
-        while !isShutDown {
-            // The poll this reader waited for can have refilled the buffer.
-            if bufferIndex < buffer.count {
-                return handOverBuffered()
-            }
-            try Task.checkCancellation()
-            let polled = try await poll()
-            if !polled.messages.isEmpty, !isShutDown {
-                return handOverPolled(polled)
-            }
-        }
-        return nil
+        return received
     }
 
     /// Hands every message to `handler` until the consumer shuts down, the
-    /// task is cancelled, or an error ends the reading: a client that is shut
-    /// down or cannot come back without the caller, or an invalid address.
-    /// Other errors are logged and reading goes on, and a handler that throws
-    /// is logged too. The ``AutoCommitAfter`` triggers fire here, after the
-    /// handler returned or threw.
+    /// task is cancelled, or a read error ends the reading: a client that is
+    /// shut down or cannot come back without the caller, or an invalid
+    /// address. Other read errors are logged and reading goes on. Under
+    /// ``CommitAfter/processing`` a message counts once its handler returned,
+    /// and a handler that throws leaves its message uncounted and ends the
+    /// reading with its error. Under any other policy a handler that throws
+    /// is logged.
     public func consumeMessages(_ handler: @Sendable (ReceivedMessage) async throws -> Void) async throws {
-        let after = configuration.autoCommit.afterTrigger
+        await countUnprocessed()
+        let processing = configuration.pollOptions.autoCommit?.after == .processing
         while true {
             let received: ReceivedMessage
             do {
-                guard let next = try await next() else {
+                guard let next = try await take() else {
                     return
                 }
                 received = next
-            } catch let error as IggyError {
-                let ends = Self.endsConsumption(error) ? true : !(await client.canRecover)
-                if ends {
+            } catch let error as ReadError {
+                let recovers = await client.canRecover
+                if Self.endsConsumption(error.error) || !recovers {
                     logger.error("Reading stopped", metadata: metadata.merging(["error": "\(error)"]) { $1 })
                     throw error
                 }
                 logger.error("Failed to read a message", metadata: metadata.merging(["error": "\(error)"]) { $1 })
                 continue
             }
+            let endsBatch = isLastOfBatch
             do {
                 try await handler(received)
             } catch {
                 logger.error(
                     "The handler failed", metadata: metadata.merging(["offset": "\(received.message.offset)", "error": "\(error)"]) { $1 })
+                if processing {
+                    throw error
+                }
+                continue
             }
-            switch after {
-            case .consumingEachMessage?:
-                queueCommit(received.position)
-            case .consumingEveryNthMessage(let n)?:
-                if received.message.offset % UInt64(n) == 0 {
-                    queueCommit(received.position)
-                }
-            case .consumingAllMessages?:
-                if received.message.offset == received.currentOffset {
-                    queueCommit(received.position)
-                }
-            case nil:
-                break
+            if processing, let reader {
+                await reader.count(received.checkpoint, messages: 1, endsBatch: endsBatch)
             }
         }
     }
 
     // MARK: Offsets
 
-    /// Stores `offset` for a partition, the current one when nil, under the
-    /// context of the last message handed over from it. Before any was, it
-    /// is stored without a context.
-    public func storeOffset(_ offset: UInt64, partitionID: UInt32? = nil) async throws {
-        let partition = partitionID ?? currentPartitionID
-        if let last = lastConsumed[partition] {
-            try await storePosition(ConsumerPosition(partitionID: partition, offset: offset, context: last.context))
-            return
+    /// Stores the offset of a checkpoint the consumer handed over, now. A
+    /// checkpoint of an older generation of its partition stores nothing.
+    /// Throws ``IggyErrorCode/invalidCommand`` under ``CommitPolicy/onRead``.
+    public func commit(_ checkpoint: Checkpoint) async throws {
+        try await requireReader().commit(checkpoint)
+    }
+
+    /// Counts a checkpoint the consumer handed over, under
+    /// ``CommitPolicy/auto(_:)``, for messages processed concurrently with
+    /// ``CommitAfter/marked``. Throws ``IggyErrorCode/invalidCommand``
+    /// without `auto`.
+    public func mark(_ checkpoint: Checkpoint) async throws {
+        try await requireReader().mark(checkpoint)
+    }
+
+    /// Sets where `partitionID` reads next, as
+    /// ``PartitionReader/seek(partitionID:to:)`` does, and drops its messages
+    /// not handed over yet. A seek back hands the messages over again.
+    public func seek(partitionID: UInt32, to start: PollingStrategy) async throws {
+        try await requireReader().seek(partitionID: partitionID, to: start)
+        if buffer?.partitionID == partitionID {
+            buffer = nil
+            bufferIndex = 0
         }
-        try await offsetWrites.lock()
-        defer { offsetWrites.unlock() }
-        try await client.storeConsumerOffset(consumer: consumer, streamID: streamID, topicID: topicID, partitionID: partition, offset: offset)
-        lastStored[partition] = ConsumerPosition(partitionID: partition, offset: offset, context: .empty)
-    }
-
-    /// Stores the exact position delivered, as
-    /// ``ReceivedMessage/position`` gives it, even after later polls. Without
-    /// ``ConsumerConfiguration/allowReplay`` a position at or below the one
-    /// already stored in the same partition history is not sent.
-    public func storePosition(_ position: ConsumerPosition) async throws {
-        try await store(position, allowReplay: configuration.allowReplay)
-    }
-
-    /// Deletes the stored offset of a partition, the current one when nil.
-    /// Unlike the Rust consumer, a group member's nil is not left to the
-    /// server, since the Swift client routes the request by its partition.
-    public func deleteOffset(partitionID: UInt32? = nil) async throws {
-        let partition = partitionID ?? currentPartitionID
-        try await offsetWrites.lock()
-        defer { offsetWrites.unlock() }
-        // A cancelled delete can still have committed, so a later store must
-        // not be skipped as covered.
-        lastStored[partition] = nil
-        try await client.deleteConsumerOffset(consumer: consumer, streamID: streamID, topicID: topicID, partitionID: partition)
+        lastConsumed[partitionID] = nil
+        if unprocessed?.checkpoint.partitionID == partitionID {
+            unprocessed = nil
+        }
     }
 
     /// The offset of the last message handed over from the partition.
@@ -397,17 +289,92 @@
         lastConsumed[partitionID]?.offset
     }
 
-    /// The offset this consumer last stored for the partition.
-    public func lastStoredOffset(partitionID: UInt32) -> UInt64? {
-        lastStored[partitionID]?.offset
-    }
-
     // MARK: Internals
 
     private var metadata: Logger.Metadata {
         ["consumer": "\(name)", "stream": "\(streamID)", "topic": "\(topicID)"]
     }
 
+    private var isLastOfBatch: Bool {
+        guard let buffer else {
+            return true
+        }
+        return bufferIndex >= buffer.messages.count
+    }
+
+    private func requireReader() throws -> PartitionReader {
+        guard let reader else {
+            throw IggyError(.invalidCommand, context: "the consumer is not initialized")
+        }
+        return reader
+    }
+
+    /// The next message from the buffer, or from the next batch of the
+    /// reader once it is empty, without those that replay skips.
+    private func take() async throws -> ReceivedMessage? {
+        try await reading.lock()
+        defer { reading.unlock() }
+        while !isShutDown {
+            if let buffered = buffer, bufferIndex < buffered.messages.count {
+                // A seek of its partition can have replaced its generation since
+                // the batch was taken, as `yield_current` checks in the Rust SDK.
+                if let reader, await !reader.holds(buffered) {
+                    buffer = nil
+                    continue
+                }
+                return await handOver(buffered)
+            }
+            buffer = nil
+            guard let reader else {
+                throw IggyError(.invalidCommand, context: "the consumer is not initialized")
+            }
+            guard var batch = try await reader.nextBatch(), !isShutDown else {
+                return nil
+            }
+            skipReplayed(&batch)
+            buffer = batch
+            bufferIndex = 0
+        }
+        return nil
+    }
+
+    private func handOver(_ batch: PolledMessages) async -> ReceivedMessage {
+        let index = bufferIndex
+        bufferIndex += 1
+        let message = batch.messages[index]
+        let checkpoint = batch.checkpoint(at: index) ?? Checkpoint(partitionID: batch.partitionID, offset: message.offset, context: batch.context)
+        currentPartitionID = batch.partitionID
+        lastConsumed[batch.partitionID] = checkpoint
+        let endsBatch = bufferIndex == batch.messages.count
+        if let reader {
+            await reader.deliver(checkpoint)
+            if configuration.pollOptions.autoCommit?.after == .delivery {
+                await reader.count(checkpoint, messages: 1, endsBatch: endsBatch)
+            }
+        }
+        return ReceivedMessage(message: message, currentOffset: batch.currentOffset, partitionID: batch.partitionID, checkpoint: checkpoint)
+    }
+
+    private func countUnprocessed() async {
+        guard let (checkpoint, endsBatch) = unprocessed, let reader else {
+            return
+        }
+        unprocessed = nil
+        await reader.count(checkpoint, messages: 1, endsBatch: endsBatch)
+    }
+
+    /// Drops the messages at or below the last one handed over from the same
+    /// partition history, unless replay is allowed.
+    private func skipReplayed(_ batch: inout PolledMessages) {
+        guard !configuration.allowReplay, let consumed = lastConsumed[batch.partitionID],
+            PartitionReader.sameHistory(consumed.context, batch.context)
+        else {
+            return
+        }
+        batch.messages.removeAll { $0.offset <= consumed.offset }
+        batch.count = UInt32(batch.messages.count)
+    }
+
     private func waitForStreamAndTopic() async throws {
         let retries = configuration.initRetries ?? 0
         var ticker = RetryTicker(interval: configuration.initRetryInterval)
@@ -442,294 +409,36 @@
         }
     }
 
-    private func joinGroup() async throws {
-        try await membership.lock()
-        defer { membership.unlock() }
-        // The final leave of a shutdown may have gone already.
-        if joinedGroup || isShutDown {
-            return
-        }
-        if try await client.getConsumerGroup(streamID: streamID, topicID: topicID, groupID: consumer.id) == nil {
-            guard configuration.createConsumerGroupIfNotExists else {
-                throw IggyError(.consumerGroupNameNotFound, context: "consumer group \(name) does not exist and auto-creation is disabled")
-            }
-            logger.info("Creating the consumer group", metadata: metadata)
-            do {
-                _ = try await client.createConsumerGroup(streamID: streamID, topicID: topicID, name: name)
-            } catch let error as IggyError where error.code == .consumerGroupNameAlreadyExists {
-                // Another member created it first.
-            }
-        }
-        logger.info("Joining the consumer group", metadata: metadata)
-        try await client.joinConsumerGroup(streamID: streamID, topicID: topicID, groupID: consumer.id)
-        joinedGroup = true
-    }
-
-    /// Returns false once the client shut down, which ends the subscription.
-    private func observe(_ event: DiagnosticEvent) async -> Bool {
-        switch event {
-        case .shutdown:
-            logger.warning("The client has been shut down", metadata: metadata)
-            joinedGroup = false
-            canPoll = false
-            return false
-        case .connected, .disconnected, .signedOut:
-            joinedGroup = false
-        case .signedIn:
-            break
-        }
-        // Events can arrive after one that reports a later state, so the
-        // state of the client decides.
-        canPoll = await client.state == .authenticated
-        return true
-    }
-
-    /// One poll, as the Rust consumer's poll future: the interval wait, the
-    /// sign-in wait, a join when the membership was lost, and the request.
-    /// An empty result is not an error.
-    private func poll() async throws -> PolledMessages {
-        if let pollInterval = configuration.pollInterval, let lastPolledAt {
-            try await Task.sleep(until: lastPolledAt + pollInterval, clock: .continuous)
-        }
-        let retryInterval = configuration.pollingRetryInterval
-        // A client that only the caller brings back never recovers, so the
-        // poll goes on and fails with the reason instead of waiting for good.
-        // Without a heartbeat or a commit to send, nothing else signs the
-        // client in again, so the wait does.
-        while !canPoll, !isShutDown, await client.canRecover {
-            if (try? await client.requireAuthenticated()) != nil {
-                break
-            }
-            try await Task.sleep(for: retryInterval)
-        }
-        if isConsumerGroup, configuration.autoJoinConsumerGroup, !joinedGroup {
-            do {
-                try await joinGroup()
-            } catch {
-                logger.error("Failed to join the consumer group", metadata: metadata.merging(["error": "\(error)"]) { $1 })
-                try await Task.sleep(for: retryInterval)
-                throw error
-            }
-        }
-        // A shutdown may have sent its final commits and leave already.
-        if isShutDown {
-            return .empty
-        }
-        lastPolledAt = .now
-        let continuations = nextPositions
-        let strategy = configuration.pollingStrategy
-        // A refusal does not name its partition, and it answers the last one
-        // the strategy was asked for.
-        let polledPartition = NIOLockedValueBox(PolledMessages.noAssignedPartition)
-        do {
-            var polled = try await client.pollMessages(
-                streamID: streamID, topicID: topicID, partitionID: partitionID, consumer: consumer, count: configuration.batchLength,
-                autoCommit: configuration.autoCommit.whenTrigger == .pollingMessages
-            ) { partition in
-                polledPartition.withLockedValue { $0 = partition }
-                return continuations[partition].map { .offset($0.offset, context: $0.context) } ?? strategy
-            }
-            if polled.partitionID == PolledMessages.noAssignedPartition {
-                nextPositions.removeAll()
-                try await Task.sleep(for: retryInterval)
-                return polled
-            }
-            if !configuration.allowReplay, let consumed = lastConsumed[polled.partitionID], Self.sameHistory(consumed.context, polled.context) {
-                polled.messages.removeAll { $0.offset <= consumed.offset }
-                polled.count = UInt32(polled.messages.count)
-            }
-            return polled
-        } catch let error as IggyError {
-            if error.code == .historyUnavailable || error.code == .consumerGroupPartitionNotOwned {
-                nextPositions[polledPartition.withLockedValue { $0 }] = nil
-            }
-            logger.error("Failed to poll messages", metadata: metadata.merging(["error": "\(error)"]) { $1 })
-            if isConsumerGroup, configuration.autoJoinConsumerGroup, error.code == .consumerGroupMemberNotFound {
-                // The membership was revoked; the next poll joins again.
-                joinedGroup = false
-                return PolledMessages(partitionID: PolledMessages.noAssignedPartition, currentOffset: 0, count: 0, messages: [])
-            }
-            if isConsumerGroup, [.disconnected, .unauthenticated, .staleClient].contains(error.code) {
-                joinedGroup = false
-            }
-            try await Task.sleep(for: retryInterval)
-            throw error
-        }
-    }
-
-    private func handOverPolled(_ polled: PolledMessages) -> ReceivedMessage {
-        let partition = polled.partitionID
-        currentPartitionID = partition
-        buffer = polled.messages
-        bufferIndex = 0
-        bufferedContext = polled.context
-        bufferedCurrentOffset = polled.currentOffset
-        return handOverBuffered()
-    }
-
-    private func handOverBuffered() -> ReceivedMessage {
-        let partition = currentPartitionID
-        let message = buffer[bufferIndex]
-        bufferIndex += 1
-        let position = ConsumerPosition(partitionID: partition, offset: message.offset, context: bufferedContext)
-        lastConsumed[partition] = position
-        let drained = bufferIndex == buffer.count
-        switch configuration.autoCommit.whenTrigger {
-        case .consumingEachMessage?:
-            queueCommit(position)
-        case .consumingEveryNthMessage(let n)?:
-            if message.offset % UInt64(n) == 0 {
-                queueCommit(position)
-            }
-        case .consumingAllMessages?:
-            if drained {
-                queueCommit(position)
-            }
-        case .pollingMessages?, nil:
-            break
-        }
-        let received = ReceivedMessage(message: message, currentOffset: bufferedCurrentOffset, partitionID: partition, context: bufferedContext)
-        if drained {
-            // The strategy only says where a partition starts; the next poll
-            // of it continues after the last message handed over. A `next`
-            // poll that commits starts after the offset it stored instead.
-            if configuration.pollingStrategy.kind != .next || configuration.autoCommit.whenTrigger != .pollingMessages {
-                nextPositions[partition] = ConsumerPosition(partitionID: partition, offset: message.offset + 1, context: bufferedContext)
-            }
-            buffer.removeAll(keepingCapacity: true)
-            bufferIndex = 0
-        }
-        return received
-    }
-
-    /// Queues a commit for the commit task. A later one for the same
-    /// partition replaces a queued one not sent yet.
-    private func queueCommit(_ position: ConsumerPosition) {
-        guard isInitialized, !isShutDown else {
-            logger.error("The commit was not queued because the consumer is not running", metadata: metadata)
-            return
-        }
-        pendingCommits[position.partitionID] = position
-        commitWakeup.yield()
-    }
-
-    /// Sends the queued commits. Returns false once the consumer is shut
-    /// down and nothing is left, which ends the commit task.
-    private func sendPendingCommits() async -> Bool {
-        while let partition = pendingCommits.keys.first, let position = pendingCommits.removeValue(forKey: partition) {
-            await storeInBackground(position)
-        }
-        return !isShutDown
-    }
-
-    /// Returns false once the consumer is shut down, which ends the interval
-    /// task before it stores anything more, or right after the store that a
-    /// shutdown waits for.
-    private func storeOnTick() async -> Bool {
-        guard !isShutDown else {
-            return false
-        }
-        intervalIsStoring = true
-        defer { intervalIsStoring = false }
-        for position in lastConsumed.values {
-            await storeInBackground(position)
-        }
-        return !isShutDown
-    }
-
-    private func storeFinalPositions(_ positions: [ConsumerPosition]) async {
-        for position in positions {
-            do {
-                try await store(position, allowReplay: false)
-            } catch {
-                logger.warning(
-                    "The final checkpoint was not acknowledged",
-                    metadata: metadata.merging(["partition": "\(position.partitionID)", "offset": "\(position.offset)", "error": "\(error)"]) { $1 })
-            }
-        }
-    }
-
-    /// Background commits have no caller to report to, so a failure is
-    /// logged. They never move a stored offset back: replay governs reading.
-    private func storeInBackground(_ position: ConsumerPosition) async {
-        do {
-            try await store(position, allowReplay: false)
-        } catch {
-            logger.error(
-                "Failed to store the offset",
-                metadata: metadata.merging(["partition": "\(position.partitionID)", "offset": "\(position.offset)", "error": "\(error)"]) { $1 })
-        }
-    }
-
-    private func store(_ position: ConsumerPosition, allowReplay: Bool) async throws {
-        try await offsetWrites.lock()
-        defer { offsetWrites.unlock() }
-        if !allowReplay, storedPositionCovers(position) {
-            return
-        }
-        try await client.storeConsumerPosition(consumer: consumer, streamID: streamID, topicID: topicID, position: position)
-        lastStored[position.partitionID] = position
-    }
-
-    /// A store without a context proves nothing about a partition history.
-    private func storedPositionCovers(_ position: ConsumerPosition) -> Bool {
-        guard let stored = lastStored[position.partitionID], stored.context.incarnation != 0, position.context.incarnation != 0 else {
-            return false
-        }
-        return Self.sameHistory(stored.context, position.context) && position.offset <= stored.offset
-    }
-
-    /// Offsets compare only within one incarnation and owner. The metadata
-    /// frontier is left out: another group's change can move it between two
-    /// polls of the same partition.
-    static func sameHistory(_ left: PartitionContext, _ right: PartitionContext) -> Bool {
-        left.incarnation == right.incarnation && left.ownerGeneration == right.ownerGeneration
-    }
-
     static func endsConsumption(_ error: IggyError) -> Bool {
         switch error.code {
         case .invalidServerAddress, .invalidClientAddress, .clientShutdown: true
         default: false
         }
     }
-
-    /// Waits for `task` up to `timeout`, then cancels it and waits for it to
-    /// end: a waiter that gives up does not cancel what it waits for.
-    private static func wait(for task: Task<Void, Never>, upTo timeout: Duration) async {
-        await withTaskGroup(of: Void.self) { group in
-            group.addTask { await task.value }
-            group.addTask {
-                if (try? await Task.sleep(for: timeout)) != nil {
-                    task.cancel()
-                }
-            }
-            _ = await group.next()
-            group.cancelAll()
-        }
-    }
 }
 
 extension IggyClient {
-    /// A standalone consumer of one partition, identified by `name`. Building
-    /// it never talks to the server; ``IggyConsumer/initialize()`` does.
+    /// A standalone consumer of `partitions`, identified by `name`. Building
+    /// it never talks to the server, ``IggyConsumer/initialize()`` does.
     /// Throws ``IggyErrorCode/invalidConfiguration`` for an option out of
-    /// range.
+    /// range or partitions that are not 1 to 1000 distinct ids.
     public func consumer(
-        name: String, streamID: Identifier, topicID: Identifier, partitionID: UInt32, configuration: ConsumerConfiguration = ConsumerConfiguration()
+        name: String, streamID: Identifier, topicID: Identifier, partitions: [UInt32], configuration: ConsumerConfiguration = ConsumerConfiguration()
     ) throws -> IggyConsumer {
         try IggyConsumer(
-            client: self, name: name, consumer: .consumer(try Identifier(named: name)), streamID: streamID, topicID: topicID, partitionID: partitionID,
+            client: self, name: name, consumer: .consumer(try Identifier(named: name)), streamID: streamID, topicID: topicID, partitions: partitions,
             configuration: configuration)
     }
 
     /// A member of the consumer group `name`, which reads the partitions the
-    /// server assigns to it. Building it never talks to the server;
-    /// ``IggyConsumer/initialize()`` does.
+    /// server assigns to it. Building it never talks to the server,
+    /// ``IggyConsumer/initialize()`` does. Throws what building a standalone
+    /// consumer throws.
     public func consumerGroup(
         name: String, streamID: Identifier, topicID: Identifier, configuration: ConsumerConfiguration = ConsumerConfiguration()
     ) throws -> IggyConsumer {
         try IggyConsumer(
-            client: self, name: name, consumer: .group(try Identifier(named: name)), streamID: streamID, topicID: topicID, partitionID: nil,
+            client: self, name: name, consumer: .group(try Identifier(named: name)), streamID: streamID, topicID: topicID, partitions: nil,
             configuration: configuration)
     }
 }
diff --git a/foreign/swift/Sources/Iggy/Consumer/PartitionReader.swift b/foreign/swift/Sources/Iggy/Consumer/PartitionReader.swift
new file mode 100644
index 0000000..0823678
--- /dev/null
+++ b/foreign/swift/Sources/Iggy/Consumer/PartitionReader.swift
@@ -0,0 +1,1930 @@
+// 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.
+
+import Logging
+import NIOConcurrencyHelpers
+
+/// Options of a ``PartitionReader``.
+public struct ReaderConfiguration: Sendable {
+    /// The default wait of a read, as in the Rust SDK.
+    public static let defaultMaxWait: Duration = .seconds(5)
+
+    /// The partitions a standalone consumer reads, each once. A group member
+    /// reads the partitions the server assigns to it and leaves this nil.
+    public var partitions: [UInt32]?
+    /// Where a partition without a position starts. ``PollingStrategy/first``
+    /// reads from offset zero.
+    public var start: PollingStrategy
+    /// The limits of each read and the commit policy. `count` caps a read of
+    /// many partitions as a whole.
+    public var options: PollOptions
+    /// The pause after a failed read before its partitions are read again.
+    public var retryInterval: Duration
+    /// Joins the group when the reader is built, and again after the
+    /// membership was lost.
+    public var autoJoinConsumerGroup: Bool
+    /// Creates a missing group before joining it. It needs a group named by
+    /// name.
+    public var createConsumerGroupIfNotExists: Bool
+
+    public init(
+        partitions: [UInt32]? = nil, start: PollingStrategy = .next, options: PollOptions = .wait(ReaderConfiguration.defaultMaxWait),
+        retryInterval: Duration = .seconds(1), autoJoinConsumerGroup: Bool = true, createConsumerGroupIfNotExists: Bool = true
+    ) {
+        self.partitions = partitions
+        self.start = start
+        self.options = options
+        self.retryInterval = retryInterval
+        self.autoJoinConsumerGroup = autoJoinConsumerGroup
+        self.createConsumerGroupIfNotExists = createConsumerGroupIfNotExists
+    }
+
+    func validate(group: Bool) throws {
+        try options.validate()
+        try options.deferred.validateFetch(count: options.count)
+        guard retryInterval > .zero, retryInterval.wholeMicroseconds != nil else {
+            throw IggyError(.invalidConfiguration, context: "retryInterval must be positive, got \(retryInterval)")
+        }
+        if group {
+            guard partitions == nil else {
+                throw IggyError(.invalidConfiguration, context: "a consumer group reads its assignment, not chosen partitions")
+            }
+        } else {
+            guard let partitions, !partitions.isEmpty, partitions.count <= Requests.maxPartitionsPerRequest, Set(partitions).count == partitions.count
+            else {
+                throw IggyError(.invalidConfiguration, context: "a standalone reader names 1 to \(Requests.maxPartitionsPerRequest) partitions, each once")
+            }
+        }
+    }
+}
+
+/// An error of a ``PartitionReader``. The reader stays usable after it and
+/// reads on by itself.
+public struct ReadError: Error, Sendable, CustomStringConvertible {
+    /// The partition of the error, nil for an error of the whole reader.
+    public let partitionID: UInt32?
+    public let error: IggyError
+    /// For a message that does not decode: the continuation after it. Its
+    /// partition stops until a ``PartitionReader/seek(partitionID:to:)``,
+    /// to this continuation to step over the message. The messages before it
+    /// arrive in a batch before this error.
+    public let skip: PollingStrategy?
+    /// The offset of the message that does not decode.
+    public let offset: UInt64?
+    /// The record of the message that does not decode, as the server sent it.
+    public let raw: [UInt8]?
+
+    init(partitionID: UInt32?, error: IggyError, skip: PollingStrategy? = nil, offset: UInt64? = nil, raw: [UInt8]? = nil) {
+        self.partitionID = partitionID
+        self.error = error
+        self.skip = skip
+        self.offset = offset
+        self.raw = raw
+    }
+
+    public var description: String {
+        let partition = partitionID.map { "partition \($0): " } ?? ""
+        let offset = offset.map { " at offset \($0)" } ?? ""
+        return "\(partition)\(error)\(offset)"
+    }
+}
+
+/// Reads the partitions of one topic in batches, a port of `PartitionReader`
+/// in the Rust SDK. Build one with
+/// ``IggyClient/reader(streamID:topicID:consumer:configuration:)``.
+///
+/// - A standalone consumer reads the partitions of its configuration. A
+///   consumer group member reads its assignment: the reader joins the group,
+///   joins again after a lost membership, and leaves it in ``close()``.
+/// - It groups the partitions by their primary and reads each group with one
+///   request, which the server holds until a message arrives or the wait of
+///   ``PollOptions/maxWait`` ran out. Partitions whose last read ended before
+///   their head spread over the free requests, each share in a fetch of its
+///   own beside that one. A moved primary, a rebalance and a lost connection
+///   are repaired inside.
+/// - ``next()`` returns the next batch of one partition, never empty, in
+///   offset order within one generation of the partition: from its first
+///   read, from each ``seek(partitionID:to:)``, and from each new assignment
+///   to this member.
+/// - The ``CommitPolicy`` of the options decides who stores offsets. With
+///   `manual`, ``commit(_:)`` stores a checkpoint. With `onRead`, the server
+///   stores the offset of each read, the reader reads only once ``next()``
+///   waits with nothing queued and then fills its free requests, a seek to an
+///   offset stores the offset before it, and ``close()`` moves the offset
+///   back to the last delivered message. After a reply lost to a broken
+///   connection, a read that the server stored but never delivered shows as
+///   a gap before the next reply, and the reader reads that gap again
+///   without a commit and delivers it first. A partition that leaves the
+///   assignment still hands over the reads that the server stored for it.
+///   With `auto`, the reader counts
+///   each batch as its ``CommitAfter`` says, or each ``mark(_:)``, and stores
+///   the latest counted position at the interval or the event of the policy,
+///   before a rebalance takes a partition away, and in ``close()``.
+/// - A failed read is thrown as a ``ReadError`` and its partitions are read
+///   again after ``ReaderConfiguration/retryInterval``. With a zero
+///   ``PollOptions/maxWait``, a partition that a read found empty waits it
+///   too.
+///   ``IggyErrorCode/historyUnavailable`` for a position read in a history
+///   that is gone, and a message that does not decode, stop the partition
+///   until a seek.
+public actor PartitionReader: AsyncSequence {
+    public typealias Element = PolledMessages
+
+    /// The most requests of a reader in flight, as
+    /// `MAX_DEFERRED_REQUESTS_PER_CONSUMER` in the Rust SDK, so a reader
+    /// leaves deferred connections of its client to others.
+    static let maxRequestsInFlight = 16
+    /// How often a waiting group member checks the assignment that its
+    /// client syncs, as `ASSIGNMENT_REFRESH_INTERVAL` in the Rust SDK.
+    static let assignmentCheckInterval: Duration = .seconds(1)
+    /// A reader without ``CommitPolicy/onRead`` fetches a group while fewer
+    /// replies of the group wait for ``next()``, so the next fetch goes out
+    /// before the reply before it is taken, and the queue stays bounded.
+    static let waitingRepliesPerGroup = 2
+    /// `[partition:4][current offset:8][count:4][context:24]` leads the reply
+    /// of a poll.
+    private static let pollReplyHeadSize = 40
+    private static let lastReaderID = NIOLockedValueBox<UInt64>(0)
+
+    public nonisolated let streamID: Identifier
+    public nonisolated let topicID: Identifier
+    public nonisolated let consumer: Consumer
+
+    let id: UInt64
+    private let client: IggyClient
+    private let configuration: ReaderConfiguration
+    private let logger: Logger
+    private let isGroup: Bool
+    /// One reader of batches at a time.
+    private let reading = AsyncMutex()
+    /// Held by a join and by the final leave, so a join in flight ends
+    /// before the leave and none starts after it.
+    private let membership = AsyncMutex()
+    /// Held by every store and delete, so a coverage check, its request, and
+    /// the record of it see no other store in between.
+    private let offsetWrites = AsyncMutex()
+    private let commitWakeups: AsyncStream<Void>
+    private let commitWakeup: AsyncStream<Void>.Continuation
+
+    private var isStarted = false
+    private var isClosed = false
+    private var closeTask: Task<Void, any Error>?
+    /// Follows the client's events: reading waits while the client is signed
+    /// out and can come back on its own.
+    private var canRead = true
+    private var joinedGroup = false
+    private var joinedSessionScope: UInt64?
+    private var lastAssignmentSync: ContinuousClock.Instant?
+    private var eventsTask: Task<Void, Never>?
+    private var intervalTask: Task<Void, Never>?
+    private var commitTask: Task<Void, Never>?
+
+    /// The partitions the reader reads grouped by primary, each group in the
+    /// order of its next fetch.
+    private var fetchGroups: [[UInt32]] = []
+    private var assigned: Set<UInt32> = []
+    /// Moves on when a primary or the assignment can have moved. Nothing is
+    /// read until the partitions are grouped again.
+    private var groupingEpoch: UInt64 = 1
+    private var groupedEpoch: UInt64 = 0
+    private var generations: [UInt32: UInt64] = [:]
+    private var sought: [UInt32: UInt64] = [:]
+    private var lastGeneration: UInt64 = 0
+    private var requests: [UInt64: Request] = [:]
+    /// The request that reads each partition.
+    private var activeReads: [UInt32: UInt64] = [:]
+    private var lastRequestID: UInt64 = 0
+    /// What the requests brought back, in arrival order.
+    private var arrivals: [Arrival] = []
+    /// Where each partition reads next. Without one it starts as the
+    /// configuration says.
+    private var positions: [UInt32: PollingStrategy] = [:]
+    /// With `onRead`: the offset the next reply of each partition starts at,
+    /// unless a lost read stored messages before it.
+    private var expected: [UInt32: UInt64] = [:]
+    private var baselines: [UInt32: (next: UInt64, context: PartitionContext)] = [:]
+    private var uncertain: Set<UInt32> = []
+    /// With `onRead`: the partitions with a committing read whose outcome is
+    /// unknown. Only they read a forward gap again, any other jump is a
+    /// store of the application or of another session.
+    private var lostReads: Set<UInt32> = []
+    /// With `onRead`: the partitions that a committing read of another
+    /// caller blocks, by its operation. They read again once it is
+    /// abandoned.
+    private var blockedBy: [UInt32: UInt128Value] = [:]
+    /// With `onRead`: the messages each partition read and the server stored
+    /// in its current generation, which the close moves back over.
+    private var stored: [UInt32: Span] = [:]
+    private var gaps: [UInt32: Gap] = [:]
+    private var isolations: [UInt32: Span] = [:]
+    /// The partitions whose last fetch section ended before the head of the
+    /// partition, which a read answers at once.
+    private var backlogged: Set<UInt32> = []
+    /// Partitions read with a poll of their own next: a fetch section that
+    /// the server refused with `invalidSizeBytes`.
+    private var pollAlone: Set<UInt32> = []
+    private var pausedUntil: [UInt32: ContinuousClock.Instant] = [:]
+    /// Partitions that read nothing until a seek.
+    private var stopped: Set<UInt32> = []
+    private var pollCursor: UInt32?
+    private var readerWakeup: AsyncStream<Void>.Continuation?
+    private var storesWaiting: [UInt32: Int] = [:]
+    private var storeWakeups: [AsyncStream<Void>.Continuation] = []
+
+    private var delivered: [UInt32: Checkpoint] = [:]
+    private var committed: [UInt32: Checkpoint] = [:]
+    private var counted: [UInt32: Counted] = [:]
+    /// Counted positions of partitions a rebalance took away, stored first by
+    /// the next flush.
+    private var retiring: [Checkpoint] = []
+    /// With ``CommitAfter/processing``: the last batch ``next()`` returned,
+    /// which counts once the application asks for the next one.
+    private var uncounted: (checkpoint: Checkpoint, messages: UInt32)?
+
+    private struct Request {
+        let kind: RequestKind
+        let partitions: [UInt32]
+        /// Whether the request reads or moves the stored offsets of its
+        /// partitions: it commits, or one of them starts after its stored
+        /// offset.
+        let progresses: Bool
+        let commits: Bool
+        let generations: [UInt32: UInt64]
+        /// Where a poll starts and how many messages it asks for.
+        var start: PollingStrategy?
+        var count: UInt32 = 0
+        let task: Task<Void, Never>
+    }
+
+    private enum RequestKind {
+        /// The fetch of a readiness group.
+        case main
+        /// The fetch of the partitions of a group that turned idle while
+        /// its main fetch waited.
+        case second
+        /// With `onRead`: a committing fetch of backlogged partitions beside
+        /// the fetch of the group that may wait.
+        case backlog
+        /// A poll of one partition.
+        case single
+        /// A poll that reads a gap again, without a commit.
+        case gap
+        /// A poll of one message of a section that did not decode.
+        case isolate
+
+        var isPoll: Bool {
+            switch self {
+            case .main, .second, .backlog: false
+            case .single, .gap, .isolate: true
+            }
+        }
+    }
+
+    private enum Reply: Sendable {
+        case fetched([FetchSection])
+        case polled([UInt8])
+    }
+
+    private enum Arrival {
+        case section(FetchSection, generation: UInt64, request: UInt64)
+        case batch(PolledMessages, generation: UInt64)
+        case failure(ReadError)
+
+        var partitionID: UInt32? {
+            switch self {
+            case .section(let section, _, _): section.partitionID
+            case .batch(let batch, _): batch.partitionID
+            case .failure(let error): error.partitionID
+            }
+        }
+
+        var isFailure: Bool {
+            if case .failure = self { true } else { false }
+        }
+    }
+
+    /// Offsets `first` up to `last`, or `first` up to before `end`, of one
+    /// partition history.
+    private struct Span {
+        var first: UInt64
+        var last: UInt64
+        var context: PartitionContext
+    }
+
+    /// A range that a lost read stored and never delivered, read again from
+    /// `next` up to before `end`, then the replies held behind it.
+    private struct Gap {
+        var next: UInt64
+        let end: UInt64
+        let context: PartitionContext
+        var held: [Arrival]
+    }
+
+    private struct Counted {
+        var checkpoint: Checkpoint
+        var sinceStore: UInt32
+    }
+
+    init(client: IggyClient, streamID: Identifier, topicID: Identifier, consumer: Consumer, configuration: ReaderConfiguration) throws {
+        try configuration.validate(group: consumer.kind == .consumerGroup)
+        id = Self.lastReaderID.withLockedValue { last in
+            last += 1
+            return last
+        }
+        self.client = client
+        self.streamID = streamID
+        self.topicID = topicID
+        self.consumer = consumer
+        var configuration = configuration
+        if configuration.start.kind == .first {
+            configuration.start = .offset(0)
+        }
+        self.configuration = configuration
+        logger = client.logger
+        isGroup = consumer.kind == .consumerGroup
+        (commitWakeups, commitWakeup) = AsyncStream<Void>.makeStream(bufferingPolicy: .bufferingNewest(1))
+    }
+
+    deinit {
+        eventsTask?.cancel()
+        intervalTask?.cancel()
+        commitTask?.cancel()
+        commitWakeup.finish()
+        for request in requests.values where !request.commits {
+            request.task.cancel()
+        }
+    }
+
+    public nonisolated func makeAsyncIterator() -> AsyncIterator {
+        AsyncIterator(reader: self)
+    }
+
+    public struct AsyncIterator: AsyncIteratorProtocol {
+        /// Nil once an error or the close ended the iteration. The reader
+        /// itself can be read on after an error.
+        var reader: PartitionReader?
+
+        public mutating func next() async throws -> PolledMessages? {
+            guard let reader else {
+                return nil
+            }
+            do {
+                let batch = try await reader.next()
+                if batch == nil {
+                    self.reader = nil
+                }
+                return batch
+            } catch {
+                self.reader = nil
+                throw error
+            }
+        }
+    }
+
+    // MARK: Lifecycle
+
+    /// Joins the group of a member that joins on its own and starts the
+    /// tasks of the reader.
+    func start() async throws {
+        guard !isStarted else {
+            return
+        }
+        let events = await client.events
+        eventsTask = Task { [weak self] in
+            for await event in events {
+                guard let self, await self.observe(event) else {
+                    return
+                }
+            }
+        }
+        if isGroup, configuration.autoJoinConsumerGroup {
+            try await joinGroup()
+        }
+        if !isGroup {
+            reassign(Set(configuration.partitions ?? []))
+        }
+        if let auto = configuration.options.autoCommit {
+            if let interval = auto.interval {
+                intervalTask = Task { [weak self] in
+                    while (try? await Task.sleep(for: interval)) != nil {
+                        guard let self, await self.flushOnTick() else {
+                            return
+                        }
+                    }
+                }
+            }
+            commitTask = Task { [weak self, commitWakeups] in
+                for await _ in commitWakeups {
+                    guard let self, await self.flushOnTick() else {
+                        return
+                    }
+                }
+            }
+        }
+        isStarted = true
+    }
+
+    /// Stops the reader. It cancels the committing reads in flight, and each
+    /// waits for its one reply: nothing stored when the cancel won, or the
+    /// messages when the store won, which count as undelivered. So the close
+    /// takes about a round trip. With `manual` it stores nothing. With `auto` it
+    /// counts the last batch under ``CommitAfter/processing`` and stores the
+    /// latest counted position of each partition. With `onRead` it moves the
+    /// offset of each partition back to its last delivered message, and a
+    /// partition that delivered nothing and started at `next` gets the offset
+    /// before the first message it read, or none at offset zero. A group
+    /// member then leaves its group. Every caller waits for the same close.
+    ///
+    /// Throws the first failed store.
+    public func close() async throws {
+        if let closeTask {
+            return try await closeTask.value
+        }
+        isClosed = true
+        let task = Task { try await self.drain() }
+        closeTask = task
+        try await task.value
+    }
+
+    private func drain() async throws {
+        for (id, request) in requests {
+            if request.commits {
+                // It sends its cancel and keeps waiting for its one reply, which
+                // says whether the server stored it.
+                request.task.cancel()
+            } else {
+                cancel(id)
+            }
+        }
+        readerWakeup?.yield()
+        for task in requests.values.map(\.task) {
+            await task.value
+        }
+        _ = try? await withDeadline(.now + configuration.options.requestTimeout) { [reading] in
+            try await reading.lock()
+            reading.unlock()
+        }
+        countUncounted()
+        intervalTask?.cancel()
+        commitTask?.cancel()
+        commitWakeup.finish()
+        // A seek that stores while the close starts applies before the rewind.
+        while !storesWaiting.isEmpty {
+            let (ended, wakeup) = AsyncStream<Void>.makeStream()
+            storeWakeups.append(wakeup)
+            for await _ in ended {}
+        }
+        var failure: (any Error)?
+        if configuration.options.commitsOnRead {
+            for partition in uncertain {
+                guard let baseline = baselines[partition] else {
+                    continue
+                }
+                do {
+                    let current = try await client.consumerOffsetNext(
+                        consumer: consumer, streamID: streamID, topicID: topicID, partitionID: partition, context: baseline.context)
+                    if current > baseline.next {
+                        recordStored(partition, through: current, from: baseline.next, context: baseline.context)
+                    }
+                } catch {
+                    failure = failure ?? error
+                }
+            }
+            for (partition, span) in stored {
+                do {
+                    try await rewind(partition, over: span)
+                } catch {
+                    failure = failure ?? error
+                }
+            }
+        }
+        if configuration.options.autoCommit != nil {
+            do {
+                try await flushCounted()
+            } catch {
+                failure = failure ?? error
+            }
+        }
+        // The server refuses a store from a member that left, so the stores go
+        // first. A lost session error clears `joinedGroup` while the client can
+        // still hold the member under the same session, so the leave always goes.
+        if isGroup, configuration.autoJoinConsumerGroup, (try? await membership.lock()) != nil {
+            defer { membership.unlock() }
+            joinedGroup = false
+            do {
+                try await client.leaveConsumerGroup(streamID: streamID, topicID: topicID, groupID: consumer.id)
+            } catch {
+                logger.debug("Failed to leave the consumer group", metadata: metadata.merging(["error": "\(error)"]) { $1 })
+            }
+        }
+        eventsTask?.cancel()
+        eventsTask = nil
+        if let failure {
+            throw failure
+        }
+    }
+
+    // MARK: Reading
+
+    /// The next batch of one partition, never empty, or nil once the reader
+    /// is closed. Throws a ``ReadError``, after which the reader can be read
+    /// on.
+    public func next() async throws -> PolledMessages? {
+        countUncounted()
+        guard let batch = try await nextBatch() else {
+            return nil
+        }
+        if let checkpoint = batch.checkpoint {
+            deliver(checkpoint)
+            switch configuration.options.autoCommit?.after {
+            case .delivery?:
+                count(checkpoint, messages: UInt32(batch.messages.count), endsBatch: true)
+            case .processing?:
+                uncounted = (checkpoint, UInt32(batch.messages.count))
+            case .marked?, nil:
+                break
+            }
+        }
+        return batch
+    }
+
+    /// The next batch without recording its delivery, for a caller that
+    /// records each message it hands over with ``deliver(_:)``.
+    func nextBatch() async throws -> PolledMessages? {
+        try await reading.lock()
+        defer { reading.unlock() }
+        while !isClosed {
+            if let batch = try takeArrival() {
+                return batch
+            }
+            try Task.checkCancellation()
+            try await waitForSignIn()
+            guard await prepare(), !isClosed, !needsRegroup else {
+                continue
+            }
+            await waitForArrival()
+        }
+        return nil
+    }
+
+    /// Whether a batch that this reader returned still counts. A seek of its
+    /// partition, or a new assignment without `onRead`, drops it.
+    func holds(_ batch: PolledMessages) -> Bool {
+        guard let stamp = batch.stamp, stamp.reader == id else {
+            return true
+        }
+        return keeps(batch.partitionID, stamp.generation)
+    }
+
+    /// Records the last message the application took from its partition.
+    /// With `onRead` the close moves the offset back to it.
+    func deliver(_ checkpoint: Checkpoint) {
+        guard isCurrent(checkpoint) else {
+            return
+        }
+        delivered[checkpoint.partitionID] = checkpoint
+    }
+
+    /// Stores the offset of a checkpoint that this reader delivered, now.
+    /// A checkpoint of an older generation of its partition, or at or below
+    /// the last commit of its generation, stores nothing.
+    ///
+    /// Throws ``IggyErrorCode/invalidCommand`` with ``CommitPolicy/onRead``
+    /// and for a checkpoint that this reader did not deliver, and the errors
+    /// of the store.
+    public func commit(_ checkpoint: Checkpoint) async throws {
+        guard !configuration.options.commitsOnRead else {
+            throw IggyError(.invalidCommand, context: "the server stores the offsets of an onRead reader")
+        }
+        guard checkpoint.stamp?.reader == id else {
+            throw IggyError(.invalidCommand, context: "checkpoint \(checkpoint) was not delivered by this reader")
+        }
+        guard isCurrent(checkpoint) else {
+            return
+        }
+        try await store(checkpoint)
+    }
+
+    /// Counts a checkpoint that this reader delivered, under
+    /// ``CommitPolicy/auto(_:)``. It stores nothing now: the policy stores
+    /// the latest counted position of the partition. With
+    /// ``CommitAfter/marked`` only marks count.
+    ///
+    /// Throws ``IggyErrorCode/invalidCommand`` without `auto` and for a
+    /// checkpoint that this reader did not deliver.
+    public func mark(_ checkpoint: Checkpoint) throws {
+        guard configuration.options.autoCommit != nil, checkpoint.stamp?.reader == id else {
+            throw IggyError(.invalidCommand, context: "only an auto commit reader marks the checkpoints it delivered")
+        }
+        count(checkpoint, messages: 1, endsBatch: false)
+    }
+
+    /// Sets the next read position of `partitionID` and starts a new
+    /// generation of it. It drops the queued batches of the partition,
+    /// ignores its late replies, and resumes a stopped partition. Without
+    /// `onRead` it stores no offset. With `onRead` a seek to an offset above
+    /// zero first stores the offset before it, under the history of a
+    /// continuation or else of the last delivered message, once the
+    /// committing read of the partition in flight ended, so a restart resumes
+    /// at the new position. A refused store fails the seek and nothing moves.
+    /// Every other seek stores nothing.
+    ///
+    /// Throws ``IggyErrorCode/invalidCommand`` for a partition the reader
+    /// does not read and for a continuation of another partition, and the
+    /// errors of the store.
+    public func seek(partitionID: UInt32, to start: PollingStrategy) async throws {
+        try start.validate(partitionID: partitionID)
+        guard assigned.contains(partitionID) else {
+            throw IggyError(.invalidCommand, context: "the reader does not read partition \(partitionID)")
+        }
+        var start = start
+        if start.kind == .first {
+            start = PollingStrategy(kind: .offset, value: 0, context: start.context, partitionID: start.partitionID)
+        }
+        var before: Checkpoint?
+        if configuration.options.commitsOnRead, start.kind == .offset, start.value > 0 {
+            let context = start.context ?? delivered[partitionID]?.context ?? .empty
+            let checkpoint = Checkpoint(partitionID: partitionID, offset: start.value - 1, context: context)
+            try await holdingReads(of: partitionID) {
+                try await offsetWrites.lock()
+                defer { offsetWrites.unlock() }
+                try await client.storeConsumerPosition(consumer: consumer, streamID: streamID, topicID: topicID, checkpoint: checkpoint)
+            }
+            before = checkpoint
+            if context != .empty {
+                start = .continuation(partitionID: partitionID, kind: .offset, value: start.value, context: context)
+            }
+        }
+        newGeneration(of: partitionID, seeking: true)
+        positions[partitionID] = start
+        if let before {
+            // The stored offset counts as delivered, so the close moves back to it over a read nobody received.
+            expected[partitionID] = start.value
+            delivered[partitionID] = Checkpoint(
+                partitionID: partitionID, offset: before.offset, context: before.context, stamp: ReaderStamp(reader: id, generation: lastGeneration))
+            stored[partitionID] = Span(first: before.offset, last: before.offset, context: before.context)
+        }
+        readerWakeup?.yield()
+    }
+
+    /// The partitions the reader reads: those of a standalone consumer, or
+    /// the current assignment of a group member, which the first read
+    /// learns.
+    public var partitions: [UInt32] {
+        assigned.sorted()
+    }
+
+    // MARK: Internals
+
+    private var metadata: Logger.Metadata {
+        ["stream": "\(streamID)", "topic": "\(topicID)", "consumer": "\(consumer)"]
+    }
+
+    private var groupKey: ConsumerGroupState.GroupKey {
+        ConsumerGroupState.GroupKey(stream: streamID, topic: topicID, group: consumer.id)
+    }
+
+    private var needsRegroup: Bool {
+        groupedEpoch != groupingEpoch
+    }
+
+    /// The server hands a member its partitions as its shards learn of the
+    /// join, so the assignment a sync holds can be part of it. It is read
+    /// again at the check interval, as the Rust reader refreshes it.
+    private var assignmentSyncDue: Bool {
+        lastAssignmentSync.map { ContinuousClock.now - $0 >= Self.assignmentCheckInterval } ?? true
+    }
+
+    private func joinGroup() async throws {
+        try await membership.lock()
+        defer { membership.unlock() }
+        if isClosed || (joinedGroup && joinedSessionScope == client.core.groupState.sessionScope) {
+            return
+        }
+        if try await client.getConsumerGroup(streamID: streamID, topicID: topicID, groupID: consumer.id) == nil {
+            guard configuration.createConsumerGroupIfNotExists, let name = consumer.id.name else {
+                throw IggyError(.consumerGroupNameNotFound, context: "consumer group \(consumer.id) does not exist and is not created")
+            }
+            logger.info("Creating the consumer group", metadata: metadata)
+            do {
+                _ = try await client.createConsumerGroup(streamID: streamID, topicID: topicID, name: name)
+            } catch let error as IggyError where error.code == .consumerGroupNameAlreadyExists {
+                // Another member created it first.
+            }
+        }
+        try await client.joinConsumerGroup(streamID: streamID, topicID: topicID, groupID: consumer.id)
+        joinedGroup = true
+        joinedSessionScope = client.core.groupState.sessionScope
+    }
+
+    /// Returns false once the client shut down, which ends the subscription.
+    private func observe(_ event: DiagnosticEvent) async -> Bool {
+        switch event {
+        case .shutdown:
+            joinedGroup = false
+            canRead = false
+            readerWakeup?.yield()
+            return false
+        case .signedOut:
+            joinedGroup = false
+        case .connected, .disconnected, .signedIn:
+            if joinedSessionScope != client.core.groupState.sessionScope {
+                joinedGroup = false
+            }
+        }
+        canRead = await client.state == .authenticated
+        return true
+    }
+
+    /// A client that only the caller brings back never recovers, so reading
+    /// goes on and fails with the reason instead of waiting for good.
+    private func waitForSignIn() async throws {
+        while !canRead, !isClosed, await client.canRecover {
+            if (try? await client.requireAuthenticated()) != nil {
+                break
+            }
+            try await Task.sleep(for: configuration.retryInterval)
+        }
+    }
+
+    /// Joins again once the membership was lost, syncs an assignment the
+    /// client does not hold, and groups the partitions by primary. Returns
+    /// false when nothing can be read yet, after the retry interval. A
+    /// failure is queued for ``next()``.
+    private func prepare() async -> Bool {
+        do {
+            if isGroup {
+                if configuration.autoJoinConsumerGroup, !joinedGroup || joinedSessionScope != client.core.groupState.sessionScope {
+                    try await joinGroup()
+                }
+                if isClosed {
+                    return false
+                }
+                let state = client.core.groupState
+                if !state.hasAssignment(groupKey) || assignmentSyncDue {
+                    try await client.syncGroupAssignment(streamID: streamID, topicID: topicID, groupID: consumer.id, lineage: nil)
+                    lastAssignmentSync = .now
+                }
+                guard state.isRegistered(groupKey) else {
+                    throw IggyError(.consumerGroupMemberNotFound, context: "group \(consumer.id) in topic \(topicID) of stream \(streamID)")
+                }
+                let current = Set(state.assignedPartitions(groupKey) ?? [])
+                if current != assigned {
+                    reassign(current)
+                }
+            }
+            if assigned.isEmpty {
+                try await Task.sleep(for: configuration.retryInterval)
+                return false
+            }
+            if needsRegroup {
+                let epoch = groupingEpoch
+                let kept = fetchGroups.joined().filter(assigned.contains)
+                let order = kept + assigned.subtracting(kept).sorted()
+                fetchGroups = try await client.readinessGroups(streamID: streamID, topicID: topicID, partitions: order)
+                groupedEpoch = epoch
+            }
+            if configuration.options.commitsOnRead {
+                for partition in assigned.sorted() where needsBaseline(partition) && activeReads[partition] == nil {
+                    let generation = generations[partition]
+                    var options = configuration.options.deferred
+                    options.maxWait = .zero
+                    options.minCount = 1
+                    let sections = try await client.fetchMessages(
+                        streamID: streamID, topicID: topicID, targets: [(partitionID: partition, strategy: .next)], consumer: consumer,
+                        count: 1, autoCommit: false, options: options)
+                    guard let section = sections.first(where: { $0.partitionID == partition }) else {
+                        throw IggyError(.cannotReadMessage, context: "the baseline read returned no section for partition \(partition)")
+                    }
+                    if let error = section.error {
+                        throw error
+                    }
+                    let baseline = (next: section.firstOffset ?? section.nextOffset, context: section.context)
+                    guard generations[partition] == generation, !isClosed else {
+                        continue
+                    }
+                    baselines[partition] = baseline
+                    expected[partition] = baseline.next
+                    positions[partition] = .continuation(partitionID: partition, kind: .next, value: 0, context: baseline.context)
+                }
+            }
+            return true
+        } catch is CancellationError {
+            return false
+        } catch {
+            let code = (error as? IggyError)?.code
+            if let code, [.consumerGroupMemberNotFound, .disconnected, .unauthenticated, .staleClient].contains(code) {
+                joinedGroup = false
+            }
+            logger.error("Failed to join the consumer group or to group its partitions", metadata: metadata.merging(["error": "\(error)"]) { $1 })
+            try? await Task.sleep(for: configuration.retryInterval)
+            if code == .consumerGroupMemberNotFound, configuration.autoJoinConsumerGroup {
+                return false
+            }
+            if let code, [.transientNotAccepted, .transientNotCommitted, .deferredPollCapacityExceeded, .notPrimary].contains(code) {
+                return false
+            }
+            report(ReadError(partitionID: nil, error: Self.iggyError(error)))
+            return false
+        }
+    }
+
+    /// Moves to a new set of partitions. The reads that commit nothing are
+    /// cancelled. A read that commits ends with its reply, whose batches stay
+    /// queued, as the server stored them. Counted positions of the partitions
+    /// that leave are stored first.
+    private func reassign(_ partitions: Set<UInt32>) {
+        for (id, request) in requests where !request.commits {
+            cancel(id)
+        }
+        let leaving = assigned.subtracting(partitions)
+        if !leaving.isEmpty {
+            let retired = leaving.compactMap { counted.removeValue(forKey: $0)?.checkpoint }.filter(isCurrent)
+            if !retired.isEmpty {
+                retiring.append(contentsOf: retired)
+                commitWakeup.yield()
+            }
+            if !configuration.options.commitsOnRead {
+                arrivals.removeAll { arrival in arrival.partitionID.map(leaving.contains) ?? false }
+            }
+        }
+        for partition in leaving {
+            if configuration.options.commitsOnRead, let gap = gaps[partition] {
+                giveUp(gap, of: partition)
+            }
+            forget(partition)
+            generations[partition] = nil
+            stored[partition] = nil
+            delivered[partition] = nil
+        }
+        for partition in partitions.subtracting(assigned) {
+            newGeneration(of: partition)
+        }
+        assigned = partitions
+        groupingEpoch += 1
+    }
+
+    /// Starts a generation of `partition` that reads from its start.
+    private func newGeneration(of partition: UInt32, seeking: Bool = false) {
+        lastGeneration += 1
+        generations[partition] = lastGeneration
+        if seeking {
+            sought[partition] = lastGeneration
+        }
+        if seeking || !configuration.options.commitsOnRead {
+            arrivals.removeAll { $0.partitionID == partition && !$0.isFailure }
+        }
+        if let id = activeReads[partition], requests[id]?.commits == false {
+            cancel(id)
+        }
+        forget(partition)
+        stored[partition] = nil
+        delivered[partition] = nil
+        if uncounted?.checkpoint.partitionID == partition {
+            uncounted = nil
+        }
+    }
+
+    private func forget(_ partition: UInt32) {
+        positions[partition] = nil
+        expected[partition] = nil
+        baselines[partition] = nil
+        uncertain.remove(partition)
+        lostReads.remove(partition)
+        blockedBy[partition] = nil
+        backlogged.remove(partition)
+        gaps[partition] = nil
+        isolations[partition] = nil
+        pollAlone.remove(partition)
+        pausedUntil[partition] = nil
+        stopped.remove(partition)
+    }
+
+    /// Whether a read of `partition` under `generation` still counts. A seek
+    /// or a new assignment of the partition drops it. A partition that left
+    /// the assignment keeps the reads that the server stored under `onRead`.
+    private func keeps(_ partition: UInt32, _ generation: UInt64) -> Bool {
+        if generations[partition] == generation {
+            return true
+        }
+        return configuration.options.commitsOnRead && generation >= sought[partition, default: 0]
+    }
+
+    private func isCurrent(_ checkpoint: Checkpoint) -> Bool {
+        guard let stamp = checkpoint.stamp else {
+            return assigned.contains(checkpoint.partitionID)
+        }
+        return stamp.reader == id && generations[checkpoint.partitionID] == stamp.generation
+    }
+
+    /// Starts what the reader can read, then waits until a request ends, the
+    /// close begins, a pause ends, or the assignment is due for a check.
+    private func waitForArrival() async {
+        let (wakeups, wakeup) = AsyncStream<Void>.makeStream(bufferingPolicy: .bufferingNewest(1))
+        readerWakeup = wakeup
+        defer { readerWakeup = nil }
+        dispatch()
+        guard arrivals.isEmpty else {
+            return
+        }
+        var wakeAt = ContinuousClock.now + Self.assignmentCheckInterval
+        if let pauseEnd = pausedUntil.values.min() {
+            wakeAt = Swift.min(wakeAt, pauseEnd)
+        }
+        let timer = Task {
+            if (try? await Task.sleep(until: wakeAt, clock: .continuous)) != nil {
+                wakeup.yield()
+            }
+        }
+        defer { timer.cancel() }
+        for await _ in wakeups {
+            break
+        }
+    }
+
+    /// The next batch that a request brought back, or the error queued next.
+    /// A batch of an older generation of its partition is dropped. A section
+    /// is decoded only now, after the next fetch of its group went out. A
+    /// section that does not decode is read again one message at a time.
+    private func takeArrival() throws -> PolledMessages? {
+        while !arrivals.isEmpty {
+            let arrival = arrivals.removeFirst()
+            defer { dispatch() }
+            var batch: PolledMessages
+            let generation: UInt64
+            switch arrival {
+            case .failure(let error):
+                throw error
+            case .section(let section, let sectionGeneration, _):
+                generation = sectionGeneration
+                guard keeps(section.partitionID, generation) else {
+                    continue
+                }
+                do {
+                    batch = try section.decode()
+                } catch {
+                    isolate(section)
+                    continue
+                }
+            case .batch(let polled, let batchGeneration):
+                generation = batchGeneration
+                guard keeps(polled.partitionID, generation) else {
+                    continue
+                }
+                batch = polled
+            }
+            guard !batch.messages.isEmpty else {
+                continue
+            }
+            batch.stamp = ReaderStamp(reader: id, generation: generation)
+            return batch
+        }
+        return nil
+    }
+
+    /// Reads a section that does not decode again, one message at a time
+    /// from its first message, so the messages before the bad one are
+    /// delivered and the error names the bad one.
+    private func isolate(_ section: FetchSection) {
+        let partition = section.partitionID
+        guard let first = section.firstOffset else {
+            return
+        }
+        arrivals.removeAll { $0.partitionID == partition && !$0.isFailure }
+        if let id = activeReads[partition], requests[id]?.commits == false {
+            cancel(id)
+        }
+        isolations[partition] = Span(first: first, last: section.nextOffset - 1, context: section.context)
+    }
+
+    // MARK: Requests
+
+    /// Starts what the reader can read now.
+    private func dispatch() {
+        guard !isClosed, !needsRegroup else {
+            return
+        }
+        let now = ContinuousClock.now
+        pausedUntil = pausedUntil.filter { $0.value > now }
+        for (partition, operation) in blockedBy where !client.core.pollRouter.retainsFetch(operation) {
+            blockedBy[partition] = nil
+            stopped.remove(partition)
+        }
+        if !configuration.options.commitsOnRead || onDemand {
+            fetchAhead()
+        }
+        pollSingles()
+    }
+
+    /// With `onRead` a read goes out only for a reader that waits with
+    /// nothing queued, so the server stores a batch only once it was asked
+    /// for.
+    private var onDemand: Bool {
+        readerWakeup != nil && arrivals.isEmpty
+    }
+
+    /// The idle partitions of a group that its fetches read now.
+    private struct GroupTurn {
+        let group: [UInt32]
+        /// Partitions whose last section ended before their head, least
+        /// recently served first.
+        let backlogged: [UInt32]
+        let quiet: [UInt32]
+        let hasMain: Bool
+        let second: UInt64?
+        /// Whether the turn fetches the quiet partitions. With `onRead` they
+        /// wait for the main fetch of the group in flight instead.
+        let startsQuiet: Bool
+        var extra = 0
+
+        /// A turn that restarts the second fetch of its group takes no new
+        /// request.
+        var needsRequest: Bool { !startsQuiet || second == nil }
+
+        /// Every fetch holds at least one partition.
+        var extraLimit: Int { startsQuiet ? backlogged.count : backlogged.count - 1 }
+    }
+
+    /// Fetches the partitions of each group that no request reads, as the
+    /// Rust reader schedules its groups. Every group gets one fetch before
+    /// any gets more: its quiet partitions join the main fetch of the group
+    /// once none is in flight, else its second fetch, which starts again
+    /// with them, or with `onRead` they wait for the main fetch to end. The
+    /// requests left free, less those the polls of single partitions need
+    /// and those the queued replies hold, go round robin to groups with
+    /// backlogged partitions, which a read answers at once, each share in a
+    /// fetch of its own with the full limits.
+    private func fetchAhead() {
+        let commits = configuration.options.commitsOnRead
+        var turns: [GroupTurn] = []
+        for group in fetchGroups where waitingReplies(of: group) < Self.waitingRepliesPerGroup {
+            let joining = group.filter { isIdle($0) && isFetchable($0) }
+            var hasMain = false
+            var second: UInt64?
+            for partition in group {
+                guard let id = activeReads[partition], let request = requests[id] else {
+                    continue
+                }
+                switch request.kind {
+                case .main: hasMain = true
+                case .second: second = id
+                case .backlog, .single, .gap, .isolate: break
+                }
+            }
+            let quiet = joining.filter { !backlogged.contains($0) }
+            let turn = GroupTurn(
+                group: group, backlogged: joining.filter(backlogged.contains), quiet: quiet, hasMain: hasMain, second: second,
+                startsQuiet: !quiet.isEmpty && !(commits && hasMain))
+            if turn.startsQuiet || !turn.backlogged.isEmpty {
+                turns.append(turn)
+            }
+        }
+        var free = Self.maxRequestsInFlight - requests.count
+        var taking: [Int] = []
+        for index in turns.indices.filter({ !turns[$0].hasMain }) + turns.indices.filter({ turns[$0].hasMain }) {
+            if !turns[index].needsRequest {
+                taking.append(index)
+            } else if free > 0 {
+                free -= 1
+                taking.append(index)
+            }
+        }
+        var extra = free - singlePollReserve - queuedReplies
+        var dealt = true
+        while extra > 0, dealt {
+            dealt = false
+            for index in taking where extra > 0 && turns[index].extra < turns[index].extraLimit {
+                turns[index].extra += 1
+                extra -= 1
+                dealt = true
+            }
+        }
+        for index in taking {
+            startFetches(of: turns[index])
+        }
+    }
+
+    private func startFetches(of turn: GroupTurn) {
+        let spread: RequestKind = configuration.options.commitsOnRead ? .backlog : .main
+        guard turn.startsQuiet else {
+            for share in Self.deal(turn.backlogged, into: turn.extra + 1) {
+                startGroupFetch(share, of: turn.group, kind: spread)
+            }
+            return
+        }
+        let shared = turn.extra == 0 ? turn.quiet + turn.backlogged : turn.quiet
+        var targets = Set(shared)
+        if let second = turn.second, let request = finish(second) {
+            request.task.cancel()
+            targets.formUnion(request.partitions)
+        }
+        startGroupFetch(Array(targets), of: turn.group, kind: turn.hasMain ? .second : .main)
+        if turn.extra > 0 {
+            for share in Self.deal(turn.backlogged, into: turn.extra) {
+                startGroupFetch(share, of: turn.group, kind: spread)
+            }
+        }
+    }
+
+    /// A fetch of `partitions` in the order of their group. A fetch that
+    /// moves a stored offset leaves out the partitions whose store waits.
+    private func startGroupFetch(_ partitions: [UInt32], of group: [UInt32], kind: RequestKind) {
+        let members = Set(partitions)
+        var ordered = group.filter(members.contains)
+        if configuration.options.commitsOnRead || ordered.contains(where: { startPoint(of: $0).kind == .next }) {
+            ordered.removeAll { storesWaiting[$0] != nil }
+        }
+        if !ordered.isEmpty {
+            startFetch(ordered, kind: kind)
+        }
+    }
+
+    /// `partitions` dealt round robin into `count` shares.
+    private static func deal(_ partitions: [UInt32], into count: Int) -> [[UInt32]] {
+        var shares = [[UInt32]](repeating: [], count: Swift.max(count, 1))
+        for (index, partition) in partitions.enumerated() {
+            shares[index % shares.count].append(partition)
+        }
+        return shares.filter { !$0.isEmpty }
+    }
+
+    /// The requests that the polls of single partitions waiting now take,
+    /// within the share that ``pollSingles()`` keeps for them.
+    private var singlePollReserve: Int {
+        let limit = Swift.max(Self.maxRequestsInFlight - fetchGroups.count, 1)
+        let polls = requests.values.filter { $0.kind.isPoll }.count
+        let waiting = fetchGroups.joined().filter { isIdle($0) && singleRead(of: $0) != nil }.count
+        return Swift.min(waiting, Swift.max(limit - polls, 0))
+    }
+
+    /// The fetch replies that wait for ``next()``. Each holds a request of
+    /// the budget.
+    private var queuedReplies: Int {
+        Set(
+            arrivals.compactMap { arrival -> UInt64? in
+                if case .section(_, _, let request) = arrival { request } else { nil }
+            }
+        ).count
+    }
+
+    /// Polls, one partition each, the gaps, the sections that did not
+    /// decode, and the partitions a fetch cannot read, taking turns when the
+    /// request limit binds. A request per group stays free of them.
+    private func pollSingles() {
+        let partitions = Array(fetchGroups.joined())
+        let limit = Swift.max(Self.maxRequestsInFlight - fetchGroups.count, 1)
+        var polls = requests.values.filter { $0.kind.isPoll }.count
+        let start = pollCursor.flatMap(partitions.firstIndex(of:)).map { $0 + 1 } ?? 0
+        for index in partitions.indices {
+            guard polls < limit, requests.count < Self.maxRequestsInFlight else {
+                return
+            }
+            let partition = partitions[(start + index) % partitions.count]
+            guard isIdle(partition), let (kind, strategy, count) = singleRead(of: partition) else {
+                continue
+            }
+            if startPoll(partition, kind: kind, strategy: strategy, count: count) {
+                pollCursor = partition
+                polls += 1
+            }
+        }
+    }
+
+    /// The poll a partition needs, if a fetch cannot read it.
+    private func singleRead(of partition: UInt32) -> (RequestKind, PollingStrategy, UInt32)? {
+        if let isolation = isolations[partition] {
+            let start: PollingStrategy =
+                isolation.context == .empty
+                ? .offset(isolation.first) : .continuation(partitionID: partition, kind: .offset, value: isolation.first, context: isolation.context)
+            return (.isolate, start, 1)
+        }
+        if let gap = gaps[partition] {
+            let count = UInt32(clamping: Swift.min(gap.end - gap.next, UInt64(configuration.options.count)))
+            return (.gap, .continuation(partitionID: partition, kind: .offset, value: gap.next, context: gap.context), count)
+        }
+        guard !isFetchable(partition), !configuration.options.commitsOnRead || onDemand else {
+            return nil
+        }
+        return (.single, startPoint(of: partition), configuration.options.count)
+    }
+
+    private func isIdle(_ partition: UInt32) -> Bool {
+        assigned.contains(partition) && activeReads[partition] == nil && pausedUntil[partition] == nil && !stopped.contains(partition)
+            && !needsBaseline(partition)
+    }
+
+    private func needsBaseline(_ partition: UInt32) -> Bool {
+        configuration.options.commitsOnRead && baselines[partition] == nil && startPoint(of: partition).kind == .next
+    }
+
+    /// A fetch reads a partition that starts at an offset or after its
+    /// stored offset, outside a gap and a section that did not decode.
+    private func isFetchable(_ partition: UInt32) -> Bool {
+        !pollAlone.contains(partition) && gaps[partition] == nil && isolations[partition] == nil && IggyClient.fetchTakes(startPoint(of: partition))
+    }
+
+    /// Where the next read of a partition starts. With `onRead` a partition
+    /// that was read goes on after its stored offset.
+    private func startPoint(of partition: UInt32) -> PollingStrategy {
+        positions[partition] ?? configuration.start
+    }
+
+    private func waitingReplies(of group: [UInt32]) -> Int {
+        let members = Set(group)
+        var replies: Set<UInt64> = []
+        for case .section(let section, _, let request) in arrivals where members.contains(section.partitionID) {
+            replies.insert(request)
+        }
+        return replies.count
+    }
+
+    private func startFetch(_ partitions: [UInt32], kind: RequestKind) {
+        let targets = partitions.map { (partitionID: $0, strategy: startPoint(of: $0)) }
+        let commits = configuration.options.commitsOnRead
+        let progresses = commits || targets.contains { $0.strategy.kind == .next }
+        let options = configuration.options
+        start(kind, partitions: partitions, progresses: progresses, commits: commits) { [client, streamID, topicID, consumer] in
+            .fetched(
+                try await client.fetchMessages(
+                    streamID: streamID, topicID: topicID, targets: targets, consumer: consumer, count: options.count, autoCommit: commits,
+                    options: options.deferred))
+        }
+    }
+
+    /// Starts a poll of one partition, unless it would read or move the
+    /// stored offset while a store of the partition waits. A gap and a
+    /// section that did not decode are read again without a wait and without
+    /// a commit.
+    @discardableResult
+    private func startPoll(_ partition: UInt32, kind: RequestKind, strategy: PollingStrategy, count: UInt32) -> Bool {
+        let commits = kind == .single && configuration.options.commitsOnRead
+        let progresses = commits || strategy.kind == .next
+        if progresses, storesWaiting[partition] != nil {
+            return false
+        }
+        var options = configuration.options.deferred
+        if kind != .single {
+            options.maxWait = .zero
+        }
+        options.minCount = 1
+        start(kind, partitions: [partition], progresses: progresses, commits: commits, poll: (strategy, count)) {
+            [client, streamID, topicID, consumer, options] in
+            .polled(
+                try await client.pollBody(
+                    streamID: streamID, topicID: topicID, partitionID: partition, consumer: consumer, strategy: strategy, count: count,
+                    autoCommit: commits, options: options))
+        }
+        return true
+    }
+
+    private func start(
+        _ kind: RequestKind, partitions: [UInt32], progresses: Bool, commits: Bool, poll: (start: PollingStrategy, count: UInt32)? = nil,
+        _ read: @escaping @Sendable () async throws -> Reply
+    ) {
+        lastRequestID += 1
+        let id = lastRequestID
+        let task = Task { [weak self] in
+            let outcome: Result<Reply, any Error>
+            do {
+                outcome = .success(try await read())
+            } catch {
+                outcome = .failure(error)
+            }
+            await self?.requestEnded(id, outcome)
+        }
+        let requestGenerations = Dictionary(uniqueKeysWithValues: partitions.map { ($0, generations[$0] ?? 0) })
+        requests[id] = Request(
+            kind: kind, partitions: partitions, progresses: progresses, commits: commits, generations: requestGenerations, start: poll?.start,
+            count: poll?.count ?? 0, task: task)
+        for partition in partitions {
+            activeReads[partition] = id
+        }
+    }
+
+    /// Forgets a request, so its partitions can be read again, and wakes the
+    /// stores that wait for it.
+    @discardableResult
+    private func finish(_ id: UInt64) -> Request? {
+        guard let request = requests.removeValue(forKey: id) else {
+            return nil
+        }
+        for partition in request.partitions where activeReads[partition] == id {
+            activeReads[partition] = nil
+        }
+        if request.progresses {
+            for wakeup in storeWakeups {
+                wakeup.finish()
+            }
+            storeWakeups.removeAll()
+        }
+        return request
+    }
+
+    /// Cancels a request that commits nothing: its reply is dropped.
+    private func cancel(_ id: UInt64) {
+        finish(id)?.task.cancel()
+    }
+
+    private func requestEnded(_ id: UInt64, _ outcome: Result<Reply, any Error>) {
+        guard let request = finish(id) else {
+            return
+        }
+        switch outcome {
+        case .success(.fetched(let sections)):
+            received(sections, from: id, of: request)
+        case .success(.polled(let body)):
+            received(body, of: request)
+        case .failure(let error):
+            failed(request, with: error)
+        }
+        dispatch()
+        readerWakeup?.yield()
+    }
+
+    /// Moves each partition the fetch read to its next position and queues
+    /// the sections that hold messages, still undecoded. The partitions that
+    /// returned messages go last in the next fetch of their group, so each
+    /// one gets its turn.
+    private func received(_ sections: [FetchSection], from id: UInt64, of request: Request) {
+        var unanswered = Set(request.partitions)
+        var served: Set<UInt32> = []
+        var idle: Set<UInt32> = []
+        for section in sections {
+            let partition = section.partitionID
+            guard unanswered.remove(partition) != nil, let generation = request.generations[partition] else {
+                continue
+            }
+            if let operation = section.uncertainty?.operationID {
+                // Abandoned first, so a seek or a new assignment meanwhile leaves no block.
+                try? client.abandonOperation(operation)
+            }
+            guard generations[partition] == generation else {
+                if request.commits, section.error == nil, let first = section.firstOffset {
+                    if keeps(partition, generation) {
+                        // The server stored this read, so a partition that left the assignment still hands it over.
+                        arrivals.append(.section(section, generation: generation, request: id))
+                    } else if assigned.contains(partition) {
+                        // A seek dropped the batch, so the close moves the offset back over it.
+                        recordStored(partition, through: section.nextOffset, from: first, context: section.context)
+                    }
+                }
+                continue
+            }
+            if let error = section.error {
+                backlogged.remove(partition)
+                if request.commits, error.code == .fetchProgressUncertain {
+                    lost([partition], to: error)
+                    failed([partition], with: error)
+                    continue
+                }
+                if error.code == .invalidSizeBytes {
+                    // The server takes one record past `maxBytes` per fetch reply, so
+                    // this partition reads alone next.
+                    pollAlone.insert(partition)
+                    continue
+                }
+                failed([partition], with: error)
+                continue
+            }
+            if section.messagesCount == 0 {
+                idle.insert(partition)
+            }
+            if section.nextOffset <= section.currentOffset, section.messagesCount > 0 || section.currentOffset > 0 {
+                backlogged.insert(partition)
+            } else {
+                backlogged.remove(partition)
+            }
+            let arrival = Arrival.section(section, generation: generation, request: id)
+            if !accept(arrival, of: partition, reachingThrough: section.nextOffset, context: section.context, firstOffset: section.firstOffset) {
+                continue
+            }
+            if section.messagesCount > 0 {
+                served.insert(partition)
+            }
+        }
+        pauseIdle(idle.union(unanswered.filter { request.generations[$0] == generations[$0] }))
+        if !served.isEmpty {
+            fetchGroups = fetchGroups.map { group in group.filter { !served.contains($0) } + group.filter(served.contains) }
+        }
+    }
+
+    /// A read that does not wait answers an idle partition at once, so the
+    /// partition reads again only after the retry interval.
+    private func pauseIdle(_ partitions: Set<UInt32>) {
+        guard configuration.options.maxWait == .zero, !partitions.isEmpty else {
+            return
+        }
+        let until = ContinuousClock.now + configuration.retryInterval
+        for partition in partitions {
+            pausedUntil[partition] = until
+        }
+    }
+
+    /// Records a read of a partition that ends before `end`, and queues it.
+    /// With `onRead` a read that starts past the offset the partition
+    /// expected found a gap that a lost read stored: the reply waits until
+    /// the gap was read again. Returns whether the arrival was queued.
+    private func accept(
+        _ arrival: Arrival, of partition: UInt32, reachingThrough end: UInt64, context: PartitionContext, firstOffset: UInt64?
+    ) -> Bool {
+        let onRead = configuration.options.commitsOnRead
+        let start = firstOffset ?? end
+        if onRead, lostReads.contains(partition), let expected = expected[partition], start > expected, context.incarnation != 0 {
+            logger.warning(
+                "A lost read stored messages that were never delivered, reading them again",
+                metadata: metadata.merging(["partition": "\(partition)", "from": "\(expected)", "to": "\(start)"]) { $1 })
+            gaps[partition] = Gap(next: expected, end: start, context: context, held: firstOffset == nil ? [] : [arrival])
+            recordStored(partition, through: end, from: expected, context: context)
+            self.expected[partition] = end
+            positions[partition] = .next
+            return false
+        }
+        if onRead {
+            if let firstOffset {
+                recordStored(partition, through: end, from: firstOffset, context: context)
+                // This store fences out any older read of the partition parked on the server.
+                lostReads.remove(partition)
+            }
+            if firstOffset != nil || expected[partition] != nil {
+                expected[partition] = end
+            }
+            positions[partition] = firstOffset == nil && positions[partition] == nil ? nil : .next
+        } else if firstOffset != nil {
+            positions[partition] = .continuation(partitionID: partition, kind: .offset, value: end, context: context)
+        }
+        guard firstOffset != nil else {
+            return false
+        }
+        arrivals.append(arrival)
+        return true
+    }
+
+    private func recordStored(_ partition: UInt32, through end: UInt64, from first: UInt64, context: PartitionContext) {
+        guard end > 0 else {
+            return
+        }
+        if var span = stored[partition], Self.sameHistory(span.context, context) {
+            span.last = Swift.max(span.last, end - 1)
+            stored[partition] = span
+        } else {
+            stored[partition] = Span(first: first, last: end - 1, context: context)
+        }
+    }
+
+    /// A poll of one partition: a read that a fetch cannot take, a gap read
+    /// again, or one message of a section that did not decode.
+    private func received(_ body: [UInt8], of request: Request) {
+        guard let partition = request.partitions.first, let generation = request.generations[partition] else {
+            return
+        }
+        guard generations[partition] == generation else {
+            if request.commits, let polled = try? Responses.polledMessages(body[...]), let first = polled.messages.first, let last = polled.messages.last {
+                if keeps(partition, generation) {
+                    // The server stored this read, so a partition that left the assignment still hands it over.
+                    arrivals.append(.batch(polled, generation: generation))
+                } else if assigned.contains(partition) {
+                    // A seek dropped the batch, so the close moves the offset back over it.
+                    recordStored(partition, through: last.offset + 1, from: first.offset, context: polled.context)
+                }
+            }
+            return
+        }
+        let polled: PolledMessages
+        do {
+            polled = try Responses.polledMessages(body[...])
+        } catch {
+            undecodable(partition, body: body, error: error, of: request)
+            return
+        }
+        switch request.kind {
+        case .gap:
+            guard var gap = gaps[partition] else {
+                return
+            }
+            var batch = polled
+            batch.messages.removeAll { $0.offset < gap.next || $0.offset >= gap.end }
+            batch.count = UInt32(batch.messages.count)
+            if let last = batch.messages.last {
+                gap.next = last.offset + 1
+                arrivals.append(.batch(batch, generation: generation))
+            }
+            if batch.messages.isEmpty || gap.next >= gap.end {
+                arrivals.append(contentsOf: gap.held)
+                gaps[partition] = nil
+            } else {
+                gaps[partition] = gap
+            }
+        case .isolate:
+            guard var isolation = isolations[partition] else {
+                return
+            }
+            if let message = polled.messages.first {
+                arrivals.append(.batch(polled, generation: generation))
+                isolation.first = message.offset + 1
+                stepPast(message.offset, of: partition, context: polled.context)
+            }
+            if polled.messages.isEmpty || isolation.first > isolation.last {
+                isolations[partition] = nil
+            } else {
+                isolations[partition] = isolation
+            }
+        default:
+            pollAlone.remove(partition)
+            // An empty reply tells nothing about where the next read starts.
+            guard let first = polled.messages.first, let last = polled.messages.last else {
+                pauseIdle([partition])
+                return
+            }
+            _ = accept(
+                .batch(polled, generation: generation), of: partition, reachingThrough: last.offset + 1, context: polled.context,
+                firstOffset: first.offset)
+        }
+    }
+
+    /// A poll reply that does not decode is read again one message at a
+    /// time from its start. One message read alone names the bad message:
+    /// its partition stops until a seek steps over it. A committing poll
+    /// stored what it read, so the next read finds that as a gap. A poll
+    /// without a known start names no message and stops its partition.
+    private func undecodable(_ partition: UInt32, body: [UInt8], error: any Error, of request: Request) {
+        let raw = Array(body.dropFirst(Self.pollReplyHeadSize))
+        if request.kind == .isolate, let isolation = isolations[partition] {
+            isolations[partition] = nil
+            stopped.insert(partition)
+            let skip = PollingStrategy.continuation(partitionID: partition, kind: .offset, value: isolation.first + 1, context: isolation.context)
+            report(ReadError(partitionID: partition, error: Self.iggyError(error), skip: skip, offset: isolation.first, raw: raw))
+            return
+        }
+        if request.kind == .gap, let gap = gaps[partition] {
+            isolations[partition] = Span(first: gap.next, last: Swift.min(gap.end, gap.next + UInt64(request.count)) - 1, context: gap.context)
+            return
+        }
+        if request.commits, expected[partition] != nil {
+            lost([partition], to: Self.iggyError(error))
+            pausedUntil[partition] = .now + configuration.retryInterval
+            return
+        }
+        if !request.commits, let start = request.start, start.kind == .offset, request.count > 0 {
+            isolations[partition] = Span(first: start.value, last: start.value + UInt64(request.count) - 1, context: start.context ?? .empty)
+            return
+        }
+        stopped.insert(partition)
+        report(ReadError(partitionID: partition, error: Self.iggyError(error), raw: raw))
+    }
+
+    /// A message that an isolation delivered moves on the gap it lies in, or
+    /// the position that a poll which did not decode left at its start.
+    private func stepPast(_ offset: UInt64, of partition: UInt32, context: PartitionContext) {
+        if var gap = gaps[partition] {
+            gap.next = Swift.max(gap.next, offset + 1)
+            if gap.next >= gap.end {
+                arrivals.append(contentsOf: gap.held)
+                gaps[partition] = nil
+            } else {
+                gaps[partition] = gap
+            }
+            return
+        }
+        let position = startPoint(of: partition)
+        if !configuration.options.commitsOnRead, position.kind == .offset, position.value <= offset {
+            positions[partition] = .continuation(partitionID: partition, kind: .offset, value: offset + 1, context: context)
+        }
+    }
+
+    private func failed(_ request: Request, with error: any Error) {
+        if error is CancellationError {
+            return
+        }
+        if let operation = (error as? IggyError)?.operationID, (error as? IggyError)?.code == .fetchProgressUncertain {
+            try? client.abandonOperation(operation)
+        }
+        let partitions = request.partitions.filter { request.generations[$0] == generations[$0] }
+        guard !partitions.isEmpty else {
+            return
+        }
+        if request.commits, let error = error as? IggyError, error.code == .transientNotCommitted || error.code == .fetchProgressUncertain {
+            lost(partitions, to: error)
+        }
+        if request.kind == .gap, let partition = partitions.first, (error as? IggyError)?.code == .consumerGroupPartitionNotOwned,
+            let gap = gaps.removeValue(forKey: partition)
+        {
+            giveUp(gap, of: partition)
+        }
+        failed(partitions, with: Self.iggyError(error))
+    }
+
+    /// A committing read whose outcome is unknown: the close reconciles its
+    /// partitions, the next read finds what it stored as a gap, and its block
+    /// goes now, as `abandon_lost` in the Rust SDK. The partitions read again
+    /// after the retry interval.
+    private func lost(_ partitions: [UInt32], to error: IggyError) {
+        uncertain.formUnion(partitions)
+        lostReads.formUnion(partitions)
+        if let operation = error.operationID {
+            try? client.abandonOperation(operation)
+        }
+    }
+
+    /// The partition moved to another member before its gap was read again:
+    /// those messages are lost to this reader, the batches after them are not.
+    private func giveUp(_ gap: Gap, of partition: UInt32) {
+        arrivals.append(contentsOf: gap.held)
+        report(
+            ReadError(
+                partitionID: partition,
+                error: IggyError(
+                    .fetchProgressUncertain,
+                    context: "offsets \(gap.next) to \(gap.end - 1) of partition \(partition) were stored by a lost read and moved away",
+                    partitionID: partition)))
+    }
+
+    /// Repairs what a failed read of `partitions` points at, and they wait
+    /// for the retry interval: a moved primary groups again, a lost owner
+    /// syncs the assignment, a position without a history reads again, a lost
+    /// membership joins again. What the reader cannot repair is queued for
+    /// ``next()``.
+    private func failed(_ partitions: [UInt32], with error: IggyError) {
+        let until = ContinuousClock.now + configuration.retryInterval
+        switch error.code {
+        case .notPrimary:
+            groupingEpoch += 1
+            return
+        case .consumerGroupPartitionNotOwned:
+            for partition in partitions {
+                positions[partition] = nil
+                expected[partition] = nil
+            }
+            // The next turn syncs the assignment. The cache of the client
+            // still holds the other partitions of this member.
+            lastAssignmentSync = nil
+            groupingEpoch += 1
+            return
+        case .historyUnavailable:
+            for partition in partitions {
+                if positions[partition]?.context != nil {
+                    stopped.insert(partition)
+                    report(ReadError(partitionID: partition, error: error))
+                } else {
+                    forget(partition)
+                    pausedUntil[partition] = until
+                }
+            }
+            client.core.pollRouter.dropRoutes(partitions.map { PollRouter.RouteKey(stream: streamID, topic: topicID, partitionID: $0) })
+            return
+        case .consumerGroupMemberNotFound:
+            joinedGroup = false
+            if configuration.autoJoinConsumerGroup {
+                return
+            }
+        case .transientNotAccepted, .transientNotCommitted, .deferredPollCapacityExceeded, .fetchProgressUncertain:
+            for partition in partitions {
+                pausedUntil[partition] = until
+            }
+            logger.warning("A read was not answered, reading again", metadata: metadata.merging(["error": "\(error)"]) { $1 })
+            return
+        case .partitionLaneBlocked where configuration.options.commitsOnRead:
+            for partition in partitions {
+                stopped.insert(partition)
+                blockedBy[partition] = error.operationID
+            }
+        case .disconnected, .unauthenticated, .staleClient:
+            joinedGroup = false
+        default:
+            break
+        }
+        for partition in partitions {
+            pausedUntil[partition] = until
+        }
+        logger.error("Failed to read messages", metadata: metadata.merging(["partitions": "\(partitions)", "error": "\(error)"]) { $1 })
+        report(ReadError(partitionID: partitions.count == 1 ? partitions.first : nil, error: error))
+    }
+
+    /// Queues an error for ``next()``. The latest one of each partition and
+    /// of the whole reader waits, as `pending_errors` in the Rust SDK.
+    private func report(_ error: ReadError) {
+        arrivals.removeAll { arrival in
+            if case .failure(let queued) = arrival { queued.partitionID == error.partitionID } else { false }
+        }
+        arrivals.append(.failure(error))
+    }
+
+    // MARK: Commits
+
+    /// Under ``CommitAfter/processing``, the batch that ``next()`` returned
+    /// last counts once the application asks for the next one.
+    private func countUncounted() {
+        if let (checkpoint, messages) = uncounted {
+            uncounted = nil
+            count(checkpoint, messages: messages, endsBatch: true)
+        }
+    }
+
+    /// Counts `messages` up to `checkpoint` under ``CommitPolicy/auto(_:)``
+    /// and wakes the store task when its event makes a store due. A count
+    /// only moves forward within a generation, and a newer generation
+    /// replaces it.
+    func count(_ checkpoint: Checkpoint, messages: UInt32, endsBatch: Bool) {
+        guard let auto = configuration.options.autoCommit, isCurrent(checkpoint) else {
+            return
+        }
+        var entry = counted[checkpoint.partitionID] ?? Counted(checkpoint: checkpoint, sinceStore: 0)
+        let generation = { (checkpoint: Checkpoint) in checkpoint.stamp?.generation ?? 0 }
+        if generation(checkpoint) > generation(entry.checkpoint) {
+            entry = Counted(checkpoint: checkpoint, sinceStore: 0)
+        } else if generation(checkpoint) < generation(entry.checkpoint) {
+            return
+        } else if !Self.sameHistory(entry.checkpoint.context, checkpoint.context) || checkpoint.offset > entry.checkpoint.offset {
+            entry.checkpoint = checkpoint
+        }
+        entry.sinceStore = entry.sinceStore &+ messages
+        counted[checkpoint.partitionID] = entry
+        let due: Bool
+        switch auto.every {
+        case nil: due = false
+        case .message?: due = true
+        case .messages(let every)?: due = entry.sinceStore >= every
+        case .batch?: due = endsBatch
+        }
+        if due {
+            commitWakeup.yield()
+        }
+    }
+
+    /// Returns false once the reader is closed, which ends the store tasks.
+    private func flushOnTick() async -> Bool {
+        guard !isClosed else {
+            return false
+        }
+        do {
+            try await flushCounted()
+        } catch {
+            logger.error("Failed to store the counted offsets", metadata: metadata.merging(["error": "\(error)"]) { $1 })
+        }
+        return !isClosed
+    }
+
+    /// Stores each counted position that is ahead of the last store of its
+    /// generation, the retired ones first. Every partition is tried, and the
+    /// first failure is thrown.
+    private func flushCounted() async throws {
+        var failure: (any Error)?
+        let retired = retiring
+        retiring.removeAll()
+        for checkpoint in retired {
+            do {
+                try await store(checkpoint)
+            } catch {
+                failure = failure ?? error
+            }
+        }
+        counted = counted.filter { isCurrent($0.value.checkpoint) }
+        for (partition, entry) in counted {
+            do {
+                try await store(entry.checkpoint)
+                if var current = counted[partition] {
+                    current.sinceStore = current.sinceStore >= entry.sinceStore ? current.sinceStore - entry.sinceStore : 0
+                    counted[partition] = current
+                }
+            } catch {
+                logger.error(
+                    "Failed to store the counted offset", metadata: metadata.merging(["checkpoint": "\(entry.checkpoint)", "error": "\(error)"]) { $1 })
+                failure = failure ?? error
+            }
+        }
+        if let failure {
+            throw failure
+        }
+    }
+
+    /// Stores a checkpoint unless the last store of its history covers it.
+    private func store(_ checkpoint: Checkpoint) async throws {
+        if committedCovers(checkpoint) {
+            return
+        }
+        try await afterProgress(of: checkpoint.partitionID) {
+            try await offsetWrites.lock()
+            defer { offsetWrites.unlock() }
+            if committedCovers(checkpoint) {
+                return
+            }
+            try await client.storeConsumerPosition(consumer: consumer, streamID: streamID, topicID: topicID, checkpoint: checkpoint)
+            committed[checkpoint.partitionID] = checkpoint
+        }
+    }
+
+    private func committedCovers(_ checkpoint: Checkpoint) -> Bool {
+        guard let last = committed[checkpoint.partitionID], Self.sameHistory(last.context, checkpoint.context),
+            last.stamp?.generation == checkpoint.stamp?.generation
+        else {
+            return false
+        }
+        return checkpoint.offset <= last.offset
+    }
+
+    /// Moves the offset of a partition back over the messages the server
+    /// stored but nobody received, as `rewind` in the Rust SDK.
+    private func rewind(_ partition: UInt32, over span: Span) async throws {
+        if isGroup, !assigned.contains(partition) {
+            return
+        }
+        let last = delivered[partition].flatMap { Self.sameHistory($0.context, span.context) && $0.offset >= span.first ? $0.offset : nil }
+        if let last {
+            guard last < span.last else {
+                return
+            }
+        } else if configuration.start.kind != .next {
+            // A partition that started elsewhere had its own stored offset
+            // before, which the reads may not have moved.
+            return
+        }
+        try await afterProgress(of: partition) {
+            try await offsetWrites.lock()
+            defer { offsetWrites.unlock() }
+            if let last {
+                try await client.storeConsumerPosition(
+                    consumer: consumer, streamID: streamID, topicID: topicID,
+                    checkpoint: Checkpoint(partitionID: partition, offset: last, context: span.context))
+            } else if span.first == 0 {
+                try await client.deleteConsumerOffset(
+                    consumer: consumer, streamID: streamID, topicID: topicID, partitionID: partition, context: span.context)
+            } else {
+                try await client.storeConsumerPosition(
+                    consumer: consumer, streamID: streamID, topicID: topicID,
+                    checkpoint: Checkpoint(partitionID: partition, offset: span.first - 1, context: span.context))
+            }
+        }
+    }
+
+    /// Runs a store or delete of `partition` as ``holdingReads(of:_:)``, then
+    /// starts the reads that waited for it.
+    private func afterProgress(of partition: UInt32, _ write: () async throws -> Void) async throws {
+        defer { dispatch() }
+        try await holdingReads(of: partition, write)
+    }
+
+    /// Runs a store or delete of `partition` once no read in flight reads or
+    /// moves its stored offset, and no such read starts meanwhile, so the two
+    /// never reorder on the server.
+    private func holdingReads(of partition: UInt32, _ write: () async throws -> Void) async throws {
+        storesWaiting[partition, default: 0] += 1
+        defer {
+            if storesWaiting[partition] == 1 {
+                storesWaiting[partition] = nil
+            } else {
+                storesWaiting[partition]? -= 1
+            }
+            if storesWaiting.isEmpty {
+                for wakeup in storeWakeups {
+                    wakeup.finish()
+                }
+                storeWakeups.removeAll()
+            }
+        }
+        while activeReads[partition].flatMap({ requests[$0] })?.progresses == true {
+            let (ended, wakeup) = AsyncStream<Void>.makeStream()
+            storeWakeups.append(wakeup)
+            for await _ in ended {}
+            try Task.checkCancellation()
+        }
+        try await write()
+    }
+
+    /// Offsets compare only within one incarnation and owner.
+    static func sameHistory(_ left: PartitionContext, _ right: PartitionContext) -> Bool {
+        left.incarnation == right.incarnation && left.ownerGeneration == right.ownerGeneration
+    }
+
+    private static func iggyError(_ error: any Error) -> IggyError {
+        (error as? IggyError) ?? IggyError(.cannotReadMessage, context: "\(error)")
+    }
+}
+
+extension IggyClient {
+    /// A reader of the partitions of one topic. A standalone `consumer`
+    /// reads ``ReaderConfiguration/partitions``. A consumer group member,
+    /// `.group(id)`, reads its assignment and joins the group here, after
+    /// creating it when it is missing and named by name. Throws
+    /// ``IggyErrorCode/invalidConfiguration`` for options out of range, and
+    /// what the join throws.
+    public func reader(
+        streamID: Identifier, topicID: Identifier, consumer: Consumer, configuration: ReaderConfiguration = ReaderConfiguration()
+    ) async throws -> PartitionReader {
+        let reader = try PartitionReader(client: self, streamID: streamID, topicID: topicID, consumer: consumer, configuration: configuration)
+        try await reader.start()
+        return reader
+    }
+}
diff --git a/foreign/swift/Sources/Iggy/Errors/IggyError.swift b/foreign/swift/Sources/Iggy/Errors/IggyError.swift
index 516358b..4da28a6 100644
--- a/foreign/swift/Sources/Iggy/Errors/IggyError.swift
+++ b/foreign/swift/Sources/Iggy/Errors/IggyError.swift
@@ -31,7 +31,7 @@
 /// `context` carries whatever extra detail was available where the error was
 /// raised, such as the transport failure message or the protocol window the
 /// server accepts. It never carries credentials, and it takes no part in
-/// equality: two errors are equal when their codes are, as in the Rust SDK.
+/// equality. Recovery errors also compare their operation and partition IDs.
 public struct IggyError: Error, Sendable, Hashable, CustomStringConvertible {
     /// The classified code.
     public let code: IggyErrorCode
@@ -41,11 +41,17 @@
     public let rawCode: UInt32
     /// Extra detail about the failure, when any was available.
     public let context: String?
+    /// A client-local recovery handle, never a replayable fetch receipt.
+    public let operationID: UInt128Value?
+    /// The partition whose progress is blocked by `operationID`, when known.
+    public let partitionID: UInt32?
 
-    public init(_ code: IggyErrorCode, context: String? = nil) {
+    public init(_ code: IggyErrorCode, context: String? = nil, operationID: UInt128Value? = nil, partitionID: UInt32? = nil) {
         self.code = code
         self.rawCode = code.rawValue
         self.context = context
+        self.operationID = operationID
+        self.partitionID = partitionID
     }
 
     /// Maps a code received from the server, keeping unknown codes visible.
@@ -58,22 +64,28 @@
             self.context = "unknown error code \(wireCode)"
         }
         self.rawCode = wireCode
+        self.operationID = nil
+        self.partitionID = nil
     }
 
     public var description: String {
+        let identity = operationID.map { " operation=\($0)" } ?? ""
+        let partition = partitionID.map { " partition=\($0)" } ?? ""
         if let context {
-            return "\(code.name) (\(rawCode)): \(context)"
+            return "\(code.name) (\(rawCode))\(identity)\(partition): \(context)"
         }
-        return "\(code.name) (\(rawCode))"
+        return "\(code.name) (\(rawCode))\(identity)\(partition)"
     }
 
     public static func == (lhs: IggyError, rhs: IggyError) -> Bool {
-        lhs.code == rhs.code && lhs.rawCode == rhs.rawCode
+        lhs.code == rhs.code && lhs.rawCode == rhs.rawCode && lhs.operationID == rhs.operationID && lhs.partitionID == rhs.partitionID
     }
 
     public func hash(into hasher: inout Hasher) {
         hasher.combine(code)
         hasher.combine(rawCode)
+        hasher.combine(operationID)
+        hasher.combine(partitionID)
     }
 }
 
diff --git a/foreign/swift/Sources/Iggy/Errors/IggyErrorCode.swift b/foreign/swift/Sources/Iggy/Errors/IggyErrorCode.swift
index 5e9d1f2..18930a3 100644
--- a/foreign/swift/Sources/Iggy/Errors/IggyErrorCode.swift
+++ b/foreign/swift/Sources/Iggy/Errors/IggyErrorCode.swift
@@ -284,6 +284,8 @@
     case operationUncertain = 14006
     case laneBlocked = 14007
     case operationNotRetained = 14008
+    case partitionLaneBlocked = 14009
+    case fetchProgressUncertain = 14010
 }
 
 extension IggyErrorCode {
@@ -550,6 +552,8 @@
         case .operationUncertain: "operation_uncertain"
         case .laneBlocked: "lane_blocked"
         case .operationNotRetained: "operation_not_retained"
+        case .partitionLaneBlocked: "partition_lane_blocked"
+        case .fetchProgressUncertain: "fetch_progress_uncertain"
         }
     }
 }
diff --git a/foreign/swift/Sources/Iggy/Iggy.swift b/foreign/swift/Sources/Iggy/Iggy.swift
index a0dc490..8a87cf1 100644
--- a/foreign/swift/Sources/Iggy/Iggy.swift
+++ b/foreign/swift/Sources/Iggy/Iggy.swift
@@ -21,5 +21,5 @@
     public static let sdkName = "swift-sdk"
     /// Version of this SDK as reported to the server. Provisional until the
     /// release wiring lands; the release tooling reads this constant.
-    public static let sdkVersion = "0.1.4"
+    public static let sdkVersion = "0.1.5"
 }
diff --git a/foreign/swift/Sources/Iggy/Models/IggyMessage.swift b/foreign/swift/Sources/Iggy/Models/IggyMessage.swift
index 746fc55..fccd68b 100644
--- a/foreign/swift/Sources/Iggy/Models/IggyMessage.swift
+++ b/foreign/swift/Sources/Iggy/Models/IggyMessage.swift
@@ -182,6 +182,11 @@
     /// Number of messages the server reported.
     public var count: UInt32
     public var messages: [IggyMessage]
+    /// The start of the request, which the continuation of an empty batch
+    /// keeps.
+    var start: PollingStrategy?
+    /// The reader and generation that delivered the batch.
+    var stamp: ReaderStamp?
 
     public init(partitionID: UInt32, currentOffset: UInt64, count: UInt32, messages: [IggyMessage], context: PartitionContext = .empty) {
         self.context = context
@@ -204,6 +209,55 @@
     static let resyncRequiredPartition: UInt32 = UInt32.max
 }
 
+/// One partition of a fetch reply, as `FetchMessagesSection` in the Rust
+/// SDK. A section with a nonzero ``status`` holds no position: its offsets
+/// and context are zero, and the partition stays where the fetch started it.
+struct FetchSection: Sendable, Hashable {
+    var partitionID: UInt32
+    /// Zero for a partition that was read, else the code of the error the
+    /// server answered for this partition alone.
+    var status: UInt32
+    /// The newest offset the partition had when it was read.
+    var currentOffset: UInt64
+    var messagesCount: UInt32
+    var context: PartitionContext
+    /// Where the next fetch of the partition starts: after the last message
+    /// read, or, with nothing read, where this fetch started, past messages
+    /// that retention removed.
+    var nextOffset: UInt64
+    /// The undecoded batch records, as a poll reply carries them after its
+    /// header. They share the buffer of the whole reply.
+    var records: ArraySlice<UInt8>
+    /// ``IggyErrorCode/fetchProgressUncertain`` naming the fetch, for a
+    /// committing section whose error leaves the outcome unknown.
+    var uncertainty: IggyError? = nil
+
+    /// The error of the partition, nil for one that was read.
+    var error: IggyError? {
+        uncertainty ?? (status == 0 ? nil : IggyError(wireCode: status))
+    }
+
+    /// The messages of the section. A section with an error throws it.
+    func decode() throws -> PolledMessages {
+        if let error {
+            throw error
+        }
+        let messages = try Responses.decode(records) { reader in try Batch.decodeMessages(reader.rest, expectedCount: messagesCount) }
+        return PolledMessages(partitionID: partitionID, currentOffset: currentOffset, count: messagesCount, messages: messages, context: context)
+    }
+
+    /// The offset of the first message, read from the table alone. Nil
+    /// without messages. The offsets of a partition have no gaps, so the
+    /// messages end right before ``nextOffset``.
+    var firstOffset: UInt64? {
+        let count = UInt64(messagesCount)
+        guard count > 0, count <= nextOffset else {
+            return nil
+        }
+        return nextOffset - count
+    }
+}
+
 /// One confirmation of a committed batch.
 public struct SendConfirmation: Sendable, Hashable {
     public var streamID: UInt32
diff --git a/foreign/swift/Sources/Iggy/Models/Messaging.swift b/foreign/swift/Sources/Iggy/Models/Messaging.swift
index 6a5cf8b..47a38d7 100644
--- a/foreign/swift/Sources/Iggy/Models/Messaging.swift
+++ b/foreign/swift/Sources/Iggy/Models/Messaging.swift
@@ -150,6 +150,12 @@
 }
 
 /// Where a poll starts reading a partition.
+///
+/// A continuation, from ``PolledMessages/continuation``, also carries the
+/// partition and the partition history it was read from: a poll of another
+/// partition refuses it, and a poll after the partition was recreated fails
+/// with ``IggyErrorCode/historyUnavailable`` instead of reading the offset in
+/// another history.
 public struct PollingStrategy: Sendable, Hashable, CustomStringConvertible {
     public enum Kind: UInt8, Sendable, Hashable {
         case offset = 1
@@ -161,28 +167,36 @@
 
     public var kind: Kind
     public var value: UInt64
-    /// The partition incarnation and owner a continuation offset was read
-    /// under. A poll that carries one is refused once the partition was
-    /// recreated, instead of reading the offset in another incarnation.
-    public var context: PartitionContext?
+    /// The partition incarnation and owner a continuation was read under.
+    var context: PartitionContext?
+    /// The partition a continuation belongs to.
+    var partitionID: UInt32?
 
-    public init(kind: Kind, value: UInt64, context: PartitionContext? = nil) {
+    public init(kind: Kind, value: UInt64) {
+        self.kind = kind
+        self.value = value
+    }
+
+    init(kind: Kind, value: UInt64, context: PartitionContext?, partitionID: UInt32? = nil) {
         self.kind = kind
         self.value = value
         self.context = context
+        self.partitionID = partitionID
     }
 
-    /// Resume from a specific offset. Pass the ``PolledMessages/context`` of
-    /// the poll the offset came from to have the poll refused once the
-    /// partition was recreated.
-    public static func offset(_ offset: UInt64, context: PartitionContext? = nil) -> PollingStrategy {
-        PollingStrategy(kind: .offset, value: offset, context: context)
+    /// Start at a specific offset.
+    public static func offset(_ offset: UInt64) -> PollingStrategy {
+        PollingStrategy(kind: .offset, value: offset)
     }
 
-    /// Resume from the first message at or after a timestamp, under
-    /// `context` as for ``offset(_:context:)``.
-    public static func timestamp(_ timestamp: IggyTimestamp, context: PartitionContext? = nil) -> PollingStrategy {
-        PollingStrategy(kind: .timestamp, value: timestamp.microseconds, context: context)
+    /// Start at the first message at or after a timestamp.
+    public static func timestamp(_ timestamp: IggyTimestamp) -> PollingStrategy {
+        PollingStrategy(kind: .timestamp, value: timestamp.microseconds)
+    }
+
+    /// A start bound to `partitionID` and the history of `context`.
+    static func continuation(partitionID: UInt32, kind: Kind, value: UInt64, context: PartitionContext) -> PollingStrategy {
+        PollingStrategy(kind: kind, value: value, context: context, partitionID: partitionID)
     }
 
     /// Start from the beginning of the partition.
@@ -192,6 +206,13 @@
     /// Continue after the consumer's stored offset.
     public static let next = PollingStrategy(kind: .next, value: 0)
 
+    /// Refuses a continuation of another partition.
+    func validate(partitionID: UInt32) throws {
+        if let bound = self.partitionID, bound != partitionID {
+            throw IggyError(.invalidCommand, context: "a continuation of partition \(bound) cannot read partition \(partitionID)")
+        }
+    }
+
     public var description: String {
         switch kind {
         case .offset: "offset|\(value)"
@@ -209,18 +230,130 @@
     }
 }
 
-/// A checkpoint to commit: the inclusive offset of the last consumed
-/// message, with the partition incarnation and owner of the poll that
-/// delivered it, so a delayed commit cannot land on a recreated partition.
-public struct ConsumerPosition: Sendable, Hashable {
-    public var partitionID: UInt32
-    public var offset: UInt64
-    public var context: PartitionContext
+/// The limits of a deferred poll, as `DeferredPollOptions` in the Rust SDK.
+///
+/// The server answers once `minCount` messages are readable, the next
+/// message would pass `maxBytes`, or `maxWait` ran out. At the end of the
+/// wait it reads what there is, so a reply can hold fewer messages, or none.
+/// `requestTimeout` bounds the whole call: a reconnect and a sync of the
+/// group assignment on the way, routing, admission, the wait, the reply and
+/// every retry. A request that waits must leave one second between its wait
+/// and its timeout for the final read and the reply.
+///
+/// `maxBytes` covers the whole reply body, the poll metadata and the batch
+/// framing included. A first message larger than that comes alone in its
+/// own reply.
+struct DeferredPollOptions: Sendable, Hashable {
+    /// `MAX_DEFERRED_POLL_WAIT_US`, the longest wait the protocol carries.
+    static let maxWaitMicroseconds: UInt64 = 600_000_000
+    /// `MAX_DEFERRED_POLL_TIMEOUT_US`: the longest wait and 30 seconds more.
+    static let maxRequestTimeoutMicroseconds: UInt64 = 630_000_000
+    /// `MIN_DEFERRED_POLL_REPLY_MARGIN_US`: the server spends it on the final
+    /// read and the reply, so an idle wait answers before the client gives up.
+    static let replyMarginMicroseconds: UInt64 = 1_000_000
+    /// `MIN_DEFERRED_POLL_MAX_BYTES`, the smallest limit that can return a
+    /// message: the poll reply header, one batch header and one message header.
+    static let minMaxBytes: UInt32 = 344
+    static let maxReplyBytes = UInt32(VSRFrame.maxFrameSize - VSRFrame.headerSize)
 
-    public init(partitionID: UInt32, offset: UInt64, context: PartitionContext) {
-        self.partitionID = partitionID
-        self.offset = offset
-        self.context = context
+    var maxWait: Duration
+    var minCount: UInt32
+    var maxBytes: UInt32
+    var requestTimeout: Duration
+
+    /// One message, a 1 MiB reply, a one-second wait and an eleven-second
+    /// request timeout by default.
+    init(maxWait: Duration = .seconds(1), minCount: UInt32 = 1, maxBytes: UInt32 = 1_048_576, requestTimeout: Duration = .seconds(11)) {
+        self.maxWait = maxWait
+        self.minCount = minCount
+        self.maxBytes = maxBytes
+        self.requestTimeout = requestTimeout
+    }
+
+    /// Refuses, before anything is sent, what the server refuses, as
+    /// `DeferredPollOptions::validate` in the Rust SDK.
+    func validate(count: UInt32) throws {
+        guard minCount >= 1, minCount <= count else {
+            throw IggyError(.invalidMessagesCount, context: "minCount \(minCount) must be between 1 and the count \(count)")
+        }
+        try validateBudget()
+        guard maxBytes >= Self.minMaxBytes else {
+            throw IggyError(.invalidSizeBytes, context: "maxBytes \(maxBytes) must be at least \(Self.minMaxBytes)")
+        }
+    }
+
+    /// A new fetch leaves time for local setup before its final read and
+    /// reply, even without a wait. Check at the wire's microsecond precision.
+    func validateFetch(count: UInt32) throws {
+        try validate(count: count)
+        let (wait, timeout) = try budgetMicroseconds()
+        guard timeout > Self.replyMarginMicroseconds, timeout >= wait + Self.replyMarginMicroseconds else {
+            throw IggyError(
+                .invalidCommand,
+                context: "a fetch needs requestTimeout \(requestTimeout) above one second and at least one second past maxWait \(maxWait)")
+        }
+    }
+
+    func validateBudget() throws {
+        let (wait, timeout) = try budgetMicroseconds()
+        guard wait <= Self.maxWaitMicroseconds, timeout >= 1, timeout <= Self.maxRequestTimeoutMicroseconds,
+            wait == 0 || timeout >= wait + Self.replyMarginMicroseconds
+        else {
+            throw IggyError(.invalidCommand, context: "maxWait \(maxWait) or requestTimeout \(requestTimeout) is out of range")
+        }
+    }
+
+    /// The options of an attempt `elapsed` into its call, as
+    /// `DeferredPollOptions::remaining` in the Rust SDK. The wait and the
+    /// timeout shrink together, so a wait keeps its reply margin. A spent
+    /// timeout is refused as not accepted: the attempt never left the client.
+    func remaining(after elapsed: Duration) throws -> DeferredPollOptions {
+        var options = self
+        options.requestTimeout = requestTimeout - elapsed
+        guard let timeout = options.requestTimeout.wholeMicroseconds, timeout > 0 else {
+            throw IggyError(.transientNotAccepted, context: "the request timeout of \(requestTimeout) ran out before the request was sent")
+        }
+        options.maxWait = max(.zero, maxWait - elapsed)
+        return options
+    }
+
+    /// The wait and the timeout in whole microseconds, as the wire carries them.
+    func budgetMicroseconds() throws -> (wait: UInt64, timeout: UInt64) {
+        guard let wait = maxWait.wholeMicroseconds, let timeout = requestTimeout.wholeMicroseconds else {
+            throw IggyError(.invalidCommand, context: "maxWait \(maxWait) and requestTimeout \(requestTimeout) must not be negative")
+        }
+        return (wait, timeout)
+    }
+
+    /// The wait and the timeout as an attempt sends them. Whole microseconds
+    /// of a shrunk budget can lose a part of the reply margin, so the wait
+    /// gives way, as in `deferred_payload` of the Rust SDK.
+    func wireBudget() throws -> (wait: UInt64, timeout: UInt64) {
+        let (wait, timeout) = try budgetMicroseconds()
+        let room = timeout > Self.replyMarginMicroseconds ? timeout - Self.replyMarginMicroseconds : 0
+        return (min(wait, room), timeout)
+    }
+
+    /// ``wireBudget()`` of a fetch attempt. A timeout shrunk below the reply
+    /// margin is one the server refuses, so the attempt is refused as not
+    /// accepted: it never left the client.
+    func fetchWireBudget() throws -> (wait: UInt64, timeout: UInt64) {
+        let (wait, timeout) = try budgetMicroseconds()
+        guard timeout >= Self.replyMarginMicroseconds else {
+            throw IggyError(.transientNotAccepted, context: "the request timeout left for the fetch, \(requestTimeout), is below the reply margin")
+        }
+        return (min(wait, timeout - Self.replyMarginMicroseconds), timeout)
+    }
+
+    /// `[wait_us:8][min_count:4][max_bytes:4][request_timeout_us:8]`, which
+    /// turns a poll into a deferred one. A reply larger than a frame fails the
+    /// connection that reads it, so the server must not fill more.
+    func encode(into writer: inout ByteWriter) throws {
+        let (wait, timeout) = try wireBudget()
+        writer.write(wait)
+        writer.write(minCount)
+        writer.write(min(maxBytes, Self.maxReplyBytes))
+        writer.write(timeout)
     }
 }
 
diff --git a/foreign/swift/Sources/Iggy/Models/PollOptions.swift b/foreign/swift/Sources/Iggy/Models/PollOptions.swift
new file mode 100644
index 0000000..d8cc225
--- /dev/null
+++ b/foreign/swift/Sources/Iggy/Models/PollOptions.swift
@@ -0,0 +1,262 @@
+// 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.
+
+/// When the offsets of the messages that a read returns are stored.
+public enum CommitPolicy: Sendable, Hashable {
+    /// Nothing is stored. The application commits checkpoints.
+    case manual
+    /// The server stores the offset of the last message of each read as part
+    /// of the read, in one round trip. While the primary stays up a message
+    /// is delivered at most once. A failover before the store replicates can
+    /// deliver the last batch again.
+    case onRead
+    /// The client stores the latest counted position, at least once with
+    /// ``CommitAfter/processing``. A single poll refuses it with
+    /// ``IggyErrorCode/invalidConfiguration``, as it has nothing to count. A
+    /// ``PartitionReader`` and an ``IggyConsumer`` accept it.
+    case auto(AutoCommit)
+}
+
+/// Where a committing deferred poll (106) or fetch (109) reported an error,
+/// as `CommitErrorScope` in the Rust SDK.
+enum CommitErrorScope {
+    /// The whole reply is the error.
+    case request
+    /// One section of a fetch reply carries the error status.
+    case section
+
+    /// Whether `code`, reported in this scope, proves that the server stored
+    /// no offset. Any other code, a lost reply and a transport failure after
+    /// the send leave the outcome unknown.
+    func provesNothingStored(_ code: IggyErrorCode) -> Bool {
+        switch self {
+        case .request:
+            switch code {
+            case .featureUnavailable, .invalidIdentifier, .transientNotAccepted, .invalidSizeBytes, .historyUnavailable, .notPrimary,
+                .deferredPollCapacityExceeded, .streamIdNotFound, .streamNameNotFound, .topicIdNotFound, .topicNameNotFound, .partitionNotFound,
+                .tooManyConsumerOffsets, .invalidOffset, .consumerGroupPartitionNotOwned:
+                true
+            default:
+                false
+            }
+        case .section:
+            switch code {
+            case .transientNotAccepted, .invalidSizeBytes, .historyUnavailable, .notPrimary, .partitionNotFound, .tooManyConsumerOffsets,
+                .consumerGroupPartitionNotOwned:
+                true
+            default:
+                false
+            }
+        }
+    }
+}
+
+/// When ``CommitPolicy/auto(_:)`` stores. It needs `interval` or `every`. It
+/// also stores on close, on shutdown and before a rebalance takes a partition
+/// away. A store only moves forward, and a position of an older generation of
+/// its partition, from before a seek or a new assignment, is dropped.
+public struct AutoCommit: Sendable, Hashable {
+    /// Store the latest counted position at this interval, when it moved.
+    public var interval: Duration?
+    /// Also store it on this event.
+    public var every: CommitEvery?
+    /// When a message counts.
+    public var after: CommitAfter
+
+    public init(interval: Duration? = nil, every: CommitEvery? = nil, after: CommitAfter = .processing) {
+        self.interval = interval
+        self.every = every
+        self.after = after
+    }
+
+    /// Every 10,000 messages or 5 seconds, whichever comes first, after
+    /// ``CommitAfter/processing``.
+    public static let `default` = AutoCommit(interval: .seconds(5), every: .messages(10_000), after: .processing)
+
+    func validate() throws {
+        guard interval != nil || every != nil else {
+            throw IggyError(.invalidConfiguration, context: "auto commit needs an interval or an event, or it stores only on close")
+        }
+        if let interval {
+            guard interval > .zero, interval.wholeMicroseconds != nil else {
+                throw IggyError(.invalidConfiguration, context: "the auto commit interval must be positive, got \(interval)")
+            }
+        }
+        if case .messages(0)? = every {
+            throw IggyError(.invalidConfiguration, context: "auto commit every n messages needs n of at least 1")
+        }
+    }
+}
+
+/// The event of ``AutoCommit/every``.
+public enum CommitEvery: Sendable, Hashable {
+    /// Each counted message, or each counted batch of a ``PartitionReader``.
+    case message
+    /// This many counted messages of a partition since its last store.
+    case messages(UInt32)
+    /// The last message of each batch.
+    case batch
+}
+
+/// When a message counts for ``CommitPolicy/auto(_:)``.
+public enum CommitAfter: Sendable, Hashable {
+    /// Once the application asks for the next message or batch, returns from
+    /// its handler with success, or closes the reader. A handler that throws
+    /// leaves its message uncounted.
+    case processing
+    /// When the message is handed over.
+    case delivery
+    /// Only when the application marks its checkpoint, so messages processed
+    /// concurrently count once they are done.
+    case marked
+}
+
+/// The limits of one poll of one partition, as `PollOptions` in the Rust SDK.
+///
+/// The server answers once `minCount` messages are readable, the next message
+/// would pass `maxBytes`, or `maxWait` ran out, and then returns what there
+/// is, possibly nothing. The request timeout is `maxWait` plus ten seconds.
+public struct PollOptions: Sendable, Hashable {
+    /// Ten seconds past the wait leave room for routing, the final read and
+    /// the reply, as in the Rust SDK.
+    static let requestTimeoutMargin: Duration = .seconds(10)
+
+    /// Most messages in one batch. Default 1000.
+    public var count: UInt32
+    /// Zero answers at once. At most 600 seconds.
+    public var maxWait: Duration
+    /// At least 1 and at most `count`. Default 1.
+    public var minCount: UInt32
+    /// The whole reply body, default 1 MiB, at least 344 bytes. A first
+    /// message larger than that comes alone in its own reply.
+    public var maxBytes: UInt32
+    /// Default ``CommitPolicy/manual``.
+    public var commit: CommitPolicy
+
+    public init(count: UInt32 = 1000, maxWait: Duration = .zero, minCount: UInt32 = 1, maxBytes: UInt32 = 1_048_576, commit: CommitPolicy = .manual) {
+        self.count = count
+        self.maxWait = maxWait
+        self.minCount = minCount
+        self.maxBytes = maxBytes
+        self.commit = commit
+    }
+
+    /// The defaults with a wait of up to `maxWait` for `minCount` messages.
+    public static func wait(_ maxWait: Duration) -> PollOptions {
+        PollOptions(maxWait: maxWait)
+    }
+
+    /// The limit of the whole request: `maxWait` plus ten seconds.
+    public var requestTimeout: Duration {
+        maxWait + Self.requestTimeoutMargin
+    }
+
+    /// Refuses, before anything is sent, what the server refuses, and
+    /// ``CommitPolicy/auto(_:)`` without an interval or an event.
+    func validate() throws {
+        if case .auto(let auto) = commit {
+            try auto.validate()
+        }
+        try deferred.validate(count: count)
+    }
+
+    var commitsOnRead: Bool {
+        commit == .onRead
+    }
+
+    var autoCommit: AutoCommit? {
+        if case .auto(let auto) = commit { auto } else { nil }
+    }
+
+    /// The wire limits of these options.
+    var deferred: DeferredPollOptions {
+        DeferredPollOptions(maxWait: maxWait, minCount: minCount, maxBytes: maxBytes, requestTimeout: requestTimeout)
+    }
+}
+
+/// The position after one delivered message, to commit. It is bound to its
+/// partition and, from a ``PartitionReader``, to the reader and the
+/// generation of the partition that delivered it, so a delayed commit cannot
+/// land on a recreated or reassigned partition.
+public struct Checkpoint: Sendable, Hashable, CustomStringConvertible {
+    /// The partition of the delivered message.
+    public let partitionID: UInt32
+    /// The offset of the delivered message, which a commit stores.
+    public let offset: UInt64
+    /// The partition incarnation and owner the read ran under.
+    let context: PartitionContext
+    /// The reader and generation that delivered it, nil for a single poll.
+    let stamp: ReaderStamp?
+
+    init(partitionID: UInt32, offset: UInt64, context: PartitionContext, stamp: ReaderStamp? = nil) {
+        self.partitionID = partitionID
+        self.offset = offset
+        self.context = context
+        self.stamp = stamp
+    }
+
+    public var description: String {
+        "\(partitionID)|\(offset)"
+    }
+}
+
+/// The reader and the generation of a partition that delivered a batch.
+struct ReaderStamp: Sendable, Hashable {
+    let reader: UInt64
+    let generation: UInt64
+}
+
+extension PolledMessages {
+    /// The position after the last message, to commit the whole batch. Nil
+    /// for an empty batch.
+    public var checkpoint: Checkpoint? {
+        messages.last.map(checkpoint(of:))
+    }
+
+    /// The position after `messages[index]`, to commit a processed part of
+    /// the batch. Nil for an index out of range.
+    public func checkpoint(at index: Int) -> Checkpoint? {
+        messages.indices.contains(index) ? checkpoint(of: messages[index]) : nil
+    }
+
+    /// Where the next poll of this partition starts: after the last message,
+    /// or, for an empty batch, the start of its request, with `first` as
+    /// offset zero. It belongs to the partition of the batch, and a poll of
+    /// another partition refuses it. It carries the context of the batch, so
+    /// a poll after a change of the partition history fails with
+    /// ``IggyErrorCode/historyUnavailable``. An empty batch with no context
+    /// names no history, so its continuation is the start as it was.
+    public var continuation: PollingStrategy {
+        if let last = messages.last {
+            return .continuation(partitionID: partitionID, kind: .offset, value: last.offset + 1, context: context)
+        }
+        guard let start else {
+            return .next
+        }
+        guard context != .empty else {
+            return start
+        }
+        if start.kind == .first {
+            return .continuation(partitionID: partitionID, kind: .offset, value: 0, context: context)
+        }
+        return .continuation(partitionID: partitionID, kind: start.kind, value: start.value, context: context)
+    }
+
+    private func checkpoint(of message: IggyMessage) -> Checkpoint {
+        Checkpoint(partitionID: partitionID, offset: message.offset, context: context, stamp: stamp)
+    }
+}
diff --git a/foreign/swift/Sources/Iggy/Models/Resources.swift b/foreign/swift/Sources/Iggy/Models/Resources.swift
index 4c0accb..dbbda04 100644
--- a/foreign/swift/Sources/Iggy/Models/Resources.swift
+++ b/foreign/swift/Sources/Iggy/Models/Resources.swift
@@ -646,8 +646,10 @@
     /// Whole microseconds, or nil when the duration is negative or does not
     /// fit a `UInt64`.
     var wholeMicroseconds: UInt64? {
+        // A duration between -1 s and zero has zero seconds and negative
+        // attoseconds.
         let (seconds, attoseconds) = components
-        guard seconds >= 0 else {
+        guard seconds >= 0, attoseconds >= 0 else {
             return nil
         }
         let (scaled, overflow) = UInt64(seconds).multipliedReportingOverflow(by: 1_000_000)
diff --git a/foreign/swift/Sources/Iggy/Protocol/CommandCode.swift b/foreign/swift/Sources/Iggy/Protocol/CommandCode.swift
index ba8e172..7145f68 100644
--- a/foreign/swift/Sources/Iggy/Protocol/CommandCode.swift
+++ b/foreign/swift/Sources/Iggy/Protocol/CommandCode.swift
@@ -50,6 +50,10 @@
     // 102 was flush_unsaved_buffer; the server keeps the code reserved.
     // 103 and 104 are retired and must never be reused.
     case getPartitionRoute = 105
+    case pollMessagesDeferred = 106
+    // 107 was wait_for_messages and must never be reused.
+    case cancelDeferredRequest = 108
+    case fetchMessages = 109
     case getConsumerOffset = 120
     case storeConsumerOffset = 121
     case deleteConsumerOffset = 122
@@ -106,6 +110,9 @@
         case .pollMessages: "message.poll"
         case .sendMessages: "message.send"
         case .getPartitionRoute: "partition.route"
+        case .pollMessagesDeferred: "message.poll_deferred"
+        case .cancelDeferredRequest: "message.cancel_deferred"
+        case .fetchMessages: "message.fetch"
         case .getConsumerOffset: "consumer_offset.get"
         case .storeConsumerOffset: "consumer_offset.store"
         case .deleteConsumerOffset: "consumer_offset.delete"
@@ -143,6 +150,12 @@
         }
     }
 
+    /// Codes of requests that the server parks until their wait ends. Their
+    /// deadline is their own request timeout, and command 108 cancels them.
+    static func isDeferred(_ code: UInt32) -> Bool {
+        code == CommandCode.pollMessagesDeferred.rawValue || code == CommandCode.fetchMessages.rawValue
+    }
+
     /// Codes that carry the sign-in handshake.
     static func isRegister(_ code: UInt32) -> Bool {
         code == CommandCode.loginRegister.rawValue
diff --git a/foreign/swift/Sources/Iggy/Protocol/ProtocolVersion.swift b/foreign/swift/Sources/Iggy/Protocol/ProtocolVersion.swift
index 70440e4..37360ad 100644
--- a/foreign/swift/Sources/Iggy/Protocol/ProtocolVersion.swift
+++ b/foreign/swift/Sources/Iggy/Protocol/ProtocolVersion.swift
@@ -32,7 +32,7 @@
     /// The protocol version this SDK implements: the version of the
     /// `iggy_binary_protocol` crate it was ported from. The golden fixture
     /// carries the crate's value and a test fails when the two diverge.
-    public static let current = ProtocolVersion(major: 0, minor: 12, patch: 0)
+    public static let current = ProtocolVersion(major: 0, minor: 12, patch: 1)
 
     public var major: UInt32 { packed >> 20 }
     public var minor: UInt32 { (packed >> 10) & 0x3FF }
diff --git a/foreign/swift/Sources/Iggy/Protocol/Requests.swift b/foreign/swift/Sources/Iggy/Protocol/Requests.swift
index be2494f..32ed0cc 100644
--- a/foreign/swift/Sources/Iggy/Protocol/Requests.swift
+++ b/foreign/swift/Sources/Iggy/Protocol/Requests.swift
@@ -164,14 +164,61 @@
 
     // MARK: Messages
 
+    /// `MAX_PARTITIONS_PER_REQUEST`: the most partitions one fetch names.
+    static let maxPartitionsPerRequest = 1000
+
+    /// A poll, and with `options` a deferred poll (command 106): the same
+    /// body followed by its options.
     static func pollMessages(
-        consumer: Consumer, streamID: Identifier, topicID: Identifier, partitionID: UInt32?, strategy: PollingStrategy, count: UInt32, autoCommit: Bool
+        consumer: Consumer, streamID: Identifier, topicID: Identifier, partitionID: UInt32?, strategy: PollingStrategy, count: UInt32, autoCommit: Bool,
+        options: DeferredPollOptions? = nil
     ) throws -> [UInt8] {
         var writer = ByteWriter()
         try consumerTarget(consumer, streamID, topicID, partitionID, into: &writer)
         strategy.encode(into: &writer)
         writer.write(count)
         writer.write(autoCommit)
+        try options?.encode(into: &writer)
+        return writer.bytes
+    }
+
+    /// One target of a fetch: where its read starts, the bytes it may fill,
+    /// and the fence of the partition history it reads.
+    struct FetchTarget: Sendable, Hashable {
+        let partitionID: UInt32
+        let strategy: PollingStrategy
+        let maxBytes: UInt32
+        let fence: PartitionContext
+    }
+
+    /// `[consumer][stream][topic][wait_us:8][request_timeout_us:8][min_count:4][count:4][max_bytes:4][auto_commit:1][targets_count:4]`,
+    /// then per target `[partition_id:4][strategy:9][max_bytes:4][incarnation:8][owner_generation:8]`.
+    static func fetchMessages(
+        consumer: Consumer, streamID: Identifier, topicID: Identifier, targets: [FetchTarget], count: UInt32, autoCommit: Bool,
+        options: DeferredPollOptions
+    ) throws -> [UInt8] {
+        guard let targetsCount = UInt32(exactly: targets.count) else {
+            throw IggyError(.invalidCommand, context: "a fetch cannot name \(targets.count) partitions")
+        }
+        var writer = ByteWriter()
+        try consumer.encode(into: &writer)
+        try streamID.encode(into: &writer)
+        try topicID.encode(into: &writer)
+        let (wait, timeout) = try options.fetchWireBudget()
+        writer.write(wait)
+        writer.write(timeout)
+        writer.write(options.minCount)
+        writer.write(count)
+        writer.write(options.maxBytes)
+        writer.write(autoCommit)
+        writer.write(targetsCount)
+        for target in targets {
+            writer.write(target.partitionID)
+            target.strategy.encode(into: &writer)
+            writer.write(target.maxBytes)
+            writer.write(target.fence.incarnation)
+            writer.write(target.fence.ownerGeneration)
+        }
         return writer.bytes
     }
 
diff --git a/foreign/swift/Sources/Iggy/Protocol/Responses.swift b/foreign/swift/Sources/Iggy/Protocol/Responses.swift
index da710c1..3c06573 100644
--- a/foreign/swift/Sources/Iggy/Protocol/Responses.swift
+++ b/foreign/swift/Sources/Iggy/Protocol/Responses.swift
@@ -566,6 +566,55 @@
         }
     }
 
+    /// `FETCH_SECTION_HEADER_SIZE`: one entry of the section table of a fetch reply.
+    static let fetchSectionHeaderSize = 56
+
+    /// `FetchMessagesResponse::head_size`: the section count and the table
+    /// of `sections` entries, which come before the records.
+    static func fetchHeadSize(_ sections: Int) -> Int {
+        MemoryLayout<UInt32>.size + sections * fetchSectionHeaderSize
+    }
+
+    /// A fetch reply: `[sections_count:4]`, then per section
+    /// `[partition_id:4][status:4][current_offset:8][messages_count:4][context:24][next_offset:8][records_len:4]`,
+    /// then the records of every section in table order. As
+    /// `FetchMessagesResponse::decode` checks, a section with an error holds
+    /// no records, and the records fill the body exactly.
+    static func fetchSections(_ body: ArraySlice<UInt8>) throws -> [FetchSection] {
+        try decode(body) { reader in
+            let count = try reader.readUInt32()
+            // Compared as `UInt32` so a hostile count never traps a 32-bit `Int`.
+            guard count <= UInt32(Requests.maxPartitionsPerRequest), fetchHeadSize(Int(count)) <= body.count else {
+                throw WireError.validation("a fetch reply of \(body.count) bytes cannot hold \(count) sections")
+            }
+            var sections: [FetchSection] = []
+            var lengths: [UInt32] = []
+            sections.reserveCapacity(Int(count))
+            lengths.reserveCapacity(Int(count))
+            for _ in 0..<count {
+                sections.append(
+                    FetchSection(
+                        partitionID: try reader.readUInt32(), status: try reader.readUInt32(), currentOffset: try reader.readUInt64(),
+                        messagesCount: try reader.readUInt32(), context: try PartitionContext.decode(from: &reader), nextOffset: try reader.readUInt64(),
+                        records: []))
+                lengths.append(try reader.readUInt32())
+            }
+            for index in sections.indices {
+                guard sections[index].status == 0 || lengths[index] == 0 else {
+                    throw WireError.validation("the section of partition \(sections[index].partitionID) has an error and records")
+                }
+                guard let length = Int(exactly: lengths[index]), length <= reader.remaining else {
+                    throw WireError.validation("the records of partition \(sections[index].partitionID) run past the fetch reply")
+                }
+                sections[index].records = try reader.readBytes(length)
+            }
+            guard reader.isAtEnd else {
+                throw WireError.validation("a fetch reply has \(reader.remaining) bytes after its records")
+            }
+            return sections
+        }
+    }
+
     static func polledMessages(_ body: ArraySlice<UInt8>, verifyChecksums: Bool = false) throws -> PolledMessages {
         try decode(body) { reader in
             let partitionID = try reader.readUInt32()
diff --git a/foreign/swift/Sources/Iggy/Protocol/VSRFrame.swift b/foreign/swift/Sources/Iggy/Protocol/VSRFrame.swift
index d252884..355456f 100644
--- a/foreign/swift/Sources/Iggy/Protocol/VSRFrame.swift
+++ b/foreign/swift/Sources/Iggy/Protocol/VSRFrame.swift
@@ -104,6 +104,27 @@
         /// can be replayed as it is after a reconnect.
         let clientID: UInt128Value
         let session: UInt64
+
+        /// A cancellation for a deferred poll or fetch, as
+        /// `RequestHeader::deferred_cancellation` in Rust: the header of this
+        /// request with command 108, no body and no partition context. It
+        /// must follow the request on the same connection, so the server sees
+        /// the request queued or admitted when the cancellation arrives. Nil
+        /// for any other request.
+        var deferredCancellation: [UInt8]? {
+            let code = UInt32(littleEndianBytes: bytes[RequestOffset.reserved..<RequestOffset.reserved + 4])
+            guard operation == .nonReplicated, CommandCode.isDeferred(code) else {
+                return nil
+            }
+            var frame = Array(bytes[..<VSRFrame.headerSize])
+            frame.withUnsafeMutableBytes { buffer in
+                buffer.storeBytes(of: UInt32(VSRFrame.headerSize).littleEndian, toByteOffset: RequestOffset.size, as: UInt32.self)
+                buffer.storeBytes(of: CommandCode.cancelDeferredRequest.rawValue.littleEndian, toByteOffset: RequestOffset.reserved, as: UInt32.self)
+            }
+            let context = RequestOffset.partitionIncarnation..<RequestOffset.minimumMetadataOp + 8
+            frame.replaceSubrange(context, with: repeatElement(0, count: context.count))
+            return frame
+        }
     }
 
     /// Builds the complete request frame for a command code and its encoded
@@ -192,6 +213,14 @@
         return Command(rawValue: header[header.startIndex + ReplyOffset.command])
     }
 
+    static func readRequestID(_ header: ArraySlice<UInt8>) -> UInt64? {
+        guard header.count >= RequestOffset.request + 8 else {
+            return nil
+        }
+        let start = header.startIndex + RequestOffset.request
+        return UInt64(littleEndianBytes: header[start..<start + 8])
+    }
+
     static func readReplyRequestID(_ header: ArraySlice<UInt8>) -> UInt64? {
         guard header.count >= ReplyOffset.request + 8 else {
             return nil
diff --git a/foreign/swift/Sources/Iggy/Transport/TCPConnection.swift b/foreign/swift/Sources/Iggy/Transport/TCPConnection.swift
index 64601cd..d4515e8 100644
--- a/foreign/swift/Sources/Iggy/Transport/TCPConnection.swift
+++ b/foreign/swift/Sources/Iggy/Transport/TCPConnection.swift
@@ -35,11 +35,31 @@
     var localAddress: String? { get }
     /// Writes a frame and reads the reply, bounded by `timeout`. A timeout
     /// leaves the stream at an unknown boundary, so the connection closes
-    /// itself and every later call fails.
-    func exchange(_ frame: [UInt8], timeout: Duration) async throws -> RawFrame
+    /// itself and every later call fails. So does a cancelled caller, unless
+    /// `cancellation` holds the frame that cancels a deferred request: then
+    /// that frame follows the request and the connection stays usable. A
+    /// request that ``DeferredCancellation/isAnswered`` waits for its one
+    /// reply, any other gets `CancellationError` and the connection drops
+    /// its late reply.
+    func exchange(_ frame: [UInt8], timeout: Duration, cancellation: DeferredCancellation?) async throws -> RawFrame
     func close() async
 }
 
+/// The command 108 frame that cancels a deferred request.
+struct DeferredCancellation: Sendable {
+    let frame: [UInt8]
+    /// A committing request answers its cancellation once: with
+    /// `transientNotAccepted` when nothing was stored, or with its messages
+    /// when its read committed first. Any other request is not answered.
+    let isAnswered: Bool
+}
+
+extension IggyConnection {
+    func exchange(_ frame: [UInt8], timeout: Duration) async throws -> RawFrame {
+        try await exchange(frame, timeout: timeout, cancellation: nil)
+    }
+}
+
 /// A dialer that produces connections; swapped out by tests.
 protocol IggyConnector: Sendable {
     func connect(to endpoint: Endpoint, tls: TLSOptions?, timeout: Duration, noDelay: Bool) async throws -> any IggyConnection
@@ -121,7 +141,7 @@
         return TCPConnection(channel: channel, handler: handler)
     }
 
-    func exchange(_ frame: [UInt8], timeout: Duration) async throws -> RawFrame {
+    func exchange(_ frame: [UInt8], timeout: Duration, cancellation: DeferredCancellation?) async throws -> RawFrame {
         let channel = self.channel
         let handler = self.handler
         return try await withTaskCancellationHandler {
@@ -131,10 +151,14 @@
                 }
             }
         } onCancel: {
-            // The caller stopped waiting, and a reply that still arrives would
-            // be read as the answer to the next request.
             channel.eventLoop.execute {
-                handler.abort(with: IggyError(.disconnected, context: "the request was cancelled"), on: channel)
+                if let cancellation {
+                    handler.cancel(with: cancellation, on: channel)
+                } else {
+                    // The caller stopped waiting, and a reply that still arrives
+                    // would be read as the answer to the next request.
+                    handler.abort(with: IggyError(.disconnected, context: "the request was cancelled"), on: channel)
+                }
             }
         }
     }
@@ -182,6 +206,11 @@
     typealias InboundIn = RawFrame
 
     private var pending: CheckedContinuation<RawFrame, Error>?
+    private var pendingRequestID: UInt64?
+    /// The highest request id whose deferred request was cancelled. Ids only
+    /// rise on a connection, so a reply at or below it answers a cancelled
+    /// request, one the server sent before the cancellation reached it.
+    private var cancelledThrough: UInt64?
     private var replyDeadline: Scheduled<Void>?
     private var handshake: CheckedContinuation<Void, Error>?
     private var handshakeDeadline: Scheduled<Void>?
@@ -198,14 +227,47 @@
             continuation.resume(throwing: IggyError(.notConnected, context: "the connection closed before the request was written: \(cause)"))
             return
         }
+        let requestID = VSRFrame.readRequestID(frame[...])
+        if let requestID, let cancelledThrough, requestID <= cancelledThrough {
+            // The caller cancelled the request before it was written.
+            continuation.resume(throwing: CancellationError())
+            return
+        }
         if let previous = pending {
             replyDeadline?.cancel()
             previous.resume(throwing: IggyError(.invalidCommand, context: "a reply was already awaited on this connection"))
         }
         pending = continuation
+        pendingRequestID = requestID
         replyDeadline = channel.eventLoop.scheduleTask(in: .nanoseconds(timeout.nanosecondsClamped)) { [self] in
             abort(with: IggyError(.disconnected, context: "timed out after \(timeout) waiting for the reply"), on: channel)
         }
+        write(frame, on: channel)
+    }
+
+    /// Ends a deferred request whose caller stopped waiting. One that is
+    /// written is cancelled with its command 108 frame, and one that is not
+    /// written yet never will be. The server answers no cancellation. It
+    /// answers a cancelled committing request once, so that request keeps
+    /// waiting for its reply. Any other cancelled request is not answered.
+    func cancel(with cancellation: DeferredCancellation, on channel: Channel) {
+        guard let requestID = VSRFrame.readRequestID(cancellation.frame[...]) else {
+            return
+        }
+        let written = !closed && pendingRequestID == requestID
+        if written, cancellation.isAnswered {
+            write(cancellation.frame, on: channel)
+            return
+        }
+        cancelledThrough = max(cancelledThrough ?? 0, requestID)
+        guard written else {
+            return
+        }
+        fail(with: CancellationError())
+        write(cancellation.frame, on: channel)
+    }
+
+    private func write(_ frame: [UInt8], on channel: Channel) {
         var buffer = channel.allocator.buffer(capacity: frame.count)
         buffer.writeBytes(frame)
         channel.writeAndFlush(buffer).whenFailure { [self] error in
@@ -226,11 +288,12 @@
         channel.close(promise: nil)
     }
 
-    private func fail(with error: IggyError) {
+    private func fail(with error: any Error) {
         replyDeadline?.cancel()
         replyDeadline = nil
         pending?.resume(throwing: error)
         pending = nil
+        pendingRequestID = nil
     }
 
     /// Waits for the TLS handshake for at most `timeout`. A peer that accepts
@@ -262,10 +325,17 @@
 
     func channelRead(context: ChannelHandlerContext, data: NIOAny) {
         let frame = unwrapInboundIn(data)
+        let header = frame.header[...]
+        if let cancelledThrough, VSRFrame.peekCommand(header) == .reply, let requestID = VSRFrame.readReplyRequestID(header),
+            requestID <= cancelledThrough
+        {
+            return
+        }
         if let pending {
             replyDeadline?.cancel()
             replyDeadline = nil
             self.pending = nil
+            pendingRequestID = nil
             pending.resume(returning: frame)
         }
         // A frame nobody waits for is either a stray reply or a session-level
diff --git a/foreign/swift/Sources/Iggy/Utilities/AsyncMutex.swift b/foreign/swift/Sources/Iggy/Utilities/AsyncMutex.swift
index 5da12aa..f1e74c7 100644
--- a/foreign/swift/Sources/Iggy/Utilities/AsyncMutex.swift
+++ b/foreign/swift/Sources/Iggy/Utilities/AsyncMutex.swift
@@ -69,6 +69,18 @@
         }
     }
 
+    /// Takes the lock if it is free, without waiting. A free lock has no
+    /// waiters, as ``unlock()`` hands a held lock to the longest one.
+    func tryLock() -> Bool {
+        state.withLockedValue { state in
+            guard !state.locked else {
+                return false
+            }
+            state.locked = true
+            return true
+        }
+    }
+
     /// Hands the lock to the longest waiter, or frees it.
     func unlock() {
         let next = state.withLockedValue { state -> CheckedContinuation<Void, Error>? in
diff --git a/foreign/swift/Tests/IggyE2ETests/ClientE2ETests.swift b/foreign/swift/Tests/IggyE2ETests/ClientE2ETests.swift
index cc155fc..0c8b752 100644
--- a/foreign/swift/Tests/IggyE2ETests/ClientE2ETests.swift
+++ b/foreign/swift/Tests/IggyE2ETests/ClientE2ETests.swift
@@ -373,7 +373,7 @@
             #expect(baseOffsets == [0, 4, 8])
 
             let polled = try await client.pollMessages(
-                streamID: scratch.stream, topicID: scratch.topic, partitionID: 0, consumer: .default, strategy: .offset(0), count: 12, autoCommit: false)
+                streamID: scratch.stream, topicID: scratch.topic, partitionID: 0, consumer: .default, strategy: .offset(0), options: PollOptions(count: 12))
             #expect(polled.partitionID == 0)
             #expect(polled.currentOffset == 11)
             #expect(polled.messages.count == 12)
@@ -387,13 +387,13 @@
             #expect(ids.count == 12)
 
             let last = try await client.pollMessages(
-                streamID: scratch.stream, topicID: scratch.topic, partitionID: 0, consumer: .default, strategy: .last, count: 1, autoCommit: false)
+                streamID: scratch.stream, topicID: scratch.topic, partitionID: 0, consumer: .default, strategy: .last, options: PollOptions(count: 1))
             #expect(last.messages.map(\.offset) == [11])
             let tail = try await client.pollMessages(
-                streamID: scratch.stream, topicID: scratch.topic, partitionID: 0, consumer: .default, strategy: .offset(10), count: 100, autoCommit: false)
+                streamID: scratch.stream, topicID: scratch.topic, partitionID: 0, consumer: .default, strategy: .offset(10), options: PollOptions(count: 100))
             #expect(tail.messages.map(\.offset) == [10, 11])
             let empty = try await client.pollMessages(
-                streamID: scratch.stream, topicID: scratch.topic, partitionID: 1, consumer: .default, strategy: .first, count: 10, autoCommit: false)
+                streamID: scratch.stream, topicID: scratch.topic, partitionID: 1, consumer: .default, strategy: .first, options: PollOptions(count: 10))
             #expect(empty.messages.isEmpty)
         }
     }
@@ -410,7 +410,7 @@
             _ = try await client.sendBatch(streamID: scratch.stream, topicID: scratch.topic, partitioning: .partition(0), messages: [message, binary])
 
             let polled = try await client.pollMessages(
-                streamID: scratch.stream, topicID: scratch.topic, partitionID: 0, consumer: .default, strategy: .first, count: 10, autoCommit: false)
+                streamID: scratch.stream, topicID: scratch.topic, partitionID: 0, consumer: .default, strategy: .first, options: PollOptions(count: 10))
             #expect(polled.messages.count == 2)
             let first = polled.messages[0]
             #expect(first.id == id)
@@ -479,7 +479,8 @@
             #expect(stored.partitionID == 0)
 
             let next = try await client.pollMessages(
-                streamID: scratch.stream, topicID: scratch.topic, partitionID: 0, consumer: consumer, strategy: .next, count: 10, autoCommit: true)
+                streamID: scratch.stream, topicID: scratch.topic, partitionID: 0, consumer: consumer, strategy: .next,
+                options: PollOptions(count: 10, commit: .onRead))
             #expect(next.messages.map(\.offset) == [4])
             #expect(try await client.getConsumerOffset(consumer: consumer, streamID: scratch.stream, topicID: scratch.topic, partitionID: 0)?.storedOffset == 4)
 
@@ -499,15 +500,15 @@
             _ = try await client.sendBatch(
                 streamID: scratch.stream, topicID: scratch.topic, partitioning: .partition(1), messages: try E2EEnvironment.messages(3))
             let polled = try await client.pollMessages(
-                streamID: scratch.stream, topicID: scratch.topic, partitionID: 1, consumer: consumer, strategy: .first, count: 3, autoCommit: false)
-            #expect(polled.messages.map(\.offset) == [0, 1, 2])
-            let position = ConsumerPosition(partitionID: 1, offset: 2, context: polled.context)
-            try await client.storeConsumerPosition(consumer: consumer, streamID: scratch.stream, topicID: scratch.topic, position: position)
-            #expect(try await client.getConsumerOffset(consumer: consumer, streamID: scratch.stream, topicID: scratch.topic, partitionID: 1)?.storedOffset == 2)
-            let continued = PollingStrategy.offset(1, context: polled.context)
+                streamID: scratch.stream, topicID: scratch.topic, partitionID: 1, consumer: consumer, strategy: .first, options: PollOptions(count: 1))
+            #expect(polled.messages.map(\.offset) == [0])
+            let continued = polled.continuation
             let more = try await client.pollMessages(
-                streamID: scratch.stream, topicID: scratch.topic, partitionID: 1, consumer: consumer, strategy: continued, count: 10, autoCommit: false)
+                streamID: scratch.stream, topicID: scratch.topic, partitionID: 1, consumer: consumer, strategy: continued, options: PollOptions(count: 10))
             #expect(more.messages.map(\.offset) == [1, 2])
+            let checkpoint = try #require(more.checkpoint)
+            try await client.storeConsumerPosition(consumer: consumer, streamID: scratch.stream, topicID: scratch.topic, checkpoint: checkpoint)
+            #expect(try await client.getConsumerOffset(consumer: consumer, streamID: scratch.stream, topicID: scratch.topic, partitionID: 1)?.storedOffset == 2)
 
             // Another client recreates the partition, so its id now names a
             // new incarnation this client has not seen.
@@ -522,14 +523,14 @@
             // Neither a checkpoint nor a continuation of the old incarnation
             // lands on the new one.
             await expectIggyError(.historyUnavailable) {
-                try await client.storeConsumerPosition(consumer: consumer, streamID: scratch.stream, topicID: scratch.topic, position: position)
+                try await client.storeConsumerPosition(consumer: consumer, streamID: scratch.stream, topicID: scratch.topic, checkpoint: checkpoint)
             }
             await expectIggyError(.historyUnavailable) {
                 _ = try await client.pollMessages(
-                    streamID: scratch.stream, topicID: scratch.topic, partitionID: 1, consumer: consumer, strategy: continued, count: 10, autoCommit: false)
+                    streamID: scratch.stream, topicID: scratch.topic, partitionID: 1, consumer: consumer, strategy: continued, options: PollOptions(count: 10))
             }
             let fresh = try await client.pollMessages(
-                streamID: scratch.stream, topicID: scratch.topic, partitionID: 1, consumer: consumer, strategy: .first, count: 10, autoCommit: false)
+                streamID: scratch.stream, topicID: scratch.topic, partitionID: 1, consumer: consumer, strategy: .first, options: PollOptions(count: 10))
             #expect(fresh.messages.count == 1)
             #expect(fresh.context.incarnation != polled.context.incarnation)
         }
@@ -567,40 +568,48 @@
         withUnsafeBytes(of: value.littleEndian) { Array($0) }
     }
 
-    @Test func consumerGroupPolling() async throws {
+    @Test func consumerGroupReading() async throws {
         try await E2EEnvironment.withSession() { session in
             let client = session.client
             let scratch = try await session.scratchTopic(partitions: 3)
             let group = try await client.createConsumerGroup(streamID: scratch.stream, topicID: scratch.topic, name: "swift-e2e-group")
             let groupID = Identifier(numeric: group.id)
+            let options = PollOptions(count: 2, maxWait: .seconds(1), commit: .onRead)
 
-            // Polling a group this client never joined is an error, not silence.
-            await expectIggyError(.consumerGroupMemberNotFound) {
-                _ = try await client.pollMessages(
-                    streamID: scratch.stream, topicID: scratch.topic, partitionID: nil, consumer: .group(groupID), strategy: .next, count: 2, autoCommit: true)
+            // Reading a group this client never joined is an error, not silence.
+            let outsider = try await client.reader(
+                streamID: scratch.stream, topicID: scratch.topic, consumer: .group(groupID),
+                configuration: ReaderConfiguration(options: options, autoJoinConsumerGroup: false))
+            do {
+                _ = try await outsider.next()
+                Issue.record("a reader outside the group read")
+            } catch let error as ReadError {
+                #expect(error.error.code == .consumerGroupMemberNotFound)
             }
+            try await outsider.close()
 
-            try await client.joinConsumerGroup(streamID: scratch.stream, topicID: scratch.topic, groupID: groupID)
             for partition: UInt32 in 0..<3 {
                 _ = try await client.sendBatch(
                     streamID: scratch.stream, topicID: scratch.topic, partitioning: .partition(partition), messages: try E2EEnvironment.messages(2))
             }
-            // The server installs the member's partitions after the join, so
-            // the member polls until it has read every one of them. A group
-            // poll without a partition round-robins the owned partitions.
-            var polledPartitions = Set<UInt32>()
-            let polledAll = try await E2EEnvironment.eventually {
-                let polled = try await client.pollMessages(
-                    streamID: scratch.stream, topicID: scratch.topic, partitionID: nil, consumer: .group(groupID), strategy: .next, count: 2, autoCommit: true)
-                if !polled.messages.isEmpty {
-                    #expect(polled.messages.count == 2)
-                    polledPartitions.insert(polled.partitionID)
+            // The reader joins, and the server installs the member's
+            // partitions after the join, so it reads until it has read every
+            // one of them.
+            let reader = try await client.reader(
+                streamID: scratch.stream, topicID: scratch.topic, consumer: .group(groupID), configuration: ReaderConfiguration(options: options))
+            // A read of the group shares its count between the partitions of
+            // one primary, so a batch can hold fewer than two messages.
+            var read: [UInt32: Int] = [:]
+            let readAll = try await E2EEnvironment.eventually {
+                guard let batch = try await reader.next() else {
+                    return false
                 }
-                return polledPartitions.count == 3
+                read[batch.partitionID, default: 0] += batch.messages.count
+                return read.count == 3 && read.values.allSatisfy { $0 == 2 }
             }
-            #expect(polledAll)
-            #expect(polledPartitions == [0, 1, 2])
-            try await client.leaveConsumerGroup(streamID: scratch.stream, topicID: scratch.topic, groupID: groupID)
+            #expect(readAll)
+            #expect(read == [0: 2, 1: 2, 2: 2])
+            try await reader.close()
             try await client.deleteConsumerGroup(streamID: scratch.stream, topicID: scratch.topic, groupID: groupID)
         }
     }
@@ -615,7 +624,7 @@
             // segment, so the request is accepted and removes nothing.
             try await client.deleteSegments(streamID: scratch.stream, topicID: scratch.topic, partitionID: 0, count: 1)
             let polled = try await client.pollMessages(
-                streamID: scratch.stream, topicID: scratch.topic, partitionID: 0, consumer: .default, strategy: .first, count: 10, autoCommit: false)
+                streamID: scratch.stream, topicID: scratch.topic, partitionID: 0, consumer: .default, strategy: .first, options: PollOptions(count: 10))
             #expect(polled.messages.map(\.offset) == [0, 1, 2])
             // Topic stats come from the replica that answers, which can trail
             // the partition primary until it applies the commit.
@@ -635,7 +644,7 @@
             let many = try (0..<1000).map { try IggyMessage("message \($0)") }
             _ = try await client.sendBatch(streamID: scratch.stream, topicID: scratch.topic, partitioning: .partition(0), messages: [big] + many)
             let polled = try await client.pollMessages(
-                streamID: scratch.stream, topicID: scratch.topic, partitionID: 0, consumer: .default, strategy: .first, count: 1001, autoCommit: false)
+                streamID: scratch.stream, topicID: scratch.topic, partitionID: 0, consumer: .default, strategy: .first, options: PollOptions(count: 1001))
             #expect(polled.messages.count == 1001)
             #expect(polled.messages[0].payload.count == 512 * 1024)
             #expect(polled.messages[1000].payloadString == "message 999")
diff --git a/foreign/swift/Tests/IggyE2ETests/ConsumerE2ETests.swift b/foreign/swift/Tests/IggyE2ETests/ConsumerE2ETests.swift
index af7620a..f71be4f 100644
--- a/foreign/swift/Tests/IggyE2ETests/ConsumerE2ETests.swift
+++ b/foreign/swift/Tests/IggyE2ETests/ConsumerE2ETests.swift
@@ -45,29 +45,34 @@
 
 @Suite("Consumer", requiresServer)
 struct ConsumerE2ETests {
-    /// The default auto-commit stores with every poll, so a second consumer
-    /// of the same name resumes after what the first one read.
-    @Test func aConsumerResumesAfterTheOffsetItStored() async throws {
+    /// Each policy stores the position after the last message handed over
+    /// by the shutdown: the server with the read and the shutdown moving it
+    /// back over what was not handed over, or the consumer flushing what it
+    /// counted. A second consumer of the same name resumes after it.
+    @Test(arguments: [CommitPolicy.onRead, .auto(.default), .auto(AutoCommit(interval: .seconds(60), after: .delivery))])
+    func aConsumerResumesAfterTheLastMessageHandedOver(commit: CommitPolicy) async throws {
         try await E2EEnvironment.withSession { session in
             let client = session.client
             let scratch = try await session.scratchTopic(partitions: 1)
             let name = E2EEnvironment.uniqueName("swift-e2e-reader")
             var batch = try E2EEnvironment.messages(5)
             try await client.sendMessages(streamID: scratch.stream, topicID: scratch.topic, partitioning: .partition(0), messages: &batch)
+            let configuration = ConsumerConfiguration(pollOptions: PollOptions(count: 5, maxWait: .seconds(1), commit: commit))
 
-            let first = try client.consumer(name: name, streamID: scratch.stream, topicID: scratch.topic, partitionID: 0)
+            let first = try client.consumer(name: name, streamID: scratch.stream, topicID: scratch.topic, partitions: [0], configuration: configuration)
             try await first.initialize()
-            let received = try await read(first, 5)
-            #expect(received.map(\.message.payloadString) == (0..<5).map { "message \($0)" })
-            #expect(received.map(\.partitionID) == Array(repeating: 0, count: 5))
+            let received = try await read(first, 3)
+            #expect(received.map(\.message.payloadString) == (0..<3).map { "message \($0)" })
+            #expect(received.map(\.partitionID) == Array(repeating: 0, count: 3))
             #expect(received.last?.currentOffset == 4)
             await first.shutdown()
+            let stored = try await client.getConsumerOffset(
+                consumer: .consumer(try Identifier(named: name)), streamID: scratch.stream, topicID: scratch.topic, partitionID: 0)
+            #expect(stored?.storedOffset == 2, "\(commit)")
 
-            var more = try E2EEnvironment.messages(2, prefix: "more")
-            try await client.sendMessages(streamID: scratch.stream, topicID: scratch.topic, partitioning: .partition(0), messages: &more)
-            let second = try client.consumer(name: name, streamID: scratch.stream, topicID: scratch.topic, partitionID: 0)
+            let second = try client.consumer(name: name, streamID: scratch.stream, topicID: scratch.topic, partitions: [0], configuration: configuration)
             try await second.initialize()
-            #expect(try await read(second, 2).map(\.message.offset) == [5, 6])
+            #expect(try await read(second, 2).map(\.message.offset) == [3, 4], "\(commit)")
             await second.shutdown()
         }
     }
@@ -83,7 +88,8 @@
                 try await client.sendMessages(streamID: scratch.stream, topicID: scratch.topic, partitioning: .partition(partition), messages: &batch)
             }
             let name = E2EEnvironment.uniqueName("swift-e2e-group")
-            let member = try client.consumerGroup(name: name, streamID: scratch.stream, topicID: scratch.topic)
+            let member = try client.consumerGroup(
+                name: name, streamID: scratch.stream, topicID: scratch.topic, configuration: ConsumerConfiguration(pollOptions: .wait(.seconds(1))))
             try await member.initialize()
             let received = try await read(member, 4)
             #expect(Set(received.map(\.partitionID)) == [0, 1])
diff --git a/foreign/swift/Tests/IggyE2ETests/DeferredPollE2ETests.swift b/foreign/swift/Tests/IggyE2ETests/DeferredPollE2ETests.swift
new file mode 100644
index 0000000..63834dd
--- /dev/null
+++ b/foreign/swift/Tests/IggyE2ETests/DeferredPollE2ETests.swift
@@ -0,0 +1,185 @@
+// 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.
+
+import Foundation
+import Iggy
+import Testing
+
+@Suite("Deferred polls", requiresServer)
+struct DeferredPollE2ETests {
+    private static let parking: Duration = .seconds(10)
+    /// Far below ``parking``, so only a read that the message woke returns
+    /// in time.
+    private static let prompt: Duration = .seconds(2)
+
+    /// One member owns all three partitions and its reader waits on all of
+    /// them, one read per primary. Before each read another partition gets
+    /// a message, so a read that missed it would wait out its ten seconds.
+    @Test func aReaderReadsThePartitionThatGotAMessage() async throws {
+        try await E2EEnvironment.withSession { session in
+            let client = session.client
+            let scratch = try await session.scratchTopic(partitions: 3)
+            let group = try Identifier(named: E2EEnvironment.uniqueName("swift-e2e-ready"))
+            let reader = try await client.reader(
+                streamID: scratch.stream, topicID: scratch.topic, consumer: .group(group),
+                configuration: ReaderConfiguration(options: PollOptions(count: 10, maxWait: Self.parking, commit: .onRead)))
+            #expect(
+                try await E2EEnvironment.eventually {
+                    try await client.getConsumerGroup(streamID: scratch.stream, topicID: scratch.topic, groupID: group)?.members.first?.partitions.count == 3
+                })
+            for (round, partition) in [UInt32(2), 0, 1].enumerated() {
+                try await client.sendBatch(
+                    streamID: scratch.stream, topicID: scratch.topic, partitioning: .partition(partition), messages: [try IggyMessage("round \(round)")])
+                let started = ContinuousClock.now
+                let batch = try #require(try await reader.next())
+                let elapsed = ContinuousClock.now - started
+                #expect(elapsed < Self.prompt, "round \(round) took \(elapsed)")
+                #expect(batch.partitionID == partition, "round \(round)")
+                #expect(batch.messages.map(\.payloadString) == ["round \(round)"], "round \(round)")
+            }
+            try await reader.close()
+        }
+    }
+
+    /// A read of many partitions takes them in order while its count leaves
+    /// room, so a partition with a backlog would hide the partitions after
+    /// it. The reader moves the partitions that a read served to the end of
+    /// the next one, so a message on another partition gets through before
+    /// the backlog drains, whichever primaries hold the partitions.
+    @Test func aBacklogDoesNotHideAMessageOnAnotherPartition() async throws {
+        try await E2EEnvironment.withSession { session in
+            let client = session.client
+            let scratch = try await session.scratchTopic(partitions: 2)
+            let backlog = try (0..<1000).map { try IggyMessage("backlog \($0)") }
+            try await client.sendBatch(streamID: scratch.stream, topicID: scratch.topic, partitioning: .partition(0), messages: backlog)
+            try await client.sendBatch(streamID: scratch.stream, topicID: scratch.topic, partitioning: .partition(1), messages: ["late"])
+            let reader = try await client.reader(
+                streamID: scratch.stream, topicID: scratch.topic, consumer: .consumer(try Identifier(named: E2EEnvironment.uniqueName("swift-e2e-backlog"))),
+                configuration: ReaderConfiguration(partitions: [0, 1], start: .first, options: PollOptions(count: 1, maxWait: .seconds(1))))
+            var payloads: [String] = []
+            for _ in 0..<4 {
+                payloads += try #require(try await reader.next()).messages.map(\.payloadString)
+            }
+            #expect(payloads.contains("late"), "\(payloads)")
+            try await reader.close()
+        }
+    }
+
+    /// A consumer parked on an idle partition hands a message over as soon
+    /// as it is sent, not once its wait ends.
+    @Test func aConsumerHandsOverAMessageSentAfterAnIdlePeriodAtOnce() async throws {
+        try await E2EEnvironment.withSession { session in
+            let client = session.client
+            let scratch = try await session.scratchTopic(partitions: 1)
+            let consumer = try client.consumer(
+                name: E2EEnvironment.uniqueName("swift-e2e-idle"), streamID: scratch.stream, topicID: scratch.topic, partitions: [0],
+                configuration: ConsumerConfiguration(pollOptions: .wait(Self.parking)))
+            try await consumer.initialize()
+            let reading = Task { try await consumer.next() }
+            try await Task.sleep(for: .seconds(3))
+            let sent = ContinuousClock.now
+            try await client.sendBatch(streamID: scratch.stream, topicID: scratch.topic, partitioning: .partition(0), messages: ["after idle"])
+            let received = try await reading.value
+            let elapsed = ContinuousClock.now - sent
+            #expect(elapsed < Self.prompt, "took \(elapsed)")
+            #expect(received?.message.payloadString == "after idle")
+            await consumer.shutdown()
+        }
+    }
+
+    /// The server drops a cancelled poll without a reply, so the next poll
+    /// on the same client gets its own answer.
+    @Test func aCancelledParkedPollLeavesTheClientInStep() async throws {
+        try await E2EEnvironment.withSession { session in
+            let client = session.client
+            let scratch = try await session.scratchTopic(partitions: 1)
+            let poll = { @Sendable in
+                try await client.pollMessages(
+                    streamID: scratch.stream, topicID: scratch.topic, partitionID: 0, strategy: .next,
+                    options: PollOptions(count: 10, maxWait: Self.parking, commit: .onRead))
+            }
+            let parked = Task { try await poll() }
+            try await Task.sleep(for: .milliseconds(500))
+            parked.cancel()
+            await #expect(throws: CancellationError.self) { try await parked.value }
+            try await client.sendBatch(streamID: scratch.stream, topicID: scratch.topic, partitioning: .partition(0), messages: ["after cancel"])
+            let started = ContinuousClock.now
+            let polled = try await poll()
+            let elapsed = ContinuousClock.now - started
+            #expect(elapsed < Self.prompt, "took \(elapsed)")
+            #expect(polled.messages.map(\.payloadString) == ["after cancel"])
+        }
+    }
+
+    /// The poll asks for 2 messages and finds 1, so it stays parked until the
+    /// cancellation, which ends it on the server without a commit. A poll
+    /// that the server still held would expire after `maxWait` and commit the
+    /// offset of the message it found.
+    @Test func aCancelledCommittingPollStoresNoOffset() async throws {
+        try await E2EEnvironment.withSession { session in
+            let client = session.client
+            let scratch = try await session.scratchTopic(partitions: 1)
+            try await client.sendBatch(streamID: scratch.stream, topicID: scratch.topic, partitioning: .partition(0), messages: ["unseen"])
+            let options = PollOptions(count: 10, maxWait: .seconds(1), minCount: 2, commit: .onRead)
+            let parked = Task {
+                try await client.pollMessages(streamID: scratch.stream, topicID: scratch.topic, partitionID: 0, strategy: .next, options: options)
+            }
+            try await Task.sleep(for: .milliseconds(200))
+            parked.cancel()
+            await #expect(throws: CancellationError.self) { try await parked.value }
+            try await Task.sleep(for: options.maxWait + .seconds(1))
+            let polled = try await client.pollMessages(
+                streamID: scratch.stream, topicID: scratch.topic, partitionID: 0, strategy: .next, options: PollOptions(count: 10))
+            #expect(polled.messages.map(\.payloadString) == ["unseen"])
+        }
+    }
+
+    /// A disconnect while a committing poll waits leaves its outcome unknown,
+    /// so the poll fails as uncertain and its partition is blocked, across
+    /// the new session, until the caller abandons the poll.
+    @Test func aCommittingPollThatWaitsWhenTheClientDisconnectsBlocksItsPartition() async throws {
+        try await E2EEnvironment.withSession { session in
+            let client = session.client
+            let scratch = try await session.scratchTopic(partitions: 1)
+            let consumer = Consumer.consumer(try Identifier(named: E2EEnvironment.uniqueName("swift-e2e-lost")))
+            let options = PollOptions(count: 1, maxWait: Self.parking, commit: .onRead)
+            let poll = { @Sendable in
+                try await client.pollMessages(
+                    streamID: scratch.stream, topicID: scratch.topic, partitionID: 0, consumer: consumer, strategy: .next, options: options)
+            }
+            // A read that waits for nothing first opens the route and the
+            // connection, so the committing poll only writes its frame.
+            _ = try await client.pollMessages(
+                streamID: scratch.stream, topicID: scratch.topic, partitionID: 0, consumer: consumer, strategy: .offset(0),
+                options: PollOptions(count: 1, maxWait: .zero))
+            let parked = Task { try await poll() }
+            try await Task.sleep(for: .milliseconds(500))
+
+            await client.disconnect()
+            let lost = await #expect(throws: IggyError.self) { _ = try await parked.value }
+
+            #expect(lost?.code == .fetchProgressUncertain, "\(String(describing: lost))")
+            try await client.connect()
+            _ = try await client.login(username: E2EEnvironment.rootUsername, password: E2EEnvironment.rootPassword)
+            let blocked = await #expect(throws: IggyError.self) { _ = try await poll() }
+            #expect(blocked?.code == .partitionLaneBlocked && blocked?.operationID == lost?.operationID, "\(String(describing: blocked))")
+            try client.abandonOperation(try #require(lost?.operationID))
+            try await client.sendBatch(streamID: scratch.stream, topicID: scratch.topic, partitioning: .partition(0), messages: ["after"])
+            #expect(try await poll().messages.map(\.payloadString) == ["after"])
+        }
+    }
+}
diff --git a/foreign/swift/Tests/IggyE2ETests/ProducerE2ETests.swift b/foreign/swift/Tests/IggyE2ETests/ProducerE2ETests.swift
index d8a946c..b72a556 100644
--- a/foreign/swift/Tests/IggyE2ETests/ProducerE2ETests.swift
+++ b/foreign/swift/Tests/IggyE2ETests/ProducerE2ETests.swift
@@ -38,7 +38,7 @@
             #expect(response.confirmations.map(\.partitionID) == [1, 1, 1])
             #expect(response.confirmations.map(\.baseOffset) == [0, 2, 4])
             let polled = try await client.pollMessages(
-                streamID: stream, topicID: try Identifier(named: name), partitionID: 1, strategy: .first, count: 10, autoCommit: false)
+                streamID: stream, topicID: try Identifier(named: name), partitionID: 1, strategy: .first, options: PollOptions(count: 10))
             #expect(polled.messages.map(\.payloadString) == (0..<5).map { "message \($0)" })
             await producer.shutdown()
         }
@@ -57,7 +57,7 @@
             }
             await producer.shutdown()
             let polled = try await client.pollMessages(
-                streamID: scratch.stream, topicID: scratch.topic, partitionID: 0, strategy: .first, count: 10, autoCommit: false)
+                streamID: scratch.stream, topicID: scratch.topic, partitionID: 0, strategy: .first, options: PollOptions(count: 10))
             #expect(polled.messages.count == 6)
             #expect(polled.messages.first?.payloadString == "batch 0 0")
             #expect(polled.messages.last?.payloadString == "batch 2 1")
diff --git a/foreign/swift/Tests/IggyE2ETests/ReaderE2ETests.swift b/foreign/swift/Tests/IggyE2ETests/ReaderE2ETests.swift
new file mode 100644
index 0000000..3fba46c
--- /dev/null
+++ b/foreign/swift/Tests/IggyE2ETests/ReaderE2ETests.swift
@@ -0,0 +1,124 @@
+// 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.
+
+import Foundation
+import Iggy
+import Testing
+
+@Suite("Partition reader", requiresServer)
+struct ReaderE2ETests {
+    private static let options = PollOptions(count: 3, maxWait: .seconds(1))
+
+    private static func storedOffset(
+        _ client: IggyClient, _ consumer: Consumer, _ scratch: TestSession.ScratchTopic, partition: UInt32 = 0
+    ) async throws -> UInt64? {
+        try await client.getConsumerOffset(consumer: consumer, streamID: scratch.stream, topicID: scratch.topic, partitionID: partition)?.storedOffset
+    }
+
+    /// With the manual policy only a commit stores, and the close stores
+    /// nothing more.
+    @Test func aManualReaderStoresOnlyWhatItCommits() async throws {
+        try await E2EEnvironment.withSession { session in
+            let client = session.client
+            let scratch = try await session.scratchTopic(partitions: 1)
+            try await client.sendBatch(streamID: scratch.stream, topicID: scratch.topic, partitioning: .partition(0), messages: try E2EEnvironment.messages(3))
+            let consumer = Consumer.consumer(try Identifier(named: E2EEnvironment.uniqueName("swift-e2e-manual")))
+            let reader = try await client.reader(
+                streamID: scratch.stream, topicID: scratch.topic, consumer: consumer,
+                configuration: ReaderConfiguration(partitions: [0], options: Self.options))
+            let batch = try #require(try await reader.next())
+            #expect(batch.messages.map(\.offset) == [0, 1, 2])
+            #expect(try await Self.storedOffset(client, consumer, scratch) == nil)
+            try await reader.commit(try #require(batch.checkpoint(at: 1)))
+            #expect(try await Self.storedOffset(client, consumer, scratch) == 1)
+            try await reader.close()
+            #expect(try await Self.storedOffset(client, consumer, scratch) == 1)
+        }
+    }
+
+    /// A seek starts the partition again where it says, and a continuation
+    /// of another partition is refused.
+    @Test func aSeekReadsAgainFromItsStart() async throws {
+        try await E2EEnvironment.withSession { session in
+            let client = session.client
+            let scratch = try await session.scratchTopic(partitions: 2)
+            try await client.sendBatch(streamID: scratch.stream, topicID: scratch.topic, partitioning: .partition(0), messages: try E2EEnvironment.messages(3))
+            let reader = try await client.reader(
+                streamID: scratch.stream, topicID: scratch.topic, consumer: .consumer(try Identifier(named: E2EEnvironment.uniqueName("swift-e2e-seek"))),
+                configuration: ReaderConfiguration(partitions: [0, 1], start: .first, options: Self.options))
+            let batch = try #require(try await reader.next())
+            #expect(batch.messages.map(\.offset) == [0, 1, 2])
+            await expectIggyError(.invalidCommand) {
+                try await reader.seek(partitionID: 1, to: batch.continuation)
+            }
+            try await reader.seek(partitionID: 0, to: .offset(1))
+            #expect(try #require(try await reader.next()).messages.map(\.offset) == [1, 2])
+            try await reader.close()
+        }
+    }
+
+    /// With the onRead policy the server stores each read. A seek to an
+    /// offset stores the offset before it first, so the reads that follow go
+    /// on from there.
+    @Test func anOnReadSeekStoresTheOffsetBeforeItsStart() async throws {
+        try await E2EEnvironment.withSession { session in
+            let client = session.client
+            let scratch = try await session.scratchTopic(partitions: 1)
+            try await client.sendBatch(streamID: scratch.stream, topicID: scratch.topic, partitioning: .partition(0), messages: try E2EEnvironment.messages(3))
+            let consumer = Consumer.consumer(try Identifier(named: E2EEnvironment.uniqueName("swift-e2e-on-read")))
+            var options = Self.options
+            options.commit = .onRead
+            let reader = try await client.reader(
+                streamID: scratch.stream, topicID: scratch.topic, consumer: consumer, configuration: ReaderConfiguration(partitions: [0], options: options))
+            #expect(try #require(try await reader.next()).messages.map(\.offset) == [0, 1, 2])
+            #expect(try await Self.storedOffset(client, consumer, scratch) == 2)
+            try await reader.seek(partitionID: 0, to: .offset(1))
+            #expect(try await Self.storedOffset(client, consumer, scratch) == 0)
+            #expect(try #require(try await reader.next()).messages.map(\.offset) == [1, 2])
+            #expect(try await Self.storedOffset(client, consumer, scratch) == 2)
+            try await reader.close()
+        }
+    }
+
+    /// A first message larger than `maxBytes` comes alone in one reply, to a
+    /// single poll and to a reader alike.
+    @Test func aMessageLargerThanMaxBytesComesAlone() async throws {
+        try await E2EEnvironment.withSession { session in
+            let client = session.client
+            let scratch = try await session.scratchTopic(partitions: 1)
+            let large = try IggyMessage(payload: Array(repeating: UInt8(7), count: 4096))
+            try await client.sendBatch(
+                streamID: scratch.stream, topicID: scratch.topic, partitioning: .partition(0), messages: [large, try IggyMessage("small")])
+            let options = PollOptions(count: 10, maxBytes: 1024)
+            let polled = try await client.pollMessages(streamID: scratch.stream, topicID: scratch.topic, partitionID: 0, strategy: .first, options: options)
+            #expect(polled.messages.map(\.payload.count) == [4096])
+            let rest = try await client.pollMessages(
+                streamID: scratch.stream, topicID: scratch.topic, partitionID: 0, strategy: polled.continuation, options: options)
+            #expect(rest.messages.map(\.payloadString) == ["small"])
+
+            let reader = try await client.reader(
+                streamID: scratch.stream, topicID: scratch.topic, consumer: .consumer(try Identifier(named: E2EEnvironment.uniqueName("swift-e2e-large"))),
+                configuration: ReaderConfiguration(partitions: [0], start: .first, options: options))
+            var offsets: [UInt64] = []
+            while offsets.count < 2 {
+                offsets += try #require(try await reader.next()).messages.map(\.offset)
+            }
+            #expect(offsets == [0, 1])
+            try await reader.close()
+        }
+    }
+}
diff --git a/foreign/swift/Tests/IggyE2ETests/ResourceOptionsE2ETests.swift b/foreign/swift/Tests/IggyE2ETests/ResourceOptionsE2ETests.swift
index aad50e5..f58fc8a 100644
--- a/foreign/swift/Tests/IggyE2ETests/ResourceOptionsE2ETests.swift
+++ b/foreign/swift/Tests/IggyE2ETests/ResourceOptionsE2ETests.swift
@@ -97,7 +97,7 @@
             #expect(fetched.projection == created.projection)
             #expect(fetched.encryptionKeyFingerprint == (try EncryptionKey.fingerprint(key)))
             let polled = try await client.pollMessages(
-                streamID: Identifier(named: renamed), topicID: topicID, partitionID: 0, strategy: .first, count: 1, autoCommit: false)
+                streamID: Identifier(named: renamed), topicID: topicID, partitionID: 0, strategy: .first, options: PollOptions(count: 1))
             #expect(polled.messages.map(\.payloadString) == ["sealed payload"])
 
             // No stream key is resettable and the projections are read-only,
diff --git a/foreign/swift/Tests/IggyTests/ClientTransportTests.swift b/foreign/swift/Tests/IggyTests/ClientTransportTests.swift
index 307c5ea..f77aa69 100644
--- a/foreign/swift/Tests/IggyTests/ClientTransportTests.swift
+++ b/foreign/swift/Tests/IggyTests/ClientTransportTests.swift
@@ -89,8 +89,8 @@
             self.released = released
         }
 
-        func exchange(_ frame: [UInt8], timeout: Duration) async throws -> RawFrame {
-            try await underlying.exchange(frame, timeout: timeout)
+        func exchange(_ frame: [UInt8], timeout: Duration, cancellation: DeferredCancellation?) async throws -> RawFrame {
+            try await underlying.exchange(frame, timeout: timeout, cancellation: cancellation)
         }
 
         func close() async {
diff --git a/foreign/swift/Tests/IggyTests/ConsumerTests.swift b/foreign/swift/Tests/IggyTests/ConsumerTests.swift
index c973fa0..8c4b3ad 100644
--- a/foreign/swift/Tests/IggyTests/ConsumerTests.swift
+++ b/foreign/swift/Tests/IggyTests/ConsumerTests.swift
@@ -20,85 +20,30 @@
 
 @testable import Iggy
 
-private func signedInClient(
-    _ server: FakeVSRServer, requestTimeout: Duration = .seconds(10), heartbeatInterval: Duration = .seconds(60)
-) async throws -> IggyClient {
+private func signedInClient(_ server: FakeVSRServer) async throws -> IggyClient {
     var configuration = ClientConfiguration(address: server.address, autoLogin: .usernamePassword(username: "iggy", password: "iggy"))
     configuration.reconnection = ReconnectionOptions(maxRetries: nil, interval: .milliseconds(20), reestablishAfter: .zero)
-    configuration.requestTimeout = requestTimeout
+    configuration.requestTimeout = .seconds(10)
     configuration.connectTimeout = .seconds(2)
-    configuration.heartbeatInterval = heartbeatInterval
+    configuration.heartbeatInterval = .seconds(60)
     let client = IggyClient(configuration: configuration, connector: NIOConnector(), logger: testLogger)
     try await client.connect()
     return client
 }
 
-/// Answers the lookups a consumer makes before it reads: the stream, the
-/// topic and the group exist. `poll` answers every poll.
-private func serve(_ server: FakeVSRServer, poll: @escaping @Sendable (FakeVSRServer.Request) async -> FakeVSRServer.Reply) async {
-    await server.respond { request in
-        switch request.code.flatMap(CommandCode.init(rawValue:)) {
-        case .getStream: return .ok(GoldenFixture.shared.bytes("response.get_stream"))
-        case .getTopic: return .ok(GoldenFixture.shared.bytes("response.get_topic"))
-        case .getConsumerGroup, .createConsumerGroup: return .ok(GoldenFixture.shared.bytes("response.consumer_group_details"))
-        case .syncConsumerGroup: return .ok(FakeVSRServer.assignmentBody(generation: 1, partitions: [1]))
-        case .pollMessages: return await poll(request)
-        default: return FakeVSRServer.defaultReply(request, address: server.address)
-        }
-    }
-}
-
-/// Answers each poll with one message, as a partition holding `count`
-/// messages does: `next` starts after the stored offset, or at zero when
-/// none is stored, and a poll that commits stores the offset it returns.
-private func serveOneByOne(_ server: FakeVSRServer, count: UInt64) async {
-    let stored = Locked<UInt64?>(nil)
-    await server.respond { request in
-        switch request.code.flatMap(CommandCode.init(rawValue:)) {
-        case .getStream: return .ok(GoldenFixture.shared.bytes("response.get_stream"))
-        case .getTopic: return .ok(GoldenFixture.shared.bytes("response.get_topic"))
-        case .storeConsumerOffset:
-            stored.withLock { $0 = storedOffset(request) }
-            return .ok([])
-        case .pollMessages:
-            let poll = polledStrategy(request)
-            let offset = poll.kind == PollingStrategy.Kind.next.rawValue ? stored.withLock { $0.map { $0 + 1 } ?? 0 } : poll.value
-            guard offset < count else {
-                return .ok(FakeVSRServer.polledBody(partitionID: 1, offsets: []))
-            }
-            if poll.autoCommit {
-                stored.withLock { $0 = offset }
-            }
-            return .ok(FakeVSRServer.polledBody(partitionID: 1, offsets: [offset]))
-        default: return FakeVSRServer.defaultReply(request, address: server.address)
-        }
-    }
-}
-
-/// Answers the polls with the batches in turn, then with empty polls.
-private func batches(
-    _ batches: [[UInt64]], partitionID: UInt32 = 1, context: PartitionContext = FakeVSRServer.routeContext
-) -> @Sendable (FakeVSRServer.Request) async -> FakeVSRServer.Reply {
-    let next = Locked(0)
-    return { _ in
-        let index = next.withLock { index in
-            defer { index += 1 }
-            return index
-        }
-        let offsets = index < batches.count ? batches[index] : []
-        return .ok(FakeVSRServer.polledBody(partitionID: partitionID, offsets: offsets, context: context))
-    }
-}
-
 private func requests(_ server: FakeVSRServer, _ code: CommandCode) async -> [FakeVSRServer.Request] {
     await server.requests.filter { $0.code == code.rawValue }
 }
 
-/// The strategy and auto-commit flag a poll ends with:
-/// `[kind:1][value:8][count:4][auto_commit:1]`.
-private func polledStrategy(_ request: FakeVSRServer.Request) -> (kind: UInt8, value: UInt64, autoCommit: Bool) {
-    let end = request.payload.count
-    return (request.payload[end - 14], UInt64(littleEndianBytes: request.payload[end - 13..<end - 5]), request.payload[end - 1] == 1)
+/// The strategy, count and auto-commit flag a poll carries before its
+/// options: `[kind:1][value:8][count:4][auto_commit:1]`.
+private func polledStrategy(_ request: FakeVSRServer.Request) -> (kind: UInt8, value: UInt64, count: UInt32, autoCommit: Bool) {
+    let end = request.payload.count - FakeVSRServer.Request.deferredOptionsSize
+    return (
+        request.payload[end - 14], UInt64(littleEndianBytes: request.payload[end - 13..<end - 5]),
+        UInt32(littleEndianBytes: request.payload[end - 5..<end - 1]),
+        request.payload[end - 1] == 1
+    )
 }
 
 /// The offset a store ends with: `[partition:4][offset:8][ack:1]`.
@@ -107,6 +52,232 @@
     return UInt64(littleEndianBytes: request.payload[end - 9..<end - 1])
 }
 
+/// The partition of a store, before its offset.
+private func storedPartition(_ request: FakeVSRServer.Request) -> UInt32 {
+    let end = request.payload.count
+    return UInt32(littleEndianBytes: request.payload[end - 13..<end - 9])
+}
+
+/// The partition a poll reads, right before its strategy.
+private func polledPartition(_ request: FakeVSRServer.Request) -> UInt32 {
+    let end = request.payload.count - FakeVSRServer.Request.deferredOptionsSize
+    return UInt32(littleEndianBytes: request.payload[end - 18..<end - 14])
+}
+
+/// A topic as the server reads it. Partition `p` holds the messages at
+/// `0..<ends[p]`. A `next` read starts after the stored offset, a read that
+/// commits stores the last offset it returns, and a read that finds nothing
+/// waits for a message until its wait ends. A message in `undecodable` does
+/// not decode. A message in `oversized` comes alone to a poll, and a fetch
+/// refuses its section with `invalidSizeBytes`. `losesReply` drops the reply
+/// of that many committing fetches after they stored their offsets.
+private final class FakeTopic: Sendable {
+    private struct State {
+        var ends: [UInt32: UInt64]
+        var stored: [UInt32: UInt64] = [:]
+        var assignment: [UInt32]
+        var generation: UInt64 = 1
+        var undecodable: Set<UInt64> = []
+        var oversized: Set<UInt64> = []
+        var losesReply = 0
+        var reportsHead = false
+
+        func start(_ partition: UInt32, kind: PollingStrategy.Kind, value: UInt64, count: UInt32) -> UInt64 {
+            let end = ends[partition, default: 0]
+            switch kind {
+            case .next: return stored[partition].map { $0 + 1 } ?? 0
+            case .offset: return value
+            case .last: return end > UInt64(count) ? end - UInt64(count) : 0
+            case .first, .timestamp: return 0
+            }
+        }
+
+        func holds(_ partition: UInt32, from start: UInt64) -> Bool {
+            start < ends[partition, default: 0]
+        }
+
+        mutating func read(_ partition: UInt32, from start: UInt64, count: UInt64, commits: Bool) -> [UInt64] {
+            var end = Swift.min(ends[partition, default: 0], start + count)
+            if oversized.contains(start) {
+                end = Swift.min(end, start + 1)
+            } else if let large = oversized.filter({ $0 > start && $0 < end }).min() {
+                end = large
+            }
+            guard start < end else {
+                return []
+            }
+            if commits {
+                stored[partition] = Swift.max(stored[partition] ?? 0, end - 1)
+            }
+            return Array(start..<end)
+        }
+    }
+
+    private static let waitStep: Duration = .milliseconds(10)
+    private let state: Locked<State>
+
+    /// With `reportsHead` a fetch section carries the newest offset of its
+    /// partition, as the server sends it, else the last offset it read.
+    init(
+        ends: [UInt32: UInt64], assignment: [UInt32] = [1], undecodable: Set<UInt64> = [], oversized: Set<UInt64> = [], losesReply: Int = 0,
+        reportsHead: Bool = false
+    ) {
+        state = Locked(State(ends: ends, assignment: assignment, undecodable: undecodable, oversized: oversized))
+        state.withLock { state in
+            state.losesReply = losesReply
+            state.reportsHead = reportsHead
+        }
+    }
+
+    var stored: [UInt32: UInt64] {
+        state.withLock { $0.stored }
+    }
+
+    func append(_ count: UInt64, to partition: UInt32) {
+        state.withLock { $0.ends[partition, default: 0] += count }
+    }
+
+    func loseReplies(_ count: Int) {
+        state.withLock { $0.losesReply = count }
+    }
+
+    /// Moves the stored offset of `partition` as a store of another client does.
+    func store(_ offset: UInt64, for partition: UInt32) {
+        state.withLock { $0.stored[partition] = offset }
+    }
+
+    /// A rebalance: the member holds `partitions` in the next generation.
+    func assign(_ partitions: [UInt32]) {
+        state.withLock { state in
+            state.assignment = partitions
+            state.generation += 1
+        }
+    }
+
+    /// The answer to an assignment sync, a store, a delete, a poll or a
+    /// fetch. Nil for any other request.
+    func reply(to request: FakeVSRServer.Request) async -> FakeVSRServer.Reply? {
+        switch request.code.flatMap(CommandCode.init(rawValue:)) {
+        case .syncConsumerGroup:
+            let (generation, partitions) = state.withLock { ($0.generation, $0.assignment) }
+            return .ok(FakeVSRServer.assignmentBody(generation: generation, partitions: partitions))
+        case .getConsumerOffset:
+            let partition = UInt32(littleEndianBytes: request.payload.suffix(4))
+            return state.withLock { state in
+                guard let offset = state.stored[partition] else {
+                    return .ok([])
+                }
+                var writer = ByteWriter()
+                writer.write(partition)
+                writer.write(state.ends[partition, default: 1] - 1)
+                writer.write(offset)
+                return .ok(writer.bytes)
+            }
+        case .storeConsumerOffset:
+            state.withLock { $0.stored[storedPartition(request)] = storedOffset(request) }
+            return .ok([])
+        case .deleteConsumerOffset:
+            let end = request.payload.count
+            let partition = UInt32(littleEndianBytes: request.payload[end - 5..<end - 1])
+            state.withLock { $0.stored[partition] = nil }
+            return .ok([])
+        case .pollMessagesDeferred:
+            return await poll(request)
+        case .fetchMessages:
+            return await fetch(request)
+        default:
+            return nil
+        }
+    }
+
+    private func poll(_ request: FakeVSRServer.Request) async -> FakeVSRServer.Reply {
+        let partition = polledPartition(request)
+        let poll = polledStrategy(request)
+        guard let kind = PollingStrategy.Kind(rawValue: poll.kind) else {
+            return .status(IggyErrorCode.invalidCommand.rawValue)
+        }
+        await waitForMessages(upTo: request.deferredOptions.wait) { state in
+            state.holds(partition, from: state.start(partition, kind: kind, value: poll.value, count: poll.count))
+        }
+        let (offsets, undecodable) = state.withLock { state in
+            let offsets = state.read(
+                partition, from: state.start(partition, kind: kind, value: poll.value, count: poll.count), count: UInt64(poll.count),
+                commits: poll.autoCommit)
+            return (offsets, !state.undecodable.isDisjoint(with: offsets))
+        }
+        let body = FakeVSRServer.polledBody(partitionID: partition, offsets: offsets)
+        // A record cut short does not decode.
+        return .ok(undecodable ? Array(body.dropLast(4)) : body)
+    }
+
+    /// Reads the targets in request order while the count leaves room.
+    private func fetch(_ request: FakeVSRServer.Request) async -> FakeVSRServer.Reply {
+        guard let fetch = try? request.fetch else {
+            return .status(IggyErrorCode.invalidCommand.rawValue)
+        }
+        await waitForMessages(upTo: fetch.wait) { state in
+            fetch.targets.contains { state.holds($0.partitionID, from: state.start($0.partitionID, kind: $0.kind, value: $0.value, count: fetch.count)) }
+        }
+        let (sections, losesReply) = state.withLock { state in
+            var room = UInt64(fetch.count)
+            let sections = fetch.targets.map { target in
+                let start = state.start(target.partitionID, kind: target.kind, value: target.value, count: fetch.count)
+                if state.oversized.contains(start), state.holds(target.partitionID, from: start) {
+                    return FakeVSRServer.FetchedSection(partitionID: target.partitionID, status: IggyErrorCode.invalidSizeBytes.rawValue)
+                }
+                let offsets = state.read(target.partitionID, from: start, count: room, commits: fetch.autoCommit)
+                room -= UInt64(offsets.count)
+                var section = FakeVSRServer.FetchedSection(
+                    partitionID: target.partitionID, offsets: offsets, nextOffset: offsets.last.map { $0 + 1 } ?? start,
+                    currentOffset: state.reportsHead ? state.ends[target.partitionID].map { $0 > 0 ? $0 - 1 : 0 } : nil)
+                if !state.undecodable.isDisjoint(with: offsets) {
+                    section.records = [1, 2, 3]
+                }
+                return section
+            }
+            let losesReply = fetch.autoCommit && state.losesReply > 0
+            if losesReply {
+                state.losesReply -= 1
+            }
+            return (sections, losesReply)
+        }
+        return losesReply ? .close : .ok(FakeVSRServer.fetchedBody(sections))
+    }
+
+    private func waitForMessages(upTo wait: UInt64, until holds: (State) -> Bool) async {
+        let deadline = ContinuousClock.now + .microseconds(wait)
+        while ContinuousClock.now < deadline, !state.withLock({ holds($0) }) {
+            try? await Task.sleep(for: Self.waitStep)
+        }
+    }
+}
+
+/// Answers as a server that holds `topic` does: the stream, the topic and
+/// the group exist. `script` answers first, where it returns a reply.
+private func serve(
+    _ server: FakeVSRServer, topic: FakeTopic, script: @escaping @Sendable (FakeVSRServer.Request) async -> FakeVSRServer.Reply? = { _ in nil }
+) async {
+    await server.respond { request in
+        if let reply = await script(request) {
+            return reply
+        }
+        if let reply = await topic.reply(to: request) {
+            return reply
+        }
+        switch request.code.flatMap(CommandCode.init(rawValue:)) {
+        case .getStream: return .ok(GoldenFixture.shared.bytes("response.get_stream"))
+        case .getTopic: return .ok(GoldenFixture.shared.bytes("response.get_topic"))
+        case .getConsumerGroup, .createConsumerGroup: return .ok(GoldenFixture.shared.bytes("response.consumer_group_details"))
+        default: return FakeVSRServer.defaultReply(request, address: server.address)
+        }
+    }
+}
+
+/// The fetches the server got, decoded.
+private func fetches(_ server: FakeVSRServer) async throws -> [FakeVSRServer.Request.Fetch] {
+    try await requests(server, .fetchMessages).map { try $0.fetch }
+}
+
 private func read(_ consumer: IggyConsumer, _ count: Int) async throws -> [UInt64] {
     var offsets: [UInt64] = []
     while offsets.count < count, let received = try await consumer.next() {
@@ -115,644 +286,1137 @@
     return offsets
 }
 
-private final class WeakConsumer: @unchecked Sendable {
-    weak var value: IggyConsumer?
+/// The offsets of the next `count` batches of `reader`.
+private func batches(_ reader: PartitionReader, _ count: Int) async throws -> [[UInt64]] {
+    var batches: [[UInt64]] = []
+    while batches.count < count, let batch = try await reader.next() {
+        batches.append(batch.messages.map(\.offset))
+    }
+    return batches
 }
 
-@Suite("Consumer", .serialized)
+/// The error `reader` throws next, or nil when it returns a batch or nothing
+/// within two seconds.
+private func readError(_ reader: PartitionReader) async -> ReadError? {
+    do {
+        _ = try await withDeadline(.now + .seconds(2)) { try await reader.next() }
+    } catch let error as ReadError {
+        return error
+    } catch {}
+    return nil
+}
+
+private func standalone(
+    _ client: IggyClient, partitions: [UInt32] = [1], start: PollingStrategy = .next, count: UInt32 = 2, commit: CommitPolicy = .manual
+) async throws -> PartitionReader {
+    try await client.reader(
+        streamID: 1, topicID: 2, consumer: .consumer(9),
+        configuration: ReaderConfiguration(
+            partitions: partitions, start: start, options: PollOptions(count: count, maxWait: .milliseconds(200), commit: commit),
+            retryInterval: .milliseconds(50)))
+}
+
+/// A member of group 3 that reads two messages a time.
+private func member(_ client: IggyClient, commit: CommitPolicy, maxWait: Duration = .milliseconds(200)) async throws -> PartitionReader {
+    try await client.reader(
+        streamID: 1, topicID: 2, consumer: .group(3),
+        configuration: ReaderConfiguration(
+            options: PollOptions(count: 2, maxWait: maxWait, commit: commit), retryInterval: .milliseconds(50)))
+}
+
+private let memberGroupKey = ConsumerGroupState.GroupKey(stream: 1, topic: 2, group: 3)
+
+@Suite("Partition reader and consumer", .serialized, .timeLimit(.minutes(1)))
 struct ConsumerTests {
+    // MARK: Single polls
+
+    @Test func aSinglePollRefusesAutoCommitAndAContinuationOfAnotherPartition() async throws {
+        let server = try await FakeVSRServer.started()
+        let topic = FakeTopic(ends: [1: 3])
+        await serve(server, topic: topic)
+        let client = try await signedInClient(server)
+        await expectCode(.invalidConfiguration) {
+            _ = try await client.pollMessages(streamID: 1, topicID: 2, partitionID: 1, strategy: .first, options: PollOptions(commit: .auto(.default)))
+        }
+        let polled = try await client.pollMessages(streamID: 1, topicID: 2, partitionID: 1, strategy: .first, options: PollOptions(count: 2))
+        #expect(polled.messages.map(\.offset) == [0, 1])
+        #expect(polled.checkpoint?.offset == 1 && polled.checkpoint(at: 0)?.offset == 0 && polled.checkpoint(at: 2) == nil)
+        await expectCode(.invalidCommand) {
+            _ = try await client.pollMessages(streamID: 1, topicID: 2, partitionID: 2, strategy: polled.continuation)
+        }
+        let rest = try await client.pollMessages(streamID: 1, topicID: 2, partitionID: 1, strategy: polled.continuation)
+        #expect(rest.messages.map(\.offset) == [2])
+        let empty = try await client.pollMessages(streamID: 1, topicID: 2, partitionID: 1, strategy: rest.continuation)
+        #expect(empty.messages.isEmpty && empty.continuation.kind == .offset && empty.continuation.value == 3)
+        // Every poll is deferred, with the request timeout ten seconds past
+        // its wait, less what routing took.
+        let sent = await requests(server, .pollMessagesDeferred)
+        #expect(sent.count == 3 && sent.allSatisfy { (9_000_000...10_000_000).contains($0.deferredOptions.timeout) })
+        await client.shutdown()
+        await server.stop()
+    }
+
     // MARK: Reading
 
-    /// A batch is handed over one message at a time, and the next poll of the
-    /// partition continues after the last one handed over, under the context
-    /// that delivered it.
-    @Test func aPollContinuesAfterTheLastMessageHandedOver() async throws {
+    /// The partitions of one primary share a fetch, and the ones a fetch
+    /// served go last in the next one, so a backlog does not starve another
+    /// partition.
+    @Test func aReaderFetchesItsPartitionsTogetherAndServedOnesGoLast() async throws {
         let server = try await FakeVSRServer.started()
-        defer { Task { await server.stop() } }
-        await serve(server, poll: batches([[0, 1, 2], [3, 4]]))
+        let topic = FakeTopic(ends: [1: 6, 2: 6])
+        await serve(server, topic: topic)
         let client = try await signedInClient(server)
-        let configuration = ConsumerConfiguration(pollingStrategy: .offset(0), batchLength: 3, autoCommit: .disabled)
-        let consumer = try client.consumer(name: "reader", streamID: 1, topicID: 2, partitionID: 1, configuration: configuration)
-        try await consumer.initialize()
-        #expect(try await read(consumer, 5) == [0, 1, 2, 3, 4])
-        let polls = await requests(server, .pollMessages)
-        #expect(polls.prefix(2).map { polledStrategy($0).value } == [0, 3])
-        #expect(polls[1].context == FakeVSRServer.routeContext)
-        #expect(await consumer.currentPartitionID == 1)
-        #expect(await consumer.lastConsumedOffset(partitionID: 1) == 4)
-        await consumer.shutdown()
-        await client.shutdown()
-    }
-
-    /// A `next` poll starts after the offset stored on the server. Unless the
-    /// poll commits that offset itself, it can return the same messages
-    /// again, so the consumer continues after the last one handed over.
-    @Test func nextContinuesAfterTheLastMessageUnlessThePollCommits() async throws {
-        let next = PollingStrategy.Kind.next.rawValue
-        let offset = PollingStrategy.Kind.offset.rawValue
-        let cases: [(AutoCommit, [UInt8])] = [
-            (.when(.consumingEveryNthMessage(2)), [next, offset, offset]), (.intervalOrWhen(.seconds(1), .pollingMessages), [next, next, next]),
-        ]
-        for (autoCommit, kinds) in cases {
-            let server = try await FakeVSRServer.started()
-            await serveOneByOne(server, count: 3)
-            let client = try await signedInClient(server)
-            let configuration = ConsumerConfiguration(batchLength: 1, autoCommit: autoCommit)
-            let consumer = try client.consumer(name: "reader", streamID: 1, topicID: 2, partitionID: 1, configuration: configuration)
-            try await consumer.initialize()
-            // A consumer that stalls reads nothing more, so the read is bounded.
-            #expect(try await withDeadline(.now + .seconds(5)) { try await read(consumer, 3) } == [0, 1, 2], "\(autoCommit)")
-            #expect(await requests(server, .pollMessages).prefix(3).map { polledStrategy($0).kind } == kinds, "\(autoCommit)")
-            await consumer.shutdown()
-            await client.shutdown()
-            await server.stop()
+        let reader = try await standalone(client, partitions: [1, 2], start: .first)
+        var partitions: [UInt32] = []
+        for _ in 0..<3 {
+            partitions.append(try #require(try await reader.next()).partitionID)
         }
+        #expect(partitions == [1, 2, 1])
+        let sent = try await fetches(server)
+        #expect(sent.first?.targets.map(\.partitionID) == [1, 2])
+        #expect(sent.dropFirst().first?.targets.map(\.partitionID) == [2, 1])
+        try await reader.close()
+        await client.shutdown()
+        await server.stop()
     }
 
-    /// A message at or below the last one handed over from the same
-    /// partition history is skipped, unless replay is allowed. Another
-    /// incarnation is another history.
-    @Test func aReturnedMessageIsHandedOverAgainOnlyWithReplay() async throws {
-        let renewed = PartitionContext(incarnation: 8, ownerGeneration: 0, metadataOp: 11)
-        for (allowReplay, expected) in [(false, [UInt64(0), 1, 2, 1]), (true, [0, 1, 1, 2, 1])] {
-            let server = try await FakeVSRServer.started()
-            let returned = Locked(0)
-            await serve(server) { _ in
-                let poll = returned.withLock { count in
-                    defer { count += 1 }
-                    return count
-                }
-                switch poll {
-                case 0: return .ok(FakeVSRServer.polledBody(partitionID: 1, offsets: [0, 1]))
-                case 1: return .ok(FakeVSRServer.polledBody(partitionID: 1, offsets: [1, 2]))
-                case 2: return .ok(FakeVSRServer.polledBody(partitionID: 1, offsets: [1], context: renewed))
-                default: return .ok(FakeVSRServer.polledBody(partitionID: 1, offsets: []))
-                }
+    /// Partitions whose last section ended before their head read at once,
+    /// so each one takes a fetch of its own while requests are free, with
+    /// the full limits, as the base consumer kept a poll per partition.
+    @Test func backloggedPartitionsOfOneGroupReadInFetchesOfTheirOwn() async throws {
+        let server = try await FakeVSRServer.started()
+        let topic = FakeTopic(ends: [1: 20, 2: 20, 3: 20, 4: 20], reportsHead: true)
+        await serve(server, topic: topic)
+        let client = try await signedInClient(server)
+        let reader = try await standalone(client, partitions: [1, 2, 3, 4], start: .first)
+        _ = try #require(try await reader.next())
+
+        let read = { @Sendable in Set(((try? await fetches(server)) ?? []).dropFirst().flatMap { $0.targets.map(\.partitionID) }) }
+        #expect(await eventually { await read() == [1, 2, 3, 4] })
+
+        let spread = try await fetches(server).dropFirst()
+        #expect(spread.allSatisfy { $0.targets.count == 1 && $0.count == 2 }, "\(spread.map { $0.targets.map(\.partitionID) })")
+        try await reader.close()
+        await client.shutdown()
+        await server.stop()
+    }
+
+    /// Once `next()` waits, an `onRead` reader deals its backlogged partitions
+    /// over the free requests too, one committing fetch each with the full
+    /// limits, beside the one fetch of the quiet partitions that may wait.
+    @Test func backloggedPartitionsOfAnOnReadGroupReadInCommittingFetchesOfTheirOwn() async throws {
+        let server = try await FakeVSRServer.started()
+        let topic = FakeTopic(ends: [1: 20, 2: 20, 3: 20, 4: 20, 5: 0], reportsHead: true)
+        await serve(server, topic: topic)
+        let client = try await signedInClient(server)
+        let reader = try await standalone(client, partitions: [1, 2, 3, 4, 5], start: .first, commit: .onRead)
+        #expect(try await batches(reader, 1) == [[0, 1]])
+
+        _ = try #require(try await reader.next())
+
+        let committing = { @Sendable in (try? await fetches(server).filter(\.autoCommit)) ?? [] }
+        #expect(await eventually { await committing().count == 6 })
+        let spread = await committing().dropFirst()
+        #expect(Set(spread.map { $0.targets.map(\.partitionID) }) == [[5], [1], [2], [3], [4]], "\(spread.map { $0.targets.map(\.partitionID) })")
+        #expect(spread.allSatisfy { $0.count == 2 })
+        try await reader.close()
+        await client.shutdown()
+        await server.stop()
+    }
+
+    /// A section refused as not primary groups the partitions again, and
+    /// the partition is read on without an error for the caller.
+    @Test func aReaderGroupsAgainAfterNotPrimary() async throws {
+        let server = try await FakeVSRServer.started()
+        let topic = FakeTopic(ends: [1: 2])
+        let refused = Locked(false)
+        await serve(server, topic: topic) { request in
+            guard request.code == CommandCode.fetchMessages.rawValue, !refused.exchange(true) else {
+                return nil
             }
-            let client = try await signedInClient(server)
-            let configuration = ConsumerConfiguration(autoCommit: .disabled, allowReplay: allowReplay)
-            let consumer = try client.consumer(name: "reader", streamID: 1, topicID: 2, partitionID: 1, configuration: configuration)
-            try await consumer.initialize()
-            #expect(try await read(consumer, expected.count) == expected)
-            await consumer.shutdown()
-            await client.shutdown()
-            await server.stop()
-        }
-    }
-
-    /// A failed poll is thrown after the retry interval and the consumer
-    /// keeps reading on the next call, while an iterator ends at the error.
-    @Test func aFailedPollIsThrownAndReadingGoesOn() async throws {
-        let server = try await FakeVSRServer.started()
-        defer { Task { await server.stop() } }
-        let failed = Locked(false)
-        await serve(server) { _ in
-            failed.exchange(true) ? .ok(FakeVSRServer.polledBody(partitionID: 1, offsets: [0])) : .status(IggyErrorCode.partitionNotFound.rawValue)
+            return .ok(FakeVSRServer.fetchedBody([.init(partitionID: 1, status: IggyErrorCode.notPrimary.rawValue)]))
         }
         let client = try await signedInClient(server)
-        let configuration = ConsumerConfiguration(autoCommit: .disabled, pollingRetryInterval: .milliseconds(150))
-        let consumer = try client.consumer(name: "reader", streamID: 1, topicID: 2, partitionID: 1, configuration: configuration)
-        try await consumer.initialize()
-        var iterator = consumer.makeAsyncIterator()
-        let clock = ContinuousClock()
-        let elapsed = await clock.measure {
-            await expectCode(.partitionNotFound) { _ = try await iterator.next() }
-        }
-        #expect(elapsed >= .milliseconds(140))
-        // An iterator that reads on takes the message, and the read below
-        // then waits for good.
-        try #require(try await iterator.next() == nil)
-        #expect(try await consumer.next()?.message.offset == 0)
-        await consumer.shutdown()
+        let reader = try await standalone(client, start: .first)
+        #expect(try await batches(reader, 1) == [[0, 1]])
+        #expect(try await fetches(server).count >= 2)
+        try await reader.close()
         await client.shutdown()
+        await server.stop()
     }
 
-    /// With no heartbeat and no commit to send, nothing else brings the
-    /// client back after a lost poll, so the next poll signs it in again. A
-    /// poll still waiting for the client ends with nil at shutdown.
-    @Test func aPollSignsTheClientInAgainUntilTheConsumerShutsDown() async throws {
+    /// A committing fetch that lost its reply stored its messages without
+    /// delivering them. The next read starts past them, so the reader reads
+    /// that gap again without a commit and delivers it first.
+    @Test func aLostCommittingReplyIsReadAgainWithoutACommit() async throws {
         let server = try await FakeVSRServer.started()
-        defer { Task { await server.stop() } }
-        let polls = Locked(0)
-        let refusesSignIn = Locked(false)
-        let refusedSignIns = Locked(0)
-        await server.respond { request in
-            if refusesSignIn.withLock({ $0 }), FakeVSRServer.sessionReply(request) != nil {
-                refusedSignIns.withLock { $0 += 1 }
+        let topic = FakeTopic(ends: [1: 6])
+        await serve(server, topic: topic)
+        let client = try await signedInClient(server)
+        let reader = try await standalone(client, commit: .onRead)
+        #expect(try await batches(reader, 1) == [[0, 1]])
+        topic.loseReplies(1)
+        #expect(try await batches(reader, 2) == [[2, 3], [4, 5]])
+        #expect(topic.stored[1] == 5)
+        let gapReads = await requests(server, .pollMessagesDeferred).map(polledStrategy)
+        #expect(gapReads.map(\.kind) == [PollingStrategy.Kind.offset.rawValue] && gapReads.map(\.value) == [2] && gapReads.allSatisfy { !$0.autoCommit })
+        try await reader.close()
+        await client.shutdown()
+        await server.stop()
+    }
+
+    /// A read that starts past the expected offset while no read of its
+    /// partition was lost follows a store of the application or another
+    /// session, so the reader delivers from there and reads nothing again.
+    @Test func anOnReadReaderTakesAForwardJumpWithoutALostReadAsIs() async throws {
+        let server = try await FakeVSRServer.started()
+        let topic = FakeTopic(ends: [1: 6])
+        await serve(server, topic: topic)
+        let client = try await signedInClient(server)
+        let reader = try await standalone(client, commit: .onRead)
+        #expect(try await batches(reader, 1) == [[0, 1]])
+        topic.store(3, for: 1)
+
+        #expect(try await batches(reader, 1) == [[4, 5]])
+
+        #expect(await requests(server, .pollMessagesDeferred).isEmpty)
+        try await reader.close()
+        #expect(topic.stored[1] == 5)
+        await client.shutdown()
+        await server.stop()
+    }
+
+    @Test(arguments: [UInt64?.none, .some(4)], [false, true])
+    func aLostFirstOnReadReplyIsReadAgainFromTheStoredBaseline(stored: UInt64?, trailing: Bool) async throws {
+        let server = try await FakeVSRServer.started()
+        let start = stored.map { $0 + 1 } ?? 0
+        let topic = FakeTopic(ends: [1: start + (trailing ? 3 : 2)], losesReply: 1)
+        if let stored {
+            topic.store(stored, for: 1)
+        }
+        await serve(server, topic: topic)
+        let client = try await signedInClient(server)
+        let reader = try await standalone(client, commit: .onRead)
+
+        let batch = try #require(try await reader.next())
+
+        #expect(batch.messages.map(\.offset) == [start, start + 1])
+        if trailing {
+            #expect(try await batches(reader, 1) == [[start + 2]])
+        }
+        #expect(try await fetches(server).filter { !$0.autoCommit }.count == 1)
+        try await reader.close()
+        await client.shutdown()
+        await server.stop()
+    }
+
+    @Test(arguments: [UInt64?.none, .some(4)])
+    func closingAfterTheFirstOnReadReplyIsLostRestoresTheStoredBaseline(stored: UInt64?) async throws {
+        let server = try await FakeVSRServer.started()
+        let start = stored.map { $0 + 1 } ?? 0
+        let topic = FakeTopic(ends: [1: start + 2], losesReply: 1)
+        if let stored {
+            topic.store(stored, for: 1)
+        }
+        let ready = Locked(false)
+        let released = Locked(false)
+        await serve(server, topic: topic) { request in
+            guard request.code == CommandCode.fetchMessages.rawValue, (try? request.fetch.autoCommit) == true else {
+                return nil
+            }
+            let reply = await topic.reply(to: request)
+            ready.withLock { $0 = true }
+            while !released.withLock({ $0 }) {
+                try? await Task.sleep(for: .milliseconds(10))
+            }
+            return reply
+        }
+        let client = try await signedInClient(server)
+        let reader = try await standalone(client, commit: .onRead)
+        let pending = Task { try? await reader.next() }
+        #expect(await eventually { ready.withLock { $0 } && topic.stored[1] == start + 1 })
+        pending.cancel()
+        _ = await pending.value
+        let closing = Task { try await reader.close() }
+        released.withLock { $0 = true }
+        try await closing.value
+
+        #expect(topic.stored[1] == stored)
+        await client.shutdown()
+        await server.stop()
+    }
+
+    /// A committing section whose error leaves the outcome unknown may have
+    /// stored its read, so the reader reads that range again, or the close
+    /// moves the offset back over it.
+    @Test(arguments: [false, true], [IggyErrorCode.shardCommunicationError, .cannotDecryptData, .staleClient, .unauthorized])
+    func anUncertainCommittedSectionKeepsItsSiblingAndRecoversOrRewinds(closing: Bool, status: IggyErrorCode) async throws {
+        let server = try await FakeVSRServer.started()
+        let topic = FakeTopic(ends: [1: 7, 2: 1])
+        topic.store(4, for: 1)
+        let answered = Locked(false)
+        await serve(server, topic: topic) { request in
+            guard request.code == CommandCode.fetchMessages.rawValue, (try? request.fetch.autoCommit) == true,
+                !answered.withLock({ $0 })
+            else {
+                return nil
+            }
+            answered.withLock { $0 = true }
+            _ = await topic.reply(to: request)
+            return .ok(
+                FakeVSRServer.fetchedBody([
+                    FakeVSRServer.FetchedSection(partitionID: 1, status: status.rawValue),
+                    FakeVSRServer.FetchedSection(partitionID: 2, offsets: [0], nextOffset: 1),
+                ]))
+        }
+        let client = try await signedInClient(server)
+        let reader = try await standalone(client, partitions: [1, 2], count: 3, commit: .onRead)
+
+        let sibling = try #require(try await reader.next())
+        #expect(sibling.partitionID == 2 && sibling.messages.map(\.offset) == [0])
+        if !closing {
+            let recovered = try #require(try await reader.next())
+            #expect(recovered.partitionID == 1 && recovered.messages.map(\.offset) == [5, 6])
+        }
+        try await reader.close()
+        #expect(topic.stored[1] == (closing ? 4 : 6))
+        #expect(topic.stored[2] == 0)
+        await client.shutdown()
+        await server.stop()
+    }
+
+    /// The close of an `onRead` reader cancels a committing fetch that waits
+    /// on the server, and that fetch waits for its one reply: nothing stored
+    /// when the cancel won, or the messages when the store won, which the
+    /// close moves back as undelivered. So the close takes a round trip, not
+    /// the wait of the fetch.
+    @Test(arguments: [true, false])
+    func anOnReadCloseCancelsAParkedCommittingFetch(cancelWins: Bool) async throws {
+        let server = try await FakeVSRServer.started()
+        let topic = FakeTopic(ends: [1: 0])
+        let cancelled = Locked(false)
+        await serve(server, topic: topic) { request in
+            switch request.code {
+            case CommandCode.cancelDeferredRequest.rawValue:
+                cancelled.withLock { $0 = true }
                 return .stall
-            }
-            switch request.code.flatMap(CommandCode.init(rawValue:)) {
-            case .getStream: return .ok(GoldenFixture.shared.bytes("response.get_stream"))
-            case .getTopic: return .ok(GoldenFixture.shared.bytes("response.get_topic"))
-            case .pollMessages:
-                let poll = polls.withLock { poll in
-                    defer { poll += 1 }
-                    return poll
+            case CommandCode.fetchMessages.rawValue where (try? request.fetch.autoCommit) == true:
+                let parked = ContinuousClock.now + .seconds(10)
+                while !cancelled.withLock({ $0 }), ContinuousClock.now < parked {
+                    try? await Task.sleep(for: .milliseconds(10))
                 }
-                return poll == 1 ? .ok(FakeVSRServer.polledBody(partitionID: 1, offsets: [0])) : .close
-            default: return FakeVSRServer.defaultReply(request, address: server.address)
-            }
-        }
-        let client = try await signedInClient(server, requestTimeout: .milliseconds(500), heartbeatInterval: .zero)
-        let configuration = ConsumerConfiguration(autoCommit: .disabled, pollingRetryInterval: .milliseconds(100))
-        let consumer = try client.consumer(name: "reader", streamID: 1, topicID: 2, partitionID: 1, configuration: configuration)
-        try await consumer.initialize()
-        // A poll written and lost with its connection may have committed.
-        await expectCode(.transientNotCommitted) { _ = try await consumer.next() }
-        // withDeadline returns nil at the deadline. A client that is not
-        // signed in again stays down, and the read below then waits for good.
-        try #require(try await withDeadline(.now + .seconds(5)) { try await consumer.next()?.message.offset } == 0)
-        await expectCode(.transientNotCommitted) { _ = try await consumer.next() }
-        refusesSignIn.withLock { $0 = true }
-        async let ended = withDeadline(.now + .seconds(5)) { try await consumer.next() == nil }
-        #expect(await eventually { refusedSignIns.withLock { $0 } > 0 })
-        await consumer.shutdown()
-        #expect(try await ended == true)
-        await client.shutdown()
-    }
-
-    /// A refused continuation is dropped, so the next poll of the partition
-    /// starts where the strategy says again.
-    @Test func aRecreatedPartitionDropsItsContinuation() async throws {
-        let server = try await FakeVSRServer.started()
-        defer { Task { await server.stop() } }
-        let poll = Locked(0)
-        await serve(server) { _ in
-            let index = poll.withLock { index in
-                defer { index += 1 }
-                return index
-            }
-            switch index {
-            case 0: return .ok(FakeVSRServer.polledBody(partitionID: 1, offsets: [5]))
-            case 1: return .status(IggyErrorCode.historyUnavailable.rawValue)
-            default: return .ok(FakeVSRServer.polledBody(partitionID: 1, offsets: []))
-            }
-        }
-        let client = try await signedInClient(server)
-        let configuration = ConsumerConfiguration(pollingStrategy: .first, autoCommit: .disabled, pollingRetryInterval: .milliseconds(10))
-        let consumer = try client.consumer(name: "reader", streamID: 1, topicID: 2, partitionID: 1, configuration: configuration)
-        try await consumer.initialize()
-        #expect(try await consumer.next()?.message.offset == 5)
-        await expectCode(.historyUnavailable) { _ = try await consumer.next() }
-        let reader = Task { try await consumer.next() }
-        #expect(await eventually { await requests(server, .pollMessages).count >= 3 })
-        let kinds = await requests(server, .pollMessages).prefix(3).map { polledStrategy($0).kind }
-        #expect(kinds == [PollingStrategy.Kind.first.rawValue, PollingStrategy.Kind.offset.rawValue, PollingStrategy.Kind.first.rawValue])
-        await consumer.shutdown()
-        _ = try? await reader.value
-        await client.shutdown()
-    }
-
-    // MARK: Committing
-
-    @Test func pollingMessagesCommitsWithThePollItself() async throws {
-        let server = try await FakeVSRServer.started()
-        defer { Task { await server.stop() } }
-        await serve(server, poll: batches([[0, 1]]))
-        let client = try await signedInClient(server)
-        let consumer = try client.consumer(name: "reader", streamID: 1, topicID: 2, partitionID: 1)
-        try await consumer.initialize()
-        #expect(try await read(consumer, 2) == [0, 1])
-        #expect(await requests(server, .pollMessages).first.map { polledStrategy($0).autoCommit } == true)
-        await consumer.shutdown()
-        await client.shutdown()
-    }
-
-    /// The message triggers queue a commit as messages are handed over:
-    /// every message, an offset that divides by n, or the end of a batch.
-    @Test func messageTriggersCommitAsMessagesAreHandedOver() async throws {
-        let cases: [(AutoCommitWhen, [UInt64])] = [
-            (.consumingEachMessage, [0, 1, 2, 3]), (.consumingEveryNthMessage(2), [0, 2]), (.consumingAllMessages, [2, 3]),
-        ]
-        for (trigger, expected) in cases {
-            let server = try await FakeVSRServer.started()
-            await serve(server, poll: batches([[0, 1, 2], [3]]))
-            let client = try await signedInClient(server)
-            let consumer = try client.consumer(
-                name: "reader", streamID: 1, topicID: 2, partitionID: 1, configuration: ConsumerConfiguration(autoCommit: .when(trigger)))
-            try await consumer.initialize()
-            #expect(try await read(consumer, 4) == [0, 1, 2, 3])
-            #expect(await requests(server, .pollMessages).first.map { polledStrategy($0).autoCommit } == false)
-            // Queued commits collapse to the latest per partition, so wait
-            // for the last one and check every store is one of the expected.
-            #expect(await eventually { await requests(server, .storeConsumerOffset).map(storedOffset).last == expected.last })
-            let stored = await requests(server, .storeConsumerOffset).map(storedOffset)
-            #expect(stored.allSatisfy(expected.contains), "\(trigger) stored \(stored)")
-            #expect(await requests(server, .storeConsumerOffset).allSatisfy { $0.context == FakeVSRServer.routeContext })
-            await consumer.shutdown()
-            await client.shutdown()
-            await server.stop()
-        }
-    }
-
-    @Test func anIntervalStoresTheReadingPositionOfEveryPartitionRead() async throws {
-        let server = try await FakeVSRServer.started()
-        defer { Task { await server.stop() } }
-        await serve(server, poll: batches([[0, 1]]))
-        let client = try await signedInClient(server)
-        let consumer = try client.consumer(
-            name: "reader", streamID: 1, topicID: 2, partitionID: 1, configuration: ConsumerConfiguration(autoCommit: .interval(.milliseconds(50))))
-        try await consumer.initialize()
-        #expect(try await read(consumer, 2) == [0, 1])
-        #expect(await eventually { await requests(server, .storeConsumerOffset).map(storedOffset).contains(1) })
-        // A position already stored in the same history is not sent again.
-        let stores = await requests(server, .storeConsumerOffset).count
-        try await Task.sleep(for: .milliseconds(150))
-        #expect(await requests(server, .storeConsumerOffset).count == stores)
-        await consumer.shutdown()
-        await client.shutdown()
-    }
-
-    /// A store by hand keeps the context of the last message handed over,
-    /// and without replay a position the stored one covers is not sent.
-    @Test func aStoreByHandKeepsTheContextAndSkipsCoveredPositions() async throws {
-        let server = try await FakeVSRServer.started()
-        defer { Task { await server.stop() } }
-        await serve(server, poll: batches([[4, 5]]))
-        let client = try await signedInClient(server)
-        let consumer = try client.consumer(
-            name: "reader", streamID: 1, topicID: 2, partitionID: 1, configuration: ConsumerConfiguration(autoCommit: .disabled))
-        // Before anything was read the store has no captured context, so it
-        // covers nothing later.
-        try await consumer.storeOffset(1)
-        try await consumer.initialize()
-        let first = try #require(try await consumer.next())
-        _ = try await consumer.next()
-        try await consumer.storeOffset(5)
-        try await consumer.storePosition(first.position)
-        let stores = await requests(server, .storeConsumerOffset)
-        // The position at 4 is covered by the store at 5 and is not sent.
-        #expect(stores.map(storedOffset) == [1, 5])
-        #expect(await consumer.lastStoredOffset(partitionID: 1) == 5)
-        // After a delete the same position goes again.
-        try await consumer.deleteOffset()
-        try await consumer.storeOffset(5)
-        #expect(await requests(server, .storeConsumerOffset).count == 3)
-        #expect(await requests(server, .deleteConsumerOffset).count == 1)
-        await consumer.shutdown()
-        await client.shutdown()
-    }
-
-    /// Stores run one at a time, so a position that the store in flight
-    /// covers is not sent after it, even when the two calls overlap.
-    @Test func overlappingStoresNeverMoveTheStoredOffsetBack() async throws {
-        let server = try await FakeVSRServer.started()
-        defer { Task { await server.stop() } }
-        let poll = batches([[3, 5]])
-        await server.respond { [poll] request in
-            switch request.code.flatMap(CommandCode.init(rawValue:)) {
-            case .getStream: return .ok(GoldenFixture.shared.bytes("response.get_stream"))
-            case .getTopic: return .ok(GoldenFixture.shared.bytes("response.get_topic"))
-            case .pollMessages: return await poll(request)
-            case .storeConsumerOffset:
-                try? await Task.sleep(for: .milliseconds(200))
-                return .ok([])
-            default: return FakeVSRServer.defaultReply(request, address: server.address)
-            }
-        }
-        let client = try await signedInClient(server)
-        let consumer = try client.consumer(
-            name: "reader", streamID: 1, topicID: 2, partitionID: 1, configuration: ConsumerConfiguration(autoCommit: .disabled))
-        try await consumer.initialize()
-        let older = try #require(try await consumer.next())
-        let newer = try #require(try await consumer.next())
-        let storing = Task { try await consumer.storePosition(newer.position) }
-        #expect(await eventually { await requests(server, .storeConsumerOffset).count == 1 })
-        try await consumer.storePosition(older.position)
-        try await storing.value
-        #expect(await requests(server, .storeConsumerOffset).map(storedOffset) == [5])
-        #expect(await consumer.lastStoredOffset(partitionID: 1) == 5)
-        await consumer.shutdown()
-        await client.shutdown()
-    }
-
-    /// The after triggers fire once the handler returned, whether it
-    /// succeeded or threw: every message, an offset that divides by n, or the
-    /// message at the partition's newest offset.
-    @Test func consumeMessagesCommitsAfterTheHandler() async throws {
-        let cases: [(AutoCommitAfter, [UInt64])] = [
-            (.consumingEachMessage, [0, 1, 2, 3]), (.consumingEveryNthMessage(3), [0, 3]), (.consumingAllMessages, [3]),
-        ]
-        for (trigger, expected) in cases {
-            let server = try await FakeVSRServer.started()
-            await serve(server, poll: batches([[0, 1, 2, 3]]))
-            let client = try await signedInClient(server)
-            let consumer = try client.consumer(
-                name: "reader", streamID: 1, topicID: 2, partitionID: 1, configuration: ConsumerConfiguration(autoCommit: .after(trigger)))
-            try await consumer.initialize()
-            struct Refused: Error {}
-            let handled = Locked<[UInt64]>([])
-            let consuming = Task {
-                try await consumer.consumeMessages { received in
-                    handled.withLock { $0.append(received.message.offset) }
-                    // Slow enough that every commit goes out before the
-                    // next one is queued and could replace it.
-                    try? await Task.sleep(for: .milliseconds(30))
-                    if received.message.offset == 3 {
-                        throw Refused()
-                    }
+                if cancelWins {
+                    return .status(IggyErrorCode.transientNotAccepted.rawValue)
                 }
+                topic.append(2, to: 1)
+                return await topic.reply(to: request)
+            default:
+                return nil
             }
-            #expect(await eventually { await requests(server, .storeConsumerOffset).map(storedOffset).last == 3 })
-            #expect(handled.withLock { $0 } == [0, 1, 2, 3])
-            #expect(await requests(server, .storeConsumerOffset).map(storedOffset) == expected, "\(trigger)")
-            await consumer.shutdown()
-            try await consuming.value
-            await client.shutdown()
-            await server.stop()
         }
-    }
-
-    @Test func consumeMessagesEndsWhenTheClientCannotComeBack() async throws {
-        let server = try await FakeVSRServer.started()
-        defer { Task { await server.stop() } }
-        await serve(server, poll: batches([]))
         let client = try await signedInClient(server)
-        let configuration = ConsumerConfiguration(autoCommit: .disabled, pollingRetryInterval: .milliseconds(10))
-        let consumer = try client.consumer(name: "reader", streamID: 1, topicID: 2, partitionID: 1, configuration: configuration)
-        try await consumer.initialize()
-        await client.disconnect()
-        await #expect(throws: IggyError.self) { try await consumer.consumeMessages { _ in } }
-        await consumer.shutdown()
+        let reader = try await standalone(client, commit: .onRead)
+        let pending = Task { try? await reader.next() }
+        #expect(await eventually { (try? await fetches(server).filter(\.autoCommit).count) == 1 })
+
+        let started = ContinuousClock.now
+        try await reader.close()
+        let closing = ContinuousClock.now - started
+
+        #expect(closing < .seconds(2), "the close took \(closing)")
+        #expect(cancelled.withLock { $0 })
+        #expect(topic.stored[1] == nil)
+        _ = await pending.value
         await client.shutdown()
+        await server.stop()
     }
 
-    // MARK: Groups
-
-    /// A member joins on initialize, creating the group when it is missing,
-    /// and joins again once a poll finds the membership gone.
-    @Test func aGroupMemberJoinsAndJoinsAgainAfterLosingTheMembership() async throws {
+    /// The offset a seek under `onRead` stored is the last delivered message
+    /// of the new generation, so the close moves the offset back to it over a
+    /// read that the server stored and nobody received, whatever the start.
+    @Test func anOnReadCloseMovesTheOffsetBackToTheSoughtPosition() async throws {
         let server = try await FakeVSRServer.started()
-        defer { Task { await server.stop() } }
-        let revoked = Locked(false)
-        let groupExists = Locked(false)
-        await server.respond { request in
-            switch request.code.flatMap(CommandCode.init(rawValue:)) {
-            case .getStream: return .ok(GoldenFixture.shared.bytes("response.get_stream"))
-            case .getTopic: return .ok(GoldenFixture.shared.bytes("response.get_topic"))
-            case .getConsumerGroup: return groupExists.withLock { $0 } ? .ok(GoldenFixture.shared.bytes("response.consumer_group_details")) : .ok([])
-            case .createConsumerGroup:
-                groupExists.withLock { $0 = true }
-                return .ok(GoldenFixture.shared.bytes("response.consumer_group_details"))
-            case .syncConsumerGroup: return .ok(FakeVSRServer.assignmentBody(generation: 1, partitions: [1]))
-            case .pollMessages:
-                if !revoked.exchange(true) {
-                    return .status(IggyErrorCode.consumerGroupMemberNotFound.rawValue)
-                }
-                return .ok(FakeVSRServer.polledBody(partitionID: 1, offsets: [0]))
-            default: return FakeVSRServer.defaultReply(request, address: server.address)
+        let topic = FakeTopic(ends: [1: 4])
+        let held = Locked(false)
+        let released = Locked(false)
+        await serve(server, topic: topic) { request in
+            guard request.code == CommandCode.fetchMessages.rawValue, (try? request.fetch.autoCommit) == true, topic.stored[1] == 0 else {
+                return nil
             }
+            let reply = await topic.reply(to: request)
+            held.withLock { $0 = true }
+            while !released.withLock({ $0 }) {
+                try? await Task.sleep(for: .milliseconds(10))
+            }
+            return reply
         }
         let client = try await signedInClient(server)
-        let consumer = try client.consumerGroup(
-            name: "billing", streamID: 1, topicID: 2, configuration: ConsumerConfiguration(autoCommit: .disabled, pollingRetryInterval: .milliseconds(10)))
-        try await consumer.initialize()
-        #expect(await requests(server, .createConsumerGroup).count == 1)
-        #expect(try await consumer.next()?.message.offset == 0)
-        #expect(await requests(server, .joinConsumerGroup).count == 2)
-        await consumer.shutdown()
+        let reader = try await standalone(client, start: .first, commit: .onRead)
+        #expect(try await batches(reader, 1) == [[0, 1]])
+        try await reader.seek(partitionID: 1, to: .offset(1))
+        #expect(topic.stored[1] == 0)
+        let pending = Task { try? await reader.next() }
+        #expect(await eventually { held.withLock { $0 } && topic.stored[1] == 2 })
+        pending.cancel()
+        _ = await pending.value
+        released.withLock { $0 = true }
+
+        try await reader.close()
+
+        #expect(topic.stored[1] == 0)
+        await client.shutdown()
+        await server.stop()
+    }
+
+    /// While the store of a seek under `onRead` waits, no committing read of
+    /// the partition starts, and the new generation then reads from the
+    /// sought offset without a round trip for its stored offset.
+    @Test func anOnReadSeekBeforeTheFirstReadReadsFromItsOffsetAfterTheStore() async throws {
+        let server = try await FakeVSRServer.started()
+        let topic = FakeTopic(ends: [1: 5])
+        let held = Locked(false)
+        let released = Locked(false)
+        await serve(server, topic: topic) { request in
+            guard request.code == CommandCode.storeConsumerOffset.rawValue else {
+                return nil
+            }
+            let reply = await topic.reply(to: request)
+            held.withLock { $0 = true }
+            while !released.withLock({ $0 }) {
+                try? await Task.sleep(for: .milliseconds(10))
+            }
+            return reply
+        }
+        let client = try await signedInClient(server)
+        let reader = try await standalone(client, start: .first, commit: .onRead)
+        let seeking = Task { try await reader.seek(partitionID: 1, to: .offset(3)) }
+        #expect(await eventually { held.withLock { $0 } })
+        let pending = Task { try? await reader.next() }
+        released.withLock { $0 = true }
+        try await seeking.value
+
+        let batch = (await pending.value) ?? nil
+        #expect(batch?.messages.map(\.offset) == [3, 4])
+        let first = try #require(try await fetches(server).first)
+        #expect(first.autoCommit && first.targets.map(\.kind) == [.offset] && first.targets.map(\.value) == [3])
+        #expect(topic.stored[1] == 4)
+        try await reader.close()
+        await client.shutdown()
+        await server.stop()
+    }
+
+    /// There is no offset before zero, so a seek under `onRead` to offset
+    /// zero stores and deletes nothing: the first read from zero stores.
+    @Test func anOnReadSeekToOffsetZeroStoresNothing() async throws {
+        let server = try await FakeVSRServer.started()
+        let topic = FakeTopic(ends: [1: 2])
+        await serve(server, topic: topic)
+        let client = try await signedInClient(server)
+        let reader = try await standalone(client, commit: .onRead)
+        #expect(try await batches(reader, 1) == [[0, 1]])
+
+        try await reader.seek(partitionID: 1, to: .offset(0))
+
+        #expect(topic.stored[1] == 1)
+        #expect(await requests(server, .storeConsumerOffset).isEmpty)
+        #expect(await requests(server, .deleteConsumerOffset).isEmpty)
+        #expect(try await batches(reader, 1) == [[0, 1]])
+        try await reader.close()
+        await client.shutdown()
+        await server.stop()
+    }
+
+    /// A seek under `onRead` whose store the server refuses fails with that
+    /// error and moves nothing: the partition reads on in its generation.
+    @Test func anOnReadSeekWhoseStoreIsRefusedMovesNothing() async throws {
+        let server = try await FakeVSRServer.started()
+        let topic = FakeTopic(ends: [1: 4])
+        await serve(server, topic: topic) { request in
+            request.code == CommandCode.storeConsumerOffset.rawValue ? .status(IggyErrorCode.unauthorized.rawValue) : nil
+        }
+        let client = try await signedInClient(server)
+        let reader = try await standalone(client, commit: .onRead)
+        let before = try #require(try await reader.next())
+
+        let refused = await #expect(throws: IggyError.self) { try await reader.seek(partitionID: 1, to: .offset(4)) }
+
+        #expect(refused?.code == .unauthorized)
+        let after = try #require(try await reader.next())
+        #expect(after.messages.map(\.offset) == [2, 3] && after.stamp?.generation == before.stamp?.generation)
+        #expect(topic.stored[1] == 3)
+        try await reader.close()
+        await client.shutdown()
+        await server.stop()
+    }
+
+    /// A seek under `onRead` that stores while the close starts keeps its
+    /// store: the read in flight ends in the old generation, and the close
+    /// moves the offset back to the sought position.
+    @Test func anOnReadSeekThatWaitsWhenTheCloseStartsKeepsItsStore() async throws {
+        let server = try await FakeVSRServer.started()
+        let topic = FakeTopic(ends: [1: 4])
+        let held = Locked(false)
+        let released = Locked(false)
+        await serve(server, topic: topic) { request in
+            guard request.code == CommandCode.fetchMessages.rawValue, (try? request.fetch.autoCommit) == true, topic.stored[1] == 1 else {
+                return nil
+            }
+            let reply = await topic.reply(to: request)
+            held.withLock { $0 = true }
+            while !released.withLock({ $0 }) {
+                try? await Task.sleep(for: .milliseconds(10))
+            }
+            return reply
+        }
+        let client = try await signedInClient(server)
+        let reader = try await standalone(client, commit: .onRead)
+        #expect(try await batches(reader, 1) == [[0, 1]])
+        let pending = Task { try? await reader.next() }
+        #expect(await eventually { held.withLock { $0 } && topic.stored[1] == 3 })
+        let seeking = Task { try await reader.seek(partitionID: 1, to: .offset(1)) }
+        let closing = Task { try await reader.close() }
+        released.withLock { $0 = true }
+        try await seeking.value
+        try await closing.value
+        _ = await pending.value
+
+        #expect(topic.stored[1] == 0)
+        await client.shutdown()
+        await server.stop()
+    }
+
+    /// A section that the member no longer owns says nothing about its other
+    /// partitions, so the close still moves back what the same reply stored
+    /// for them.
+    @Test func anOnReadCloseRewindsTheSiblingOfASectionThatLostItsOwner() async throws {
+        let server = try await FakeVSRServer.started()
+        let topic = FakeTopic(ends: [1: 0, 2: 2], assignment: [1, 2])
+        let ready = Locked(false)
+        let released = Locked(false)
+        await serve(server, topic: topic) { request in
+            guard request.code == CommandCode.fetchMessages.rawValue, (try? request.fetch.autoCommit) == true, !ready.withLock({ $0 }) else {
+                return nil
+            }
+            _ = await topic.reply(to: request)
+            ready.withLock { $0 = true }
+            while !released.withLock({ $0 }) {
+                try? await Task.sleep(for: .milliseconds(10))
+            }
+            return .ok(
+                FakeVSRServer.fetchedBody([
+                    FakeVSRServer.FetchedSection(partitionID: 1, status: IggyErrorCode.consumerGroupPartitionNotOwned.rawValue),
+                    FakeVSRServer.FetchedSection(partitionID: 2, offsets: [0, 1]),
+                ]))
+        }
+        let client = try await signedInClient(server)
+        let reader = try await member(client, commit: .onRead)
+        let pending = Task { try? await reader.next() }
+        #expect(await eventually { ready.withLock { $0 } && topic.stored[2] == 1 })
+        pending.cancel()
+        _ = await pending.value
+
+        let closing = Task { try await reader.close() }
+        released.withLock { $0 = true }
+        try await closing.value
+
+        #expect(topic.stored[2] == nil)
+        await client.shutdown()
+        await server.stop()
+    }
+
+    /// A section that does not decode is read again one message at a time:
+    /// the messages before the bad one arrive, the error names the bad one
+    /// with its offset, its record and the continuation after it, and its
+    /// partition reads again only after a seek.
+    @Test func anUndecodableMessageIsNamedAndSkippedWithASeek() async throws {
+        let server = try await FakeVSRServer.started()
+        let topic = FakeTopic(ends: [1: 3], undecodable: [1])
+        await serve(server, topic: topic)
+        let client = try await signedInClient(server)
+        let reader = try await standalone(client, start: .first, count: 3)
+        #expect(try await batches(reader, 1) == [[0]])
+        var failure: ReadError?
+        do {
+            _ = try await reader.next()
+        } catch let error as ReadError {
+            failure = error
+        }
+        let error = try #require(failure)
+        #expect(error.partitionID == 1 && error.offset == 1 && error.raw?.isEmpty == false)
+        let skip = try #require(error.skip)
+        #expect(skip.kind == .offset && skip.value == 2)
+        try await reader.seek(partitionID: 1, to: skip)
+        #expect(try await batches(reader, 1) == [[2]])
+        try await reader.close()
+        await client.shutdown()
+        await server.stop()
+    }
+
+    /// A poll of one partition whose reply does not decode is read again one
+    /// message at a time, so the error names the bad message and the
+    /// continuation after it.
+    @Test func anUndecodablePollIsReadAgainOneMessageAtATime() async throws {
+        let server = try await FakeVSRServer.started()
+        let topic = FakeTopic(ends: [1: 3], undecodable: [0], oversized: [0])
+        await serve(server, topic: topic)
+        let client = try await signedInClient(server)
+        let reader = try await standalone(client, start: .first)
+
+        let error = try #require(await readError(reader))
+
+        #expect(error.partitionID == 1 && error.offset == 0 && error.skip?.value == 1)
+        try await reader.seek(partitionID: 1, to: try #require(error.skip))
+        #expect(try await batches(reader, 1) == [[1, 2]])
+        try await reader.close()
+        await client.shutdown()
+        await server.stop()
+    }
+
+    /// A gap that a lost read stored is read again one message at a time
+    /// once its reply does not decode, so the messages before the bad one
+    /// arrive and the error names the bad one.
+    @Test func anUndecodableGapReadNamesTheBadMessage() async throws {
+        let server = try await FakeVSRServer.started()
+        let topic = FakeTopic(ends: [1: 6], undecodable: [3])
+        await serve(server, topic: topic)
+        let client = try await signedInClient(server)
+        let reader = try await standalone(client, commit: .onRead)
+        #expect(try await batches(reader, 1) == [[0, 1]])
+        topic.loseReplies(1)
+
+        #expect(try await batches(reader, 1) == [[2]])
+        let error = try #require(await readError(reader))
+
+        #expect(error.partitionID == 1 && error.offset == 3 && error.skip?.value == 4)
+        try await reader.seek(partitionID: 1, to: try #require(error.skip))
+        #expect(try await batches(reader, 1) == [[4, 5]])
+        #expect(topic.stored[1] == 5)
+        try await reader.close()
+        await client.shutdown()
+        await server.stop()
+    }
+
+    /// An empty committing poll says nothing about where the next read
+    /// starts, so the next reply opens no gap.
+    @Test func anEmptyCommittingPollKeepsTheExpectedStart() async throws {
+        let server = try await FakeVSRServer.started()
+        let topic = FakeTopic(ends: [1: 2])
+        let committingFetches = Locked(0)
+        await serve(server, topic: topic) { request in
+            if request.code == CommandCode.pollMessagesDeferred.rawValue, polledStrategy(request).autoCommit {
+                return .ok(FakeVSRServer.polledBody(partitionID: 1, offsets: []))
+            }
+            guard request.code == CommandCode.fetchMessages.rawValue, (try? request.fetch.autoCommit) == true else {
+                return nil
+            }
+            let fetch = committingFetches.withLock { count in
+                count += 1
+                return count
+            }
+            return fetch == 2 ? .ok(FakeVSRServer.fetchedBody([.init(partitionID: 1, status: IggyErrorCode.invalidSizeBytes.rawValue)])) : nil
+        }
+        let client = try await signedInClient(server)
+        let reader = try await standalone(client, commit: .onRead)
+        #expect(try await batches(reader, 1) == [[0, 1]])
+        let pending = Task { try await batches(reader, 1) }
+        #expect(await eventually { await requests(server, .pollMessagesDeferred).count == 1 })
+        topic.append(1, to: 1)
+
+        #expect(try await pending.value == [[2]])
+        #expect(await requests(server, .pollMessagesDeferred).count == 1)
+        try await reader.close()
+        await client.shutdown()
+        await server.stop()
+    }
+
+    /// Two partitions whose history is gone stop in one reply, and each one
+    /// reports its own error.
+    @Test func everyPartitionThatStopsInOneReplyReportsItsError() async throws {
+        let server = try await FakeVSRServer.started()
+        let topic = FakeTopic(ends: [1: 1, 2: 1])
+        await serve(server, topic: topic) { request in
+            guard request.code == CommandCode.fetchMessages.rawValue, let fetch = try? request.fetch, fetch.targets.allSatisfy({ $0.value == 1 })
+            else {
+                return nil
+            }
+            return .ok(
+                FakeVSRServer.fetchedBody(
+                    fetch.targets.map { FakeVSRServer.FetchedSection(partitionID: $0.partitionID, status: IggyErrorCode.historyUnavailable.rawValue) }))
+        }
+        let client = try await signedInClient(server)
+        let reader = try await standalone(client, partitions: [1, 2], start: .first)
+        #expect(try await batches(reader, 2) == [[0], [0]])
+
+        var reported: [UInt32] = []
+        for _ in 0..<2 {
+            if let error = await readError(reader) {
+                #expect(error.error.code == .historyUnavailable)
+                reported += error.partitionID.map { [$0] } ?? []
+            }
+        }
+
+        #expect(reported.sorted() == [1, 2])
+        try await reader.close()
+        await client.shutdown()
+        await server.stop()
+    }
+
+    /// A fetch reply takes one record past `maxBytes`. A section refused for
+    /// its first message is read with a poll of its own, which returns that
+    /// message alone with the same limits.
+    @Test func anOversizedFirstMessageOfAFetchSectionIsPolledAlone() async throws {
+        let server = try await FakeVSRServer.started()
+        let topic = FakeTopic(ends: [1: 3], oversized: [0])
+        await serve(server, topic: topic)
+        let client = try await signedInClient(server)
+        let reader = try await standalone(client, start: .first, count: 3)
+        #expect(try await batches(reader, 2) == [[0], [1, 2]])
+        let polls = await requests(server, .pollMessagesDeferred)
+        #expect(polls.count == 1 && polls.first?.deferredOptions.maxBytes == 1_048_576)
+        try await reader.close()
+        await client.shutdown()
+        await server.stop()
+    }
+
+    // MARK: Commit policies
+
+    @Test func aManualReaderStoresOnlyTheCheckpointsItCommits() async throws {
+        let server = try await FakeVSRServer.started()
+        let topic = FakeTopic(ends: [1: 4])
+        await serve(server, topic: topic)
+        let client = try await signedInClient(server)
+        let reader = try await standalone(client, start: .first)
+        let first = try #require(try await reader.next())
+        _ = try #require(try await reader.next())
+        #expect(topic.stored.isEmpty)
+        try await reader.commit(try #require(first.checkpoint(at: 0)))
+        #expect(topic.stored[1] == 0)
+        await expectCode(.invalidCommand) { try await reader.mark(try #require(first.checkpoint)) }
+        let other = try await standalone(client, start: .first)
+        let foreign = try #require(try await other.next())
+        await expectCode(.invalidCommand) { try await reader.commit(try #require(foreign.checkpoint)) }
+        try await reader.close()
+        try await other.close()
+        #expect(topic.stored[1] == 0)
+
+        let onRead = try await standalone(client, start: .first, commit: .onRead)
+        let read = try #require(try await onRead.next())
+        await expectCode(.invalidCommand) { try await onRead.commit(try #require(read.checkpoint)) }
+        try await onRead.close()
+        await client.shutdown()
+        await server.stop()
+    }
+
+    /// `delivery` counts a batch when it is returned, `processing` once the
+    /// next one is asked for or the reader closes, `marked` only when it is
+    /// marked.
+    @Test(arguments: [CommitAfter.delivery, .processing, .marked])
+    func anAutoReaderStoresWhatItCounted(after: CommitAfter) async throws {
+        let server = try await FakeVSRServer.started()
+        let topic = FakeTopic(ends: [1: 4])
+        await serve(server, topic: topic)
+        let client = try await signedInClient(server)
+        let reader = try await standalone(client, start: .first, commit: .auto(AutoCommit(every: .message, after: after)))
+        let first = try #require(try await reader.next())
+        switch after {
+        case .delivery:
+            #expect(await eventually { topic.stored[1] == 1 })
+        case .processing:
+            _ = try #require(try await reader.next())
+            #expect(await eventually { topic.stored[1] == 1 })
+        case .marked:
+            try await reader.mark(try #require(first.checkpoint(at: 0)))
+            #expect(await eventually { topic.stored[1] == 0 })
+        }
+        try await reader.close()
+        let closed: [CommitAfter: UInt64] = [.delivery: 1, .processing: 3, .marked: 0]
+        #expect(topic.stored[1] == closed[after], "\(after)")
+        await client.shutdown()
+        await server.stop()
+    }
+
+    /// Without an event or an interval the policy stores only on close,
+    /// which the options refuse.
+    @Test func autoCommitNeedsAnIntervalOrAnEvent() async throws {
+        #expect(throws: IggyError.self) { try PollOptions(commit: .auto(AutoCommit())).validate() }
+        #expect(throws: IggyError.self) { try PollOptions(commit: .auto(AutoCommit(every: .messages(0)))).validate() }
+        try PollOptions(commit: .auto(.default)).validate()
+        #expect(AutoCommit.default == AutoCommit(interval: .seconds(5), every: .messages(10_000), after: .processing))
+    }
+
+    /// A rebalance stores the counted position of a partition before the
+    /// member gives it up.
+    @Test func aRebalanceStoresTheCountedPositionFirst() async throws {
+        let server = try await FakeVSRServer.started()
+        let topic = FakeTopic(ends: [1: 2, 2: 2], assignment: [1])
+        await serve(server, topic: topic)
+        let client = try await signedInClient(server)
+        let reader = try await client.reader(
+            streamID: 1, topicID: 2, consumer: .group(3),
+            configuration: ReaderConfiguration(
+                options: PollOptions(count: 2, maxWait: .milliseconds(200), commit: .auto(AutoCommit(interval: .seconds(60), after: .delivery))),
+                retryInterval: .milliseconds(50)))
+        #expect(try await batches(reader, 1) == [[0, 1]])
+        #expect(topic.stored.isEmpty)
+        topic.assign([2])
+        client.core.groupState.invalidateAssignment(ConsumerGroupState.GroupKey(stream: 1, topic: 2, group: 3))
+        #expect(try await batches(reader, 1) == [[0, 1]])
+        #expect(topic.stored[1] == 1)
+        try await reader.close()
+        await client.shutdown()
+        await server.stop()
+    }
+
+    /// With reads that do not wait, a partition that a reply left out or
+    /// found empty waits the retry interval instead of being read again at
+    /// once.
+    @Test(arguments: ["left out", "empty section", "empty poll"])
+    func aPartitionAReplyFoundNothingForWaitsTheRetryIntervalWhenReadsDoNotWait(reply: String) async throws {
+        let server = try await FakeVSRServer.started()
+        let topic = FakeTopic(ends: [1: 0])
+        await serve(server, topic: topic) { request in
+            reply == "left out" && request.code == CommandCode.fetchMessages.rawValue ? .ok(FakeVSRServer.fetchedBody([])) : nil
+        }
+        let client = try await signedInClient(server)
+        let reader = try await client.reader(
+            streamID: 1, topicID: 2, consumer: .consumer(9),
+            configuration: ReaderConfiguration(
+                partitions: [1], start: reply == "empty poll" ? .last : .next, options: PollOptions(count: 2, maxWait: .zero),
+                retryInterval: .milliseconds(50)))
+        let pending = Task { try await reader.next() }
+        let window: Duration = .milliseconds(500)
+        try await Task.sleep(for: window)
+        let fetched = await requests(server, .fetchMessages).count
+        let polled = await requests(server, .pollMessagesDeferred).count
+        pending.cancel()
+        try await reader.close()
+
+        #expect((1...12).contains(fetched + polled), "\(reply): \(fetched) fetches and \(polled) polls in \(window)")
+        await client.shutdown()
+        await server.stop()
+    }
+
+    /// Under `onRead` the server stored a read in flight, so its batch
+    /// reaches the application after its partition left the assignment.
+    @Test func anOnReadReaderHandsOverAReadThatEndsAfterItsPartitionLeft() async throws {
+        let server = try await FakeVSRServer.started()
+        let topic = FakeTopic(ends: [1: 0, 2: 0], assignment: [1, 2])
+        await serve(server, topic: topic)
+        let client = try await signedInClient(server)
+        let reader = try await member(client, commit: .onRead, maxWait: .seconds(10))
+        let pending = Task { try await reader.next() }
+        #expect(await eventually { (try? await fetches(server).filter(\.autoCommit).count) == 1 })
+        topic.assign([1])
+        client.core.groupState.invalidateAssignment(memberGroupKey)
+        #expect(await eventually { await reader.partitions == [1] })
+        topic.append(2, to: 2)
+        #expect(await eventually { topic.stored[2] == 1 })
+        topic.append(1, to: 1)
+
+        let batch = try #require(try await pending.value)
+
+        #expect(batch.partitionID == 2 && batch.messages.map(\.offset) == [0, 1])
+        try await reader.close()
+        await client.shutdown()
+        await server.stop()
+    }
+
+    /// Under `onRead` a partition that leaves while it reads again what a
+    /// lost read stored hands over the batch the server stored after that
+    /// gap, and reports the gap as lost.
+    @Test func anOnReadReaderHandsOverTheBatchAfterAGapWhenItsPartitionLeaves() async throws {
+        let server = try await FakeVSRServer.started()
+        let topic = FakeTopic(ends: [1: 6, 2: 1], assignment: [1])
+        let gapReads = Locked(0)
+        let released = Locked(false)
+        await serve(server, topic: topic) { request in
+            guard request.code == CommandCode.pollMessagesDeferred.rawValue, !polledStrategy(request).autoCommit else {
+                return nil
+            }
+            gapReads.withLock { $0 += 1 }
+            while !released.withLock({ $0 }) {
+                try? await Task.sleep(for: .milliseconds(10))
+            }
+            return nil
+        }
+        let client = try await signedInClient(server)
+        let reader = try await member(client, commit: .onRead)
+        #expect(try await batches(reader, 1) == [[0, 1]])
+        topic.loseReplies(1)
+        let pending = Task { try await reader.next() }
+        #expect(await eventually { gapReads.withLock { $0 } == 1 })
+        topic.assign([2])
+        client.core.groupState.invalidateAssignment(memberGroupKey)
+
+        let batch = try #require(try await pending.value)
+
+        #expect(batch.partitionID == 1 && batch.messages.map(\.offset) == [4, 5])
+        do {
+            _ = try await reader.next()
+            Issue.record("the gap of the partition that left was not reported")
+        } catch let error as ReadError {
+            #expect(error.partitionID == 1 && error.error.code == .fetchProgressUncertain)
+        }
+        released.withLock { $0 = true }
+        try await reader.close()
+        await client.shutdown()
+        await server.stop()
+    }
+
+    @Test(arguments: [false, true])
+    func anOnReadReaderHandsOverAReadAfterItsPartitionLeavesAndReturns(polls: Bool) async throws {
+        let server = try await FakeVSRServer.started()
+        let topic = FakeTopic(ends: [1: 0, 2: 0], assignment: [1, 2])
+        let ready = Locked(false)
+        let released = Locked(false)
+        let command: CommandCode = polls ? .pollMessagesDeferred : .fetchMessages
+        await serve(server, topic: topic) { request in
+            guard request.code == command.rawValue, polls || (try? request.fetch.autoCommit) == true,
+                !polls || polledPartition(request) == 2, !ready.withLock({ $0 })
+            else {
+                return nil
+            }
+            let reply = await topic.reply(to: request)
+            ready.withLock { $0 = true }
+            while !released.withLock({ $0 }) {
+                try? await Task.sleep(for: .milliseconds(10))
+            }
+            return reply
+        }
+        let client = try await signedInClient(server)
+        let reader = try await client.reader(
+            streamID: 1, topicID: 2, consumer: .group(3),
+            configuration: ReaderConfiguration(
+                start: polls ? .last : .next, options: PollOptions(count: 2, maxWait: .seconds(10), commit: .onRead),
+                retryInterval: .milliseconds(50)))
+        let finished = Locked(false)
+        let pending = Task {
+            defer { finished.withLock { $0 = true } }
+            return try await reader.next()
+        }
+        #expect(
+            await eventually {
+                if polls {
+                    return await requests(server, command).count == 2
+                }
+                return (try? await fetches(server).filter(\.autoCommit).count) == 1
+            })
+        topic.append(2, to: 2)
+        #expect(await eventually { ready.withLock { $0 } && topic.stored[2] == 1 })
+        topic.assign([1])
+        client.core.groupState.invalidateAssignment(memberGroupKey)
+        #expect(await eventually { await reader.partitions == [1] })
+        topic.assign([1, 2])
+        client.core.groupState.invalidateAssignment(memberGroupKey)
+        #expect(await eventually { await reader.partitions == [1, 2] })
+        released.withLock { $0 = true }
+        #expect(await eventually { finished.withLock { $0 } })
+        topic.append(1, to: 1)
+
+        let batch = try #require(try await pending.value)
+
+        #expect(batch.partitionID == 2 && batch.messages.map(\.offset) == [0, 1])
+        try await reader.close()
+        await client.shutdown()
+        await server.stop()
+    }
+
+    @Test(arguments: [false, true])
+    func aResumedSessionDoesNotRejoinTheReadersConsumerGroup(resumes: Bool) async throws {
+        let server = try await FakeVSRServer.started()
+        let topic = FakeTopic(ends: [1: 1], assignment: [1])
+        await serve(server, topic: topic)
+        let client = try await signedInClient(server)
+        let reader = try await member(client, commit: .manual)
+        let scope = client.core.groupState.sessionScope
+        if resumes {
+            await client.core.disconnectTransport()
+            try await client.ping()
+        } else {
+            await client.disconnect()
+            try await client.connect()
+            _ = try await client.login(username: "iggy", password: "iggy")
+        }
+        #expect((client.core.groupState.sessionScope == scope) == resumes)
+
+        #expect(try await batches(reader, 1) == [[0]])
+        #expect(await requests(server, .joinConsumerGroup).count == (resumes ? 1 : 2))
+
+        try await reader.close()
+        await client.shutdown()
+        await server.stop()
+    }
+
+    /// A read refused for the session leaves the member joined on the server
+    /// when the client keeps that session, so the close leaves the group
+    /// whatever the reader saw.
+    @Test func aMemberLeavesItsGroupOnCloseAfterALostSessionError() async throws {
+        let server = try await FakeVSRServer.started()
+        let topic = FakeTopic(ends: [1: 2], assignment: [1])
+        let refused = Locked(false)
+        await serve(server, topic: topic) { request in
+            guard request.code == CommandCode.fetchMessages.rawValue, !refused.exchange(true) else {
+                return nil
+            }
+            return .ok(FakeVSRServer.fetchedBody([.init(partitionID: 1, status: IggyErrorCode.staleClient.rawValue)]))
+        }
+        let client = try await signedInClient(server)
+        let reader = try await member(client, commit: .manual)
+        #expect(await readError(reader)?.error.code == .staleClient)
+
+        try await reader.close()
+
         #expect(await requests(server, .leaveConsumerGroup).count == 1)
         await client.shutdown()
+        await server.stop()
     }
 
-    /// A shutdown during a join leaves the group once the join is answered,
-    /// whether initialize() or a poll that lost the membership started it.
-    /// Nothing is polled after the shutdown.
-    @Test func aShutdownDuringAJoinLeavesTheGroupAfterIt() async throws {
-        for joinsOnPoll in [false, true] {
-            let server = try await FakeVSRServer.started()
-            let joins = Locked(0)
-            let slowJoin = joinsOnPoll ? 2 : 1
-            await server.respond { request in
-                switch request.code.flatMap(CommandCode.init(rawValue:)) {
-                case .getStream: return .ok(GoldenFixture.shared.bytes("response.get_stream"))
-                case .getTopic: return .ok(GoldenFixture.shared.bytes("response.get_topic"))
-                case .getConsumerGroup: return .ok(GoldenFixture.shared.bytes("response.consumer_group_details"))
-                case .syncConsumerGroup: return .ok(FakeVSRServer.assignmentBody(generation: 1, partitions: [1]))
-                case .joinConsumerGroup:
-                    let join = joins.withLock { join in
-                        join += 1
-                        return join
-                    }
-                    if join == slowJoin {
-                        try? await Task.sleep(for: .milliseconds(200))
-                    }
-                    return .ok([])
-                case .pollMessages: return .status(IggyErrorCode.consumerGroupMemberNotFound.rawValue)
-                default: return FakeVSRServer.defaultReply(request, address: server.address)
-                }
-            }
-            let client = try await signedInClient(server)
-            let consumer = try client.consumerGroup(name: "billing", streamID: 1, topicID: 2, configuration: ConsumerConfiguration(autoCommit: .disabled))
-            let reading = Task { () -> ReceivedMessage? in
-                try await consumer.initialize()
-                guard joinsOnPoll else {
-                    return nil
-                }
-                return try await consumer.next()
-            }
-            #expect(await eventually { joins.withLock { $0 } == slowJoin })
-            await consumer.shutdown()
-            #expect(await requests(server, .leaveConsumerGroup).count == 1, "joins on poll: \(joinsOnPoll)")
-            #expect(try await reading.value == nil)
-            #expect(await requests(server, .pollMessages).count == (joinsOnPoll ? 1 : 0), "joins on poll: \(joinsOnPoll)")
-            await client.shutdown()
-            await server.stop()
-        }
-    }
-
-    /// A poll that would join again once the consumer shut down sends
-    /// neither the join nor the poll, so no membership outlives the consumer.
-    @Test func aPollAfterShutdownDoesNotJoinAgain() async throws {
+    /// The server hands a member its partitions as its shards learn of the
+    /// join, so a sync right after it can hold part of the assignment. The
+    /// reader syncs again at its check interval, without a fence or a
+    /// heartbeat, and reads the partition the first sync did not hold.
+    @Test func aMemberReadsAPartitionItsFirstSyncDidNotHold() async throws {
         let server = try await FakeVSRServer.started()
-        defer { Task { await server.stop() } }
-        await serve(server) { _ in .status(IggyErrorCode.consumerGroupMemberNotFound.rawValue) }
-        let client = try await signedInClient(server)
-        let configuration = ConsumerConfiguration(pollInterval: .milliseconds(300), autoCommit: .disabled)
-        let consumer = try client.consumerGroup(name: "billing", streamID: 1, topicID: 2, configuration: configuration)
-        try await consumer.initialize()
-        let reading = Task { try await consumer.next() }
-        #expect(await eventually { await requests(server, .pollMessages).count == 1 })
-        await consumer.shutdown()
-        #expect(try await reading.value == nil)
-        #expect(await requests(server, .joinConsumerGroup).count == 1)
-        #expect(await requests(server, .pollMessages).count == 1)
-        await client.shutdown()
-    }
-
-    @Test func aMissingGroupIsNotCreatedWhenAutoCreationIsOff() async throws {
-        let server = try await FakeVSRServer.started()
-        defer { Task { await server.stop() } }
-        await server.respond { request in
-            switch request.code.flatMap(CommandCode.init(rawValue:)) {
-            case .getStream: return .ok(GoldenFixture.shared.bytes("response.get_stream"))
-            case .getTopic: return .ok(GoldenFixture.shared.bytes("response.get_topic"))
-            default: return FakeVSRServer.defaultReply(request, address: server.address)
+        let topic = FakeTopic(ends: [2: 1], assignment: [1, 2])
+        let syncs = Locked(0)
+        await serve(server, topic: topic) { request in
+            guard request.code == CommandCode.syncConsumerGroup.rawValue else {
+                return nil
             }
+            let sync = syncs.withLock { count in
+                count += 1
+                return count
+            }
+            return sync == 1 ? .ok(FakeVSRServer.assignmentBody(generation: 1, partitions: [1])) : nil
         }
         let client = try await signedInClient(server)
-        let consumer = try client.consumerGroup(
-            name: "billing", streamID: 1, topicID: 2, configuration: ConsumerConfiguration(createConsumerGroupIfNotExists: false))
-        await expectCode(.consumerGroupNameNotFound) { try await consumer.initialize() }
-        #expect(await requests(server, .createConsumerGroup).isEmpty)
+        let reader = try await member(client, commit: .manual)
+
+        let batch = try await withDeadline(.now + .seconds(2)) { try await reader.next() }
+        #expect(batch??.partitionID == 2 && batch??.messages.map(\.offset) == [0], "the reader did not read partition 2 within 2 seconds")
+        #expect(await requests(server, .syncConsumerGroup).count >= 2)
+
+        try await reader.close()
         await client.shutdown()
+        await server.stop()
     }
 
-    // MARK: Lifecycle
+    // MARK: Consumer
 
-    @Test func initializeWaitsForAMissingTopicWithinItsRetries() async throws {
-        for (retries, succeeds) in [(UInt32(3), true), (1, false)] {
-            let server = try await FakeVSRServer.started()
-            let lookups = Locked(0)
-            await server.respond { request in
-                switch request.code.flatMap(CommandCode.init(rawValue:)) {
-                case .getStream: return .ok(GoldenFixture.shared.bytes("response.get_stream"))
-                case .getTopic:
-                    let lookup = lookups.withLock {
-                        $0 += 1; return $0
-                    }
-                    return lookup > 2 ? .ok(GoldenFixture.shared.bytes("response.get_topic")) : .ok([])
-                default: return FakeVSRServer.defaultReply(request, address: server.address)
-                }
-            }
-            let client = try await signedInClient(server)
-            let configuration = ConsumerConfiguration(initRetries: retries, initRetryInterval: .milliseconds(10))
-            let consumer = try client.consumer(name: "reader", streamID: 1, topicID: "created", partitionID: 1, configuration: configuration)
-            if succeeds {
-                try await consumer.initialize()
-            } else {
-                await expectCode(.topicNameNotFound) { try await consumer.initialize() }
-            }
-            #expect(lookups.withLock { $0 } == (succeeds ? 3 : 2))
-            await consumer.shutdown()
-            await client.shutdown()
-            await server.stop()
-        }
-    }
-
-    /// Shutdown sends the reading position that was not stored yet, and a
-    /// second shutdown waits for the first. Reading ends with nil.
-    @Test func shutdownStoresTheReadingPositionOnce() async throws {
+    /// The shutdown of an onRead consumer moves the offset back to the last
+    /// message handed over: the server stored the whole batch with the read.
+    @Test func anOnReadConsumerMovesTheOffsetBackOnShutdown() async throws {
         let server = try await FakeVSRServer.started()
-        defer { Task { await server.stop() } }
-        let poll = batches([[0, 1]])
-        await server.respond { [poll] request in
-            switch request.code.flatMap(CommandCode.init(rawValue:)) {
-            case .getStream: return .ok(GoldenFixture.shared.bytes("response.get_stream"))
-            case .getTopic: return .ok(GoldenFixture.shared.bytes("response.get_topic"))
-            case .pollMessages: return await poll(request)
-            case .storeConsumerOffset:
-                try? await Task.sleep(for: .milliseconds(200))
-                return .ok([])
-            default: return FakeVSRServer.defaultReply(request, address: server.address)
-            }
-        }
+        let topic = FakeTopic(ends: [1: 3])
+        await serve(server, topic: topic)
         let client = try await signedInClient(server)
         let consumer = try client.consumer(
-            name: "reader", streamID: 1, topicID: 2, partitionID: 1, configuration: ConsumerConfiguration(autoCommit: .interval(.seconds(60))))
+            name: "reader", streamID: 1, topicID: 2, partitions: [1],
+            configuration: ConsumerConfiguration(pollOptions: PollOptions(count: 3, maxWait: .milliseconds(200), commit: .onRead)))
         try await consumer.initialize()
-        #expect(try await read(consumer, 2) == [0, 1])
-        let first = Task { await consumer.shutdown() }
-        #expect(await eventually { await !requests(server, .storeConsumerOffset).isEmpty })
+        #expect(try await read(consumer, 1) == [0])
+        #expect(topic.stored[1] == 2)
         await consumer.shutdown()
-        #expect(await consumer.lastStoredOffset(partitionID: 1) == 1)
-        await first.value
-        #expect(await requests(server, .storeConsumerOffset).map(storedOffset) == [1])
+        #expect(topic.stored[1] == 0)
         #expect(try await consumer.next() == nil)
         await client.shutdown()
+        await server.stop()
     }
 
-    /// A tick storing at shutdown ends right after its store. One whose store
-    /// outlasts the drain timeout is cancelled there, so shutdown waits
-    /// neither a full interval nor the request timeout.
-    @Test func shutdownEndsAStoringTickAfterItsStoreOrAtTheDrainTimeout() async throws {
-        for (stalls, bound) in [(false, Duration.milliseconds(700)), (true, .seconds(3))] {
-            let server = try await FakeVSRServer.started()
-            let poll = batches([[0]])
-            let stores = Locked(0)
-            await server.respond { [poll] request in
-                switch request.code.flatMap(CommandCode.init(rawValue:)) {
-                case .getStream: return .ok(GoldenFixture.shared.bytes("response.get_stream"))
-                case .getTopic: return .ok(GoldenFixture.shared.bytes("response.get_topic"))
-                case .pollMessages: return await poll(request)
-                case .storeConsumerOffset:
-                    let store = stores.withLock { store in
-                        store += 1
-                        return store
-                    }
-                    // Only the store of the tick hangs, so the final store of
-                    // the shutdown goes through.
-                    if stalls, store == 1 {
-                        return .stall
-                    }
-                    try? await Task.sleep(for: .milliseconds(200))
-                    return .ok([])
-                default: return FakeVSRServer.defaultReply(request, address: server.address)
+    /// `delivery` counts a message as it is handed over, `processing` once
+    /// the next one is asked for or the consumer shuts down.
+    @Test(arguments: [CommitAfter.delivery, .processing])
+    func anAutoConsumerFlushesWhatItCountedOnShutdown(after: CommitAfter) async throws {
+        let server = try await FakeVSRServer.started()
+        let topic = FakeTopic(ends: [1: 5])
+        await serve(server, topic: topic)
+        let client = try await signedInClient(server)
+        let consumer = try client.consumer(
+            name: "reader", streamID: 1, topicID: 2, partitions: [1],
+            configuration: ConsumerConfiguration(
+                pollOptions: PollOptions(count: 5, maxWait: .milliseconds(200), commit: .auto(AutoCommit(interval: .seconds(60), after: after)))))
+        try await consumer.initialize()
+        #expect(try await read(consumer, 2) == [0, 1])
+        #expect(topic.stored.isEmpty)
+        await consumer.shutdown()
+        #expect(topic.stored[1] == 1, "\(after)")
+        await client.shutdown()
+        await server.stop()
+    }
+
+    /// Under `processing` a handler that throws leaves its message uncounted
+    /// and ends the reading with its error.
+    @Test func aHandlerThatThrowsEndsProcessingWithoutCountingItsMessage() async throws {
+        struct HandlerFailure: Error {}
+        let server = try await FakeVSRServer.started()
+        let topic = FakeTopic(ends: [1: 3])
+        await serve(server, topic: topic)
+        let client = try await signedInClient(server)
+        let consumer = try client.consumer(
+            name: "reader", streamID: 1, topicID: 2, partitions: [1],
+            configuration: ConsumerConfiguration(
+                pollOptions: PollOptions(count: 3, maxWait: .milliseconds(200), commit: .auto(AutoCommit(interval: .seconds(60), after: .processing)))))
+        try await consumer.initialize()
+        await #expect(throws: HandlerFailure.self) {
+            try await consumer.consumeMessages { received in
+                if received.message.offset == 1 {
+                    throw HandlerFailure()
                 }
             }
-            let client = try await signedInClient(server, requestTimeout: .seconds(5))
-            let configuration = ConsumerConfiguration(autoCommit: .interval(.seconds(1)), offsetDrainTimeout: .seconds(1))
-            let consumer = try client.consumer(name: "reader", streamID: 1, topicID: 2, partitionID: 1, configuration: configuration)
-            try await consumer.initialize()
-            #expect(try await read(consumer, 1) == [0])
-            #expect(await eventually { await !requests(server, .storeConsumerOffset).isEmpty })
-            let elapsed = await ContinuousClock().measure { await consumer.shutdown() }
-            #expect(elapsed < bound, "stalls: \(stalls), took \(elapsed)")
-            await client.shutdown()
-            await server.stop()
         }
+        await consumer.shutdown()
+        #expect(topic.stored[1] == 0)
+        await client.shutdown()
+        await server.stop()
     }
 
-    /// Concurrent initializations share one run: one lookup, one join.
-    @Test func concurrentInitializationsShareOneRun() async throws {
+    /// A seek can replace the generation of a partition while the consumer
+    /// waits for the batch it then buffers. That batch is dropped, and the
+    /// batches of the other partitions still arrive.
+    @Test func aConsumerDropsABufferedBatchOfAGenerationASeekReplaced() async throws {
         let server = try await FakeVSRServer.started()
-        defer { Task { await server.stop() } }
-        await serve(server, poll: batches([[0]]))
+        let topic = FakeTopic(ends: [1: 3, 2: 3])
+        await serve(server, topic: topic)
         let client = try await signedInClient(server)
-        let consumer = try client.consumerGroup(name: "billing", streamID: 1, topicID: 2, configuration: ConsumerConfiguration(autoCommit: .disabled))
-        async let first: Void = consumer.initialize()
-        async let second: Void = consumer.initialize()
-        _ = try await (first, second)
+        let consumer = try client.consumer(
+            name: "reader", streamID: 1, topicID: 2, partitions: [1, 2],
+            configuration: ConsumerConfiguration(pollingStrategy: .first, pollOptions: PollOptions(count: 6, maxWait: .milliseconds(200))))
         try await consumer.initialize()
-        #expect(await requests(server, .getStream).count == 1)
-        #expect(await requests(server, .joinConsumerGroup).count == 1)
-        #expect(try await consumer.next()?.message.offset == 0)
+        let first = try #require(try await consumer.next())
+        let stale = first.checkpoint.stamp?.generation
+        let other: UInt32 = first.partitionID == 1 ? 2 : 1
+
+        try await #require(await consumer.reader).seek(partitionID: first.partitionID, to: .offset(0))
+        var received: [ReceivedMessage] = []
+        while received.count < 5, let message = try await withDeadline(.now + .seconds(2), { try await consumer.next() }) ?? nil {
+            received.append(message)
+        }
+
+        let replaced = received.filter { $0.partitionID == first.partitionID && $0.checkpoint.stamp?.generation == stale }
+        #expect(replaced.isEmpty, "\(replaced.map(\.message.offset))")
+        #expect(received.filter { $0.partitionID == other }.map(\.message.offset) == [0, 1, 2])
         await consumer.shutdown()
         await client.shutdown()
+        await server.stop()
     }
 
-    @Test func aDroppedConsumerEndsItsTasks() async throws {
+    /// Without replay, a message at or below the last one handed over is
+    /// skipped, unless a seek moved the partition back.
+    @Test func aSeekHandsMessagesOverAgain() async throws {
         let server = try await FakeVSRServer.started()
-        defer { Task { await server.stop() } }
-        await serve(server, poll: batches([]))
+        let topic = FakeTopic(ends: [1: 3])
+        await serve(server, topic: topic)
         let client = try await signedInClient(server)
-        let weak = WeakConsumer()
-        do {
-            let consumer = try client.consumer(
-                name: "reader", streamID: 1, topicID: 2, partitionID: 1, configuration: ConsumerConfiguration(autoCommit: .interval(.milliseconds(10))))
-            try await consumer.initialize()
-            weak.value = consumer
-        }
-        #expect(await eventually { weak.value == nil })
+        let consumer = try client.consumer(
+            name: "reader", streamID: 1, topicID: 2, partitions: [1],
+            configuration: ConsumerConfiguration(pollingStrategy: .first, pollOptions: PollOptions(count: 3, maxWait: .milliseconds(200))))
+        try await consumer.initialize()
+        #expect(try await read(consumer, 3) == [0, 1, 2])
+        try await consumer.seek(partitionID: 1, to: .offset(1))
+        #expect(try await read(consumer, 2) == [1, 2])
+        #expect(await consumer.lastConsumedOffset(partitionID: 1) == 2)
+        await consumer.shutdown()
         await client.shutdown()
-    }
-
-    @Test func outOfRangeOptionsAreRefusedWhenTheConsumerIsBuilt() async throws {
-        let server = try await FakeVSRServer.started()
-        defer { Task { await server.stop() } }
-        let client = try await signedInClient(server)
-        let invalid = [
-            ConsumerConfiguration(batchLength: 0), ConsumerConfiguration(autoCommit: .interval(.zero)),
-            ConsumerConfiguration(autoCommit: .when(.consumingEveryNthMessage(0))), ConsumerConfiguration(autoCommit: .after(.consumingEveryNthMessage(0))),
-            ConsumerConfiguration(pollingRetryInterval: .zero), ConsumerConfiguration(initRetryInterval: .milliseconds(-1)),
-            ConsumerConfiguration(pollInterval: .milliseconds(-1)), ConsumerConfiguration(offsetDrainTimeout: ProducerConfiguration.maxDuration * 2),
-        ]
-        for configuration in invalid {
-            await expectCode(.invalidConfiguration) {
-                _ = try client.consumer(name: "reader", streamID: 1, topicID: 2, partitionID: 1, configuration: configuration)
-            }
-        }
-        _ = try client.consumerGroup(name: "billing", streamID: 1, topicID: 2, configuration: ConsumerConfiguration(pollInterval: .zero))
-        await client.shutdown()
+        await server.stop()
     }
 }
diff --git a/foreign/swift/Tests/IggyTests/DeferredPollingVectorTests.swift b/foreign/swift/Tests/IggyTests/DeferredPollingVectorTests.swift
new file mode 100644
index 0000000..1d4ccd0
--- /dev/null
+++ b/foreign/swift/Tests/IggyTests/DeferredPollingVectorTests.swift
@@ -0,0 +1,385 @@
+// 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.
+
+import Foundation
+import Testing
+
+@testable import Iggy
+
+/// The wire vectors of deferred polling that every SDK shares, generated by
+/// `core/common/examples/deferred_polling_fixtures.rs`. Never edit the copy.
+private struct DeferredPollingFixture: Decodable {
+    struct WireIdentifier: Decodable {
+        let kind: String
+        let value: StringOrNumber
+    }
+
+    /// A numeric identifier is a JSON number, a named one a string.
+    enum StringOrNumber: Decodable {
+        case number(UInt32)
+        case string(String)
+
+        init(from decoder: any Decoder) throws {
+            let container = try decoder.singleValueContainer()
+            if let number = try? container.decode(UInt32.self) {
+                self = .number(number)
+            } else {
+                self = .string(try container.decode(String.self))
+            }
+        }
+    }
+
+    struct WireConsumer: Decodable {
+        let kind: UInt8
+        let id: WireIdentifier
+    }
+
+    struct Strategy: Decodable {
+        let kind: UInt8
+        let value: UInt64
+    }
+
+    struct DeferredPoll: Decodable {
+        let consumer: WireConsumer
+        let streamId: WireIdentifier
+        let topicId: WireIdentifier
+        let partitionId: UInt32
+        let strategy: Strategy
+        let count: UInt32
+        let autoCommit: Bool
+        let waitUs: UInt64
+        let minCount: UInt32
+        let maxBytes: UInt32
+        let requestTimeoutUs: UInt64
+    }
+
+    struct Target: Decodable {
+        let partitionId: UInt32
+        let strategy: Strategy
+        let maxBytes: UInt32
+        let incarnation: UInt64
+        let ownerGeneration: UInt64
+    }
+
+    struct Fetch: Decodable {
+        let consumer: WireConsumer
+        let streamId: WireIdentifier
+        let topicId: WireIdentifier
+        let waitUs: UInt64
+        let requestTimeoutUs: UInt64
+        let minCount: UInt32
+        let count: UInt32
+        let maxBytes: UInt32
+        let autoCommit: Bool
+        let targets: [Target]
+    }
+
+    struct Case<Request: Decodable>: Decodable {
+        let `case`: String
+        let request: Request
+        let bodyHex: String
+    }
+
+    struct Cancellation: Decodable {
+        let `case`: String
+        let cancelsCode: UInt32
+        let client: UInt64
+        let request: UInt64
+        let session: UInt64
+        let headerHex: String
+    }
+
+    struct Context: Decodable {
+        let incarnation: UInt64
+        let ownerGeneration: UInt64
+        let metadataOp: UInt64
+    }
+
+    struct Message: Decodable {
+        let offset: UInt64
+        let id: String
+        let timestamp: UInt64
+        let originTimestamp: UInt64
+        let payload: String
+    }
+
+    struct Section: Decodable {
+        let partitionId: UInt32
+        let status: UInt32
+        let currentOffset: UInt64
+        let messagesCount: UInt32
+        let context: Context
+        let nextOffset: UInt64
+        let recordsLen: Int
+        let messages: [Message]
+    }
+
+    struct Reply: Decodable {
+        let `case`: String
+        let sections: [Section]
+        let bodyHex: String
+    }
+
+    struct Refused: Decodable {
+        let `case`: String
+        let bodyHex: String
+    }
+
+    struct RefusedSet: Decodable {
+        let fetches: [Refused]
+        let fetchReplies: [Refused]
+    }
+
+    /// Whether a committing poll or fetch that failed with `status` stored
+    /// nothing, as the Rust classifier says.
+    struct CommitOutcome: Decodable {
+        let status: UInt32
+        let error: String
+        let certain: Bool
+    }
+
+    struct CommitOutcomes: Decodable {
+        let request: [CommitOutcome]
+        let section: [CommitOutcome]
+    }
+
+    struct Constants: Decodable {
+        let codes: [String: UInt32]
+        let statusOk: UInt32
+        let errors: [String: UInt32]
+        let minDeferredPollMaxBytes: UInt32
+        let maxDeferredPollWaitUs: UInt64
+        let maxDeferredPollTimeoutUs: UInt64
+        let minDeferredPollReplyMarginUs: UInt64
+        let maxPartitionsPerRequest: Int
+        let maxRecordReplySize: Int
+        let requestHeaderSize: Int
+        let fetchSectionHeaderSize: Int
+    }
+
+    let schemaVersion: Int
+    let constants: Constants
+    let deferredPolls: [Case<DeferredPoll>]
+    let fetches: [Case<Fetch>]
+    let cancellations: [Cancellation]
+    let fetchReplies: [Reply]
+    let refused: RefusedSet
+    let commitOutcomes: CommitOutcomes
+
+    static func load() throws -> DeferredPollingFixture {
+        let url = try #require(Bundle.module.url(forResource: "deferred_polling", withExtension: "json", subdirectory: "Fixtures"))
+        let decoder = JSONDecoder()
+        decoder.keyDecodingStrategy = .convertFromSnakeCase
+        return try decoder.decode(DeferredPollingFixture.self, from: try Data(contentsOf: url))
+    }
+}
+
+extension DeferredPollingFixture.WireIdentifier {
+    var identifier: Identifier {
+        get throws {
+            switch value {
+            case .number(let number): Identifier(numeric: number)
+            case .string(let name): try Identifier(named: name)
+            }
+        }
+    }
+}
+
+extension DeferredPollingFixture.WireConsumer {
+    var consumer: Consumer {
+        get throws {
+            Consumer(kind: try #require(Consumer.Kind(rawValue: kind)), id: try id.identifier)
+        }
+    }
+}
+
+extension DeferredPollingFixture.Strategy {
+    var strategy: PollingStrategy {
+        get throws {
+            PollingStrategy(kind: try #require(PollingStrategy.Kind(rawValue: kind)), value: value)
+        }
+    }
+}
+
+extension DeferredPollingFixture.Context {
+    var context: PartitionContext {
+        PartitionContext(incarnation: incarnation, ownerGeneration: ownerGeneration, metadataOp: metadataOp)
+    }
+}
+
+/// A decimal message id of up to 128 bits.
+private func messageID(_ decimal: String) throws -> MessageID {
+    var high: UInt64 = 0
+    var low: UInt64 = 0
+    for character in decimal {
+        let digit = UInt64(try #require(character.wholeNumberValue))
+        let lowProduct = low.multipliedFullWidth(by: 10)
+        let (sum, carry) = lowProduct.low.addingReportingOverflow(digit)
+        low = sum
+        high = high &* 10 &+ lowProduct.high &+ (carry ? 1 : 0)
+    }
+    return MessageID(low: low, high: high)
+}
+
+@Suite("Deferred polling wire vectors")
+struct DeferredPollingVectorTests {
+    private let fixture: DeferredPollingFixture
+
+    init() throws {
+        fixture = try DeferredPollingFixture.load()
+    }
+
+    @Test func theConstantsMatchTheClient() throws {
+        let constants = fixture.constants
+        #expect(fixture.schemaVersion == 1)
+        // Foundation converts the keys of these tables to camelCase on some
+        // platforms only, so both spellings are looked up.
+        #expect(Self.entry(constants.codes, "poll_messages") == CommandCode.pollMessages.rawValue)
+        #expect(Self.entry(constants.codes, "poll_messages_deferred") == CommandCode.pollMessagesDeferred.rawValue)
+        #expect(Self.entry(constants.codes, "cancel_deferred_request") == CommandCode.cancelDeferredRequest.rawValue)
+        #expect(Self.entry(constants.codes, "fetch_messages") == CommandCode.fetchMessages.rawValue)
+        #expect(constants.errors.count == 9)
+        for (_, code) in constants.errors {
+            let name = try #require(IggyErrorCode(rawValue: code)?.name)
+            #expect(Self.entry(constants.errors, name) == code, "\(name)")
+        }
+        #expect(constants.statusOk == 0)
+        #expect(constants.minDeferredPollMaxBytes == DeferredPollOptions.minMaxBytes)
+        #expect(constants.maxDeferredPollWaitUs == DeferredPollOptions.maxWaitMicroseconds)
+        #expect(constants.maxDeferredPollTimeoutUs == DeferredPollOptions.maxRequestTimeoutMicroseconds)
+        #expect(constants.minDeferredPollReplyMarginUs == DeferredPollOptions.replyMarginMicroseconds)
+        #expect(constants.maxPartitionsPerRequest == Requests.maxPartitionsPerRequest)
+        #expect(constants.requestHeaderSize == VSRFrame.headerSize)
+        #expect(constants.fetchSectionHeaderSize == Responses.fetchSectionHeaderSize)
+        // A reply that holds the largest record fits the frames the client reads.
+        #expect(constants.maxRecordReplySize <= VSRFrame.maxFrameSize)
+    }
+
+    private static func entry(_ table: [String: UInt32], _ snakeCase: String) -> UInt32? {
+        let words = snakeCase.split(separator: "_")
+        let camelCase = words.prefix(1).joined() + words.dropFirst().map { $0.prefix(1).uppercased() + $0.dropFirst() }.joined()
+        return table[snakeCase] ?? table[camelCase]
+    }
+
+    @Test func deferredPollsEncodeAsTheVectors() throws {
+        for vector in fixture.deferredPolls {
+            let request = vector.request
+            let options = DeferredPollOptions(
+                maxWait: .microseconds(request.waitUs), minCount: request.minCount, maxBytes: request.maxBytes,
+                requestTimeout: .microseconds(request.requestTimeoutUs))
+            try options.validate(count: request.count)
+            let body = try Requests.pollMessages(
+                consumer: try request.consumer.consumer, streamID: try request.streamId.identifier, topicID: try request.topicId.identifier,
+                partitionID: request.partitionId, strategy: try request.strategy.strategy, count: request.count, autoCommit: request.autoCommit,
+                options: options)
+            #expect(body == [UInt8](hex: vector.bodyHex), "\(vector.case)")
+        }
+    }
+
+    @Test func fetchesEncodeAsTheVectors() throws {
+        for vector in fixture.fetches {
+            let request = vector.request
+            let options = DeferredPollOptions(
+                maxWait: .microseconds(request.waitUs), minCount: request.minCount, maxBytes: request.maxBytes,
+                requestTimeout: .microseconds(request.requestTimeoutUs))
+            let targets = try request.targets.map { target in
+                Requests.FetchTarget(
+                    partitionID: target.partitionId, strategy: try target.strategy.strategy, maxBytes: target.maxBytes,
+                    fence: PartitionContext(incarnation: target.incarnation, ownerGeneration: target.ownerGeneration, metadataOp: 0))
+            }
+            let body = try Requests.fetchMessages(
+                consumer: try request.consumer.consumer, streamID: try request.streamId.identifier, topicID: try request.topicId.identifier,
+                targets: targets, count: request.count, autoCommit: request.autoCommit, options: options)
+            #expect(body == [UInt8](hex: vector.bodyHex), "\(vector.case)")
+            // The client reads each vector back as a fetch it accepts.
+            let decoded = try RawRequest.fetch(body)
+            #expect(decoded.targets == targets, "\(vector.case)")
+        }
+    }
+
+    /// A cancellation repeats the header of the request it cancels with its
+    /// own code, no body and no partition context. The vectors are taken
+    /// before the transport stamps the checksums.
+    @Test func cancellationsRepeatTheHeaderOfTheirRequest() throws {
+        for vector in fixture.cancellations {
+            var session = ConsensusSession(clientID: UInt128Value(low: vector.client, high: 0))
+            try session.bind(vector.session)
+            for _ in 1..<vector.request {
+                _ = try session.nextRequestID()
+            }
+            let request = try VSRFrame.encodeRequest(
+                session: &session, code: vector.cancelsCode, payload: [1, 2, 3],
+                partitionContext: PartitionContext(incarnation: 5, ownerGeneration: 6, metadataOp: 7))
+            #expect(request.requestID == vector.request, "\(vector.case)")
+            var cancellation = try #require(request.deferredCancellation, "\(vector.case)")
+            cancellation.replaceSubrange(0..<32, with: repeatElement(0, count: 32))
+            #expect(cancellation == [UInt8](hex: vector.headerHex), "\(vector.case)")
+        }
+    }
+
+    @Test func fetchRepliesDecodeAsTheVectors() throws {
+        for vector in fixture.fetchReplies {
+            let sections = try Responses.fetchSections([UInt8](hex: vector.bodyHex)[...])
+            #expect(sections.map(\.partitionID) == vector.sections.map(\.partitionId), "\(vector.case)")
+            for (section, expected) in zip(sections, vector.sections) {
+                let label = "\(vector.case), partition \(expected.partitionId)"
+                #expect(section.status == expected.status, "\(label)")
+                #expect(section.currentOffset == expected.currentOffset, "\(label)")
+                #expect(section.messagesCount == expected.messagesCount, "\(label)")
+                #expect(section.context == expected.context.context, "\(label)")
+                #expect(section.nextOffset == expected.nextOffset, "\(label)")
+                #expect(section.records.count == expected.recordsLen, "\(label)")
+                guard expected.status == 0 else {
+                    #expect(section.error?.rawCode == expected.status, "\(label)")
+                    continue
+                }
+                let messages = try section.decode().messages
+                #expect(messages.map(\.offset) == expected.messages.map(\.offset), "\(label)")
+                #expect(messages.map(\.payloadString) == expected.messages.map(\.payload), "\(label)")
+                #expect(messages.map(\.timestamp.microseconds) == expected.messages.map(\.timestamp), "\(label)")
+                #expect(messages.map(\.originTimestamp.microseconds) == expected.messages.map(\.originTimestamp), "\(label)")
+                #expect(messages.map(\.id) == (try expected.messages.map { try messageID($0.id) }), "\(label)")
+            }
+        }
+    }
+
+    /// A status the table lists classifies as the table says, in both
+    /// scopes, and a status it does not list leaves the outcome unknown.
+    @Test(arguments: [CommitErrorScope.request, .section])
+    func everyCommitOutcomeMatchesTheClassifier(scope: CommitErrorScope) throws {
+        let outcomes = scope == .request ? fixture.commitOutcomes.request : fixture.commitOutcomes.section
+        #expect(outcomes.contains(where: \.certain) && outcomes.contains { !$0.certain })
+        for outcome in outcomes {
+            let code = try #require(IggyErrorCode(rawValue: outcome.status), "status \(outcome.status)")
+            #expect(code.name == outcome.error)
+            #expect(scope.provesNothingStored(code) == outcome.certain, "\(scope) \(outcome.error)")
+        }
+        let certain = Set(outcomes.filter(\.certain).map(\.status))
+        for code in IggyErrorCode.allCases where scope.provesNothingStored(code) {
+            #expect(certain.contains(code.rawValue), "\(scope) \(code.name) is certain but not listed")
+        }
+    }
+
+    @Test func refusedFetchesAndRepliesFail() {
+        for vector in fixture.refused.fetches {
+            #expect(throws: IggyError.self, "\(vector.case)") { try RawRequest.fetch([UInt8](hex: vector.bodyHex)) }
+        }
+        for vector in fixture.refused.fetchReplies {
+            #expect(throws: (any Error).self, "\(vector.case)") { try Responses.fetchSections([UInt8](hex: vector.bodyHex)[...]) }
+        }
+    }
+}
diff --git a/foreign/swift/Tests/IggyTests/DeferredRequestTests.swift b/foreign/swift/Tests/IggyTests/DeferredRequestTests.swift
new file mode 100644
index 0000000..f374ae7
--- /dev/null
+++ b/foreign/swift/Tests/IggyTests/DeferredRequestTests.swift
@@ -0,0 +1,1088 @@
+// 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.
+
+import Foundation
+import Testing
+
+@testable import Iggy
+
+private func signedInClient(_ server: FakeVSRServer, requestTimeout: Duration = .seconds(30)) async throws -> IggyClient {
+    var configuration = ClientConfiguration(address: server.address, autoLogin: .usernamePassword(username: "iggy", password: "iggy"))
+    configuration.reconnection = ReconnectionOptions(maxRetries: nil, interval: .milliseconds(20), reestablishAfter: .zero)
+    configuration.requestTimeout = requestTimeout
+    configuration.connectTimeout = .seconds(2)
+    configuration.heartbeatInterval = .seconds(60)
+    let client = IggyClient(configuration: configuration, connector: NIOConnector(), logger: testLogger)
+    try await client.connect()
+    return client
+}
+
+private func requests(_ server: FakeVSRServer, _ code: CommandCode) async -> [FakeVSRServer.Request] {
+    await server.requests.filter { $0.code == code.rawValue }
+}
+
+/// The partition a poll names: `[kind:1][consumer:6][stream:6][topic:6][flag:1][partition:4]`
+/// with numeric ids.
+private func polledPartition(_ request: FakeVSRServer.Request) -> UInt32 {
+    UInt32(littleEndianBytes: request.payload[20..<24])
+}
+
+/// A coordinator, replica 0, and primaries where replica `placement(n)` is
+/// the primary of partition `n`, by default replica `n`.
+private struct Cluster {
+    let coordinator: FakeVSRServer
+    let primaries: [FakeVSRServer]
+    let placement: @Sendable (UInt32) -> UInt8
+
+    static func started(primaries count: Int, placement: @escaping @Sendable (UInt32) -> UInt8 = { UInt8($0) }) async throws -> Cluster {
+        let coordinator = try await FakeVSRServer.started()
+        var primaries: [FakeVSRServer] = []
+        for _ in 0..<count {
+            primaries.append(try await FakeVSRServer.started())
+        }
+        let cluster = Cluster(coordinator: coordinator, primaries: primaries, placement: placement)
+        for server in [coordinator] + primaries {
+            await cluster.respond(on: server) { FakeVSRServer.defaultReply($0, address: server.address) }
+        }
+        return cluster
+    }
+
+    /// Answers a roster read, a route lookup, and a fetch with a target of
+    /// another primary as every replica of the cluster does, and anything
+    /// else with `reply`.
+    func respond(on server: FakeVSRServer, _ reply: @escaping FakeVSRServer.Responder) async {
+        let replicas = [coordinator] + primaries
+        let roster = FakeVSRServer.rosterBody(replicas.map(\.address))
+        let replica = UInt8(replicas.firstIndex { $0 === server } ?? 0)
+        let placement = placement
+        await server.respond { request in
+            if let refusal = FakeVSRServer.notPrimaryRefusal(request, replica: replica, primary: placement) {
+                return refusal
+            }
+            switch request.code.flatMap(CommandCode.init(rawValue:)) {
+            case .getClusterMetadata:
+                return .ok(roster)
+            case .getPartitionRoute:
+                let partition = UInt32(littleEndianBytes: request.payload.suffix(4))
+                return .ok(FakeVSRServer.partitionRouteBody(FakeVSRServer.routeContext, primaryReplica: placement(partition)))
+            default:
+                return await reply(request)
+            }
+        }
+    }
+
+    func stop() async {
+        await coordinator.stop()
+        for primary in primaries {
+            await primary.stop()
+        }
+    }
+}
+
+@Suite("Deferred requests", .serialized)
+struct DeferredRequestTests {
+    private static let parkingOptions = DeferredPollOptions(maxWait: .seconds(5), requestTimeout: .seconds(10))
+    /// Longer than `shortOptions` allow, so a call they bound ends well
+    /// before a request of the client would.
+    private static let clientRequestTimeout: Duration = .seconds(8)
+    private static let shortOptions = DeferredPollOptions(maxWait: .seconds(1), requestTimeout: .seconds(2))
+
+    // MARK: Polls
+
+    @Test func aDeferredPollGoesOverADataConnectionWithWhatIsLeftOfItsOptions() async throws {
+        let server = try await FakeVSRServer.started()
+        defer { Task { await server.stop() } }
+        let client = try await signedInClient(server)
+        let options = DeferredPollOptions(maxWait: .milliseconds(500), minCount: 2, maxBytes: 4096, requestTimeout: .seconds(3))
+        let polled = try await client.pollMessages(
+            streamID: 1, topicID: 2, partitionID: 1, consumer: .consumer(5), strategy: .next, count: 10, autoCommit: true, options: options)
+        #expect(polled.context == FakeVSRServer.routeContext)
+
+        let poll = try #require(await requests(server, .pollMessagesDeferred).first)
+        let body = try Requests.pollMessages(consumer: .consumer(5), streamID: 1, topicID: 2, partitionID: 1, strategy: .next, count: 10, autoCommit: true)
+        #expect(poll.payload.starts(with: body))
+        #expect(poll.payload.count == body.count + FakeVSRServer.Request.deferredOptionsSize)
+        #expect(poll.context == FakeVSRServer.routeContext)
+        let sent = poll.deferredOptions
+        #expect(sent.minCount == 2)
+        #expect(sent.maxBytes == 4096)
+        // The time the call spent before the attempt comes off both.
+        #expect(sent.wait <= 500_000)
+        #expect(sent.timeout <= 3_000_000)
+        #expect(sent.timeout >= sent.wait + 1_000_000)
+        // A parked poll would hold the lockstep coordinator connection.
+        let login = try #require(await server.requests.first)
+        #expect(poll.connection != login.connection)
+        #expect(await requests(server, .bindSession).map(\.connection) == [poll.connection])
+        #expect(await requests(server, .pollMessages).isEmpty)
+        await client.shutdown()
+    }
+
+    /// A parked progress poll holds a data connection of its own, so a send
+    /// of the same client to its partition goes out at once, as the separate
+    /// mutation lane of the Rust SDK lets it.
+    @Test func aParkedProgressPollOrdersStoresButDoesNotDelaySends() async throws {
+        let server = try await FakeVSRServer.started()
+        let reply = AsyncSemaphore(permits: 0)
+        defer {
+            reply.release(1)
+            Task { await server.stop() }
+        }
+        await server.respond { request in
+            if request.code == CommandCode.pollMessagesDeferred.rawValue {
+                _ = try? await reply.acquire(1)
+            }
+            return FakeVSRServer.defaultReply(request, address: server.address)
+        }
+        let client = try await signedInClient(server)
+        let parked = Task {
+            try await client.pollMessages(
+                streamID: 1, topicID: 2, partitionID: 1, consumer: .consumer(5), strategy: .next, count: 10, autoCommit: true,
+                options: Self.parkingOptions)
+        }
+        #expect(await eventually { await !requests(server, .pollMessagesDeferred).isEmpty })
+        let storing = Task { try await client.storeConsumerOffset(streamID: 1, topicID: 2, partitionID: 1, offset: 9) }
+        defer { storing.cancel() }
+        let started = ContinuousClock.now
+        try await client.sendBatch(streamID: 1, topicID: 2, partitioning: .partition(1), messages: ["sent"])
+        let elapsed = ContinuousClock.now - started
+        #expect(elapsed < .seconds(1), "took \(elapsed)")
+        #expect(await requests(server, .sendMessages).count == 1)
+        try await Task.sleep(for: .milliseconds(100))
+        #expect(await requests(server, .storeConsumerOffset).isEmpty)
+        reply.release(1)
+        _ = try await parked.value
+        try await storing.value
+        #expect(await requests(server, .storeConsumerOffset).count == 1)
+        await client.shutdown()
+    }
+
+    @Test func optionsTheServerWouldRefuseFailBeforeAnythingIsSent() async throws {
+        let server = try await FakeVSRServer.started()
+        defer { Task { await server.stop() } }
+        let client = try await signedInClient(server)
+        let before = await server.requests.count
+        let refusedPolls: [(DeferredPollOptions, IggyErrorCode)] = [
+            (DeferredPollOptions(minCount: 11), .invalidMessagesCount), (DeferredPollOptions(maxWait: .seconds(11)), .invalidCommand),
+            (DeferredPollOptions(maxBytes: 100), .invalidSizeBytes),
+        ]
+        for (options, code) in refusedPolls {
+            for partitionID: UInt32? in [1, nil] {
+                await expectCode(code) {
+                    _ = try await client.pollMessages(
+                        streamID: 1, topicID: 2, partitionID: partitionID, consumer: .group(3), strategy: .next, count: 10, autoCommit: false,
+                        options: options)
+                }
+            }
+        }
+        // A fetch of each readiness group replaces a deferred group poll that
+        // names no partition.
+        await expectCode(.invalidCommand) {
+            _ = try await client.pollMessages(
+                streamID: 1, topicID: 2, partitionID: nil, consumer: .group(3), strategy: .next, count: 10, autoCommit: false,
+                options: Self.parkingOptions)
+        }
+        #expect(await server.requests.count == before)
+        await client.shutdown()
+    }
+
+    /// The request timeout bounds the whole call, though the client allows
+    /// each request longer. That includes the reconnect of a client that an
+    /// outage left disconnected, which the call waits for first. A call the
+    /// server would refuse fails before that reconnect.
+    @Test func aDeferredPollEndsAReconnectAtItsRequestTimeout() async throws {
+        let server = try await FakeVSRServer.started()
+        let client = try await signedInClient(server, requestTimeout: Self.clientRequestTimeout)
+        await server.stop()
+        let poll = { (options: DeferredPollOptions) in
+            _ = try await client.pollMessages(
+                streamID: 1, topicID: 2, partitionID: 1, consumer: .consumer(5), strategy: .next, count: 10, autoCommit: false, options: options)
+        }
+        // The first poll finds the connection gone and gives up its reconnect
+        // at its deadline, which leaves the client disconnected.
+        await #expect(throws: IggyError.self) { try await poll(Self.shortOptions) }
+        #expect(await eventually { await client.state == .disconnected })
+        let elapsed = await ContinuousClock().measure {
+            await expectCode(.transientNotAccepted) { try await poll(Self.shortOptions) }
+        }
+        #expect(elapsed < Self.clientRequestTimeout - Self.shortOptions.requestTimeout, "took \(elapsed)")
+        await expectCode(.invalidMessagesCount) { try await poll(DeferredPollOptions(minCount: 11)) }
+        await expectCode(.invalidCommand) {
+            _ = try await client.fetchMessages(streamID: 1, topicID: 2, targets: [], count: 1, autoCommit: false)
+        }
+        await client.shutdown()
+    }
+
+    /// A rebalance on every attempt ends an immediate group poll empty, so
+    /// its caller polls again.
+    @Test func aGroupPollThatMeetsARebalanceOnEveryAttemptEndsEmpty() async throws {
+        let server = try await FakeVSRServer.started()
+        defer { Task { await server.stop() } }
+        await server.respond { request in
+            switch request.code.flatMap(CommandCode.init(rawValue:)) {
+            case .syncConsumerGroup:
+                return .ok(FakeVSRServer.assignmentBody(generation: 1, partitions: [0, 1, 2]))
+            case .pollMessages:
+                return .status(IggyErrorCode.consumerGroupPartitionNotOwned.rawValue)
+            default:
+                return FakeVSRServer.defaultReply(request, address: server.address)
+            }
+        }
+        let client = try await signedInClient(server)
+        let polled = try await client.pollMessages(
+            streamID: 1, topicID: 2, partitionID: nil, consumer: .group(3), strategy: .next, count: 10, autoCommit: true)
+        #expect(polled.messages.isEmpty)
+        #expect(await requests(server, .syncConsumerGroup).count == 3)
+        await client.shutdown()
+    }
+
+    // MARK: Cancellation
+
+    @Test func aCancelledRequestIsCancelledOnItsConnection() async throws {
+        for code in [CommandCode.pollMessagesDeferred, .fetchMessages] {
+            let server = try await FakeVSRServer.started()
+            let parked = Locked(true)
+            await server.respond { request in
+                if request.code == code.rawValue, parked.exchange(false) {
+                    return .stall
+                }
+                return FakeVSRServer.defaultReply(request, address: server.address)
+            }
+            let client = try await signedInClient(server)
+            let send = { @Sendable in
+                switch code {
+                case .fetchMessages:
+                    _ = try await client.fetchMessages(
+                        streamID: 1, topicID: 2, targets: [(1, .next)], count: 1, autoCommit: false, options: Self.parkingOptions)
+                default:
+                    _ = try await client.pollMessages(
+                        streamID: 1, topicID: 2, partitionID: 1, strategy: .next, count: 1, autoCommit: false, options: Self.parkingOptions)
+                }
+            }
+            let parkedRequest = Task { try await send() }
+            #expect(await eventually { await requests(server, code).count == 1 })
+            parkedRequest.cancel()
+            await #expect(throws: CancellationError.self, "\(code)") { try await parkedRequest.value }
+            #expect(await eventually { await requests(server, .cancelDeferredRequest).count == 1 }, "\(code)")
+            let request = try #require(await requests(server, code).first)
+            let cancellation = try #require(await requests(server, .cancelDeferredRequest).first)
+            #expect(cancellation.connection == request.connection, "\(code)")
+            #expect(cancellation.requestID == request.requestID, "\(code)")
+            #expect(cancellation.clientID == request.clientID && cancellation.session == request.session, "\(code)")
+            #expect(cancellation.operation == .nonReplicated, "\(code)")
+            #expect(cancellation.payload.isEmpty && cancellation.context == .empty, "\(code)")
+
+            // The connection is still bound and in step.
+            try await send()
+            let next = try #require(await requests(server, code).last)
+            #expect(next.connection == request.connection, "\(code)")
+            #expect(next.requestID > request.requestID, "\(code)")
+            #expect(await requests(server, .bindSession).count == 1, "\(code)")
+            await client.shutdown()
+            await server.stop()
+        }
+    }
+
+    /// A committing request answers its cancellation once: with
+    /// `transientNotAccepted` when the cancellation won and nothing was
+    /// stored, with its messages when its read committed first. So its
+    /// cancelled caller waits for that reply, gets the messages the server
+    /// stored, and the connection stays in step.
+    @Test(arguments: [CommandCode.pollMessagesDeferred, .fetchMessages])
+    func aCancelledCommittingReadWaitsForItsOneReply(code: CommandCode) async throws {
+        for cancellationWins in [true, false] {
+            let server = try await FakeVSRServer.started()
+            let cancelled = Locked(false)
+            let answered = Locked(false)
+            await server.respond { request in
+                switch request.code {
+                case CommandCode.cancelDeferredRequest.rawValue:
+                    cancelled.withLock { $0 = true }
+                    return .stall
+                case code.rawValue where !answered.exchange(true):
+                    _ = await eventually { cancelled.withLock { $0 } }
+                    if cancellationWins {
+                        return .status(IggyErrorCode.transientNotAccepted.rawValue)
+                    }
+                    if code == .fetchMessages {
+                        return .ok(FakeVSRServer.fetchedBody([.init(partitionID: 1, offsets: [3])]))
+                    }
+                    return .ok(FakeVSRServer.polledBody(partitionID: 1, offsets: [3]))
+                default:
+                    return FakeVSRServer.defaultReply(request, address: server.address)
+                }
+            }
+            let client = try await signedInClient(server)
+            let read = { @Sendable () async throws -> [UInt64] in
+                switch code {
+                case .fetchMessages:
+                    let sections = try await client.fetchMessages(
+                        streamID: 1, topicID: 2, targets: [(1, .next)], count: 10, autoCommit: true, options: Self.parkingOptions)
+                    return try sections.flatMap { try $0.decode().messages.map(\.offset) }
+                default:
+                    let polled = try await client.pollMessages(
+                        streamID: 1, topicID: 2, partitionID: 1, strategy: .next, count: 10, autoCommit: true, options: Self.parkingOptions)
+                    return polled.messages.map(\.offset)
+                }
+            }
+            let cancelledRead = Task { try await read() }
+            #expect(await eventually { await requests(server, code).count == 1 })
+            cancelledRead.cancel()
+            if cancellationWins {
+                await #expect(throws: CancellationError.self, "\(code)") { try await cancelledRead.value }
+            } else {
+                #expect(try await cancelledRead.value == [3], "\(code)")
+            }
+            let request = try #require(await requests(server, code).first)
+            let cancellation = try #require(await requests(server, .cancelDeferredRequest).first)
+            #expect(cancellation.connection == request.connection && cancellation.requestID == request.requestID, "\(code)")
+
+            // The reply was read, so the connection is still in step.
+            _ = try await read()
+            let next = try #require(await requests(server, code).last)
+            #expect(next.connection == request.connection && next.requestID > request.requestID, "\(code)")
+            await client.shutdown()
+            await server.stop()
+        }
+    }
+
+    /// The server can answer a poll before the cancellation reaches it. That
+    /// reply does not answer the next request on the connection.
+    @Test func aReplyToACancelledPollDoesNotAnswerTheNextOne() async throws {
+        let server = try await FakeVSRServer.started()
+        defer { Task { await server.stop() } }
+        let polls = Locked<UInt64>(0)
+        await server.respond { request in
+            guard request.code == CommandCode.pollMessagesDeferred.rawValue else {
+                return FakeVSRServer.defaultReply(request, address: server.address)
+            }
+            let poll = polls.withLock { polls in
+                polls += 1
+                return polls
+            }
+            // The first reply arrives while the second poll waits for its own.
+            try? await Task.sleep(for: poll == 1 ? .milliseconds(300) : .milliseconds(600))
+            return .ok(FakeVSRServer.polledBody(partitionID: 1, offsets: [poll]))
+        }
+        let client = try await signedInClient(server)
+        let poll = { @Sendable in
+            try await client.pollMessages(streamID: 1, topicID: 2, partitionID: 1, strategy: .next, count: 1, autoCommit: false, options: Self.parkingOptions)
+        }
+        let cancelled = Task { try await poll() }
+        #expect(await eventually { polls.withLock { $0 } == 1 })
+        cancelled.cancel()
+        await #expect(throws: CancellationError.self) { try await cancelled.value }
+        #expect(try await poll().messages.map(\.offset) == [2])
+        let sent = await requests(server, .pollMessagesDeferred)
+        #expect(Set(sent.map(\.connection)).count == 1)
+        await client.shutdown()
+    }
+
+    /// A poll or fetch may have committed what it read before losing its
+    /// reply, so it does not go again, and its partition takes no progress
+    /// until the caller abandons it.
+    @Test func aPollOrFetchThatLostItsReplyBlocksItsPartitionUntilAbandoned() async throws {
+        let server = try await FakeVSRServer.started()
+        defer { Task { await server.stop() } }
+        let dropped = Locked<Set<UInt32>>([])
+        await server.respond { request in
+            if let code = request.code, CommandCode.isDeferred(code), dropped.withLock({ $0.insert(code).inserted }) {
+                return .close
+            }
+            return FakeVSRServer.defaultReply(request, address: server.address)
+        }
+        let client = try await signedInClient(server)
+        let options = DeferredPollOptions(maxWait: .milliseconds(200), requestTimeout: .seconds(5))
+        let lost = try await reportedError {
+            _ = try await client.pollMessages(streamID: 1, topicID: 2, partitionID: 1, strategy: .next, count: 1, autoCommit: true, options: options)
+        }
+        #expect(lost.code == .fetchProgressUncertain)
+        let operation = try #require(lost.operationID)
+        #expect(await requests(server, .pollMessagesDeferred).count == 1)
+
+        let blocked = try await reportedError {
+            _ = try await client.fetchMessages(streamID: 1, topicID: 2, targets: [(1, .next)], count: 1, autoCommit: true, options: options)
+        }
+        #expect(blocked.code == .partitionLaneBlocked && blocked.operationID == operation && blocked.partitionID == 1)
+        await expectCode(.partitionLaneBlocked) {
+            try await client.storeConsumerOffset(streamID: 1, topicID: 2, partitionID: 1, offset: 7)
+        }
+        #expect(await requests(server, .fetchMessages).isEmpty)
+        #expect(await requests(server, .storeConsumerOffset).isEmpty)
+        try client.abandonOperation(operation)
+        await expectCode(.operationNotRetained) { try client.abandonOperation(operation) }
+
+        let lostFetch = try await reportedError {
+            _ = try await client.fetchMessages(streamID: 1, topicID: 2, targets: [(1, .next)], count: 1, autoCommit: true, options: options)
+        }
+        #expect(lostFetch.code == .fetchProgressUncertain && lostFetch.operationID != operation)
+        #expect(await requests(server, .fetchMessages).count == 1)
+        try client.abandonOperation(try #require(lostFetch.operationID))
+        try await client.storeConsumerOffset(streamID: 1, topicID: 2, partitionID: 1, offset: 7)
+        _ = try await client.fetchMessages(streamID: 1, topicID: 2, targets: [(1, .next)], count: 1, autoCommit: true, options: options)
+        await client.shutdown()
+    }
+
+    /// A disconnect by the caller ends a committing poll that waits with an
+    /// unknown outcome, and its block outlives the session.
+    @Test func aDisconnectEndsAWaitingCommittingPollAsUncertain() async throws {
+        let server = try await FakeVSRServer.started()
+        defer { Task { await server.stop() } }
+        let released = Locked(false)
+        await server.respond { request in
+            guard request.code == CommandCode.pollMessagesDeferred.rawValue else {
+                return FakeVSRServer.defaultReply(request, address: server.address)
+            }
+            while !released.withLock({ $0 }) {
+                try? await Task.sleep(for: .milliseconds(10))
+            }
+            return .ok(FakeVSRServer.polledBody(partitionID: 1, offsets: []))
+        }
+        let client = try await signedInClient(server)
+        let options = DeferredPollOptions(maxWait: .seconds(10), requestTimeout: .seconds(20))
+        let poll = { @Sendable in
+            _ = try await client.pollMessages(streamID: 1, topicID: 2, partitionID: 1, strategy: .next, count: 1, autoCommit: true, options: options)
+        }
+        let waiting = Task { try await poll() }
+        #expect(await eventually { await requests(server, .pollMessagesDeferred).count == 1 })
+
+        await client.disconnect()
+        let lost = try await reportedError {
+            let ended: Void? = try await withDeadline(.now + .seconds(2)) { try await waiting.value }
+            #expect(ended != nil, "the poll did not end within 2 seconds of the disconnect")
+        }
+
+        #expect(lost.code == .fetchProgressUncertain)
+        try await client.connect()
+        let blocked = try await reportedError { try await poll() }
+        #expect(blocked.code == .partitionLaneBlocked && blocked.operationID == lost.operationID)
+        released.withLock { $0 = true }
+        try client.abandonOperation(try #require(lost.operationID))
+        try await poll()
+        await client.shutdown()
+    }
+
+    /// A committing poll answered after its caller signed in again may have
+    /// stored a batch that the caller never gets, so it fails as uncertain.
+    @Test func aCommittingPollAnsweredAfterTheCallerSignedInAgainIsUncertain() async throws {
+        let server = try await FakeVSRServer.started()
+        defer { Task { await server.stop() } }
+        let released = Locked(false)
+        await server.respond { request in
+            guard request.code == CommandCode.pollMessagesDeferred.rawValue else {
+                return FakeVSRServer.defaultReply(request, address: server.address)
+            }
+            while !released.withLock({ $0 }) {
+                try? await Task.sleep(for: .milliseconds(10))
+            }
+            return .ok(FakeVSRServer.polledBody(partitionID: 1, offsets: [0]))
+        }
+        let client = try await signedInClient(server)
+        let options = DeferredPollOptions(maxWait: .seconds(10), requestTimeout: .seconds(20))
+        let waiting = Task {
+            _ = try await client.pollMessages(streamID: 1, topicID: 2, partitionID: 1, strategy: .next, count: 1, autoCommit: true, options: options)
+        }
+        #expect(await eventually { await requests(server, .pollMessagesDeferred).count == 1 })
+        _ = try await client.login(username: "iggy", password: "iggy")
+
+        released.withLock { $0 = true }
+        let lost = try await reportedError { try await waiting.value }
+
+        #expect(lost.code == .fetchProgressUncertain && lost.operationID != nil)
+        await client.shutdown()
+    }
+
+    /// A request waits for a lease within its own deadline, as one that
+    /// never left the client. A fetch takes one lease for all its targets.
+    @Test func atMostSixtyFourDeferredRequestsAreInFlight() async throws {
+        let server = try await FakeVSRServer.started()
+        defer { Task { await server.stop() } }
+        let stalled = Locked(0)
+        await server.respond { request in
+            if request.code == CommandCode.pollMessagesDeferred.rawValue || request.code == CommandCode.fetchMessages.rawValue {
+                let count = stalled.withLock { stalled in
+                    stalled += 1
+                    return stalled
+                }
+                if count <= PollRouter.maxDeferredRequests {
+                    return .stall
+                }
+            }
+            return FakeVSRServer.defaultReply(request, address: server.address)
+        }
+        let client = try await signedInClient(server)
+        let poll = { @Sendable (timeout: Duration) in
+            try await client.pollMessages(
+                streamID: 1, topicID: 2, partitionID: 1, strategy: .offset(0), count: 1, autoCommit: false,
+                options: DeferredPollOptions(maxWait: .zero, requestTimeout: timeout))
+        }
+        let fetch = { @Sendable in
+            _ = try await client.fetchMessages(
+                streamID: 1, topicID: 2, targets: [(1, .offset(0)), (2, .offset(0))], count: 1, autoCommit: false,
+                options: DeferredPollOptions(maxWait: .seconds(20), requestTimeout: .seconds(30)))
+        }
+        let parked = (0..<PollRouter.maxDeferredRequests).map { index in
+            Task {
+                if index.isMultiple(of: 2) {
+                    try await fetch()
+                } else {
+                    _ = try await poll(.seconds(30))
+                }
+            }
+        }
+        #expect(await eventually(timeout: .seconds(10)) { stalled.withLock { $0 } == PollRouter.maxDeferredRequests })
+        await expectCode(.transientNotAccepted) { _ = try await poll(.milliseconds(300)) }
+        #expect(stalled.withLock { $0 } == PollRouter.maxDeferredRequests)
+
+        parked[0].cancel()
+        #expect(try await poll(.seconds(5)).messages.isEmpty)
+        for request in parked {
+            request.cancel()
+        }
+        for request in parked {
+            _ = await request.result
+        }
+        #expect(await eventually { await requests(server, .cancelDeferredRequest).count == PollRouter.maxDeferredRequests })
+        // The freed connection served the later poll.
+        #expect(await server.connectionCount == PollRouter.maxDeferredRequests + 1)
+        await client.shutdown()
+    }
+
+    /// The leases bound the deferred requests in flight, and an idle
+    /// connection to one primary makes way for a request to another, so the
+    /// connections they leave open stay within the same bound.
+    @Test func anIdleDeferredConnectionMakesWayForAnotherPrimary() async throws {
+        let cluster = try await Cluster.started(primaries: 2)
+        defer { Task { await cluster.stop() } }
+        let (busy, other) = (cluster.primaries[0], cluster.primaries[1])
+        await cluster.respond(on: busy) { request in
+            request.code == CommandCode.fetchMessages.rawValue ? .stall : FakeVSRServer.defaultReply(request, address: busy.address)
+        }
+        let client = try await signedInClient(cluster.coordinator)
+        let fetch = { @Sendable (partition: UInt32) in
+            try await client.fetchMessages(
+                streamID: 1, topicID: 2, targets: [(partition, .offset(0))], count: 1, autoCommit: false, options: Self.parkingOptions)
+        }
+        let parked = (0..<PollRouter.maxDeferredRequests).map { _ in Task { try await fetch(1) } }
+        #expect(await eventually(timeout: .seconds(10)) { await requests(busy, .fetchMessages).count == PollRouter.maxDeferredRequests })
+        for request in parked {
+            request.cancel()
+        }
+        for request in parked {
+            _ = await request.result
+        }
+        #expect(await eventually { await requests(busy, .cancelDeferredRequest).count == PollRouter.maxDeferredRequests })
+        #expect(await busy.openConnectionCount == PollRouter.maxDeferredRequests)
+
+        #expect(try await fetch(2).isEmpty)
+        #expect(await eventually { await busy.openConnectionCount == PollRouter.maxDeferredRequests - 1 })
+        #expect(await other.openConnectionCount == 1)
+        await client.shutdown()
+    }
+
+    // MARK: Fetches
+
+    @Test(arguments: [false, true], [false, true])
+    func progressFetchesOrderEveryTargetAcrossAliasesAndRequestForms(raw: Bool, autoCommit: Bool) async throws {
+        let server = try await FakeVSRServer.started()
+        let reply = AsyncSemaphore(permits: 0)
+        defer {
+            reply.release(1)
+            Task { await server.stop() }
+        }
+        let first = Locked(true)
+        await server.respond { request in
+            if request.code == CommandCode.fetchMessages.rawValue, first.exchange(false) {
+                _ = try? await reply.acquire(1)
+            }
+            return FakeVSRServer.defaultReply(request, address: server.address)
+        }
+        let client = try await signedInClient(server)
+        let parked = Task {
+            let strategy: PollingStrategy = autoCommit ? .offset(0) : .next
+            if raw {
+                let payload = try Requests.fetchMessages(
+                    consumer: .default, streamID: 1, topicID: 2,
+                    targets: [2, 1].map { .init(partitionID: $0, strategy: strategy, maxBytes: Self.parkingOptions.maxBytes, fence: .empty) },
+                    count: 10, autoCommit: autoCommit, options: Self.parkingOptions)
+                _ = try await client.sendRawRequest(code: CommandCode.fetchMessages.rawValue, payload: payload)
+            } else {
+                _ = try await client.fetchMessages(
+                    streamID: 1, topicID: 2, targets: [(2, strategy), (1, strategy)], count: 10, autoCommit: autoCommit, options: Self.parkingOptions)
+            }
+        }
+        defer { parked.cancel() }
+        #expect(await eventually { await requests(server, .fetchMessages).count == 1 })
+        let sent = try #require(await requests(server, .fetchMessages).first)
+        #expect(try sent.fetch.targets.allSatisfy { $0.incarnation == FakeVSRServer.routeContext.incarnation })
+
+        let started = AsyncSemaphore(permits: 0)
+        let operations: [@Sendable () async throws -> Void] = [
+            {
+                try await client.storeConsumerOffset(streamID: "stream", topicID: "topic", partitionID: 1, offset: 7)
+            },
+            {
+                let payload = try Requests.deleteConsumerOffset(consumer: .default, streamID: 1, topicID: 2, partitionID: 1, ack: .quorum)
+                _ = try await client.sendRawRequest(code: CommandCode.deleteConsumerOffset.rawValue, payload: payload)
+            },
+            {
+                let payload = try Requests.pollMessages(
+                    consumer: .default, streamID: 1, topicID: 2, partitionID: 1, strategy: .next, count: 1, autoCommit: false)
+                _ = try await client.sendRawRequest(code: CommandCode.pollMessages.rawValue, payload: payload)
+            },
+            {
+                _ = try await client.pollMessages(
+                    streamID: "stream", topicID: "topic", partitionID: 1, strategy: .offset(0), count: 1, autoCommit: true, options: Self.parkingOptions)
+            },
+            {
+                _ = try await client.fetchMessages(
+                    streamID: 1, topicID: 2, targets: [(1, .next), (2, .next)], count: 1, autoCommit: false, options: Self.parkingOptions)
+            },
+        ]
+        let waiting = operations.map { operation in
+            Task {
+                started.release(1)
+                try await operation()
+            }
+        }
+        defer { waiting.forEach { $0.cancel() } }
+        #expect(try await started.acquire(waiting.count, deadline: .now + .seconds(2)))
+        _ = try await client.getConsumerOffset(streamID: 1, topicID: 2, partitionID: 1)
+        _ = try await client.pollMessages(streamID: 1, topicID: 2, partitionID: 1, strategy: .offset(0), count: 1, autoCommit: false)
+        _ = try await client.fetchMessages(streamID: 1, topicID: 2, targets: [(1, .offset(0))], count: 1, autoCommit: false, options: Self.parkingOptions)
+        try await client.storeConsumerOffset(streamID: 1, topicID: 2, partitionID: 3, offset: 4)
+        try await client.sendBatch(streamID: 1, topicID: 2, partitioning: .partition(1), messages: ["independent"])
+        try await Task.sleep(for: .milliseconds(100))
+        #expect(await requests(server, .fetchMessages).count == 2)
+        #expect(await requests(server, .storeConsumerOffset).map(polledPartition) == [3])
+        #expect(await requests(server, .deleteConsumerOffset).isEmpty)
+        #expect(await requests(server, .pollMessages).count == 1)
+        #expect(await requests(server, .pollMessagesDeferred).isEmpty)
+
+        reply.release(1)
+        try await parked.value
+        for operation in waiting {
+            try await operation.value
+        }
+        #expect(await requests(server, .fetchMessages).count == 3)
+        #expect(await requests(server, .storeConsumerOffset).count == 2)
+        #expect(await requests(server, .deleteConsumerOffset).count == 1)
+        #expect(await requests(server, .pollMessages).count == 2)
+        #expect(await requests(server, .pollMessagesDeferred).count == 1)
+        await client.shutdown()
+    }
+
+    @Test(arguments: [false, true])
+    func aPartlyAcquiredProgressRequestReleasesItsEarlierTargets(cancelled: Bool) async throws {
+        let router = PollRouter(fence: 1)
+        let first = PollRouter.ProgressKey(partitionID: 1, incarnation: 1)
+        let second = PollRouter.ProgressKey(partitionID: 2, incarnation: 1)
+        let entered = AsyncSemaphore(permits: 0)
+        let release = AsyncSemaphore(permits: 0)
+        let holder = Task {
+            try await router.withProgress([second], deadline: .now + .seconds(5)) {
+                entered.release(1)
+                _ = try await release.acquire(1)
+            }
+        }
+        defer {
+            release.release(1)
+            holder.cancel()
+        }
+        #expect(try await entered.acquire(1, deadline: .now + .seconds(1)))
+        let called = Locked(false)
+        let waiter = Task {
+            try await router.withProgress([second, first, first], deadline: .now + .seconds(1)) {
+                called.exchange(true)
+            }
+        }
+        defer { waiter.cancel() }
+        #expect(
+            await eventually(timeout: .milliseconds(500)) {
+                do {
+                    try await router.withProgress([first], deadline: .now + .milliseconds(50)) {}
+                    return false
+                } catch let error as IggyError {
+                    return error.code == .transientNotAccepted
+                } catch {
+                    return false
+                }
+            })
+        if cancelled {
+            waiter.cancel()
+            await #expect(throws: CancellationError.self) { try await waiter.value }
+        } else {
+            await expectCode(.transientNotAccepted) { _ = try await waiter.value }
+        }
+        #expect(!called.withLock { $0 })
+        try await router.withProgress([first], deadline: .now + .seconds(1)) {}
+        // A recreated partition is independent of the still-held old history.
+        try await router.withProgress([.init(partitionID: 2, incarnation: 2)], deadline: .now + .seconds(1)) {}
+        release.release(1)
+        try await holder.value
+        try await router.withProgress([second, first], deadline: .now + .seconds(1)) {}
+        await expectCode(.transientNotAccepted) {
+            try await router.withProgress([.init(partitionID: 1, incarnation: 0)], deadline: .now + .seconds(1)) {}
+        }
+        await expectCode(.transientNotAccepted) {
+            try await router.withProgress(
+                (0...PollRouter.maxProgressLanes).map { .init(partitionID: UInt32($0), incarnation: 1) }, deadline: .now + .seconds(1)
+            ) {}
+        }
+        try await router.withProgress([first], deadline: .now + .seconds(1)) {}
+    }
+
+    @Test(arguments: [false, true])
+    func aCommittingFetchCanBeCancelledOrExpireWhileWaitingForProgress(cancelled: Bool) async throws {
+        let server = try await FakeVSRServer.started()
+        let reply = AsyncSemaphore(permits: 0)
+        defer {
+            reply.release(1)
+            Task { await server.stop() }
+        }
+        let first = Locked(true)
+        await server.respond { request in
+            if request.code == CommandCode.fetchMessages.rawValue, first.exchange(false) {
+                _ = try? await reply.acquire(1)
+            }
+            return FakeVSRServer.defaultReply(request, address: server.address)
+        }
+        let client = try await signedInClient(server)
+        let parked = Task {
+            try await client.fetchMessages(
+                streamID: 1, topicID: 2, targets: [(2, .next)], count: 1, autoCommit: true, options: Self.parkingOptions)
+        }
+        defer { parked.cancel() }
+        #expect(await eventually { await requests(server, .fetchMessages).count == 1 })
+        let waiter = Task {
+            try await client.fetchMessages(
+                streamID: 1, topicID: 2, targets: [(1, .next), (2, .next)], count: 1, autoCommit: true,
+                options: DeferredPollOptions(maxWait: .zero, requestTimeout: .seconds(2)))
+        }
+        defer { waiter.cancel() }
+        if cancelled {
+            try await Task.sleep(for: .milliseconds(100))
+            waiter.cancel()
+            await #expect(throws: CancellationError.self) { try await waiter.value }
+        } else {
+            await expectCode(.transientNotAccepted) { _ = try await waiter.value }
+        }
+        #expect(await requests(server, .fetchMessages).count == 1)
+        #expect(await requests(server, .cancelDeferredRequest).isEmpty)
+        // The waiting fetch released partition1 before partition2 replied.
+        try await client.storeConsumerOffset(streamID: 1, topicID: 2, partitionID: 1, offset: 9)
+        reply.release(1)
+        _ = try await parked.value
+        try await client.deleteConsumerOffset(streamID: 1, topicID: 2, partitionID: 2)
+        await client.shutdown()
+    }
+
+    /// A fetch goes over a data connection with what is left of its options.
+    /// Each target carries its own fence, from the context of its start point
+    /// or the incarnation of its route, so the header carries none.
+    @Test func aFetchFencesEachTargetAndCarriesNoHeaderContext() async throws {
+        let server = try await FakeVSRServer.started()
+        defer { Task { await server.stop() } }
+        await server.respond { request in
+            guard request.code == CommandCode.fetchMessages.rawValue else {
+                return FakeVSRServer.defaultReply(request, address: server.address)
+            }
+            return .ok(
+                FakeVSRServer.fetchedBody([.init(partitionID: 2, offsets: [5, 6]), .init(partitionID: 1, status: IggyErrorCode.notPrimary.rawValue)]))
+        }
+        let client = try await signedInClient(server)
+        let carried = PartitionContext(incarnation: 4, ownerGeneration: 2, metadataOp: 1)
+        let unfenced = PartitionContext(incarnation: 0, ownerGeneration: 6, metadataOp: 0)
+        let options = DeferredPollOptions(maxWait: .milliseconds(500), minCount: 2, maxBytes: 4096, requestTimeout: .seconds(3))
+        let sections = try await client.fetchMessages(
+            streamID: 1, topicID: 2,
+            targets: [
+                (2, PollingStrategy(kind: .offset, value: 5, context: carried)), (1, .next), (3, PollingStrategy(kind: .offset, value: 9, context: unfenced)),
+            ], consumer: .group(3),
+            count: 10, autoCommit: true, options: options)
+        #expect(sections.map(\.partitionID) == [2, 1])
+        #expect(try sections[0].decode().messages.map(\.offset) == [5, 6])
+        #expect(sections[0].nextOffset == 7)
+        #expect(sections[1].error == IggyError(.notPrimary))
+
+        let request = try #require(await requests(server, .fetchMessages).first)
+        #expect(request.context == .empty)
+        #expect(request.operation == .nonReplicated)
+        let fetch = try request.fetch
+        let route = FakeVSRServer.routeContext
+        #expect(
+            fetch.targets == [
+                .init(partitionID: 2, kind: .offset, value: 5, maxBytes: 4096, incarnation: 4, ownerGeneration: 2),
+                .init(partitionID: 1, kind: .next, value: 0, maxBytes: 4096, incarnation: route.incarnation, ownerGeneration: 0),
+                .init(partitionID: 3, kind: .offset, value: 9, maxBytes: 4096, incarnation: route.incarnation, ownerGeneration: 6),
+            ])
+        #expect(fetch.count == 10 && fetch.minCount == 2 && fetch.maxBytes == 4096 && fetch.autoCommit)
+        // The time the call spent before the attempt comes off both.
+        #expect(fetch.wait <= 500_000)
+        #expect(fetch.timeout <= 3_000_000)
+        #expect(fetch.timeout >= fetch.wait + 1_000_000)
+        // A parked fetch would hold the lockstep coordinator connection.
+        let login = try #require(await server.requests.first)
+        #expect(request.connection != login.connection)
+        #expect(await requests(server, .bindSession).map(\.connection) == [request.connection])
+        await client.shutdown()
+    }
+
+    /// A fetch keeps the reply margin without a wait too, which a deferred
+    /// poll does not need.
+    @Test func fetchesTheServerWouldRefuseFailBeforeAnythingIsSent() async throws {
+        let server = try await FakeVSRServer.started()
+        defer { Task { await server.stop() } }
+        let client = try await signedInClient(server)
+        let before = await server.requests.count
+        let next: [(partitionID: UInt32, strategy: PollingStrategy)] = [(1, .next)]
+        let noMargin = DeferredPollOptions(maxWait: .zero, requestTimeout: .milliseconds(999))
+        let refused: [(targets: [(partitionID: UInt32, strategy: PollingStrategy)], count: UInt32, options: DeferredPollOptions, code: IggyErrorCode)] =
+            [
+                ([], 10, Self.parkingOptions, .invalidCommand), ([(1, .next), (1, .offset(3))], 10, Self.parkingOptions, .invalidCommand),
+                ((0..<1001).map { ($0, .next) }, 10, Self.parkingOptions, .invalidCommand), ([(1, .first)], 10, Self.parkingOptions, .invalidCommand),
+                ([(1, .last)], 10, Self.parkingOptions, .invalidCommand),
+                ([(1, .timestamp(IggyTimestamp(microseconds: 1)))], 10, Self.parkingOptions, .invalidCommand),
+                (next, 0, Self.parkingOptions, .invalidMessagesCount), (next, 10, DeferredPollOptions(minCount: 11), .invalidMessagesCount),
+                (next, 10, DeferredPollOptions(maxBytes: 343), .invalidSizeBytes), (next, 10, DeferredPollOptions(maxWait: .seconds(11)), .invalidCommand),
+                (next, 10, noMargin, .invalidCommand),
+                (next, 10, DeferredPollOptions(maxWait: .zero, requestTimeout: .seconds(1)), .invalidCommand),
+                (next, 10, DeferredPollOptions(maxWait: .zero, requestTimeout: .seconds(1) + .nanoseconds(999)), .invalidCommand),
+            ]
+        for scenario in refused {
+            await expectCode(scenario.code) {
+                _ = try await client.fetchMessages(
+                    streamID: 1, topicID: 2, targets: scenario.targets, count: scenario.count, autoCommit: false, options: scenario.options)
+            }
+        }
+        let exactlyOneSecond = DeferredPollOptions(maxWait: .zero, requestTimeout: .seconds(1))
+        let raw = try Requests.fetchMessages(
+            consumer: .default, streamID: 1, topicID: 2,
+            targets: [.init(partitionID: 1, strategy: .next, maxBytes: exactlyOneSecond.maxBytes, fence: .empty)],
+            count: 10, autoCommit: false, options: exactlyOneSecond)
+        await expectCode(.invalidCommand) { _ = try await client.sendRawRequest(code: CommandCode.fetchMessages.rawValue, payload: raw) }
+        #expect(await server.requests.count == before)
+        _ = try await client.pollMessages(streamID: 1, topicID: 2, partitionID: 1, strategy: .next, count: 10, autoCommit: false, options: noMargin)
+        await client.shutdown()
+    }
+
+    @Test func aFetchNeedsItsReplyMarginAfterRouting() async throws {
+        for routingDelay in [Duration.milliseconds(100), .milliseconds(1100)] {
+            let server = try await FakeVSRServer.started()
+            let delayed = Locked(false)
+            await server.respond { request in
+                if request.code == CommandCode.getPartitionRoute.rawValue, !delayed.exchange(true) {
+                    try? await Task.sleep(for: routingDelay)
+                }
+                return FakeVSRServer.defaultReply(request, address: server.address)
+            }
+            let client = try await signedInClient(server)
+            let options = DeferredPollOptions(maxWait: .zero, requestTimeout: .seconds(2))
+            let fetch = {
+                try await client.fetchMessages(streamID: 1, topicID: 2, targets: [(1, .next)], count: 10, autoCommit: false, options: options)
+            }
+            if routingDelay < .seconds(1) {
+                #expect(try await fetch().isEmpty)
+                let request = try #require(await requests(server, .fetchMessages).first)
+                let budget = try request.fetch
+                #expect(budget.wait == 0)
+                #expect(budget.timeout >= DeferredPollOptions.replyMarginMicroseconds && budget.timeout < 2_000_000)
+            } else {
+                await expectCode(.transientNotAccepted) { _ = try await fetch() }
+                #expect(await requests(server, .fetchMessages).isEmpty)
+            }
+            #expect(delayed.withLock { $0 })
+            await client.shutdown()
+            await server.stop()
+        }
+    }
+
+    /// A reply larger than a frame fails the connection that reads it, so the
+    /// limit of a fetch and of each target leaves room for its section table.
+    @Test func aFetchLeavesRoomInItsReplyFrameForTheSectionTable() async throws {
+        let server = try await FakeVSRServer.started()
+        defer { Task { await server.stop() } }
+        let client = try await signedInClient(server)
+        _ = try await client.fetchMessages(
+            streamID: 1, topicID: 2, targets: [(1, .next), (2, .offset(3))], count: 10, autoCommit: false,
+            options: DeferredPollOptions(maxWait: .zero, maxBytes: .max, requestTimeout: .seconds(5)))
+        let request = try #require(await requests(server, .fetchMessages).first)
+        let fetch = try request.fetch
+        let limit = DeferredPollOptions.maxReplyBytes - UInt32(Responses.fetchHeadSize(2))
+        #expect(fetch.maxBytes == limit)
+        #expect(fetch.targets.map(\.maxBytes) == [limit, limit])
+        await client.shutdown()
+    }
+
+    /// A group holds the partitions of one primary in the order the caller
+    /// names them, at most 1000 of them, the most one fetch names. The groups
+    /// come in replica order.
+    @Test func readinessGroupsSplitThePartitionsByPrimary() async throws {
+        let server = try await FakeVSRServer.started()
+        defer { Task { await server.stop() } }
+        let client = try await signedInClient(server)
+        #expect(try await client.readinessGroups(streamID: 1, topicID: 2, partitions: [5, 3, 4]) == [[5, 3, 4]])
+        #expect(try await client.readinessGroups(streamID: 1, topicID: 2, partitions: Array(0..<1001)) == [Array(0..<1000), [1000]])
+        await client.shutdown()
+
+        let cluster = try await Cluster.started(primaries: 2)
+        defer { Task { await cluster.stop() } }
+        let clustered = try await signedInClient(cluster.coordinator)
+        #expect(try await clustered.readinessGroups(streamID: 1, topicID: 2, partitions: [2, 0, 1]) == [[0], [1], [2]])
+        await clustered.shutdown()
+    }
+
+    /// A fetch refused as not primary is not sent again. The refusal names no
+    /// partition, so the routes of all its targets are marked moved, and the
+    /// next grouping looks each of them up again, first on the primary of its
+    /// route. A section refused so marks the route of its partition alone.
+    @Test func aFetchRefusedAsNotPrimaryMarksItsRoutesMoved() async throws {
+        // Partition 3 shares the first primary with partition 1.
+        let cluster = try await Cluster.started(primaries: 2) { partition in partition == 3 ? 1 : UInt8(partition) }
+        defer { Task { await cluster.stop() } }
+        let (first, second) = (cluster.primaries[0], cluster.primaries[1])
+        let refused = Locked(false)
+        await cluster.respond(on: first) { request in
+            guard request.code == CommandCode.fetchMessages.rawValue else {
+                return FakeVSRServer.defaultReply(request, address: first.address)
+            }
+            guard refused.exchange(true) else {
+                return .status(IggyErrorCode.notPrimary.rawValue)
+            }
+            return .ok(
+                FakeVSRServer.fetchedBody([.init(partitionID: 1, offsets: [4]), .init(partitionID: 3, status: IggyErrorCode.notPrimary.rawValue)]))
+        }
+        let client = try await signedInClient(cluster.coordinator)
+        let group = { try await client.readinessGroups(streamID: 1, topicID: 2, partitions: [3, 2, 1]) }
+        let fetch = { (targets: [UInt32]) in
+            try await client.fetchMessages(
+                streamID: 1, topicID: 2, targets: targets.map { ($0, .next) }, count: 10, autoCommit: false, options: Self.parkingOptions)
+        }
+        #expect(try await group() == [[3, 1], [2]])
+        await expectCode(.notPrimary) { _ = try await fetch([1]) }
+        #expect(await requests(first, .fetchMessages).count == 1)
+        #expect(try await group() == [[3, 1], [2]])
+        #expect(await requests(first, .getPartitionRoute).count == 1)
+        #expect(await requests(second, .getPartitionRoute).isEmpty)
+
+        #expect(try await fetch([1, 3]).map(\.error) == [nil, IggyError(.notPrimary)])
+        #expect(try await group() == [[3, 1], [2]])
+        #expect(await requests(first, .getPartitionRoute).count == 2)
+        #expect(await requests(second, .getPartitionRoute).isEmpty)
+        await client.shutdown()
+    }
+
+    /// A section refused for a gone history or a lost owner drops the route of
+    /// its partition, so the next fetch fences it with a fresh context. The
+    /// other targets keep theirs.
+    @Test func aFencedSectionDropsTheRouteOfItsPartition() async throws {
+        let server = try await FakeVSRServer.started()
+        defer { Task { await server.stop() } }
+        let lookups = Locked<[UInt32: UInt64]>([:])
+        await server.respond { request in
+            switch request.code.flatMap(CommandCode.init(rawValue:)) {
+            case .getPartitionRoute:
+                let partition = UInt32(littleEndianBytes: request.payload.suffix(4))
+                let lookup = lookups.withLock { lookups in
+                    lookups[partition, default: 0] += 1
+                    return lookups[partition, default: 0]
+                }
+                let route = FakeVSRServer.routeContext
+                let context = PartitionContext(incarnation: route.incarnation + lookup - 1, ownerGeneration: 0, metadataOp: route.metadataOp)
+                return .ok(FakeVSRServer.partitionRouteBody(context))
+            case .fetchMessages:
+                return .ok(
+                    FakeVSRServer.fetchedBody([
+                        .init(partitionID: 1, status: IggyErrorCode.historyUnavailable.rawValue),
+                        .init(partitionID: 2, status: IggyErrorCode.consumerGroupPartitionNotOwned.rawValue), .init(partitionID: 3, offsets: [0]),
+                    ]))
+            default:
+                return FakeVSRServer.defaultReply(request, address: server.address)
+            }
+        }
+        let client = try await signedInClient(server)
+        for _ in 0..<2 {
+            _ = try await client.fetchMessages(
+                streamID: 1, topicID: 2, targets: [(1, .next), (2, .next), (3, .next)], count: 10, autoCommit: false, options: Self.parkingOptions)
+        }
+        let route = FakeVSRServer.routeContext.incarnation
+        let fenced = try await requests(server, .fetchMessages).map { try $0.fetch.targets.map(\.incarnation) }
+        #expect(fenced == [[route, route, route], [route + 1, route + 1, route]])
+        await client.shutdown()
+    }
+
+    // MARK: Raw requests
+
+    @Test func rawDeferredRequestsTakeTheRouteOfTheirTypedCalls() async throws {
+        let server = try await FakeVSRServer.started()
+        defer { Task { await server.stop() } }
+        let client = try await signedInClient(server)
+        let options = DeferredPollOptions(maxWait: .milliseconds(200), requestTimeout: .seconds(5))
+        let poll = try Requests.pollMessages(
+            consumer: .consumer(5), streamID: 1, topicID: 2, partitionID: 1, strategy: .first, count: 10, autoCommit: false, options: options)
+        let polled = try await client.sendRawRequest(code: CommandCode.pollMessagesDeferred.rawValue, payload: poll)
+        #expect(try Responses.polledMessages(polled[...]).context == FakeVSRServer.routeContext)
+        let sent = try #require(await requests(server, .pollMessagesDeferred).first)
+        #expect(sent.context == FakeVSRServer.routeContext)
+        #expect(sent.payload.dropLast(FakeVSRServer.Request.deferredOptionsSize) == poll.dropLast(FakeVSRServer.Request.deferredOptionsSize))
+        #expect(sent.deferredOptions.timeout <= 5_000_000)
+        #expect(await requests(server, .bindSession).map(\.connection) == [sent.connection])
+
+        let fence = PartitionContext(incarnation: 3, ownerGeneration: 9, metadataOp: 0)
+        let fetch = try Requests.fetchMessages(
+            consumer: .consumer(5), streamID: 1, topicID: 2, targets: [Requests.FetchTarget(partitionID: 1, strategy: .next, maxBytes: .max, fence: fence)],
+            count: 10, autoCommit: false, options: DeferredPollOptions(maxWait: .milliseconds(200), maxBytes: .max, requestTimeout: .seconds(5)))
+        #expect(try await client.sendRawRequest(code: CommandCode.fetchMessages.rawValue, payload: fetch) == FakeVSRServer.fetchedBody([]))
+        let fetched = try #require(await requests(server, .fetchMessages).first)
+        #expect(fetched.context == .empty)
+        let limit = DeferredPollOptions.maxReplyBytes - UInt32(Responses.fetchHeadSize(1))
+        #expect(try fetched.fetch.maxBytes == limit)
+        #expect(try fetched.fetch.targets == [.init(partitionID: 1, kind: .next, value: 0, maxBytes: limit, incarnation: 3, ownerGeneration: 9)])
+
+        let before = await server.requests.count
+        let tooFew = try Requests.pollMessages(
+            consumer: .consumer(5), streamID: 1, topicID: 2, partitionID: 1, strategy: .first, count: 10, autoCommit: false,
+            options: DeferredPollOptions(minCount: 11))
+        // 52 bytes come before the first target with numeric ids, which is
+        // `[partition_id:4][kind:1][value:8][max_bytes:4]` and its fence.
+        let fetchedKind = 52 + 4
+        let fetchedLimit = fetchedKind + 9
+        var unknownStrategy = fetch
+        unknownStrategy[fetchedKind] = 9
+        var unknownConsumer = fetch
+        unknownConsumer[0] = 3
+        var firstTarget = fetch
+        firstTarget[fetchedKind] = PollingStrategy.Kind.first.rawValue
+        var smallTarget = fetch
+        smallTarget.replaceSubrange(fetchedLimit..<fetchedLimit + 4, with: (DeferredPollOptions.minMaxBytes - 1).littleEndianBytes)
+        let refused: [(CommandCode, [UInt8])] = [
+            (.pollMessagesDeferred, poll + [0]), (.pollMessagesDeferred, Array(poll.dropLast())), (.pollMessagesDeferred, tooFew),
+            (.fetchMessages, unknownStrategy), (.fetchMessages, unknownConsumer), (.cancelDeferredRequest, []), (.fetchMessages, fetch + [0]),
+            (.fetchMessages, Array(fetch.dropLast())),
+            (.fetchMessages, firstTarget), (.fetchMessages, smallTarget),
+        ]
+        for (code, payload) in refused {
+            await expectCode(.invalidCommand) { _ = try await client.sendRawRequest(code: code.rawValue, payload: payload) }
+        }
+        // A group poll must name its partition.
+        let groupPoll = try Requests.pollMessages(
+            consumer: .group(3), streamID: 1, topicID: 2, partitionID: nil, strategy: .next, count: 1, autoCommit: true, options: options)
+        await expectCode(.invalidIdentifier) { _ = try await client.sendRawRequest(code: CommandCode.pollMessagesDeferred.rawValue, payload: groupPoll) }
+        #expect(await server.requests.count == before)
+        await client.shutdown()
+    }
+}
diff --git a/foreign/swift/Tests/IggyTests/ErrorCodeTests.swift b/foreign/swift/Tests/IggyTests/ErrorCodeTests.swift
index 242ea3d..5c0e811 100644
--- a/foreign/swift/Tests/IggyTests/ErrorCodeTests.swift
+++ b/foreign/swift/Tests/IggyTests/ErrorCodeTests.swift
@@ -61,5 +61,7 @@
         #expect(IggyError(.invalidFormat) != IggyError(.invalidCommand))
         #expect(IggyError(wireCode: 65_535) != IggyError(.error))
         #expect(IggyError(.invalidFormat, context: "x").hashValue == IggyError(.invalidFormat).hashValue)
+        #expect(IggyError(.partitionLaneBlocked, operationID: 1, partitionID: 2) != IggyError(.partitionLaneBlocked, operationID: 3, partitionID: 2))
+        #expect(IggyError(.partitionLaneBlocked, operationID: 1, partitionID: 2) != IggyError(.partitionLaneBlocked, operationID: 1, partitionID: 3))
     }
 }
diff --git a/foreign/swift/Tests/IggyTests/Fixtures/deferred_polling.json b/foreign/swift/Tests/IggyTests/Fixtures/deferred_polling.json
new file mode 100644
index 0000000..452b461
--- /dev/null
+++ b/foreign/swift/Tests/IggyTests/Fixtures/deferred_polling.json
@@ -0,0 +1,610 @@
+{
+  "schema_version": 1,
+  "generated_by": "core/common/examples/deferred_polling_fixtures.rs",
+  "conventions": {
+    "body_hex": "Every *_hex field is lowercase hex without separators. body_hex is the command payload without framing.",
+    "header_hex": "header_hex is the whole request header of a cancellation, which has no body, before the transport stamps its checksum and checksum_body.",
+    "oversized": "A reply may carry a first record above max_bytes, up to max_record_size, so a client reply cap is at least max_record_reply_size.",
+    "refused": "Every refused fetch fails to decode or to validate. Every refused fetch reply fails to decode.",
+    "status": "A section status of status_ok was read. Any other status is the error code of that partition alone, and its section holds no records.",
+    "strategy": "strategy.kind is the wire kind: 1 offset, 2 timestamp, 3 first, 4 last, 5 next."
+  },
+  "constants": {
+    "codes": {
+      "cancel_deferred_request": 108,
+      "fetch_messages": 109,
+      "poll_messages": 100,
+      "poll_messages_deferred": 106
+    },
+    "status_ok": 0,
+    "errors": {
+      "deferred_poll_capacity_exceeded": 90,
+      "fetch_progress_uncertain": 14010,
+      "history_unavailable": 87,
+      "invalid_size_bytes": 80,
+      "not_primary": 89,
+      "partition_lane_blocked": 14009,
+      "too_many_consumer_offsets": 3024,
+      "transient_not_accepted": 58,
+      "transient_not_committed": 57
+    },
+    "min_deferred_poll_max_bytes": 344,
+    "max_deferred_poll_wait_us": 600000000,
+    "max_deferred_poll_timeout_us": 630000000,
+    "min_deferred_poll_reply_margin_us": 1000000,
+    "fetch_reply_margin_us": 200000,
+    "max_partitions_per_request": 1000,
+    "max_record_size": 64100304,
+    "max_record_reply_size": 64156564,
+    "request_header_size": 256,
+    "fetch_section_header_size": 56
+  },
+  "deferred_polls": [
+    {
+      "case": "consumer_offset_without_commit",
+      "request": {
+        "consumer": {
+          "kind": 1,
+          "id": {
+            "kind": "numeric",
+            "value": 1
+          }
+        },
+        "stream_id": {
+          "kind": "numeric",
+          "value": 1
+        },
+        "topic_id": {
+          "kind": "named",
+          "value": "orders"
+        },
+        "partition_id": 2,
+        "strategy": {
+          "kind": 1,
+          "value": 41
+        },
+        "count": 100,
+        "auto_commit": false,
+        "wait_us": 5000000,
+        "min_count": 1,
+        "max_bytes": 1048576,
+        "request_timeout_us": 6000000
+      },
+      "body_hex": "0101040100000001040100000002066f726465727301020000000129000000000000006400000000404b4c00000000000100000000001000808d5b0000000000"
+    },
+    {
+      "case": "group_next_with_commit",
+      "request": {
+        "consumer": {
+          "kind": 2,
+          "id": {
+            "kind": "named",
+            "value": "orders-group"
+          }
+        },
+        "stream_id": {
+          "kind": "numeric",
+          "value": 1
+        },
+        "topic_id": {
+          "kind": "named",
+          "value": "orders"
+        },
+        "partition_id": 2,
+        "strategy": {
+          "kind": 5,
+          "value": 0
+        },
+        "count": 100,
+        "auto_commit": true,
+        "wait_us": 5000000,
+        "min_count": 1,
+        "max_bytes": 1048576,
+        "request_timeout_us": 6000000
+      },
+      "body_hex": "02020c6f72646572732d67726f757001040100000002066f726465727301020000000500000000000000006400000001404b4c00000000000100000000001000808d5b0000000000"
+    }
+  ],
+  "fetches": [
+    {
+      "case": "consumer_two_targets_without_commit",
+      "request": {
+        "consumer": {
+          "kind": 1,
+          "id": {
+            "kind": "numeric",
+            "value": 1
+          }
+        },
+        "stream_id": {
+          "kind": "numeric",
+          "value": 1
+        },
+        "topic_id": {
+          "kind": "numeric",
+          "value": 2
+        },
+        "wait_us": 5000000,
+        "request_timeout_us": 6000000,
+        "min_count": 1,
+        "count": 1000,
+        "max_bytes": 1048576,
+        "auto_commit": false,
+        "targets": [
+          {
+            "partition_id": 1,
+            "strategy": {
+              "kind": 1,
+              "value": 41
+            },
+            "max_bytes": 524288,
+            "incarnation": 11,
+            "owner_generation": 0
+          },
+          {
+            "partition_id": 3,
+            "strategy": {
+              "kind": 5,
+              "value": 0
+            },
+            "max_bytes": 524288,
+            "incarnation": 11,
+            "owner_generation": 0
+          }
+        ]
+      },
+      "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b000000000001000000e803000000001000000200000001000000012900000000000000000008000b00000000000000000000000000000003000000050000000000000000000008000b000000000000000000000000000000"
+    },
+    {
+      "case": "group_three_targets_with_commit",
+      "request": {
+        "consumer": {
+          "kind": 2,
+          "id": {
+            "kind": "numeric",
+            "value": 9
+          }
+        },
+        "stream_id": {
+          "kind": "numeric",
+          "value": 1
+        },
+        "topic_id": {
+          "kind": "numeric",
+          "value": 2
+        },
+        "wait_us": 5000000,
+        "request_timeout_us": 6000000,
+        "min_count": 1,
+        "count": 1000,
+        "max_bytes": 1048576,
+        "auto_commit": true,
+        "targets": [
+          {
+            "partition_id": 1,
+            "strategy": {
+              "kind": 5,
+              "value": 0
+            },
+            "max_bytes": 524288,
+            "incarnation": 11,
+            "owner_generation": 4
+          },
+          {
+            "partition_id": 2,
+            "strategy": {
+              "kind": 5,
+              "value": 0
+            },
+            "max_bytes": 524288,
+            "incarnation": 11,
+            "owner_generation": 4
+          },
+          {
+            "partition_id": 4,
+            "strategy": {
+              "kind": 1,
+              "value": 7
+            },
+            "max_bytes": 524288,
+            "incarnation": 11,
+            "owner_generation": 4
+          }
+        ]
+      },
+      "body_hex": "02010409000000010401000000010402000000404b4c0000000000808d5b000000000001000000e803000000001000010300000001000000050000000000000000000008000b00000000000000040000000000000002000000050000000000000000000008000b00000000000000040000000000000004000000010700000000000000000008000b000000000000000400000000000000"
+    }
+  ],
+  "cancellations": [
+    {
+      "case": "cancel_deferred_poll",
+      "cancels_code": 106,
+      "client": 7,
+      "request": 21,
+      "session": 3,
+      "header_hex": "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000050000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007000000000000000000000000000000000000000000000000000000000000000000000000000000150000000000000002000000000000000300000000000000000000006c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
+    },
+    {
+      "case": "cancel_fetch",
+      "cancels_code": 109,
+      "client": 7,
+      "request": 22,
+      "session": 3,
+      "header_hex": "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000050000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007000000000000000000000000000000000000000000000000000000000000000000000000000000160000000000000002000000000000000300000000000000000000006c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
+    }
+  ],
+  "fetch_replies": [
+    {
+      "case": "ok_section_and_empty_start_section",
+      "sections": [
+        {
+          "partition_id": 1,
+          "status": 0,
+          "current_offset": 44,
+          "messages_count": 3,
+          "context": {
+            "incarnation": 11,
+            "owner_generation": 4,
+            "metadata_op": 42
+          },
+          "next_offset": 44,
+          "records_len": 416,
+          "messages": [
+            {
+              "offset": 41,
+              "id": "258",
+              "timestamp": 1700000000000500,
+              "origin_timestamp": 1700000000000000,
+              "payload": "first"
+            },
+            {
+              "offset": 42,
+              "id": "259",
+              "timestamp": 1700000000000500,
+              "origin_timestamp": 1700000000000000,
+              "payload": "second"
+            },
+            {
+              "offset": 43,
+              "id": "260",
+              "timestamp": 1700000000000500,
+              "origin_timestamp": 1700000000000000,
+              "payload": "third"
+            }
+          ]
+        },
+        {
+          "partition_id": 3,
+          "status": 0,
+          "current_offset": 9,
+          "messages_count": 0,
+          "context": {
+            "incarnation": 11,
+            "owner_generation": 4,
+            "metadata_op": 42
+          },
+          "next_offset": 10,
+          "records_len": 0,
+          "messages": []
+        }
+      ],
+      "body_hex": "0200000001000000000000002c00000000000000030000000b0000000000000004000000000000002a000000000000002c00000000000000a001000003000000000000000900000000000000000000000b0000000000000004000000000000002a000000000000000a000000000000000000000001000000000000002900000000000000f4411e18240a060000401e18240a0600a00100000000000088fc3268f2c0e02103000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000bb5b1cd43b71761a02010000000000000000000000000000000000000000000000000000050000000000000000000000666972737453c25c376f98c265030100000000000000000000000000000100000000000000000000000600000000000000000000007365636f6e64a761c18f632fb022040100000000000000000000000000000200000000000000000000000500000000000000000000007468697264"
+    },
+    {
+      "case": "ok_section_beside_error_sections",
+      "sections": [
+        {
+          "partition_id": 1,
+          "status": 89,
+          "current_offset": 0,
+          "messages_count": 0,
+          "context": {
+            "incarnation": 0,
+            "owner_generation": 0,
+            "metadata_op": 0
+          },
+          "next_offset": 0,
+          "records_len": 0,
+          "messages": []
+        },
+        {
+          "partition_id": 2,
+          "status": 0,
+          "current_offset": 7,
+          "messages_count": 1,
+          "context": {
+            "incarnation": 11,
+            "owner_generation": 4,
+            "metadata_op": 42
+          },
+          "next_offset": 7,
+          "records_len": 308,
+          "messages": [
+            {
+              "offset": 6,
+              "id": "340282366920938463463374607431768211455",
+              "timestamp": 1700000000000500,
+              "origin_timestamp": 1700000000000000,
+              "payload": "only"
+            }
+          ]
+        },
+        {
+          "partition_id": 4,
+          "status": 58,
+          "current_offset": 0,
+          "messages_count": 0,
+          "context": {
+            "incarnation": 0,
+            "owner_generation": 0,
+            "metadata_op": 0
+          },
+          "next_offset": 0,
+          "records_len": 0,
+          "messages": []
+        }
+      ],
+      "body_hex": "03000000010000005900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000700000000000000010000000b0000000000000004000000000000002a00000000000000070000000000000034010000040000003a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000600000000000000f4411e18240a060000401e18240a060034010000000000003e89bfefc17b805301000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000fbb4aeb35a85bdaeffffffffffffffffffffffffffffffff0000000000000000000000000400000000000000000000006f6e6c79"
+    },
+    {
+      "case": "no_sections",
+      "sections": [],
+      "body_hex": "00000000"
+    }
+  ],
+  "refused": {
+    "fetches": [
+      {
+        "case": "no_targets",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b000000000001000000e8030000000010000000000000"
+      },
+      {
+        "case": "duplicate_partition",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b000000000001000000e803000000001000000200000001000000010000000000000000000008000b00000000000000000000000000000001000000050000000000000000000008000b000000000000000000000000000000"
+      },
+      {
+        "case": "first_start_point",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b000000000001000000e803000000001000000100000001000000030000000000000000000008000b000000000000000000000000000000"
+      },
+      {
+        "case": "min_count_above_count",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b0000000000e9030000e803000000001000000100000001000000010000000000000000000008000b000000000000000000000000000000"
+      },
+      {
+        "case": "target_bytes_above_request_bytes",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000808d5b000000000001000000e803000000001000000100000001000000010000000000000000010010000b000000000000000000000000000000"
+      },
+      {
+        "case": "timeout_without_reply_margin",
+        "body_hex": "01010401000000010401000000010402000000404b4c0000000000404b4c000000000001000000e803000000001000000100000001000000010000000000000000000008000b000000000000000000000000000000"
+      }
+    ],
+    "fetch_replies": [
+      {
+        "case": "error_section_with_records",
+        "body_hex": "01000000010000005900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000"
+      },
+      {
+        "case": "truncated_table",
+        "body_hex": "02000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
+      }
+    ]
+  },
+  "commit_outcomes": {
+    "rule": "A committing deferred poll (106) or fetch (109) that fails with a certain status stored nothing. Every other status, a status not listed here, a lost reply and a transport failure after the send leave the outcome unknown.",
+    "request": [
+      {
+        "status": 5,
+        "error": "feature_unavailable",
+        "certain": true
+      },
+      {
+        "status": 6,
+        "error": "invalid_identifier",
+        "certain": true
+      },
+      {
+        "status": 30,
+        "error": "stale_client",
+        "certain": false
+      },
+      {
+        "status": 40,
+        "error": "unauthenticated",
+        "certain": false
+      },
+      {
+        "status": 41,
+        "error": "unauthorized",
+        "certain": false
+      },
+      {
+        "status": 57,
+        "error": "transient_not_committed",
+        "certain": false
+      },
+      {
+        "status": 58,
+        "error": "transient_not_accepted",
+        "certain": true
+      },
+      {
+        "status": 72,
+        "error": "cannot_decrypt_data",
+        "certain": false
+      },
+      {
+        "status": 80,
+        "error": "invalid_size_bytes",
+        "certain": true
+      },
+      {
+        "status": 87,
+        "error": "history_unavailable",
+        "certain": true
+      },
+      {
+        "status": 89,
+        "error": "not_primary",
+        "certain": true
+      },
+      {
+        "status": 90,
+        "error": "deferred_poll_capacity_exceeded",
+        "certain": true
+      },
+      {
+        "status": 1009,
+        "error": "stream_id_not_found",
+        "certain": true
+      },
+      {
+        "status": 1010,
+        "error": "stream_name_not_found",
+        "certain": true
+      },
+      {
+        "status": 2010,
+        "error": "topic_id_not_found",
+        "certain": true
+      },
+      {
+        "status": 2011,
+        "error": "topic_name_not_found",
+        "certain": true
+      },
+      {
+        "status": 3007,
+        "error": "partition_not_found",
+        "certain": true
+      },
+      {
+        "status": 3024,
+        "error": "too_many_consumer_offsets",
+        "certain": true
+      },
+      {
+        "status": 4100,
+        "error": "invalid_offset",
+        "certain": true
+      },
+      {
+        "status": 5009,
+        "error": "consumer_group_partition_not_owned",
+        "certain": true
+      },
+      {
+        "status": 11001,
+        "error": "shard_communication_error",
+        "certain": false
+      }
+    ],
+    "section": [
+      {
+        "status": 5,
+        "error": "feature_unavailable",
+        "certain": false
+      },
+      {
+        "status": 6,
+        "error": "invalid_identifier",
+        "certain": false
+      },
+      {
+        "status": 30,
+        "error": "stale_client",
+        "certain": false
+      },
+      {
+        "status": 40,
+        "error": "unauthenticated",
+        "certain": false
+      },
+      {
+        "status": 41,
+        "error": "unauthorized",
+        "certain": false
+      },
+      {
+        "status": 57,
+        "error": "transient_not_committed",
+        "certain": false
+      },
+      {
+        "status": 58,
+        "error": "transient_not_accepted",
+        "certain": true
+      },
+      {
+        "status": 72,
+        "error": "cannot_decrypt_data",
+        "certain": false
+      },
+      {
+        "status": 80,
+        "error": "invalid_size_bytes",
+        "certain": true
+      },
+      {
+        "status": 87,
+        "error": "history_unavailable",
+        "certain": true
+      },
+      {
+        "status": 89,
+        "error": "not_primary",
+        "certain": true
+      },
+      {
+        "status": 90,
+        "error": "deferred_poll_capacity_exceeded",
+        "certain": false
+      },
+      {
+        "status": 1009,
+        "error": "stream_id_not_found",
+        "certain": false
+      },
+      {
+        "status": 1010,
+        "error": "stream_name_not_found",
+        "certain": false
+      },
+      {
+        "status": 2010,
+        "error": "topic_id_not_found",
+        "certain": false
+      },
+      {
+        "status": 2011,
+        "error": "topic_name_not_found",
+        "certain": false
+      },
+      {
+        "status": 3007,
+        "error": "partition_not_found",
+        "certain": true
+      },
+      {
+        "status": 3024,
+        "error": "too_many_consumer_offsets",
+        "certain": true
+      },
+      {
+        "status": 4100,
+        "error": "invalid_offset",
+        "certain": false
+      },
+      {
+        "status": 5009,
+        "error": "consumer_group_partition_not_owned",
+        "certain": true
+      },
+      {
+        "status": 11001,
+        "error": "shard_communication_error",
+        "certain": false
+      }
+    ]
+  }
+}
diff --git a/foreign/swift/Tests/IggyTests/Fixtures/golden.json b/foreign/swift/Tests/IggyTests/Fixtures/golden.json
index 2622e33..1aed727 100644
--- a/foreign/swift/Tests/IggyTests/Fixtures/golden.json
+++ b/foreign/swift/Tests/IggyTests/Fixtures/golden.json
@@ -1,6 +1,6 @@
 {
-  "protocol_version": 12288,
-  "protocol_version_min": 12288,
+  "protocol_version": 12289,
+  "protocol_version_min": 12289,
   "no_assigned_partition": 4294967294,
   "resync_required_partition": 4294967295,
   "xxh3_64": {
@@ -903,7 +903,9 @@
     "14005": "request_id_exhausted",
     "14006": "operation_uncertain",
     "14007": "lane_blocked",
-    "14008": "operation_not_retained"
+    "14008": "operation_not_retained",
+    "14009": "partition_lane_blocked",
+    "14010": "fetch_progress_uncertain"
   },
   "vectors": {
     "consumer.numeric.42": "0101042a000000",
@@ -928,7 +930,7 @@
     "polling.next": "050000000000000000",
     "polling.offset.100": "016400000000000000",
     "polling.timestamp.1700000000000": "020068e5cf8b010000",
-    "request.bind_session": "003000000973776966742d73646b05302e312e3401000000000000000000000000000000640000000000000078000000000000005a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a",
+    "request.bind_session": "013000000973776966742d73646b05302e312e3501000000000000000000000000000000640000000000000078000000000000005a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a",
     "request.change_password": "010401000000086f6c642d706173730c6e65772d706173732d313233",
     "request.create_consumer_group": "01040100000001040200000003677270",
     "request.create_partitions": "01040100000001040200000005000000",
@@ -948,6 +950,7 @@
     "request.delete_topic": "010401000000010405000000",
     "request.delete_user": "02086f6c642d75736572",
     "request.describe_options": "01",
+    "request.fetch_messages": "0201040700000001040100000002066f7264657273404b4c0000000000808d5b00000000000100000064000000000010000102000000010000000129000000000000000000080003000000000000000900000000000000020000000500000000000000000000080003000000000000000900000000000000",
     "request.get_client": "2a000000",
     "request.get_clients": "",
     "request.get_cluster_metadata": "",
@@ -968,12 +971,14 @@
     "request.get_users": "",
     "request.join_consumer_group": "010401000000010402000000010403000000",
     "request.leave_consumer_group": "010401000000010402000000020167",
-    "request.login_register": "003000000973776966742d73646b05302e312e345a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a0469676779046967677900000000",
-    "request.login_register_with_pat": "003000000973776966742d73646b05302e312e345a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a107061742d61626331323364656634353600000000",
+    "request.login_register": "013000000973776966742d73646b05302e312e355a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a0469676779046967677900000000",
+    "request.login_register_with_pat": "013000000973776966742d73646b05302e312e355a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a107061742d61626331323364656634353600000000",
     "request.logout_user": "",
     "request.ping": "",
     "request.poll_messages": "0101040100000001040a00000001041400000001050000000164000000000000003200000001",
     "request.poll_messages.group": "0201040300000001040100000001040100000000000000000300000000000000000a00000000",
+    "request.poll_messages_deferred": "0201040700000001040100000002066f72646572730103000000050000000000000000640000000140420f00000000000100000000001000c0d8a70000000000",
+    "request.poll_messages_deferred.limits": "0201040700000001040100000002066f7264657273010300000005000000000000000064000000010046c32300000000640000005801000080098d2500000000",
     "request.send_messages": "1600000001040100000001040200000002040000000003000000000000000000000000000000000000000000000000000000e803000000000000c501000000000000dcffef596f23a1cb0300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000049398ef8abaae9bb01000000000000000000000000000000000000000000000000000000050000000000000000000000666972737422bdb8adaddffd5f0200000000000000000000000000000001000000f40100002a0000000600000000000000000000007365636f6e640207000000617474656d70740b0400000003000000020800000074726163652d69640203000000616263fcea73629656aaa0030000000000000000000000efbeadde020000000000000000000000000000000000000000000000",
     "request.store_consumer_offset": "0101040100000001040a0000000104140000000105000000393000000000000001",
     "request.store_consumer_offset.no_partition": "020104030000000104010000000104010000000000000000ffffffffffffffff00",
@@ -993,6 +998,7 @@
     "response.consumer_group_details": "010000000600000002000000086d792d67726f757001000000030000000000000001000000020000000200000003000000030000000400000005000000",
     "response.consumer_offset": "01000000e803000000000000f401000000000000",
     "response.describe_options": "040000000c7365676d656e745f73697a650c000800000000000040000000000c0000005365676d656e742073697a650a6475726162696c69747902000a0000007265706c696361746564000000000e6d6573736167655f6578706972790c0308000000ffffffffffffffff000000000e656e6372797074696f6e5f6b657901140000000000000000",
+    "response.fetch_messages": "020000000100000000000000290000000000000001000000030000000000000009000000000000000c000000000000002a000000000000003501000002000000590000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000290000000000000064401e18240a060000401e18240a06003501000000000000bd2d787d3cdb745c010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008e5ca59edbbdbfb90100000000000000000000000000000000000000000000000000000005000000000000000000000068656c6c6f",
     "response.get_clients": "010000000a000000010e0000003132372e302e302e313a38303830020000000200000014000000020d00000031302e302e302e313a3630303003000000",
     "response.get_consumer_groups": "0100000004000000020000000767726f75702d610200000004000000000000000767726f75702d62",
     "response.get_metadata_changes": "000001d7931c6e2a8f4b0d00000000000000001f0f00000000000000000000000000005e1000000000000001d7931c6e2a8f4b0d00000000000000005b1000000000000059f3d1b0e8c6a4f20000000000000000010000000001000000191000000000000000000000653d9e8b03f7c1a500000000000000001900be0109410600010000000001012700000003000000191000000000000040d2bf01094106000974656c656d65747279000000000000000000000000000000",
@@ -1004,7 +1010,7 @@
     "response.get_topics.empty": "00000000",
     "response.get_users": "0100000000e090663c130600010561646d696e000000000000000002000000c8000000000000000205616c6963650000000000000000",
     "response.identity": "2a000000",
-    "response.login_register": "2a00000064000000000000000030000006302e31312e30",
+    "response.login_register": "2a00000064000000000000000130000006302e31312e30",
     "response.partition_route": "07000000000000000a00000000000000030000000400000002",
     "response.poll_messages": "07000000660000000000000003000000000000000000000000000000000000000000000000000000070000000000000064000000000000008813000000000000e8030000000000006e01000000000000f576a2492e1328b302000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000fa7fe16d19356ec20b00000000000000000000000000000000000000000000000000000001000000000000000000000061b7f159184e9d8ac20c000000000000000000000000000000010000000a0000000c0000000100000000000000000000006202010000006b020100000076070000000000000066000000000000007017000000000000d00700000000000031010000000000006739773bc7d38608010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000009377fb7d5180f51e0d00000000000000000000000000000000000000000000000000000001000000000000000000000063",
     "response.poll_messages.empty": "00000000000000000000000000000000000000000000000000000000000000000000000000000000",
@@ -1020,17 +1026,21 @@
     "response.user_details.no_permissions": "0100000000e090663c130600010561646d696e000000000000000000000000",
     "stream_key.fingerprint": "8441e38538cbfa57",
     "user_headers.sample": "0207000000617474656d70740b0400000003000000020800000074726163652d69640203000000616263",
-    "version_info": "003000000973776966742d73646b05302e312e34",
-    "vsr.eviction.incompatible_protocol": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001032547698badcfeefcdab89674523010030000000300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e",
+    "version_info": "013000000973776966742d73646b05302e312e35",
+    "vsr.eviction.incompatible_protocol": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001032547698badcfeefcdab89674523010130000001300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e",
     "vsr.eviction.stale_client": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001032547698badcfeefcdab89674523010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000d",
     "vsr.reply.committed": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0100000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001032547698badcfeefcdab896745230100000000000000000c0000000000000000000000000000000700000000000000800000000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000",
     "vsr.reply.denied": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000000000000020000000000000029000000000000000000000000000000000000000000000000000000000000000000000000000000",
     "vsr.reply.ok": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0100000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001032547698badcfeefcdab896745230100000000000000000c0000000000000000000000000000000700000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
     "vsr.reply.update_topic_resolved": "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040100000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001032547698badcfeefcdab896745230100000000000000000c0000000000000000000000000000000700000000000000490000000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000",
+    "vsr.request.cancel_deferred": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000005000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001032547698badcfeefcdab8967452301000000000000000000000000000000000000000000000000060000000000000002000000000000006300000000000000000000006c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
+    "vsr.request.cancel_fetch": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000005000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001032547698badcfeefcdab8967452301000000000000000000000000000000000000000000000000070000000000000002000000000000006300000000000000000000006c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
     "vsr.request.create_stream": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007010000000000000000000005000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001032547698badcfeefcdab896745230100000000000000000000000000000000000000000000000001000000000000008000000000000000630000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
+    "vsr.request.fetch_messages": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000078010000000000000000000005000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001032547698badcfeefcdab8967452301000000000000000000000000000000000000000000000000070000000000000002000000000000006300000000000000000000006d0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
     "vsr.request.logout": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000005000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001032547698badcfeefcdab896745230100000000000000000000000000000000000000000000000004000000000000000300000000000000630000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
     "vsr.request.ping": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000005000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001032547698badcfeefcdab896745230100000000000000000000000000000000000000000000000001000000000000000200000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
     "vsr.request.ping.bound": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000005000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001032547698badcfeefcdab896745230100000000000000000000000000000000000000000000000003000000000000000200000000000000630000000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
+    "vsr.request.poll_messages_deferred": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040010000000000000000000005000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001032547698badcfeefcdab8967452301000000000000000000000000000000000000000000000000060000000000000002000000000000006300000000000000000000006a0000001100000000000000090000000000000034000000000000000000000000000000000000000000000000000000000000000000000000000000",
     "vsr.request.register": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003010000000000000000000005000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001032547698badcfeefcdab896745230100000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
     "vsr.request.send_messages": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004010000000000000000000005000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001032547698badcfeefcdab89674523010000000000000000000000000000000000000000000000000200000000000000a000000000000000630000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
     "vsr.request.send_messages.fenced": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004010000000000000000000005000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001032547698badcfeefcdab89674523010000000000000000000000000000000000000000000000000200000000000000a000000000000000630000000000000000000000000000001100000000000000090000000000000034000000000000000000000000000000000000000000000000000000000000000000000000000000",
diff --git a/foreign/swift/Tests/IggyTests/Fixtures/resource_options.json b/foreign/swift/Tests/IggyTests/Fixtures/resource_options.json
index c5cc2ac..202888f 100644
--- a/foreign/swift/Tests/IggyTests/Fixtures/resource_options.json
+++ b/foreign/swift/Tests/IggyTests/Fixtures/resource_options.json
@@ -16,18 +16,18 @@
   },
   "constants": {
     "protocol_version": {
-      "packed": 12288,
-      "text": "0.12.0",
+      "packed": 12289,
+      "text": "0.12.1",
       "major": 0,
       "minor": 12,
-      "patch": 0
+      "patch": 1
     },
     "protocol_version_min": {
-      "packed": 12288,
-      "text": "0.12.0",
+      "packed": 12289,
+      "text": "0.12.1",
       "major": 0,
       "minor": 12,
-      "patch": 0
+      "patch": 1
     },
     "command_codes": {
       "create_stream": {
diff --git a/foreign/swift/Tests/IggyTests/MessagingTests.swift b/foreign/swift/Tests/IggyTests/MessagingTests.swift
index ccd875b..1fb9ad5 100644
--- a/foreign/swift/Tests/IggyTests/MessagingTests.swift
+++ b/foreign/swift/Tests/IggyTests/MessagingTests.swift
@@ -272,7 +272,7 @@
         let polled = try await client.pollMessages(
             streamID: 1, topicID: 2, partitionID: 1, consumer: .consumer(5), strategy: .next, count: 10, autoCommit: true)
         #expect(polled.context == FakeVSRServer.routeContext)
-        let continued = PollingStrategy.offset(3, context: otherContext)
+        let continued = PollingStrategy(kind: .offset, value: 3, context: otherContext)
         _ = try await client.pollMessages(streamID: 1, topicID: 2, partitionID: 1, consumer: .consumer(5), strategy: continued, count: 10, autoCommit: true)
 
         let lookups = await requests(server, .getPartitionRoute)
@@ -691,7 +691,7 @@
         // A continuation of the lost partition cannot move to another one.
         syncs.withLock { $0 = 0 }
         client.core.groupState.invalidateAssignment(.init(stream: 1, topic: 2, group: 3))
-        let continued = PollingStrategy.offset(4, context: otherContext)
+        let continued = PollingStrategy(kind: .offset, value: 4, context: otherContext)
         await expectCode(.consumerGroupPartitionNotOwned) {
             _ = try await client.pollMessages(streamID: 1, topicID: 2, partitionID: nil, consumer: .group(3), strategy: continued, count: 1, autoCommit: true)
         }
@@ -723,8 +723,8 @@
         defer { Task { await server.stop() } }
         let client = try await connectedClient(server)
         try await client.storeConsumerOffset(consumer: .consumer(5), streamID: 1, topicID: 2, partitionID: 3, offset: 40)
-        let position = ConsumerPosition(partitionID: 3, offset: 41, context: otherContext)
-        try await client.storeConsumerPosition(consumer: .consumer(5), streamID: 1, topicID: 2, position: position)
+        let checkpoint = Checkpoint(partitionID: 3, offset: 41, context: otherContext)
+        try await client.storeConsumerPosition(consumer: .consumer(5), streamID: 1, topicID: 2, checkpoint: checkpoint)
         #expect(try await client.getConsumerOffset(consumer: .consumer(5), streamID: 1, topicID: 2, partitionID: 3) == nil)
         try await client.deleteConsumerOffset(consumer: .consumer(5), streamID: 1, topicID: 2, partitionID: 3)
 
@@ -1032,7 +1032,7 @@
         let started = ContinuousClock.now
         await expectCode(.transientNotAccepted) {
             _ = try await client.core.pollRouter.send(
-                .pollMessages, payload: payload, key: key, context: nil, lineage: await client.core.lineage, coordinator: client.core,
+                .pollMessages, body: .fixed(payload), key: key, context: nil, lineage: await client.core.lineage, coordinator: client.core,
                 deadline: started + budget)
         }
         #expect(ContinuousClock.now - started < .seconds(5))
@@ -1068,7 +1068,7 @@
             consumer: .consumer(5), streamID: 1, topicID: 2, partitionID: 0, strategy: .first, count: 1, autoCommit: false)
         let key = PollRouter.RouteKey(stream: 1, topic: 2, partitionID: 0)
         _ = try await client.core.pollRouter.send(
-            .pollMessages, payload: payload, key: key, context: nil, lineage: await client.core.lineage, coordinator: client.core,
+            .pollMessages, body: .fixed(payload), key: key, context: nil, lineage: await client.core.lineage, coordinator: client.core,
             deadline: .now + .seconds(10))
         let timeouts = connector.timeouts.withLock { $0 }
         #expect(timeouts.count == 1)
@@ -1116,11 +1116,11 @@
 
         await expectCode(.transientNotAccepted) {
             _ = try await client.core.pollRouter.send(
-                .pollMessages, payload: payload, key: key, context: nil, lineage: await client.core.lineage, coordinator: client.core,
+                .pollMessages, body: .fixed(payload), key: key, context: nil, lineage: await client.core.lineage, coordinator: client.core,
                 deadline: .now + .milliseconds(300))
         }
         _ = try await client.core.pollRouter.send(
-            .pollMessages, payload: payload, key: key, context: nil, lineage: await client.core.lineage, coordinator: client.core,
+            .pollMessages, body: .fixed(payload), key: key, context: nil, lineage: await client.core.lineage, coordinator: client.core,
             deadline: .now + .seconds(10))
 
         #expect(await requests(coordinator, .getPartitionRoute).isEmpty)
@@ -1574,7 +1574,7 @@
         let key = PollRouter.RouteKey(stream: 1, topic: 2, partitionID: 0)
         await expectCode(.transientNotAccepted) {
             _ = try await client.core.pollRouter.send(
-                .pollMessages, payload: payload, key: key, context: nil, lineage: await client.core.lineage, coordinator: client.core,
+                .pollMessages, body: .fixed(payload), key: key, context: nil, lineage: await client.core.lineage, coordinator: client.core,
                 deadline: .now + .milliseconds(500))
         }
         // Every miss dropped the route, so every poll took a new lookup.
@@ -1614,7 +1614,8 @@
             consumer: .consumer(5), streamID: 1, topicID: 2, partitionID: 0, strategy: .first, count: 1, autoCommit: false)
         let key = PollRouter.RouteKey(stream: 1, topic: 2, partitionID: 0)
         _ = try await client.core.pollRouter.send(
-            .pollMessages, payload: payload, key: key, context: nil, lineage: await client.core.lineage, coordinator: client.core, deadline: .now + .seconds(3))
+            .pollMessages, body: .fixed(payload), key: key, context: nil, lineage: await client.core.lineage, coordinator: client.core,
+            deadline: .now + .seconds(3))
         #expect(await requests(coordinator, .getPartitionRoute).count == 1)
         #expect(await requests(primary, .pollMessages).count == 1)
         await client.shutdown()
@@ -1652,7 +1653,8 @@
             consumer: .consumer(5), streamID: 1, topicID: 2, partitionID: 0, strategy: .first, count: 1, autoCommit: false)
         let key = PollRouter.RouteKey(stream: 1, topic: 2, partitionID: 0)
         _ = try await client.core.pollRouter.send(
-            .pollMessages, payload: payload, key: key, context: nil, lineage: await client.core.lineage, coordinator: client.core, deadline: .now + .seconds(3))
+            .pollMessages, body: .fixed(payload), key: key, context: nil, lineage: await client.core.lineage, coordinator: client.core,
+            deadline: .now + .seconds(3))
         #expect(await requests(coordinator, .getPartitionRoute).count == 2)
         #expect(await requests(primary, .pollMessages).count == 1)
         await client.shutdown()
diff --git a/foreign/swift/Tests/IggyTests/ModelTests.swift b/foreign/swift/Tests/IggyTests/ModelTests.swift
index e6a5bbc..ddd6c68 100644
--- a/foreign/swift/Tests/IggyTests/ModelTests.swift
+++ b/foreign/swift/Tests/IggyTests/ModelTests.swift
@@ -345,6 +345,121 @@
     }
 }
 
+@Suite("Deferred poll options")
+struct DeferredPollOptionsTests {
+    @Test func defaultsAreTheRustSDKDefaults() {
+        let options = DeferredPollOptions()
+        #expect(options.maxWait == .seconds(1))
+        #expect(options.minCount == 1)
+        #expect(options.maxBytes == 1_048_576)
+        #expect(options.requestTimeout == .seconds(11))
+    }
+
+    /// The count is checked first, then the wait and the timeout, then the
+    /// size, as `DeferredPollOptions::validate` checks them.
+    @Test func optionsAreRefusedAsTheRustSDKRefusesThem() {
+        let refused: [(DeferredPollOptions, IggyErrorCode)] = [
+            (DeferredPollOptions(minCount: 0), .invalidMessagesCount),
+            (DeferredPollOptions(minCount: 101), .invalidMessagesCount),
+            (DeferredPollOptions(minCount: 0, maxBytes: 0, requestTimeout: .zero), .invalidMessagesCount),
+            (DeferredPollOptions(maxBytes: 0, requestTimeout: .zero), .invalidCommand),
+            (DeferredPollOptions(maxWait: .microseconds(600_000_001), requestTimeout: .seconds(630)), .invalidCommand),
+            (DeferredPollOptions(maxWait: .zero, requestTimeout: .microseconds(630_000_001)), .invalidCommand),
+            (DeferredPollOptions(maxWait: .seconds(1), requestTimeout: .microseconds(1_999_999)), .invalidCommand),
+            (DeferredPollOptions(maxWait: .zero, requestTimeout: .nanoseconds(500)), .invalidCommand),
+            (DeferredPollOptions(maxWait: .milliseconds(-500)), .invalidCommand),
+            (DeferredPollOptions(requestTimeout: .milliseconds(-500)), .invalidCommand),
+            (DeferredPollOptions(maxBytes: 343), .invalidSizeBytes),
+        ]
+        for (options, code) in refused {
+            #expect(throws: IggyError(code), "\(options)") { try options.validate(count: 100) }
+        }
+        let accepted = [
+            DeferredPollOptions(), DeferredPollOptions(minCount: 100), DeferredPollOptions(maxBytes: 344),
+            DeferredPollOptions(maxWait: .seconds(600), requestTimeout: .seconds(630)), DeferredPollOptions(maxWait: .seconds(1), requestTimeout: .seconds(2)),
+            DeferredPollOptions(maxWait: .zero, requestTimeout: .microseconds(1)),
+        ]
+        for options in accepted {
+            #expect(throws: Never.self, "\(options)") { try options.validate(count: 100) }
+        }
+    }
+
+    /// An attempt keeps what is left of the call: the wait and the timeout
+    /// shrink together, and a spent timeout never leaves the client.
+    @Test func eachAttemptCarriesWhatIsLeftOfTheCall() throws {
+        let options = DeferredPollOptions(maxWait: .seconds(5), requestTimeout: .seconds(11))
+        let later = try options.remaining(after: .seconds(2))
+        #expect(later.maxWait == .seconds(3))
+        #expect(later.requestTimeout == .seconds(9))
+        #expect(later.minCount == options.minCount && later.maxBytes == options.maxBytes)
+        let waited = try options.remaining(after: .seconds(6))
+        #expect(waited.maxWait == .zero)
+        #expect(waited.requestTimeout == .seconds(5))
+        for spent in [Duration.seconds(11), .seconds(11) - .nanoseconds(500), .seconds(12)] {
+            #expect(throws: IggyError(.transientNotAccepted), "\(spent)") { try options.remaining(after: spent) }
+        }
+    }
+
+    /// Whole microseconds cannot cost a wait its reply margin, as
+    /// `deferred_payload` in the Rust SDK clamps it.
+    @Test func theWireWaitLeavesTheReplyMargin() throws {
+        #expect(try DeferredPollOptions().wireBudget() == (1_000_000, 11_000_000))
+        #expect(try DeferredPollOptions(maxWait: .seconds(5), requestTimeout: .seconds(3)).wireBudget() == (2_000_000, 3_000_000))
+        #expect(try DeferredPollOptions(maxWait: .seconds(5), requestTimeout: .milliseconds(500)).wireBudget() == (0, 500_000))
+    }
+
+    @Test func fetchAdmissionUsesWholeMicroseconds() throws {
+        for timeout in [Duration.microseconds(1), .microseconds(999_999), .seconds(1), .seconds(1) + .nanoseconds(999)] {
+            let options = DeferredPollOptions(maxWait: .zero, requestTimeout: timeout)
+            #expect(throws: IggyError(.invalidCommand), "\(timeout)") { try options.validateFetch(count: 1) }
+            try options.validate(count: 1)
+            try options.validateBudget()
+        }
+        for timeout in [Duration.microseconds(1_000_001), .seconds(1) + .nanoseconds(1999)] {
+            try DeferredPollOptions(maxWait: .zero, requestTimeout: timeout).validateFetch(count: 1)
+        }
+        try DeferredPollOptions(maxWait: .microseconds(1), requestTimeout: .microseconds(1_000_001)).validateFetch(count: 1)
+        #expect(throws: IggyError(.invalidCommand)) {
+            try DeferredPollOptions(maxWait: .microseconds(2), requestTimeout: .microseconds(1_000_001)).validateFetch(count: 1)
+        }
+    }
+
+    @Test func aFetchAttemptCanUseExactlyItsLastReplyMargin() throws {
+        let options = DeferredPollOptions(maxWait: .seconds(1), requestTimeout: .seconds(2))
+        try options.validateFetch(count: 1)
+        let remaining = try options.remaining(after: .seconds(1))
+        #expect(try remaining.fetchWireBudget() == (0, DeferredPollOptions.replyMarginMicroseconds))
+        let payload = try Requests.fetchMessages(
+            consumer: .default, streamID: 1, topicID: 2,
+            targets: [.init(partitionID: 1, strategy: .next, maxBytes: remaining.maxBytes, fence: .empty)],
+            count: 1, autoCommit: false, options: remaining)
+        // Numeric consumer, stream and topic ids precede the two wire timers.
+        let timerOffset = 1 + 3 * (2 + MemoryLayout<UInt32>.size)
+        var reader = ByteReader(payload)
+        try reader.skip(timerOffset)
+        #expect(try reader.readUInt64() == 0)
+        #expect(try reader.readUInt64() == DeferredPollOptions.replyMarginMicroseconds)
+        let exhausted = try options.remaining(after: .seconds(1) + .nanoseconds(1))
+        #expect(throws: IggyError(.transientNotAccepted)) {
+            try Requests.fetchMessages(
+                consumer: .default, streamID: 1, topicID: 2,
+                targets: [.init(partitionID: 1, strategy: .next, maxBytes: exhausted.maxBytes, fence: .empty)],
+                count: 1, autoCommit: false, options: exhausted)
+        }
+    }
+
+    /// A reply larger than a frame would fail the connection that reads it.
+    @Test func theByteLimitFitsAFrame() throws {
+        func encodedMaxBytes(_ maxBytes: UInt32) throws -> UInt32 {
+            var writer = ByteWriter()
+            try DeferredPollOptions(maxBytes: maxBytes).encode(into: &writer)
+            return UInt32(littleEndianBytes: writer.bytes[12..<16])
+        }
+        #expect(try encodedMaxBytes(.max) == UInt32(VSRFrame.maxFrameSize - VSRFrame.headerSize))
+        #expect(try encodedMaxBytes(344) == 344)
+    }
+}
+
 @Suite("Permissions")
 struct PermissionsTests {
     @Test func roundTripWithNestedTopics() throws {
diff --git a/foreign/swift/Tests/IggyTests/ReplicaRequestTests.swift b/foreign/swift/Tests/IggyTests/ReplicaRequestTests.swift
index f9d1bdb..c6b89de 100644
--- a/foreign/swift/Tests/IggyTests/ReplicaRequestTests.swift
+++ b/foreign/swift/Tests/IggyTests/ReplicaRequestTests.swift
@@ -98,7 +98,9 @@
         defer { cluster.stop() }
         let client = try await cluster.signedInClient()
         let requests = await cluster.leader.requests.count + cluster.backup.requests.count
-        let refused: [CommandCode] = [.createStream, .pollMessages, .getConsumerOffset, .loginUser, .logoutUser]
+        let refused: [CommandCode] = [
+            .createStream, .pollMessages, .pollMessagesDeferred, .cancelDeferredRequest, .getConsumerOffset, .loginUser, .logoutUser,
+        ]
 
         for code in refused {
             await expectCode(.invalidCommand) { _ = try await client.sendRawRequest(code: code.rawValue, payload: [], replica: 1) }
diff --git a/foreign/swift/Tests/IggyTests/ResourceOptionsTests.swift b/foreign/swift/Tests/IggyTests/ResourceOptionsTests.swift
index c723df7..bb6410d 100644
--- a/foreign/swift/Tests/IggyTests/ResourceOptionsTests.swift
+++ b/foreign/swift/Tests/IggyTests/ResourceOptionsTests.swift
@@ -86,7 +86,9 @@
         let constants = fixture.constants
         #expect(ProtocolVersion.current.packed == constants.protocolVersion.packed)
         #expect(ProtocolVersion.current.description == constants.protocolVersion.text)
-        #expect(constants.protocolVersionMin.packed == constants.protocolVersion.packed)
+        // The minimum stays behind while released clients still send the
+        // commands of the older patch.
+        #expect(constants.protocolVersionMin.packed <= constants.protocolVersion.packed)
 
         // Command and operation mirrors.
         for (name, entry) in constants.commandCodes {
diff --git a/foreign/swift/Tests/IggyTests/ResponseVectorTests.swift b/foreign/swift/Tests/IggyTests/ResponseVectorTests.swift
index 8288d99..efcb1c2 100644
--- a/foreign/swift/Tests/IggyTests/ResponseVectorTests.swift
+++ b/foreign/swift/Tests/IggyTests/ResponseVectorTests.swift
@@ -113,6 +113,47 @@
         #expect(assignment == ConsumerGroupAssignment(generation: 7, partitions: [0, 2, 4]))
     }
 
+    @Test func fetchSections() throws {
+        let sections = try Responses.fetchSections(body("response.fetch_messages"))
+        #expect(sections.map(\.partitionID) == [1, 2])
+        let read = sections[0]
+        #expect(read.error == nil)
+        #expect(read.currentOffset == 41)
+        #expect(read.nextOffset == 42)
+        #expect(read.firstOffset == 41)
+        #expect(read.context == PartitionContext(incarnation: 3, ownerGeneration: 9, metadataOp: 12))
+        let polled = try read.decode()
+        #expect(polled.partitionID == 1)
+        #expect(polled.context == read.context)
+        #expect(polled.messages.map(\.offset) == [41])
+        #expect(polled.messages.map(\.payloadString) == ["hello"])
+        let refused = sections[1]
+        #expect(refused.error == IggyError(.notPrimary))
+        #expect(refused.records.isEmpty)
+        #expect(refused.firstOffset == nil)
+        #expect(throws: IggyError(.notPrimary)) { try refused.decode() }
+        #expect(try Responses.fetchSections(FakeVSRServer.fetchedBody([])[...]).isEmpty)
+    }
+
+    /// The decoder refuses each table that `FetchMessagesResponse::decode`
+    /// refuses in Rust.
+    @Test func fetchReplyValidation() throws {
+        let reply = golden.bytes("response.fetch_messages")
+        let statusOfFirst = 8
+        let recordsLengthOfSecond = 4 + Responses.fetchSectionHeaderSize + 52
+        var tooMany = reply
+        tooMany.replaceSubrange(0..<4, with: UInt32(1001).littleEndianBytes)
+        var tableTooLong = reply
+        tableTooLong.replaceSubrange(0..<4, with: UInt32(8).littleEndianBytes)
+        var errorWithRecords = reply
+        errorWithRecords.replaceSubrange(statusOfFirst..<statusOfFirst + 4, with: IggyErrorCode.notPrimary.rawValue.littleEndianBytes)
+        var recordsPastTheBody = reply
+        recordsPastTheBody.replaceSubrange(recordsLengthOfSecond..<recordsLengthOfSecond + 4, with: UInt32(1).littleEndianBytes)
+        for hostile in [[0, 0, 0], tooMany, tableTooLong, errorWithRecords, recordsPastTheBody, Array(reply.dropLast()), reply + [0]] {
+            #expect(throws: IggyError(.invalidCommand)) { try Responses.fetchSections(hostile[...]) }
+        }
+    }
+
     @Test func consumerOffset() throws {
         let offset = try Responses.consumerOffset(body("response.consumer_offset"))
         #expect(offset == ConsumerOffsetInfo(partitionID: 1, currentOffset: 1000, storedOffset: 500))
diff --git a/foreign/swift/Tests/IggyTests/Support/FakeVSRServer.swift b/foreign/swift/Tests/IggyTests/Support/FakeVSRServer.swift
index 720d91a..70f2277 100644
--- a/foreign/swift/Tests/IggyTests/Support/FakeVSRServer.swift
+++ b/foreign/swift/Tests/IggyTests/Support/FakeVSRServer.swift
@@ -231,6 +231,53 @@
         return writer.bytes
     }
 
+    /// One section of a ``fetchedBody(_:)`` reply. A refused one carries its
+    /// `status` and nothing else.
+    struct FetchedSection {
+        var partitionID: UInt32
+        /// A message at each offset, as
+        /// ``polledBody(partitionID:offsets:currentOffset:context:)`` writes them.
+        var offsets: [UInt64] = []
+        var status: UInt32 = 0
+        /// After the last message when nil, or zero without one.
+        var nextOffset: UInt64?
+        /// The newest offset of the partition, the last one read when nil.
+        var currentOffset: UInt64?
+        var context = FakeVSRServer.routeContext
+        /// Records that replace the batch of `offsets`, for a section that
+        /// does not decode.
+        var records: [UInt8]?
+    }
+
+    /// `[partition_id:4][current_offset:8][count:4][context:24]`, the head of a
+    /// poll reply, which the records of a fetch section leave out.
+    static let polledHeadSize = 40
+
+    /// The reply to `FetchMessages`: the section table, then the records of
+    /// each section in table order.
+    static func fetchedBody(_ sections: [FetchedSection]) -> [UInt8] {
+        var table = ByteWriter()
+        var records = ByteWriter()
+        table.write(UInt32(sections.count))
+        for section in sections {
+            table.write(section.partitionID)
+            table.write(section.status)
+            guard section.status == 0 else {
+                table.write([UInt8](repeating: 0, count: Responses.fetchSectionHeaderSize - 2 * MemoryLayout<UInt32>.size))
+                continue
+            }
+            let batch = section.records ?? Array(polledBody(partitionID: section.partitionID, offsets: section.offsets).dropFirst(polledHeadSize))
+            table.write(section.currentOffset ?? section.offsets.max() ?? 0)
+            table.write(UInt32(section.offsets.count))
+            section.context.encode(into: &table)
+            table.write(section.nextOffset ?? section.offsets.last.map { $0 + 1 } ?? 0)
+            table.write(UInt32(batch.count))
+            records.write(batch)
+        }
+        table.write(records.bytes)
+        return table.bytes
+    }
+
     /// The reply to `SyncConsumerGroup` for a member.
     static func assignmentBody(generation: UInt64, partitions: [UInt32]) -> [UInt8] {
         var writer = ByteWriter()
@@ -328,6 +375,17 @@
     /// The partition context the default script routes every partition to.
     static let routeContext = PartitionContext(incarnation: 7, ownerGeneration: 0, metadataOp: 10)
 
+    /// The refusal of a whole fetch that names a partition whose primary is
+    /// not `replica`, as a server answers before it reads any target, or nil.
+    static func notPrimaryRefusal(_ request: Request, replica: UInt8, primary: (UInt32) -> UInt8) -> Reply? {
+        guard request.code == CommandCode.fetchMessages.rawValue, let fetch = try? request.fetch,
+            fetch.targets.contains(where: { primary($0.partitionID) != replica })
+        else {
+            return nil
+        }
+        return .status(IggyErrorCode.notPrimary.rawValue)
+    }
+
     /// The answer to a register and to a resume: the session is bound, as on
     /// a node that holds it. Nil for any other request.
     static func sessionReply(_ request: Request) -> Reply? {
@@ -342,8 +400,9 @@
 
     /// Answers every request successfully with an empty body, a bound
     /// session for a register and a bind, a single-node roster for the
-    /// metadata read, routes to this server under ``routeContext``, and an
-    /// empty poll.
+    /// metadata read, routes to this server under ``routeContext``, an empty
+    /// poll, deferred or not, and an empty fetch.
+    /// A cancellation gets no answer, as from the server.
     static func defaultReply(_ request: Request, address: String) -> Reply {
         if request.operation == .register {
             return .ok(registerBody())
@@ -355,14 +414,84 @@
             return .ok(registerBody(session: request.session))
         case .getPartitionRoute:
             return .ok(partitionRouteBody(routeContext))
-        case .pollMessages:
+        case .pollMessages, .pollMessagesDeferred:
             return .ok(polledBody(partitionID: 0, context: routeContext))
+        case .fetchMessages:
+            return .ok(fetchedBody([]))
+        case .cancelDeferredRequest:
+            return .stall
         default:
             return .ok([])
         }
     }
 }
 
+extension FakeVSRServer.Request {
+    /// One target of a `FetchMessages` request.
+    struct FetchTarget: Equatable {
+        var partitionID: UInt32
+        var kind: PollingStrategy.Kind
+        var value: UInt64
+        var maxBytes: UInt32
+        var incarnation: UInt64
+        var ownerGeneration: UInt64
+    }
+
+    /// A `FetchMessages` request without its consumer, stream and topic.
+    struct Fetch {
+        var wait: UInt64
+        var timeout: UInt64
+        var minCount: UInt32
+        var count: UInt32
+        var maxBytes: UInt32
+        var autoCommit: Bool
+        var targets: [FetchTarget]
+    }
+
+    /// This request read as a `FetchMessages` request.
+    var fetch: Fetch {
+        get throws {
+            var reader = ByteReader(payload)
+            _ = try reader.readUInt8()
+            for _ in 0..<3 {
+                _ = try Identifier.decode(from: &reader)
+            }
+            let wait = try reader.readUInt64()
+            let timeout = try reader.readUInt64()
+            let minCount = try reader.readUInt32()
+            let count = try reader.readUInt32()
+            let maxBytes = try reader.readUInt32()
+            let autoCommit = try reader.readBool()
+            let targets = try (0..<reader.readUInt32()).map { _ in
+                let partitionID = try reader.readUInt32()
+                guard let kind = PollingStrategy.Kind(rawValue: try reader.readUInt8()) else {
+                    throw IggyError(.invalidCommand, context: "a fetch target of partition \(partitionID) has no start point")
+                }
+                return FetchTarget(
+                    partitionID: partitionID, kind: kind, value: try reader.readUInt64(), maxBytes: try reader.readUInt32(),
+                    incarnation: try reader.readUInt64(), ownerGeneration: try reader.readUInt64())
+            }
+            guard reader.isAtEnd else {
+                throw IggyError(.invalidCommand, context: "a fetch request has \(reader.remaining) bytes after its targets")
+            }
+            return Fetch(wait: wait, timeout: timeout, minCount: minCount, count: count, maxBytes: maxBytes, autoCommit: autoCommit, targets: targets)
+        }
+    }
+
+    /// `[wait_us:8][min_count:4][max_bytes:4][request_timeout_us:8]`, which
+    /// ends every deferred poll.
+    static let deferredOptionsSize = 24
+
+    /// The options this deferred poll ends with.
+    var deferredOptions: (wait: UInt64, minCount: UInt32, maxBytes: UInt32, timeout: UInt64) {
+        let start = payload.count - Self.deferredOptionsSize
+        return (
+            UInt64(littleEndianBytes: payload[start..<start + 8]), UInt32(littleEndianBytes: payload[start + 8..<start + 12]),
+            UInt32(littleEndianBytes: payload[start + 12..<start + 16]), UInt64(littleEndianBytes: payload[start + 16..<start + 24])
+        )
+    }
+}
+
 actor FakeServerState {
     private(set) var requests: [FakeVSRServer.Request] = []
     private var responder: FakeVSRServer.Responder?
diff --git a/foreign/swift/Tests/IggyTests/Support/TestHelpers.swift b/foreign/swift/Tests/IggyTests/Support/TestHelpers.swift
index b4dc438..28d945b 100644
--- a/foreign/swift/Tests/IggyTests/Support/TestHelpers.swift
+++ b/foreign/swift/Tests/IggyTests/Support/TestHelpers.swift
@@ -72,6 +72,16 @@
     }
 }
 
+func reportedError(_ operation: () async throws -> Void) async throws -> IggyError {
+    var reported: IggyError?
+    do {
+        try await operation()
+    } catch let error as IggyError {
+        reported = error
+    }
+    return try #require(reported)
+}
+
 extension IggyClient {
     /// ``sendMessages(streamID:topicID:partitioning:messages:)`` for a batch
     /// the test does not read back, so the call can take a literal.
diff --git a/foreign/swift/Tests/IggyTests/WireVectorTests.swift b/foreign/swift/Tests/IggyTests/WireVectorTests.swift
index d103719..9bb0339 100644
--- a/foreign/swift/Tests/IggyTests/WireVectorTests.swift
+++ b/foreign/swift/Tests/IggyTests/WireVectorTests.swift
@@ -136,6 +136,21 @@
         #expect(encoded == golden.bytes("request.send_messages"))
     }
 
+    @Test func deferredMessages() throws {
+        func poll(_ options: DeferredPollOptions) throws -> [UInt8] {
+            try Requests.pollMessages(
+                consumer: .group(7), streamID: 1, topicID: "orders", partitionID: 3, strategy: .next, count: 100, autoCommit: true, options: options)
+        }
+        #expect(try poll(DeferredPollOptions()) == golden.bytes("request.poll_messages_deferred"))
+        let limits = DeferredPollOptions(maxWait: .seconds(600), minCount: 100, maxBytes: 344, requestTimeout: .seconds(630))
+        try limits.validate(count: 100)
+        #expect(try poll(limits) == golden.bytes("request.poll_messages_deferred.limits"))
+        // A deferred poll is the poll with its options appended.
+        let plain = try Requests.pollMessages(
+            consumer: .group(7), streamID: 1, topicID: "orders", partitionID: 3, strategy: .next, count: 100, autoCommit: true)
+        #expect(golden.bytes("request.poll_messages_deferred").starts(with: plain))
+    }
+
     @Test func users() throws {
         #expect(
             try Requests.createUser(username: "admin", password: "p@ssw0rd", status: .active, permissions: samplePermissions)
@@ -293,6 +308,44 @@
         #expect(request.bytes.count == VSRFrame.headerSize + 4)
     }
 
+    /// A cancellation repeats the header of the request it cancels, with its
+    /// own code, no body and no partition context.
+    @Test func deferredPollAndItsCancellation() throws {
+        var session = ConsensusSession(clientID: clientID)
+        try session.bind(99)
+        for _ in 0..<5 {
+            _ = try session.nextRequestID()
+        }
+        let request = try VSRFrame.encodeRequest(
+            session: &session, code: CommandCode.pollMessagesDeferred.rawValue, payload: golden.bytes("request.poll_messages_deferred"),
+            partitionContext: PartitionContext(incarnation: 17, ownerGeneration: 9, metadataOp: 52))
+        #expect(header(request) == golden.bytes("vsr.request.poll_messages_deferred"))
+        #expect(request.requestID == 6)
+        #expect(request.operation == .nonReplicated)
+        #expect(request.deferredCancellation == golden.bytes("vsr.request.cancel_deferred"))
+        #expect(request.deferredCancellation.flatMap { VSRFrame.readRequestID($0[...]) } == 6)
+
+        let poll = try VSRFrame.encodeRequest(session: &session, code: CommandCode.pollMessages.rawValue, payload: [])
+        #expect(poll.deferredCancellation == nil)
+    }
+
+    /// Each target of a fetch carries its own fence, so the header of the
+    /// fetch carries no partition context.
+    @Test func fetchAndItsCancellation() throws {
+        var session = ConsensusSession(clientID: clientID)
+        try session.bind(99)
+        for _ in 0..<6 {
+            _ = try session.nextRequestID()
+        }
+        let request = try VSRFrame.encodeRequest(
+            session: &session, code: CommandCode.fetchMessages.rawValue, payload: golden.bytes("request.fetch_messages"))
+        #expect(header(request) == golden.bytes("vsr.request.fetch_messages"))
+        #expect(request.requestID == 7)
+        #expect(request.operation == .nonReplicated)
+        #expect(request.deferredCancellation == golden.bytes("vsr.request.cancel_fetch"))
+        #expect(request.deferredCancellation.flatMap { VSRFrame.readRequestID($0[...]) } == 7)
+    }
+
     @Test func logoutAndUnknownCodes() throws {
         var session = ConsensusSession(clientID: clientID)
         try session.bind(99)
diff --git a/foreign/swift/Tools/golden-vectors/src/main.rs b/foreign/swift/Tools/golden-vectors/src/main.rs
index efa884d..c7cfdd3 100644
--- a/foreign/swift/Tools/golden-vectors/src/main.rs
+++ b/foreign/swift/Tools/golden-vectors/src/main.rs
@@ -48,7 +48,9 @@
 use iggy_binary_protocol::requests::consumer_groups::*;
 use iggy_binary_protocol::requests::consumer_offsets::*;
 use iggy_binary_protocol::requests::messages::{
-    GetPartitionRouteRequest, PollMessagesRequest, RawMessage, SendMessagesEncoder,
+    DeferredPollMessagesRequest, FetchMessagesRequest, FetchMessagesTarget,
+    GetPartitionRouteRequest, MAX_DEFERRED_POLL_TIMEOUT_US, MAX_DEFERRED_POLL_WAIT_US,
+    MIN_DEFERRED_POLL_MAX_BYTES, PollMessagesRequest, RawMessage, SendMessagesEncoder,
 };
 use iggy_binary_protocol::requests::partitions::{
     CreatePartitionsRequest, DeletePartitionsRequest,
@@ -71,6 +73,7 @@
 };
 use iggy_binary_protocol::responses::consumer_offsets::ConsumerOffsetResponse;
 use iggy_binary_protocol::responses::messages::{
+    FetchMessagesResponse, FetchMessagesSection, FetchMessagesSectionHeader,
     PartitionRouteResponse, PollMessagesResponseHeader, SendMessagesConfirmationResponse,
     SendMessagesResponse,
 };
@@ -96,8 +99,8 @@
     WireIdentifier, WireName, WirePartitioning, WirePollingStrategy,
 };
 use iggy_common::{
-    CompressionAlgorithm, Durability, IggyByteSize, IggyDuration, IggyError, IggyExpiry,
-    MaxTopicSize, NO_ASSIGNED_PARTITION, OptionKey, OptionsPatch,
+    CompressionAlgorithm, DeferredPollOptions, Durability, IggyByteSize, IggyDuration, IggyError,
+    IggyExpiry, MaxTopicSize, NO_ASSIGNED_PARTITION, OptionKey, OptionsPatch,
     RESYNC_REQUIRED_PARTITION_SENTINEL, StreamCreateOptions, StreamEncryptionKey,
     StreamUpdateOptions, TopicCreateOptions, TopicUpdateOptions, UserStatus, UserUpdateOptions,
     stream_key_fingerprint,
@@ -208,7 +211,7 @@
     ClientVersionInfo {
         protocol_version: IGGY_PROTOCOL_VERSION,
         sdk_name: name("swift-sdk"),
-        sdk_version: name("0.1.4"),
+        sdk_version: name("0.1.5"),
     }
 }
 
@@ -737,6 +740,107 @@
             auto_commit: false,
         },
     );
+    let deferred_poll = PollMessagesRequest {
+        consumer: WireConsumer::consumer_group(numeric(7)),
+        stream_id: numeric(1),
+        topic_id: named("orders"),
+        partition_id: Some(3),
+        strategy: WirePollingStrategy::next(),
+        count: 100,
+        auto_commit: true,
+    };
+    let defaults = DeferredPollOptions::default();
+    add(
+        &mut vectors,
+        "request.poll_messages_deferred",
+        &DeferredPollMessagesRequest {
+            poll: deferred_poll.clone(),
+            wait_us: defaults.max_wait.as_micros(),
+            min_count: defaults.min_count,
+            max_bytes: defaults.max_bytes,
+            request_timeout_us: defaults.request_timeout.as_micros(),
+        },
+    );
+    add(
+        &mut vectors,
+        "request.poll_messages_deferred.limits",
+        &DeferredPollMessagesRequest {
+            poll: deferred_poll.clone(),
+            wait_us: MAX_DEFERRED_POLL_WAIT_US,
+            min_count: deferred_poll.count,
+            max_bytes: MIN_DEFERRED_POLL_MAX_BYTES as u32,
+            request_timeout_us: MAX_DEFERRED_POLL_TIMEOUT_US,
+        },
+    );
+    // The vectors of `wire.md`: an `Offset` and a `Next` target, then a read
+    // section and a `NotPrimary` section.
+    let fetch_target = |partition_id, strategy| FetchMessagesTarget {
+        partition_id,
+        strategy,
+        max_bytes: 524_288,
+        incarnation: 3,
+        owner_generation: 9,
+    };
+    let fetch_request = FetchMessagesRequest {
+        consumer: WireConsumer::consumer_group(numeric(7)),
+        stream_id: numeric(1),
+        topic_id: named("orders"),
+        wait_us: 5_000_000,
+        request_timeout_us: 6_000_000,
+        min_count: 1,
+        count: 100,
+        max_bytes: 1_048_576,
+        auto_commit: true,
+        targets: vec![
+            fetch_target(1, WirePollingStrategy::offset(41)),
+            fetch_target(2, WirePollingStrategy::next()),
+        ],
+    };
+    add(&mut vectors, "request.fetch_messages", &fetch_request);
+    {
+        let record = batch_record(
+            1,
+            41,
+            1_700_000_000_000_100,
+            1_700_000_000_000_000,
+            &[frame(1, 0, 0, b"hello", &[])],
+        );
+        let section = |partition_id, status, records: &[u8]| FetchMessagesSectionHeader {
+            partition_id,
+            status,
+            current_offset: 0,
+            messages_count: 0,
+            context: PartitionContext::default(),
+            next_offset: 0,
+            records_len: u32::try_from(records.len()).expect("fixture records fit a section"),
+        };
+        let response = FetchMessagesResponse {
+            sections: vec![
+                FetchMessagesSection {
+                    header: FetchMessagesSectionHeader {
+                        current_offset: 41,
+                        messages_count: 1,
+                        context: PartitionContext {
+                            incarnation: 3,
+                            owner_generation: 9,
+                            metadata_op: 12,
+                        },
+                        next_offset: 42,
+                        ..section(1, 0, &record)
+                    },
+                    records: &record,
+                },
+                FetchMessagesSection {
+                    header: section(2, IggyError::NotPrimary.as_code(), &[]),
+                    records: &[],
+                },
+            ],
+        };
+        vectors.insert(
+            "response.fetch_messages".to_owned(),
+            hex(&response.to_bytes().expect("the fixture table is valid")),
+        );
+    }
     add(
         &mut vectors,
         "request.get_partition_route",
@@ -1061,6 +1165,59 @@
             &[],
         ))),
     );
+    let deferred_payload = DeferredPollMessagesRequest {
+        poll: deferred_poll,
+        wait_us: defaults.max_wait.as_micros(),
+        min_count: defaults.min_count,
+        max_bytes: defaults.max_bytes,
+        request_timeout_us: defaults.request_timeout.as_micros(),
+    }
+    .to_bytes();
+    let mut deferred_request = request_header(
+        POLL_MESSAGES_DEFERRED_CODE,
+        client_id,
+        6,
+        99,
+        &deferred_payload,
+    );
+    PartitionContext {
+        incarnation: 17,
+        owner_generation: 9,
+        metadata_op: 52,
+    }
+    .stamp(&mut deferred_request);
+    vectors.insert(
+        "vsr.request.poll_messages_deferred".to_owned(),
+        hex(bytemuck::bytes_of(&deferred_request)),
+    );
+    vectors.insert(
+        "vsr.request.cancel_deferred".to_owned(),
+        hex(bytemuck::bytes_of(
+            &deferred_request
+                .deferred_cancellation()
+                .expect("a deferred poll has a cancellation"),
+        )),
+    );
+    // Each target carries its own fence, so the header of a fetch carries none.
+    let fetch_request = request_header(
+        FETCH_MESSAGES_CODE,
+        client_id,
+        7,
+        99,
+        &fetch_request.to_bytes(),
+    );
+    vectors.insert(
+        "vsr.request.fetch_messages".to_owned(),
+        hex(bytemuck::bytes_of(&fetch_request)),
+    );
+    vectors.insert(
+        "vsr.request.cancel_fetch".to_owned(),
+        hex(bytemuck::bytes_of(
+            &fetch_request
+                .deferred_cancellation()
+                .expect("a fetch has a cancellation"),
+        )),
+    );
     vectors.insert(
         "vsr.request.unknown_code".to_owned(),
         hex(bytemuck::bytes_of(&request_header(
diff --git a/gateways/kafka/Cargo.toml b/gateways/kafka/Cargo.toml
index 8f34210..3e22b33 100644
--- a/gateways/kafka/Cargo.toml
+++ b/gateways/kafka/Cargo.toml
@@ -17,7 +17,7 @@
 
 [package]
 name = "iggy-gateway-kafka"
-version = "0.1.0-edge.1"
+version = "0.1.0-edge.2"
 description = "Kafka wire protocol gateway foundation for Apache Iggy"
 edition = "2024"
 license = "Apache-2.0"
diff --git a/gateways/kafka/src/bridge/iggy_bridge/cluster_id.rs b/gateways/kafka/src/bridge/iggy_bridge/cluster_id.rs
index c69b49d..aa4ef1d 100644
--- a/gateways/kafka/src/bridge/iggy_bridge/cluster_id.rs
+++ b/gateways/kafka/src/bridge/iggy_bridge/cluster_id.rs
@@ -26,8 +26,8 @@
 
 use iggy::prelude::{
     Consumer, Durability, Identifier, IggyError, IggyExpiry, IggyMessage, MaxTopicSize,
-    MessageClient, Partitioning, PollingStrategy, StreamClient, StreamCreateOptions, TopicClient,
-    TopicCreateOptions, TopicDetails,
+    MessageClient, Partitioning, PollOptions, PollingStrategy, StreamClient, StreamCreateOptions,
+    TopicClient, TopicCreateOptions, TopicDetails,
 };
 use tracing::{info, warn};
 
@@ -162,11 +162,13 @@
         let polled = with_request_timeout(self.client.poll_messages(
             &store.stream_id,
             &store.topic_id,
-            Some(0),
+            0,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            1,
-            false,
+            PollOptions {
+                count: 1,
+                ..PollOptions::default()
+            },
         ))
         .await;
         let polled = match polled {
diff --git a/gateways/kafka/src/bridge/iggy_bridge/fetch.rs b/gateways/kafka/src/bridge/iggy_bridge/fetch.rs
index 2e33258..30c91e2 100644
--- a/gateways/kafka/src/bridge/iggy_bridge/fetch.rs
+++ b/gateways/kafka/src/bridge/iggy_bridge/fetch.rs
@@ -23,8 +23,8 @@
 use std::sync::{Arc, Mutex, PoisonError};
 
 use iggy::prelude::{
-    Client, Consumer, IggyClient, IggyError, MessageClient, Partition, PolledMessages,
-    PollingStrategy, TopicClient,
+    Client, Consumer, IggyClient, IggyError, MIN_DEFERRED_POLL_MAX_BYTES, MessageClient, Partition,
+    PollOptions, PolledMessages, PollingStrategy, TopicClient,
 };
 use tokio::sync::{Mutex as AsyncMutex, OwnedSemaphorePermit, Semaphore};
 use tokio::time::{Instant, timeout_at};
@@ -41,6 +41,11 @@
 /// stalls only its own slot, and only until the request deadline.
 const FETCH_SLOTS: usize = 4;
 
+/// The byte cap of a poll of one message: the smallest Iggy takes. The first message comes
+/// whatever its size, and a larger cap only makes its owner reserve more read budget.
+#[allow(clippy::cast_possible_truncation)]
+pub const ONE_MESSAGE_MAX_BYTES: u32 = MIN_DEFERRED_POLL_MAX_BYTES as u32;
+
 /// The Fetch read slots, with a client for each.
 pub(super) struct FetchPool {
     permits: Arc<Semaphore>,
@@ -365,8 +370,9 @@
             .collect())
     }
 
-    /// Reads up to `count` messages of `partition`, from `offset` on, with the client of `slot`.
-    /// Hands the slot back with the result. `None` if no result comes by `deadline`.
+    /// Reads up to `options.count` messages and `options.max_bytes` bytes of `partition`, from
+    /// `offset` on, with the client of `slot`. Hands the slot back with the result. `None` if no
+    /// result comes by `deadline`.
     ///
     /// A plain consumer without auto commit, so Iggy stores no offset. Always the request's
     /// offset, never `Next`.
@@ -380,29 +386,46 @@
         kafka_topic: &str,
         partition: u32,
         offset: u64,
-        count: u32,
+        options: PollOptions,
         deadline: Instant,
     ) -> Option<(Result<PolledMessages, BridgeError>, FetchSlot)> {
-        self.poll_by(
+        self.poll_in_slot(
             slot,
             kafka_topic,
             partition,
             PollingStrategy::offset(offset),
-            count,
+            options,
             deadline,
         )
         .await
     }
 
-    /// [`Self::poll`] with any polling strategy, so `ListOffsets` can read by timestamp or the
-    /// last record through the same slots.
+    /// [`Self::poll`] of one message with any polling strategy, so `ListOffsets` can read by
+    /// timestamp or the last record through the same slots.
     pub(crate) async fn poll_by(
         self: &Arc<Self>,
         slot: FetchSlot,
         kafka_topic: &str,
         partition: u32,
         strategy: PollingStrategy,
-        count: u32,
+        deadline: Instant,
+    ) -> Option<(Result<PolledMessages, BridgeError>, FetchSlot)> {
+        let options = PollOptions {
+            count: 1,
+            max_bytes: ONE_MESSAGE_MAX_BYTES,
+            ..PollOptions::default()
+        };
+        self.poll_in_slot(slot, kafka_topic, partition, strategy, options, deadline)
+            .await
+    }
+
+    async fn poll_in_slot(
+        self: &Arc<Self>,
+        slot: FetchSlot,
+        kafka_topic: &str,
+        partition: u32,
+        strategy: PollingStrategy,
+        options: PollOptions,
         deadline: Instant,
     ) -> Option<(Result<PolledMessages, BridgeError>, FetchSlot)> {
         let (bridge, client, topic) = (
@@ -412,7 +435,7 @@
         );
         let poll = async move {
             bridge
-                .poll_with(&client, &topic, partition, &strategy, count, deadline)
+                .poll_with(&client, &topic, partition, &strategy, options, deadline)
                 .await
         };
         in_slot(slot, deadline, poll).await
@@ -424,7 +447,7 @@
         kafka_topic: &str,
         partition: u32,
         strategy: &PollingStrategy,
-        count: u32,
+        options: PollOptions,
         deadline: Instant,
     ) -> Result<PolledMessages, BridgeError> {
         let target = self.topic_target(kafka_topic)?;
@@ -434,11 +457,10 @@
                 .poll_messages(
                     &target.stream_id,
                     &target.topic_id,
-                    Some(partition),
+                    partition,
                     &consumer,
                     strategy,
-                    count,
-                    false,
+                    options,
                 )
                 .await
                 .map_err(BridgeError::Iggy)
diff --git a/gateways/kafka/src/bridge/iggy_bridge/mod.rs b/gateways/kafka/src/bridge/iggy_bridge/mod.rs
index f5601e2..092dc76 100644
--- a/gateways/kafka/src/bridge/iggy_bridge/mod.rs
+++ b/gateways/kafka/src/bridge/iggy_bridge/mod.rs
@@ -44,7 +44,7 @@
 
 pub use cluster_id::{CLUSTER_ID_TOPIC, ClusterIdCall, cluster_id_stream};
 use fetch::{FetchPool, LazyClient};
-pub(crate) use fetch::{FetchSlot, PartitionProbe, TopicProbe};
+pub(crate) use fetch::{FetchSlot, ONE_MESSAGE_MAX_BYTES, PartitionProbe, TopicProbe};
 use group_index::GroupIndex;
 pub use group_index::{OffsetGroups, ScannedGroups};
 use group_offsets::OffsetPool;
diff --git a/gateways/kafka/src/bridge/iggy_bridge/offsets.rs b/gateways/kafka/src/bridge/iggy_bridge/offsets.rs
index 1b429b9..d822f40 100644
--- a/gateways/kafka/src/bridge/iggy_bridge/offsets.rs
+++ b/gateways/kafka/src/bridge/iggy_bridge/offsets.rs
@@ -155,7 +155,7 @@
             RecordLookup::Last => PollingStrategy::last(),
         };
         let (polled, slot) = self
-            .poll_by(slot, kafka_topic, partition, strategy, 1, deadline)
+            .poll_by(slot, kafka_topic, partition, strategy, deadline)
             .await?;
         let located = polled.map(|polled| polled.messages.first().map(LocatedRecord::from));
         Some((located, slot))
diff --git a/gateways/kafka/src/protocol/handlers/fetch.rs b/gateways/kafka/src/protocol/handlers/fetch.rs
index 622abc4..5d4a097 100644
--- a/gateways/kafka/src/protocol/handlers/fetch.rs
+++ b/gateways/kafka/src/protocol/handlers/fetch.rs
@@ -25,7 +25,7 @@
 
 use bytes::Bytes;
 use iggy::prelude::{
-    IGGY_MESSAGE_HEADER_SIZE, IggyError, IggyMessage, MAX_PAYLOAD_SIZE, MAX_USER_HEADERS_SIZE,
+    DEFAULT_POLL_MAX_BYTES, IGGY_MESSAGE_HEADER_SIZE, IggyError, IggyMessage, PollOptions,
     PolledMessages,
 };
 use kafka_protocol::indexmap::IndexSet;
@@ -36,7 +36,7 @@
 use tokio::sync::watch;
 use tokio::time::{Instant, sleep_until};
 
-use crate::bridge::iggy_bridge::{FetchSlot, PartitionProbe, TopicProbe};
+use crate::bridge::iggy_bridge::{FetchSlot, ONE_MESSAGE_MAX_BYTES, PartitionProbe, TopicProbe};
 use crate::bridge::topic_map::validate_kafka_topic_name;
 use crate::bridge::{BridgeError, IggyBridge};
 use crate::error::Result;
@@ -81,15 +81,6 @@
 
 /// Most messages one partition read takes from Iggy, over all its polls.
 const MAX_POLL_COUNT: u32 = 1000;
-/// Most messages one poll asks for. Iggy caps a poll by count, not by bytes, so this caps what one
-/// poll can load: about 1 GB of the largest messages. A byte cap in Iggy would lift it.
-const MAX_PAGE_COUNT: u32 = 16;
-/// The largest message Iggy stores, header included.
-const LARGEST_MESSAGE_BYTES: u64 =
-    MAX_PAYLOAD_SIZE as u64 + MAX_USER_HEADERS_SIZE as u64 + IGGY_MESSAGE_HEADER_SIZE as u64;
-// The server answers an empty poll when the reply passes its u32 frame size. The consumer would
-// then stall.
-const _: () = assert!(MAX_PAGE_COUNT as u64 * LARGEST_MESSAGE_BYTES < u32::MAX as u64);
 
 /// First version with sessions and a top-level error code.
 const SESSION_MIN_VERSION: i16 = 7;
@@ -851,7 +842,11 @@
         if worth_polling(self.owes_first_record, budget, probe.average_size) {
             while let Some(count) = next_page(&polled, budget, probe.average_size) {
                 let asked = Instant::now();
-                let Some(page) = self.poll(want.topic, at.index, polled.next, count).await else {
+                let left = budget.saturating_sub(polled.bytes);
+                let Some(page) = self
+                    .poll(want.topic, at.index, polled.next, count, left)
+                    .await
+                else {
                     break;
                 };
                 match page {
@@ -864,7 +859,7 @@
                             asked,
                         });
                     }
-                    Ok(page) => polled.add(page, count),
+                    Ok(page) => polled.add(page),
                     Err(error) if polled.messages.is_empty() => {
                         return Read::Done(self.refuse(want, at, offset, &error));
                     }
@@ -929,14 +924,14 @@
         if !worth_polling(self.owes_first_record, budget, probe.average_size) {
             return Read::Done(unread);
         }
-        let count = poll_count(budget, probe.average_size, u64::from(MAX_PAGE_COUNT));
+        let count = poll_count(budget, probe.average_size, u64::from(MAX_POLL_COUNT));
         let asked = Instant::now();
-        match self.poll(want.topic, at.index, offset, count).await {
+        match self.poll(want.topic, at.index, offset, count, budget).await {
             None => Read::Done(unread),
             Some(Err(error)) => Read::Done(self.refuse(want, at, offset, &error)),
             Some(Ok(page)) if !page.messages.is_empty() => {
                 let mut polled = Polled::new(offset, probe.high_watermark);
-                polled.add(page, count);
+                polled.add(page);
                 self.read(want, at, offset, probe, polled).await
             }
             Some(Ok(page)) if caught_up(offset, page.current_offset) => {
@@ -1044,18 +1039,25 @@
         refused(want.partition, code)
     }
 
-    /// Polls one page, in the read slot. `None` once `give_up_at` passes.
+    /// Polls one page of at most `count` messages and [`page_max_bytes`] bytes, in the read slot.
+    /// `None` once `give_up_at` passes.
     async fn poll(
         &mut self,
         topic: &str,
         index: u32,
         offset: u64,
         count: u32,
+        budget: usize,
     ) -> Option<core::result::Result<PolledMessages, BridgeError>> {
         let give_up_at = self.fetch.give_up_at;
         if Instant::now() >= give_up_at {
             return None;
         }
+        let options = PollOptions {
+            count,
+            max_bytes: page_max_bytes(count, budget),
+            ..PollOptions::default()
+        };
         let bridge = self.fetch.bridge;
         let (page, slot) = bridge
             .poll(
@@ -1063,7 +1065,7 @@
                 topic,
                 index,
                 offset,
-                count,
+                options,
                 give_up_at,
             )
             .await?;
@@ -1553,11 +1555,13 @@
         }
     }
 
-    fn add(&mut self, page: PolledMessages, count: u32) {
-        self.done = page.messages.len() < usize::try_from(count).unwrap_or(usize::MAX);
+    fn add(&mut self, page: PolledMessages) {
         let Some(last) = page.messages.last() else {
+            self.done = true;
             return;
         };
+        // A page can come back short for bytes, so only the end of the partition ends the read.
+        self.done = last.header.offset >= page.current_offset;
         self.next = last.header.offset.saturating_add(1);
         self.high_watermark = self
             .high_watermark
@@ -1585,13 +1589,10 @@
         return None;
     }
     if polled.messages.is_empty() {
-        return Some(poll_count(budget, average_size, waiting).min(MAX_PAGE_COUNT));
+        return Some(poll_count(budget, average_size, waiting));
     }
     let fits = budget.saturating_sub(polled.bytes) / polled.largest.max(1);
-    let count = u32::try_from(fits)
-        .unwrap_or(u32::MAX)
-        .min(MAX_PAGE_COUNT)
-        .min(room);
+    let count = u32::try_from(fits).unwrap_or(u32::MAX).min(room);
     (count > 0).then_some(count)
 }
 
@@ -1612,6 +1613,19 @@
     u32::try_from(count).unwrap_or(MAX_POLL_COUNT)
 }
 
+/// The byte cap of a poll of `count` messages with `budget` bytes left.
+///
+/// The budget, but never below Iggy's default cap, since the encoder trims the page to the
+/// budget anyway. One message gets [`ONE_MESSAGE_MAX_BYTES`].
+fn page_max_bytes(count: u32, budget: usize) -> u32 {
+    if count == 1 {
+        return ONE_MESSAGE_MAX_BYTES;
+    }
+    u32::try_from(budget)
+        .unwrap_or(u32::MAX)
+        .max(DEFAULT_POLL_MAX_BYTES)
+}
+
 /// Bytes Iggy stores for `message`, header included, as the probe's average counts them.
 fn stored_bytes(message: &IggyMessage) -> usize {
     IGGY_MESSAGE_HEADER_SIZE
@@ -1904,6 +1918,33 @@
         );
     }
 
+    #[test]
+    fn given_a_budget_when_capping_a_page_should_send_it_but_never_below_the_default_cap() {
+        let budget = 4 * 1024 * 1024;
+        assert_eq!(
+            page_max_bytes(MAX_POLL_COUNT, budget),
+            u32::try_from(budget).unwrap()
+        );
+        assert_eq!(
+            page_max_bytes(MAX_POLL_COUNT, 10),
+            DEFAULT_POLL_MAX_BYTES,
+            "a small budget still reads a default page"
+        );
+        assert_eq!(page_max_bytes(2, usize::MAX), u32::MAX);
+    }
+
+    #[test]
+    fn given_one_message_when_capping_the_page_should_send_the_smallest_cap() {
+        // No cap made the owner reserve its whole read budget for one message.
+        for budget in [0, 10, 4 * 1024 * 1024, usize::MAX] {
+            assert_eq!(
+                page_max_bytes(1, budget),
+                ONE_MESSAGE_MAX_BYTES,
+                "the first message comes whatever its size"
+            );
+        }
+    }
+
     /// A page of messages from `first` on, one per payload size.
     fn page(first: u64, sizes: &[usize], current_offset: u64) -> PolledMessages {
         let messages: Vec<IggyMessage> = sizes
@@ -1918,19 +1959,22 @@
                 message
             })
             .collect();
-        PolledMessages {
-            partition_id: 0,
+        PolledMessages::new(
+            iggy::prelude::PartitionContext::default(),
+            0,
             current_offset,
-            count: u32::try_from(messages.len()).unwrap(),
+            u32::try_from(messages.len()).unwrap(),
             messages,
-            context: iggy::prelude::PartitionContext::default(),
-        }
+        )
     }
 
     #[test]
     fn given_a_new_read_when_sizing_the_first_page_should_use_the_average_up_to_the_cap() {
         let mib = 1024 * 1024;
-        assert_eq!(next_page(&Polled::new(0, 10_000), mib, 100), Some(16));
+        assert_eq!(
+            next_page(&Polled::new(0, 10_000), mib, 100),
+            Some(MAX_POLL_COUNT)
+        );
         assert_eq!(next_page(&Polled::new(0, 3), mib, 100), Some(3));
         assert_eq!(
             next_page(&Polled::new(0, 100), 10, 100),
@@ -1943,31 +1987,51 @@
     #[test]
     fn given_small_messages_when_sizing_later_pages_should_grow_to_the_cap() {
         let mut polled = Polled::new(0, 10_000);
-        polled.add(page(0, &[100; 16], 15), 16);
+        polled.add(page(0, &[100; 16], 9_999));
         assert_eq!(polled.next, 16);
-        assert_eq!(next_page(&polled, 1024 * 1024, 100), Some(MAX_PAGE_COUNT));
+        assert_eq!(
+            next_page(&polled, 1024 * 1024, 100),
+            Some(MAX_POLL_COUNT - 16)
+        );
     }
 
     #[test]
     fn given_a_large_message_when_sizing_the_next_page_should_ask_for_what_fits() {
         let mut polled = Polled::new(0, 10_000);
-        polled.add(page(0, &[100, 100, 400_000], 2), 3);
+        polled.add(page(0, &[100, 100, 400_000], 9_999));
         assert_eq!(next_page(&polled, 1024 * 1024, 100), Some(1));
         assert_eq!(next_page(&polled, 500_000, 100), None, "no room left");
     }
 
     #[test]
-    fn given_a_short_page_when_sizing_the_next_page_should_stop() {
+    fn given_a_page_short_for_bytes_when_more_waits_should_read_on() {
         let mut polled = Polled::new(0, 10_000);
-        polled.add(page(0, &[100; 3], 20_000), 16);
+        polled.add(page(0, &[100; 3], 20_000));
         assert_eq!(polled.high_watermark, 20_001, "the reply moves it up");
+        assert_eq!(
+            next_page(&polled, 1024 * 1024, 100),
+            Some(MAX_POLL_COUNT - 3)
+        );
+    }
+
+    #[test]
+    fn given_a_page_at_the_end_of_the_partition_when_sizing_the_next_page_should_stop() {
+        let mut polled = Polled::new(0, 10_000);
+        polled.add(page(0, &[100; 3], 2));
         assert_eq!(next_page(&polled, 1024 * 1024, 100), None);
+        let mut polled = Polled::new(0, 10_000);
+        polled.add(page(0, &[], 2));
+        assert_eq!(
+            next_page(&polled, 1024 * 1024, 100),
+            None,
+            "an empty page ends the read"
+        );
     }
 
     #[test]
     fn given_the_count_cap_when_sizing_the_next_page_should_not_pass_it() {
         let mut polled = Polled::new(0, 10_000);
-        polled.add(page(0, &[1; 990], 989), 990);
+        polled.add(page(0, &[1; 990], 9_999));
         assert_eq!(next_page(&polled, usize::MAX, 1), Some(10));
     }
 
diff --git a/gateways/kafka/tests/cluster_id_real_bridge_tests.rs b/gateways/kafka/tests/cluster_id_real_bridge_tests.rs
index 0083d19..570c9a1 100644
--- a/gateways/kafka/tests/cluster_id_real_bridge_tests.rs
+++ b/gateways/kafka/tests/cluster_id_real_bridge_tests.rs
@@ -25,7 +25,7 @@
 use futures::future::join_all;
 use iggy::prelude::{
     Consumer, Durability, Identifier, IggyExpiry, IggyMessage, MaxTopicSize, MessageClient,
-    Partitioning, Permissions, PollingStrategy, StreamClient, StreamCreateOptions,
+    Partitioning, Permissions, PollOptions, PollingStrategy, StreamClient, StreamCreateOptions,
     StreamPermissions, TopicClient, TopicCreateOptions, UserClient, UserCreateOptions, UserStatus,
 };
 use kafka_protocol::messages::{DescribeClusterResponse, MetadataResponse};
@@ -121,11 +121,13 @@
         .poll_messages(
             &Identifier::named(&cluster_id_stream(default_stream)).expect("stream name"),
             &Identifier::named(CLUSTER_ID_TOPIC).expect("topic name"),
-            Some(0),
+            0,
             &Consumer::default(),
             &PollingStrategy::offset(0),
-            100,
-            false,
+            PollOptions {
+                count: 100,
+                ..PollOptions::default()
+            },
         )
         .await
         .expect("poll the id topic");
diff --git a/gateways/kafka/tests/list_offsets_real_bridge_tests.rs b/gateways/kafka/tests/list_offsets_real_bridge_tests.rs
index 8e55a8d..c004059 100644
--- a/gateways/kafka/tests/list_offsets_real_bridge_tests.rs
+++ b/gateways/kafka/tests/list_offsets_real_bridge_tests.rs
@@ -26,7 +26,7 @@
 use std::time::{Duration, SystemTime, UNIX_EPOCH};
 
 use bytes::Bytes;
-use iggy::prelude::{Identifier, IggyMessage, MessageClient, Partitioning};
+use iggy::prelude::{DEFAULT_POLL_MAX_BYTES, Identifier, IggyMessage, MessageClient, Partitioning};
 use serial_test::serial;
 use tokio::io::copy_bidirectional;
 use tokio::net::{TcpListener, TcpStream};
@@ -422,6 +422,26 @@
     }
 }
 
+#[tokio::test]
+#[serial]
+async fn a_record_past_the_default_poll_cap_resolves_by_time_and_as_the_last_record() {
+    let data_dir = tempfile::tempdir().expect("tempdir");
+    let server = TestServer::spawn(data_dir.path()).await;
+    let (state, seed) = connected_state(&server).await;
+    seed.ensure_stream_and_topic("orders", 1)
+        .await
+        .expect("seed the topic");
+    let before = now_millis() - 60_000;
+    let cap = usize::try_from(DEFAULT_POLL_MAX_BYTES).expect("a u32 fits a usize");
+    produce_batch(&server, &["x".repeat(2 * cap).as_str()]).await;
+
+    let (error_code, _, offset) = send_at(&state, REQUEST_VERSION, "orders", 0, before).await;
+    assert_eq!((error_code, offset), (ERROR_NONE, 0), "by time");
+    let (error_code, _, offset) =
+        send_at(&state, MAX_TIMESTAMP_VERSION, "orders", 0, MAX_TIMESTAMP).await;
+    assert_eq!((error_code, offset), (ERROR_NONE, 0), "as the last record");
+}
+
 /// A TCP relay to `server` that leaves the next connections [`Self::stall_next`] counts
 /// unanswered, so a client on one waits in its handshake until it gives up.
 struct StallingRelay {
diff --git a/gateways/kafka/tests/phase1_e2e_tests.rs b/gateways/kafka/tests/phase1_e2e_tests.rs
index 73c5e0f..a6958d6 100644
--- a/gateways/kafka/tests/phase1_e2e_tests.rs
+++ b/gateways/kafka/tests/phase1_e2e_tests.rs
@@ -39,7 +39,7 @@
 use std::time::Duration;
 
 use bytes::{Bytes, BytesMut};
-use iggy::prelude::{Consumer, Identifier, MessageClient, PollingStrategy};
+use iggy::prelude::{Consumer, Identifier, MessageClient, PollOptions, PollingStrategy};
 use kafka_protocol::indexmap::IndexMap;
 use kafka_protocol::records::{
     Compression, NO_PARTITION_LEADER_EPOCH, NO_PRODUCER_EPOCH, NO_PRODUCER_ID, NO_SEQUENCE, Record,
@@ -327,11 +327,13 @@
         .poll_messages(
             &Identifier::named(STREAM).expect("stream name"),
             &Identifier::named(TOPIC).expect("topic name"),
-            Some(0),
+            0,
             &Consumer::new(Identifier::named("phase1-e2e-reader").expect("consumer name")),
             &PollingStrategy::offset(0),
-            2,
-            false,
+            PollOptions {
+                count: 2,
+                ..PollOptions::default()
+            },
         )
         .await
         .expect("poll produced messages")
diff --git a/gateways/kafka/tests/produce_real_bridge_tests.rs b/gateways/kafka/tests/produce_real_bridge_tests.rs
index 8b09ae2..6976f37 100644
--- a/gateways/kafka/tests/produce_real_bridge_tests.rs
+++ b/gateways/kafka/tests/produce_real_bridge_tests.rs
@@ -26,8 +26,8 @@
 
 use bytes::{Bytes, BytesMut};
 use iggy::prelude::{
-    Consumer, HeaderKey, Identifier, IggyClient, IggyMessage, MessageClient, PollingStrategy,
-    SystemClient, TopicClient,
+    Consumer, HeaderKey, Identifier, IggyClient, IggyMessage, MessageClient, PollOptions,
+    PollingStrategy, SystemClient, TopicClient,
 };
 use kafka_protocol::indexmap::IndexMap;
 use kafka_protocol::protocol::StrBytes;
@@ -264,11 +264,13 @@
         .poll_messages(
             &Identifier::named(STREAM).expect("stream name"),
             &Identifier::named(TOPIC).expect("topic name"),
-            Some(partition),
+            partition,
             &Consumer::new(Identifier::named("reader").expect("consumer name")),
             &PollingStrategy::offset(0),
-            count,
-            false,
+            PollOptions {
+                count,
+                ..PollOptions::default()
+            },
         )
         .await
         .expect("poll the partition back")
diff --git a/scripts/ci/storage-compat.sh b/scripts/ci/storage-compat.sh
index 86dca6c..06867f8 100755
--- a/scripts/ci/storage-compat.sh
+++ b/scripts/ci/storage-compat.sh
@@ -23,31 +23,44 @@
 
 # storage-compat.sh -- Prove HEAD still reads a data directory written by master.
 #
-# Supplies two iggy-server binaries to one integration test:
-#   baseline  built from --baseline-ref, copied aside. Default: on a
-#             pull_request run, the master tip under the checked-out merge ref;
-#             anywhere else, origin/master.
-#   HEAD      restored from the CI runtime archive, or built locally from HEAD
-# The test boots the baseline, seeds a data directory, swaps the binary to HEAD
-# and restarts against that same directory.
+# Runs the two halves of core/integration/tests/data_integrity/storage_compat.rs,
+# each from its own source tree, on one data directory:
+#   seed    the baseline's own seed test, run from a worktree of --baseline-ref
+#           against an iggy-server built from that worktree and copied aside.
+#           It writes the data directory and a JSON record of what the
+#           baseline served. Default baseline: on a pull_request run, the
+#           master tip under the checked-out merge ref, anywhere else,
+#           origin/master.
+#   verify  HEAD's verify test, run from this tree against an iggy-server
+#           restored from the CI runtime archive or built locally from HEAD.
+#           It boots on a copy of that directory and checks the record.
+# The seed talks to the baseline through the baseline's own SDK, so a client
+# protocol change in HEAD cannot fail it. A baseline that predates this split
+# has no seed test, and the run stops before any build with a pointer to the
+# breaking:protocol label.
 #
-# The baseline's own core/server/config.toml is extracted next to its binary and
-# both boots read it, so the swap changes the binary and nothing else.
+# The baseline's own core/server/config.toml is extracted next to its binary.
+# The seed boots the baseline on it and the verify translates it into HEAD's
+# schema, so the swap changes the binary and nothing else.
 #
-# The harness only locates binaries; it never builds them. CI supplies HEAD
+# The harness only locates binaries and never builds them. CI supplies HEAD
 # through a revision-checked runtime archive. Local runs build HEAD here.
 # The baseline is always resolved independently from its own source tree.
 #
-# Exit codes: 0 = compatible, non-zero = build failure or format regression.
+# Exit codes: 0 = compatible, non-zero = build failure, format regression or
+# a baseline without the seed test.
 
 # Empty selects the default described above once HEAD is known.
 BASELINE_REF=""
 REBUILD_BASELINE=0
 RUNTIME_ARTIFACTS=""
 
-# The single #[ignore]d test this script exists to drive. The integration crate
+# The two #[ignore]d halves this script exists to drive. The integration crate
 # is one binary built from tests/mod.rs, so tests are selected by module path.
-TEST_FILTER="data_integrity::storage_compat"
+# Neither name contains the other, so each filter selects exactly one test.
+SEED_TEST="given_baseline_server_when_seeding_data_directory_should_record_what_it_serves"
+VERIFY_TEST="given_baseline_data_directory_when_booted_by_build_under_test_should_read_back_the_record"
+TEST_MODULE="data_integrity::storage_compat"
 
 while [[ $# -gt 0 ]]; do
   case "$1" in
@@ -209,66 +222,117 @@
   exit 1
 fi
 
+# The data directory the seed writes and the verify boots a copy of, with the
+# seed's record beside it. Wiped before every seed and kept after the run, so a
+# failed verify can be inspected and rerun by hand against the same pair.
+SEED_DATA="${TARGET_DIR}/storage-compat/${BASELINE_SHA}/data"
+SEED_RECORD="${SEED_DATA}.json"
+
+# --run-ignored only: both halves are #[ignore]d, so the normal lanes skip them.
+# --no-tests=fail: a filter matching nothing has to be an error here, unlike the
+# main lane's --no-tests=warn, which would report a typo'd filter as green.
+# --retries 0: a read-back that only fails sometimes is exactly the signal this
+# check exists for, so no retry may hide it. Explicit rather than "not
+# --profile ci": a profile default or NEXTEST_RETRIES in the environment would
+# otherwise apply.
+# --ignore-default-filter: nextest intersects -E with the profile's
+# default-filter, so one that excluded this module would turn the run into a
+# --no-tests=fail abort instead of running the test.
+# The version floor for these flags lives in .config/nextest.toml.
+NEXTEST_ARGS=(
+  --locked -p integration
+  --run-ignored only
+  --no-tests=fail
+  --retries 0
+  --ignore-default-filter
+)
+
 if [ "${REBUILD_BASELINE}" -eq 1 ]; then
   rm -f "${BASELINE_SERVER}"
 fi
 
-if [ -x "${BASELINE_SERVER}" ]; then
-  echo "Reusing baseline server at ${BASELINE_SERVER}"
-else
-  # Outside the repo on purpose: an in-tree worktree gets swept up by the
-  # repo-wide find(1) in the lint scripts and by cargo's workspace globs.
-  # A fresh mktemp path per run cannot collide with an aborted run; the only
-  # residue such a run leaves is an admin entry under .git/worktrees, which
-  # prune clears.
-  git worktree prune
-  WORKTREE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/iggy-storage-compat.XXXXXX")"
-  git worktree add --detach "${WORKTREE_DIR}" "${BASELINE_SHA}"
+# Outside the repo on purpose: an in-tree worktree gets swept up by the
+# repo-wide find(1) in the lint scripts and by cargo's workspace globs.
+# A fresh mktemp path per run cannot collide with an aborted run. The only
+# residue such a run leaves is an admin entry under .git/worktrees, which
+# prune clears. Created on every run, cached binary or not, because the seed
+# test runs from the baseline's own source.
+git worktree prune
+WORKTREE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/iggy-storage-compat.XXXXXX")"
+git worktree add --detach "${WORKTREE_DIR}" "${BASELINE_SHA}"
 
-  echo "Building baseline iggy-server from ${BASELINE_SHA}..."
-  # Built from the worktree as CWD so the baseline's own rust-toolchain.toml
-  # applies, and into a target directory of the worktree's own.
-  #
-  # The two trees MUST NOT share one. Cargo keys a workspace member's unit hash
-  # on its manifest path RELATIVE to the workspace root, and records that
-  # member's sources in the dep-info relative too, so `core/configs` in the
-  # worktree and `core/configs` here hash identically and both resolve against
-  # whichever root cargo is invoked from. Sharing a target directory therefore
-  # makes the second build read the first build's rlibs as fresh: HEAD's
-  # `server` would compile against MASTER's `configs`, `consensus`,
-  # `partitions` and `shard`. Any PR touching a crate below `server` fails to
-  # build here with errors that do not reproduce anywhere else. The duplicated
-  # dependency compile is the price of the two halves being what they claim.
-  #
-  # Inside the worktree so the cleanup trap reclaims it with the worktree; only
-  # the copied binary below outlives the run.
-  #
-  # Debug profile on both sides, and no --all-features: release would compile
-  # debug_assert! out of the baseline while HEAD still panics on it, and
-  # --all-features turns on the server's `disable-mimalloc`, so the two halves
-  # would differ in ways the storage format never changed.
-  BASELINE_TARGET_DIR="${WORKTREE_DIR}/target"
-  (
-    cd "${WORKTREE_DIR}"
-    # The reference revision must not contribute counters to HEAD's coverage report.
-    if [[ "${__CARGO_LLVM_COV_RUSTC_WRAPPER:-}" == 1 ]]; then
-      export RUSTC_WRAPPER="${__CARGO_LLVM_COV_RUSTC_WRAPPER_PRE_EXISTING:-}"
+# Checked before any build. A baseline from before the split carries a single
+# test that seeds through HEAD's SDK, which a baseline server refuses at login
+# once HEAD moves the client protocol, so it cannot stand in for the seed.
+if ! grep -rqs "fn ${SEED_TEST}(" "${WORKTREE_DIR}/core/integration/tests"; then
+  echo "Baseline ${BASELINE_SHA} has no ${SEED_TEST}."
+  echo "It predates the seed and verify split of the storage compatibility test, so the data"
+  echo "directory cannot be seeded from the baseline's own source. Its single test seeds"
+  echo "through HEAD's SDK and fails whenever HEAD changes the client protocol."
+  echo "Add the breaking:protocol label to the pull request to skip this check once."
+  exit 1
+fi
+
+# Inside the worktree so the cleanup trap reclaims it with the worktree. The
+# copied binary and the seeded directory are all that outlive the run.
+BASELINE_TARGET_DIR="${WORKTREE_DIR}/target"
+
+rm -rf "${SEED_DATA}" "${SEED_RECORD}"
+
+# Every baseline cargo call runs from the worktree as CWD so the baseline's own
+# rust-toolchain.toml applies, and into a target directory of the worktree's own.
+#
+# The two trees MUST NOT share one. Cargo keys a workspace member's unit hash
+# on its manifest path RELATIVE to the workspace root, and records that
+# member's sources in the dep-info relative too, so `core/configs` in the
+# worktree and `core/configs` here hash identically and both resolve against
+# whichever root cargo is invoked from. Sharing a target directory therefore
+# makes the second build read the first build's rlibs as fresh: HEAD's
+# `server` would compile against MASTER's `configs`, `consensus`,
+# `partitions` and `shard`. Any PR touching a crate below `server` fails to
+# build here with errors that do not reproduce anywhere else. The duplicated
+# dependency compile is the price of the two halves being what they claim.
+#
+# Debug profile on both sides, and no --all-features: release would compile
+# debug_assert! out of the baseline while HEAD still panics on it, and
+# --all-features turns on the server's `disable-mimalloc`, so the two halves
+# would differ in ways the storage format never changed.
+(
+  cd "${WORKTREE_DIR}"
+  # The reference revision must not contribute counters to HEAD's coverage report.
+  if [[ "${__CARGO_LLVM_COV_RUSTC_WRAPPER:-}" == 1 ]]; then
+    export RUSTC_WRAPPER="${__CARGO_LLVM_COV_RUSTC_WRAPPER_PRE_EXISTING:-}"
+  fi
+  export CARGO_TARGET_DIR="${BASELINE_TARGET_DIR}"
+
+  if [ -x "${BASELINE_SERVER}" ]; then
+    echo "Reusing baseline server at ${BASELINE_SERVER}"
+  else
+    echo "Building baseline iggy-server from ${BASELINE_SHA}..."
+    cargo build --locked -p server --bin iggy-server
+
+    BASELINE_BUILT="${BASELINE_TARGET_DIR}/debug/iggy-server"
+    if [ ! -x "${BASELINE_BUILT}" ]; then
+      echo "Baseline build did not produce ${BASELINE_BUILT}"
+      exit 1
     fi
-    CARGO_TARGET_DIR="${BASELINE_TARGET_DIR}" cargo build --locked -p server --bin iggy-server
-  )
 
-  BASELINE_BUILT="${BASELINE_TARGET_DIR}/debug/iggy-server"
-  if [ ! -x "${BASELINE_BUILT}" ]; then
-    echo "Baseline build did not produce ${BASELINE_BUILT}"
-    exit 1
+    mkdir -p "$(dirname "${BASELINE_SERVER}")"
+    cp "${BASELINE_BUILT}" "${BASELINE_SERVER}"
   fi
 
-  mkdir -p "$(dirname "${BASELINE_SERVER}")"
-  cp "${BASELINE_BUILT}" "${BASELINE_SERVER}"
-  # Now, not at cleanup: a second full debug dependency graph is several GB, and
-  # the HEAD build plus the integration test still have to fit on the runner.
-  rm -rf "${BASELINE_TARGET_DIR}"
-fi
+  echo "Running the seed test of ${BASELINE_SHA}..."
+  # Absolute path: ServerHandle only treats this value as a literal path when it
+  # has more than one component, otherwise it falls back to a cargo_bin lookup.
+  COMPAT_BASELINE_SERVER="${BASELINE_SERVER}" \
+    COMPAT_BASELINE_CONFIG="${BASELINE_CONFIG}" \
+    COMPAT_BASELINE_DATA="${SEED_DATA}" \
+    cargo nextest run "${NEXTEST_ARGS[@]}" -E "test(${TEST_MODULE}::${SEED_TEST})"
+)
+
+# Now, not at cleanup: a second full debug dependency graph is several GB, and
+# the HEAD build plus the verify test still have to fit on the runner.
+rm -rf "${BASELINE_TARGET_DIR}"
 
 if [[ -n "$RUNTIME_ARTIFACTS" ]]; then
   ./scripts/ci/restore-runtime.sh "$RUNTIME_ARTIFACTS"
@@ -283,28 +347,9 @@
   exit 1
 fi
 
-# Absolute path: ServerHandle only treats this value as a literal path when it
-# has more than one component, otherwise it falls back to a cargo_bin lookup.
-export COMPAT_BASELINE_SERVER="${BASELINE_SERVER}"
-export COMPAT_BASELINE_CONFIG="${BASELINE_CONFIG}"
-
-echo "Running storage compatibility test..."
-# --run-ignored only: the test is #[ignore]d, so the normal lanes skip it.
-# --no-tests=fail: a filter matching nothing has to be an error here, unlike the
-# main lane's --no-tests=warn, which would report a typo'd filter as green.
-# --retries 0: a read-back that only fails sometimes is exactly the signal this
-# check exists for, so no retry may hide it. Explicit rather than "not
-# --profile ci": a profile default or NEXTEST_RETRIES in the environment would
-# otherwise apply.
-# --ignore-default-filter: nextest intersects -E with the profile's
-# default-filter, so one that excluded this module would turn the run into a
-# --no-tests=fail abort instead of running the test.
-# The version floor for these flags lives in .config/nextest.toml.
-cargo nextest run --locked -p integration \
-  --run-ignored only \
-  --no-tests=fail \
-  --retries 0 \
-  --ignore-default-filter \
-  -E "test(${TEST_FILTER})"
+echo "Running the verify test of ${HEAD_SHA}..."
+COMPAT_BASELINE_CONFIG="${BASELINE_CONFIG}" \
+  COMPAT_BASELINE_DATA="${SEED_DATA}" \
+  cargo nextest run "${NEXTEST_ARGS[@]}" -E "test(${TEST_MODULE}::${VERIFY_TEST})"
 
 echo "Storage format compatible: ${BASELINE_SHA} -> ${HEAD_SHA}"
diff --git a/scripts/run-bdd-tests.sh b/scripts/run-bdd-tests.sh
index 7ceb993..f0d8acb 100755
--- a/scripts/run-bdd-tests.sh
+++ b/scripts/run-bdd-tests.sh
@@ -35,8 +35,8 @@
 usage(){
   log "Usage: $0 [--coverage] <sdk> [feature]"
   log ""
-  log "  sdk:     rust | python | php | go | go-race | node | csharp | java | cpp | all | clean (default: all)"
-  log "  feature: basic_messaging | leader_redirection | raw_command | session_contract | stream_crud | stream_options | resource_options_patch | metadata_changes | all  (default: all)"
+  log "  sdk:     rust | python | php | go | go-race | node | csharp | java | cpp | swift | all | clean (default: all)"
+  log "  feature: basic_messaging | leader_redirection | raw_command | session_contract | stream_crud | stream_options | resource_options_patch | metadata_changes | deferred_polling | all  (default: all)"
   log ""
   log "  Every suite runs against iggy-server, taken from IGGY_SERVER_PATH"
   log "  (default: target/debug/iggy-server) with an iggy CLI at IGGY_CLI_PATH."
@@ -49,7 +49,7 @@
 }
 
 case "$FEATURE" in
-  basic_messaging|leader_redirection|raw_command|session_contract|stream_crud|stream_options|resource_options_patch|metadata_changes|all) ;;
+  basic_messaging|leader_redirection|raw_command|session_contract|stream_crud|stream_options|resource_options_patch|metadata_changes|deferred_polling|all) ;;
   *)
     log "Unknown feature: ${FEATURE}"
     usage
@@ -70,7 +70,7 @@
 
 COMPOSE_FILES=(-f docker-compose.yml)
 case "$FEATURE" in
-  basic_messaging|leader_redirection|raw_command|session_contract|stream_crud|stream_options|resource_options_patch|metadata_changes|all)
+  basic_messaging|leader_redirection|raw_command|session_contract|stream_crud|stream_options|resource_options_patch|metadata_changes|deferred_polling|all)
     COMPOSE_FILES+=(-f docker-compose.server.yml) ;;
 esac
 case "$FEATURE" in
@@ -131,6 +131,20 @@
     esac
   fi
 
+  if [ "$FEATURE" = "deferred_polling" ]; then
+    case "$svc" in
+      rust-bdd|python-bdd|php-bdd|cpp-bdd|swift-bdd|go-bdd|node-bdd|csharp-bdd|java-bdd) ;;
+      *)
+        unsupported "$FEATURE" "$svc" || return 1
+        return 0 ;;
+    esac
+  fi
+
+  if [ "$svc" = "swift-bdd" ] && [ "$FEATURE" != "deferred_polling" ] && [ "$FEATURE" != "all" ]; then
+    unsupported "$FEATURE" "$svc" || return 1
+    return 0
+  fi
+
   if [ "$FEATURE" = "resource_options_patch" ] && [ "$svc" = "php-bdd" ]; then
     unsupported "$FEATURE" "$svc" || return 1
     return 0
@@ -162,6 +176,7 @@
   csharp)   run_suite csharp-bdd "🔷"   "Running C# BDD tests"     ;;
   java)     run_suite java-bdd   "☕"   "Running Java BDD tests"   ;;
   cpp)      run_suite cpp-bdd    "⚙️"   "Running C++ BDD tests"    ;;
+  swift)    run_suite swift-bdd  "🐦"   "Running Swift BDD tests"  ;;
   all)
     run_suite rust-bdd   "🦀"   "Running Rust BDD tests"                       || exit $?
     run_suite python-bdd "🐍"   "Running Python BDD tests"                     || exit $?
@@ -173,6 +188,7 @@
     run_suite csharp-bdd "🔷"   "Running C# BDD tests"                         || exit $?
     run_suite java-bdd   "☕"   "Running Java BDD tests"                       || exit $?
     run_suite cpp-bdd    "⚙️"   "Running C++ BDD tests"                        || exit $?
+    run_suite swift-bdd  "🐦"   "Running Swift BDD tests"                      || exit $?
     ;;
   clean)
     cleanup