You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@iggy.apache.org by hg...@apache.org on 2026/10/14 13:23:12 UTC

(iggy) branch master updated: feat(server)!: route partition requests to their primaries (#4506)

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

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


The following commit(s) were added to refs/heads/master by this push:
     new c5152a500 feat(server)!: route partition requests to their primaries (#4506)
c5152a500 is described below

commit c5152a500e9611ba9e481ccbd3b8bb42f0efb31f
Author: Hubert Gruszecki <h....@gmail.com>
AuthorDate: Wed Oct 14 15:23:06 2026 +0200

    feat(server)!: route partition requests to their primaries (#4506)
---
 bdd/go/tests/tcp_test/messages_feature_send.go     |     4 +-
 bdd/go/tests/tcp_test/offset_feature_delete.go     |     2 +-
 bdd/rust/tests/steps/leader_redirection.rs         |     3 +-
 core/bench/src/analytics/report_builder.rs         |    13 +-
 core/binary_protocol/src/codes.rs                  |     6 +-
 core/binary_protocol/src/consensus/command.rs      |    11 +-
 core/binary_protocol/src/consensus/header.rs       |   426 +-
 core/binary_protocol/src/consensus/mod.rs          |     7 +-
 core/binary_protocol/src/consensus/request.rs      |    19 +-
 core/binary_protocol/src/dispatch.rs               |     4 +-
 core/binary_protocol/src/lib.rs                    |    20 +-
 core/binary_protocol/src/primitives/identifier.rs  |     3 +
 .../src/primitives/partition_history.rs            |     7 +-
 ...{get_send_context.rs => get_partition_route.rs} |    22 +-
 core/binary_protocol/src/requests/messages/mod.rs  |     4 +-
 .../partitions/retire_consumer_group_owners.rs     |     8 +-
 core/binary_protocol/src/responses/messages/mod.rs |     2 +
 .../src/responses/messages/partition_route.rs      |   157 +
 .../src/responses/messages/poll_routing.rs         |     1 +
 .../src/responses/system/get_cluster_metadata.rs   |    45 +-
 .../binary_cluster/get_cluster_metadata.rs         |     7 +-
 core/common/src/error/iggy_error.rs                |    81 +-
 core/common/src/lib.rs                             |     4 +-
 core/common/src/traits/binary_impls/messages.rs    |    19 +-
 core/common/src/traits/binary_impls/mod.rs         |    58 +-
 .../traits/binary_impls/personal_access_tokens.rs  |    39 +-
 core/common/src/traits/binary_impls/users.rs       |    39 +-
 core/common/src/traits/binary_transport.rs         |    71 +-
 core/common/src/traits/client.rs                   |    28 +
 core/common/src/traits/mod.rs                      |     2 +-
 core/common/src/types/cluster/node.rs              |     8 +-
 core/common/src/wire_conversions.rs                |    20 +
 core/configs/src/server_config/cluster.rs          |    11 +-
 core/consensus/src/client_table.rs                 |    15 +-
 core/consensus/src/impls.rs                        |   232 +-
 core/consensus/src/lib.rs                          |     7 +
 core/consensus/src/plane_helpers.rs                |   285 +-
 .../cli/system/test_cluster_metadata_command.rs    |     4 +-
 .../tests/cluster/client_table_adversarial.rs      |     1 +
 .../tests/cluster/client_table_restart.rs          |     1 +
 core/integration/tests/cluster/partition_dedup.rs  |     1 +
 .../tests/cluster/partition_primary_routing.rs     |   570 +-
 .../tests/cluster/partition_state_transfer.rs      |   100 +-
 .../verify_consumer_group_partition_assignment.rs  |    89 +-
 .../tests/sdk/consumer_group_membership.rs         |    15 +-
 core/integration/tests/server/http_client.rs       |    17 +
 .../tests/server/http_read_your_writes.rs          |    19 +-
 core/integration/tests/server/http_vsr.rs          |    82 +-
 core/integration/tests/server/mod.rs               |     3 +
 .../tests/server/pipelined_frames_vsr.rs           |   303 +
 core/integration/tests/server/raw_tcp.rs           |     7 +
 .../server/scenarios/authentication_scenario.rs    |     6 +-
 core/journal/src/lib.rs                            |    13 +-
 core/journal/src/prepare_journal.rs                |    75 +-
 core/message_bus/src/config.rs                     |    21 +-
 core/message_bus/src/lib.rs                        |    27 +
 core/message_bus/src/transports/mod.rs             |     8 +-
 core/message_bus/src/transports/quic.rs            |   860 +-
 core/metadata/src/impls/metadata.rs                |   293 +-
 core/metadata/src/stm/consumer_group.rs            |   346 +-
 core/metadata/src/stm/lifecycle.rs                 |   389 +-
 core/metadata/src/stm/mod.rs                       |    80 +-
 core/metadata/src/stm/result.rs                    |   153 +-
 core/metadata/src/stm/snapshot.rs                  |    28 +-
 core/metadata/src/stm/stream.rs                    |   141 +-
 core/partitions/src/consumer_offset_capacity.rs    |    14 +
 core/partitions/src/iggy_partition.rs              | 11654 ++++++++++++-------
 core/partitions/src/io.rs                          |   135 +-
 core/partitions/src/journal.rs                     |    63 +-
 core/partitions/src/lib.rs                         |     5 +-
 core/partitions/src/log.rs                         |     7 +-
 core/partitions/src/offset_recovery.rs             |    62 +-
 core/partitions/src/offset_storage.rs              |    31 +
 core/partitions/src/partition_storage.rs           |   104 +-
 core/partitions/src/segment_recovery.rs            |     3 +-
 core/partitions/src/state_transfer.rs              |   162 +-
 core/partitions/src/types.rs                       |    10 +-
 core/sdk/src/client_wrappers/binary_client.rs      |    21 +
 core/sdk/src/clients/client.rs                     |   110 +-
 core/sdk/src/clients/consumer.rs                   |     1 +
 core/sdk/src/clients/producer.rs                   |    10 +-
 core/sdk/src/driver.rs                             |  1325 +++
 .../src/{poll_routing.rs => driver/poll_router.rs} |   185 +-
 core/sdk/src/driver/route_cache.rs                 |   381 +
 core/sdk/src/leader_aware.rs                       |   611 +-
 core/sdk/src/lib.rs                                |     4 +-
 core/sdk/src/{leader_aware.rs => links/connect.rs} |   524 +-
 core/sdk/src/links/dial.rs                         |   324 +
 core/sdk/src/links/fake_cluster.rs                 |   442 +
 core/sdk/src/links/mod.rs                          |   448 +
 core/sdk/src/links/mux.rs                          |   815 ++
 core/sdk/src/links/quic.rs                         |   335 +
 core/sdk/src/links/replicas.rs                     |  1571 +++
 core/sdk/src/quic/quic_client.rs                   |   640 +-
 core/sdk/src/session.rs                            |    16 +-
 core/sdk/src/session_owner.rs                      |   601 +
 core/sdk/src/session_owner/lanes.rs                |   606 +
 core/sdk/src/tcp/mod.rs                            |     4 -
 core/sdk/src/tcp/tcp_client.rs                     |  1335 +--
 core/sdk/src/tcp/tcp_connection_stream.rs          |   108 -
 core/sdk/src/tcp/tcp_connection_stream_kind.rs     |    66 -
 core/sdk/src/tcp/tcp_stream.rs                     |    27 -
 core/sdk/src/tcp/tcp_tls_connection_stream.rs      |   105 -
 core/sdk/src/vsr.rs                                |   396 +-
 core/sdk/src/websocket/mod.rs                      |     4 -
 core/sdk/src/websocket/websocket_client.rs         |   760 +-
 .../src/websocket/websocket_connection_stream.rs   |   212 -
 core/sdk/src/websocket/websocket_stream.rs         |    36 -
 core/sdk/src/websocket/websocket_stream_kind.rs    |    58 -
 .../websocket/websocket_tls_connection_stream.rs   |   217 -
 core/server/README.md                              |    22 +-
 core/server/config.toml                            |    33 +-
 core/server/src/boot/mod.rs                        |    61 +-
 core/server/src/boot/topology.rs                   |    12 +-
 core/server/src/cluster_meta.rs                    |    39 +
 core/server/src/consumer_group.rs                  |     9 +-
 core/server/src/consumer_group/liveness.rs         |    10 +-
 core/server/src/dispatch/authz.rs                  |    10 +-
 core/server/src/dispatch/failure.rs                |    73 +-
 core/server/src/dispatch/host.rs                   |    40 +-
 core/server/src/dispatch/mod.rs                    |  1482 ++-
 core/server/src/dispatch/partition.rs              |   866 +-
 core/server/src/dispatch/reads.rs                  |    93 +-
 core/server/src/dispatch/submit.rs                 |  1386 ++-
 core/server/src/dispatch/test_support.rs           |    91 +-
 core/server/src/http.rs                            |    38 +-
 core/server/src/http/error.rs                      |    17 +-
 core/server/src/http/forward.rs                    |   965 +-
 core/server/src/http/handlers.rs                   |   268 +-
 core/server/src/http/reply.rs                      |     8 +
 core/server/src/http/state.rs                      |     7 +
 core/server/src/http/submit.rs                     |    98 +-
 core/server/src/lib.rs                             |     8 +-
 core/server/src/namespace.rs                       |    34 +-
 core/server/src/partition_helpers.rs               |     5 +-
 core/server/src/partition_reconciler.rs            |   702 +-
 core/server/src/reply_frame.rs                     |    12 +-
 core/server/src/rewrite.rs                         |    42 +-
 core/server_common/src/consensus_message.rs        |    16 +-
 core/shard/src/coordinator.rs                      |     5 +
 core/shard/src/lib.rs                              |  1224 +-
 core/shard/src/partition_io.rs                     |  2694 ++++-
 core/shard/src/poll.rs                             |    87 +-
 core/shard/src/poll/completion_tests.rs            |   229 +-
 core/simulator/src/bin/workload-fuzz.rs            |    64 +-
 core/simulator/src/client.rs                       |    27 +-
 core/simulator/src/deps.rs                         |    21 +-
 core/simulator/src/lib.rs                          |  2468 +++-
 core/simulator/src/packet.rs                       |     3 +-
 core/simulator/src/replica.rs                      |     6 +-
 core/simulator/src/workload/actions.rs             |     4 +
 core/simulator/src/workload/auditor.rs             |    18 +-
 core/simulator/src/workload/invariants.rs          |    30 +-
 core/simulator/src/workload/mod.rs                 |  2481 +++-
 .../src/workload/ops/join_fixture_group.rs         |    96 +
 .../src/workload/ops/leave_fixture_group.rs        |    84 +
 core/simulator/src/workload/ops/mod.rs             |    12 +-
 .../src/workload/ops/replace_fixture_topic.rs      |   112 +
 .../src/workload/ops/store_stale_owner_offset.rs   |    84 +
 core/simulator/src/workload/options.rs             |    53 +-
 core/simulator/src/workload/oracle.rs              |   384 +-
 core/simulator/src/workload/shadow.rs              |   550 +-
 core/simulator/src/workload/state_checker.rs       |   568 +-
 foreign/cpp/src/lib.rs                             |     1 +
 foreign/cpp/src/type_conversion.rs                 |     1 +
 foreign/csharp/Iggy_SDK/Contracts/ClusterNode.cs   |     3 +
 .../csharp/Iggy_SDK/Contracts/PartitionRoute.cs    |    14 +-
 .../Exceptions/IggyInvalidStatusCodeException.cs   |     2 +
 .../TcpMessageStream.PollRouting.cs                |   423 +-
 .../Implementations/TcpMessageStream.Vsr.cs        |    51 +-
 .../IggyClient/Implementations/TcpMessageStream.cs |   101 +-
 foreign/csharp/Iggy_SDK/Mappers/BinaryMapper.cs    |    21 +-
 foreign/csharp/Iggy_SDK/Utils/CommandCodes.cs      |     2 +-
 foreign/csharp/Iggy_SDK/Vsr/VsrConnection.cs       |     8 +-
 foreign/csharp/Iggy_SDK/Vsr/VsrError.cs            |     1 +
 foreign/csharp/Iggy_SDK/Vsr/VsrHeader.cs           |     4 +-
 foreign/csharp/Iggy_SDK/Vsr/VsrOperation.cs        |     2 +-
 .../ConsumerTests/OffsetContinuationTests.cs       |     9 +-
 .../Iggy_SDK_Tests/MapperTests/BinaryMapper.cs     |    59 +-
 .../VsrTests/EndpointFailoverTests.cs              |    21 +-
 .../Iggy_SDK_Tests/VsrTests/GroupPollingTests.cs   |     1 +
 foreign/csharp/Iggy_SDK_Tests/VsrTests/MockNode.cs |    41 +-
 .../VsrTests/PartitionContextTests.cs              |    54 +-
 .../Iggy_SDK_Tests/VsrTests/PrimaryPollingTests.cs |   456 +-
 foreign/go/README.md                               |     2 +-
 foreign/go/client/tcp/cluster.go                   |    12 +
 foreign/go/client/tcp/tcp_core.go                  |    73 +-
 foreign/go/client/tcp/tcp_core_review_test.go      |     2 +-
 foreign/go/client/tcp/tcp_core_test.go             |    35 +-
 foreign/go/client/tcp/tcp_failover_test.go         |     4 +-
 foreign/go/client/tcp/tcp_messaging.go             |   174 +-
 foreign/go/client/tcp/tcp_offset_management.go     |    27 +-
 foreign/go/client/tcp/tcp_partition_management.go  |     3 +
 foreign/go/client/tcp/tcp_poll_routing.go          |   248 +-
 foreign/go/client/tcp/tcp_poll_routing_test.go     |   498 +-
 foreign/go/client/tcp/tcp_stream_management.go     |     1 +
 foreign/go/client/tcp/tcp_testing_test.go          |    19 +-
 foreign/go/client/tcp/tcp_topic_cache.go           |    44 +-
 foreign/go/client/tcp/tcp_topic_cache_test.go      |    71 +-
 foreign/go/contracts/cluster_metadata_test.go      |    56 +-
 foreign/go/contracts/cluster_node.go               |    10 +-
 foreign/go/contracts/partitions.go                 |    26 +
 foreign/go/errors/errors.yaml                      |     4 +
 foreign/go/errors/errors_gen.go                    |    15 +
 foreign/go/internal/command/code.go                |     2 +-
 foreign/go/internal/vsr/envelope.go                |     5 +
 foreign/go/internal/vsr/envelope_test.go           |     4 +-
 foreign/go/internal/vsr/session.go                 |    46 +-
 foreign/go/internal/vsr/session_test.go            |    40 +-
 foreign/go/tests/e2e_test.go                       |    29 +-
 .../iggy/client/async/tcp/AsyncIggyTcpClient.java  |    69 +-
 .../iggy/client/async/tcp/AsyncTcpConnection.java  |   137 +-
 .../iggy/client/async/tcp/ClientRoutingState.java  |    36 +-
 .../client/async/tcp/ConsumerOffsetsTcpClient.java |    49 +-
 .../iggy/client/async/tcp/MessagesTcpClient.java   |   146 +-
 .../iggy/client/async/tcp/PartitionContexts.java   |   191 -
 .../apache/iggy/client/async/tcp/PollRouter.java   |   429 +-
 .../client/async/tcp/RawPartitionRequests.java     |    12 +-
 .../client/async/tcp/vsr/ConsensusSession.java     |    21 +-
 .../iggy/client/async/tcp/vsr/VsrHeaders.java      |     2 +-
 .../client/async/tcp/vsr/VsrRequestEncoder.java    |    13 +-
 .../java/org/apache/iggy/cluster/ClusterNode.java  |    14 +-
 .../org/apache/iggy/exception/IggyErrorCode.java   |     1 +
 .../org/apache/iggy/partition/PartitionRoute.java  |    47 +
 .../org/apache/iggy/serde/BytesDeserializer.java   |     5 +-
 .../java/org/apache/iggy/serde/CommandCode.java    |     2 +-
 .../AsyncIggyTcpClientEndpointFailoverTest.java    |     1 +
 .../AsyncIggyTcpClientPartitionContextTest.java    |   141 +-
 .../tcp/AsyncIggyTcpClientRawRequestTest.java      |   192 +-
 .../AsyncIggyTcpClientSendContextCacheTest.java    |    99 +-
 .../AsyncIggyTcpClientTransientFailoverTest.java   |   128 +-
 .../tcp/AsyncTcpConnectionLifecycleBusyTest.java   |    52 +-
 .../client/async/tcp/ClientRoutingStateTest.java   |    40 -
 .../iggy/client/async/tcp/LeaderAwarenessTest.java |     2 +-
 .../iggy/client/async/tcp/PollRouterTest.java      |   102 +-
 .../async/tcp/vsr/VsrRequestEncoderTest.java       |    35 +-
 .../client/blocking/http/ObjectMapperTest.java     |    19 +
 .../apache/iggy/partition/PartitionRouteTest.java  |    65 +
 .../apache/iggy/serde/BytesDeserializerTest.java   |     3 +
 foreign/node/src/client/client.socket.test.ts      |   521 +-
 foreign/node/src/client/client.socket.ts           |   461 +-
 foreign/node/src/e2e/tcp.cluster.e2e.ts            |     1 +
 foreign/node/src/wire/cluster/cluster.utils.ts     |     6 +-
 foreign/node/src/wire/command.code.ts              |     2 +-
 foreign/node/src/wire/error.code.ts                |     1 +
 .../src/wire/message/send-messages.command.test.ts |    52 +-
 .../node/src/wire/message/send-messages.command.ts |    56 +-
 foreign/node/src/wire/offset/get-offset.command.ts |     2 +-
 foreign/node/src/wire/offset/offset.utils.ts       |    12 +-
 .../src/wire/partition/partition-route.test.ts     |    46 +
 foreign/node/src/wire/partition/partition-route.ts |    51 +
 foreign/node/src/wire/vsr/index.ts                 |     2 +-
 foreign/node/src/wire/vsr/session.ts               |     5 +-
 foreign/node/src/wire/vsr/vsr.test.ts              |     4 +-
 .../swift/Sources/Iggy/Errors/IggyErrorCode.swift  |     8 +
 foreign/swift/Sources/Iggy/Models/Resources.swift  |     4 +-
 .../swift/Sources/Iggy/Protocol/CommandCode.swift  |     4 +-
 .../swift/Sources/Iggy/Protocol/Responses.swift    |     3 +-
 foreign/swift/Tests/IggyTests/Fixtures/golden.json |     8 +-
 foreign/swift/Tests/IggyTests/ProtocolTests.swift  |     6 +-
 .../Tests/IggyTests/ResponseVectorTests.swift      |     2 +
 foreign/swift/Tools/golden-vectors/src/main.rs     |     2 +
 262 files changed, 40815 insertions(+), 12863 deletions(-)

diff --git a/bdd/go/tests/tcp_test/messages_feature_send.go b/bdd/go/tests/tcp_test/messages_feature_send.go
index 29dd0cd14..413ca879e 100644
--- a/bdd/go/tests/tcp_test/messages_feature_send.go
+++ b/bdd/go/tests/tcp_test/messages_feature_send.go
@@ -73,9 +73,7 @@ var _ = ginkgo.Describe("SEND MESSAGES:", func() {
 				iggcon.None(),
 				messages,
 			)
-			// Balanced partitioning resolves to an explicit partition first,
-			// and that topic lookup is what fails when neither exists.
-			itShouldReturnSpecificError(err, ierror.ErrTopicIdNotFound)
+			itShouldReturnSpecificError(err, ierror.ErrStreamIdNotFound)
 		})
 
 		ginkgo.Context("and tries to send messages to non existing partition", func() {
diff --git a/bdd/go/tests/tcp_test/offset_feature_delete.go b/bdd/go/tests/tcp_test/offset_feature_delete.go
index f51537560..76c844bcf 100644
--- a/bdd/go/tests/tcp_test/offset_feature_delete.go
+++ b/bdd/go/tests/tcp_test/offset_feature_delete.go
@@ -159,7 +159,7 @@ var _ = ginkgo.Describe("DELETE CONSUMER OFFSET:", func() {
 				randomU32Identifier(),
 				randomU32Identifier(),
 				&partitionId)
-			itShouldReturnSpecificError(err, ierror.ErrResourceNotFound)
+			itShouldReturnSpecificError(err, ierror.ErrStreamIdNotFound)
 		})
 	})
 
diff --git a/bdd/rust/tests/steps/leader_redirection.rs b/bdd/rust/tests/steps/leader_redirection.rs
index dd0f4f02c..dde838f91 100644
--- a/bdd/rust/tests/steps/leader_redirection.rs
+++ b/bdd/rust/tests/steps/leader_redirection.rs
@@ -34,7 +34,7 @@ async fn given_cluster_config(world: &mut LeaderContext, node_count: usize) {
 }
 
 #[given(regex = r"^node (\d+) is configured on port (\d+)$")]
-async fn given_node_configured(world: &mut LeaderContext, node_id: u32, port: u16) {
+async fn given_node_configured(world: &mut LeaderContext, node_id: u8, port: u16) {
     let (quic_port, http_port, ws_port) = match port {
         8091 => (8081, 3001, 8071),               // Leader ports
         8092 => (8082, 3002, 8072),               // Follower ports
@@ -42,6 +42,7 @@ async fn given_node_configured(world: &mut LeaderContext, node_id: u32, port: u1
     };
 
     let node = ClusterNode {
+        replica_id: node_id,
         name: format!("node-{}", node_id),
         ip: "iggy-server".to_string(),
         endpoints: TransportEndpoints::new(
diff --git a/core/bench/src/analytics/report_builder.rs b/core/bench/src/analytics/report_builder.rs
index 8e5bad92b..98127c275 100644
--- a/core/bench/src/analytics/report_builder.rs
+++ b/core/bench/src/analytics/report_builder.rs
@@ -257,8 +257,9 @@ mod tests {
     use super::*;
     use iggy::prelude::{ClusterNode, ClusterNodeRole, ClusterNodeStatus, TransportEndpoints};
 
-    fn metadata_node(name: &str, role: ClusterNodeRole) -> ClusterNode {
+    fn metadata_node(replica_id: u8, name: &str, role: ClusterNodeRole) -> ClusterNode {
         ClusterNode {
+            replica_id,
             name: name.to_string(),
             ip: "127.0.0.1".to_string(),
             endpoints: TransportEndpoints::new(0, 0, 0, 0),
@@ -284,7 +285,7 @@ mod tests {
     fn given_single_node_metadata_when_classified_should_return_none() {
         let metadata = ClusterMetadata {
             name: SINGLE_NODE_CLUSTER_NAME.to_string(),
-            nodes: vec![metadata_node("iggy-node", ClusterNodeRole::Leader)],
+            nodes: vec![metadata_node(0, "iggy-node", ClusterNodeRole::Leader)],
         };
 
         assert!(cluster_info_from_metadata(metadata).is_none());
@@ -295,9 +296,9 @@ mod tests {
         let metadata = ClusterMetadata {
             name: "vsr".to_string(),
             nodes: vec![
-                metadata_node("node-1", ClusterNodeRole::Leader),
-                metadata_node("node-2", ClusterNodeRole::Follower),
-                metadata_node("node-3", ClusterNodeRole::Follower),
+                metadata_node(0, "node-1", ClusterNodeRole::Leader),
+                metadata_node(1, "node-2", ClusterNodeRole::Follower),
+                metadata_node(2, "node-3", ClusterNodeRole::Follower),
             ],
         };
 
@@ -317,7 +318,7 @@ mod tests {
     fn given_single_node_with_custom_name_when_classified_should_return_some() {
         let metadata = ClusterMetadata {
             name: "vsr".to_string(),
-            nodes: vec![metadata_node("node-1", ClusterNodeRole::Leader)],
+            nodes: vec![metadata_node(0, "node-1", ClusterNodeRole::Leader)],
         };
 
         let info = cluster_info_from_metadata(metadata).expect("named roster counts as cluster");
diff --git a/core/binary_protocol/src/codes.rs b/core/binary_protocol/src/codes.rs
index a4d125062..1f136fb33 100644
--- a/core/binary_protocol/src/codes.rs
+++ b/core/binary_protocol/src/codes.rs
@@ -57,7 +57,9 @@ pub const SEND_MESSAGES_CODE: u32 = 101;
 // 102 was FLUSH_UNSAVED_BUFFER. Never reuse it: older clients still send it.
 pub const GET_POLL_ROUTING_CODE: u32 = 103;
 pub const POLL_MESSAGES_ON_PRIMARY_CODE: u32 = 104;
-pub const GET_SEND_CONTEXT_CODE: u32 = 105;
+/// The route of one partition: its incarnation, the group view and the primary
+/// of that view. Sends, polls and consumer offsets all route with it.
+pub const GET_PARTITION_ROUTE_CODE: u32 = 105;
 
 // -- Consumer Offsets --
 pub const GET_CONSUMER_OFFSET_CODE: u32 = 120;
@@ -144,7 +146,7 @@ mod tests {
         POLL_MESSAGES_CODE,
         GET_POLL_ROUTING_CODE,
         POLL_MESSAGES_ON_PRIMARY_CODE,
-        GET_SEND_CONTEXT_CODE,
+        GET_PARTITION_ROUTE_CODE,
         SEND_MESSAGES_CODE,
         GET_CONSUMER_OFFSET_CODE,
         STORE_CONSUMER_OFFSET_CODE,
diff --git a/core/binary_protocol/src/consensus/command.rs b/core/binary_protocol/src/consensus/command.rs
index 28811ffd3..49ee96ff0 100644
--- a/core/binary_protocol/src/consensus/command.rs
+++ b/core/binary_protocol/src/consensus/command.rs
@@ -87,6 +87,11 @@ pub enum Command {
     // Backups report transport-bound sessions to the metadata primary. No reply.
     ConsumerSessionHeartbeat = 30,
     SessionRetirementProgress = 31,
+
+    // A backup relays a partition completion to the metadata primary, which
+    // may hold no replica of that partition. No reply: the commit replicates
+    // back.
+    ForwardCompletion = 32,
 }
 
 // SAFETY: Command is #[repr(u8)] with no padding bytes.
@@ -97,7 +102,7 @@ unsafe impl CheckedBitPattern for Command {
     type Bits = u8;
 
     fn is_valid_bit_pattern(bits: &u8) -> bool {
-        *bits <= Self::SessionRetirementProgress as u8
+        *bits <= Self::ForwardCompletion as u8
     }
 }
 
@@ -118,7 +123,7 @@ mod tests {
 
     #[test]
     fn replica_auth_commands_are_valid_bit_patterns() {
-        for command in 14u8..=31 {
+        for command in 14u8..=32 {
             let mut buf: AVec<u8, ConstAlign<16>> = AVec::new(16);
             buf.resize(256, 0);
             buf[60] = command;
@@ -126,7 +131,7 @@ mod tests {
         }
         let mut buf: AVec<u8, ConstAlign<16>> = AVec::new(16);
         buf.resize(256, 0);
-        buf[60] = 32;
+        buf[60] = 33;
         assert!(bytemuck::checked::try_from_bytes::<GenericHeader>(&buf).is_err());
     }
 }
diff --git a/core/binary_protocol/src/consensus/header.rs b/core/binary_protocol/src/consensus/header.rs
index 91b5318f2..afccb0cba 100644
--- a/core/binary_protocol/src/consensus/header.rs
+++ b/core/binary_protocol/src/consensus/header.rs
@@ -44,11 +44,20 @@
 //! Older replicas cannot report live sessions to a new primary, which would
 //! expire their consumer-group memberships after the configured timeout.
 //!
-//! `Prepare`, `Request`, `Reply`, and `Eviction` are unaffected. Prepares keep
-//! `checksum` as their view-independent identity, and the three client-facing
-//! headers are sealed on neither side, so SDKs are untouched.
+//! Session retirement progress (31), forwarding completion (32), and the
+//! partition incarnation fields in control headers also require matching
+//! replicas. The incarnation occupies bytes older builds treated as reserved;
+//! a mixed cluster cannot safely identify the partition history of a frame.
+//!
+//! Frame sealing does not change `Prepare`, `Request`, `Reply`, or `Eviction`.
+//! Prepares keep `checksum` as their view-independent identity, and the three
+//! client-facing headers are sealed on neither side. Separate client protocol
+//! changes, including partition routes and replica IDs in cluster metadata,
+//! require a coordinated SDK upgrade as well.
 
 use super::{Command, ConsensusError, Operation};
+use crate::WireIdentifier;
+use crate::requests::partitions::InstallConsumerGroupOwnerRequest;
 use bytemuck::{CheckedBitPattern, NoUninit};
 use std::mem::offset_of;
 
@@ -321,7 +330,18 @@ pub struct RequestHeader {
     /// Little-endian command code in `reserved[0..4]` for `NonReplicated`.
     pub reserved: [u8; 4],
     /// Captured with the request and retained across retries and route refreshes.
+    /// A poll reply carries its served context in the body
+    /// (`PollMessagesResponseHeader::context`). Clients set the three context
+    /// fields with [`PartitionContext::stamp`](crate::PartitionContext::stamp),
+    /// and [`PartitionContext`](crate::PartitionContext) documents each value.
     pub partition_incarnation: u64,
+    /// Consumer-group owner generation captured with the request. Zero for a
+    /// request without a group: a plain-consumer poll or offset write that
+    /// carries a nonzero value is refused with `ConsumerGroupPartitionNotOwned`.
+    /// On a group poll or group offset write, zero means that no owner was
+    /// captured: the server binds the request to the caller's installed owner,
+    /// or refuses it, and a poll reply names the bound owner in its body
+    /// context.
     pub owner_generation: u64,
     /// Minimum committed metadata operation needed to interpret this request.
     pub minimum_metadata_op: u64,
@@ -640,6 +660,9 @@ pub struct ReplyHeader {
     /// no existing field offset moves and `validate` does not inspect it.
     pub status: u32,
     pub reserved: [u8; 4],
+    /// Echoes the request, and the plain poll reply overwrites it with the
+    /// served incarnation. Clients read the served context from the poll
+    /// reply body (`PollMessagesResponseHeader::context`).
     pub partition_incarnation: u64,
     pub reserved_tail: [u8; 24],
 }
@@ -1399,7 +1422,8 @@ pub struct CommitHeader {
     pub commit: u64,
     pub checkpoint_op: u64,
     pub group: u64,
-    pub reserved: [u8; 80],
+    pub partition_incarnation: u64,
+    pub reserved: [u8; 72],
 }
 const _: () = {
     assert!(size_of::<CommitHeader>() == HEADER_SIZE);
@@ -1407,7 +1431,8 @@ const _: () = {
         offset_of!(CommitHeader, commit_checksum)
             == offset_of!(CommitHeader, reserved_frame) + size_of::<[u8; 66]>()
     );
-    assert!(offset_of!(CommitHeader, reserved) + size_of::<[u8; 80]>() == HEADER_SIZE);
+    assert!(offset_of!(CommitHeader, reserved) + size_of::<[u8; 72]>() == HEADER_SIZE);
+    assert!(offset_of!(CommitHeader, partition_incarnation) == 176);
 };
 
 impl ConsensusHeader for CommitHeader {
@@ -1460,7 +1485,8 @@ pub struct StartViewChangeHeader {
     pub reserved_frame: [u8; 66],
 
     pub group: u64,
-    pub reserved: [u8; 120],
+    pub partition_incarnation: u64,
+    pub reserved: [u8; 112],
 }
 const _: () = {
     assert!(size_of::<StartViewChangeHeader>() == HEADER_SIZE);
@@ -1468,7 +1494,8 @@ const _: () = {
         offset_of!(StartViewChangeHeader, group)
             == offset_of!(StartViewChangeHeader, reserved_frame) + size_of::<[u8; 66]>()
     );
-    assert!(offset_of!(StartViewChangeHeader, reserved) + size_of::<[u8; 120]>() == HEADER_SIZE);
+    assert!(offset_of!(StartViewChangeHeader, reserved) + size_of::<[u8; 112]>() == HEADER_SIZE);
+    assert!(offset_of!(StartViewChangeHeader, partition_incarnation) == 136);
 };
 
 impl ConsensusHeader for StartViewChangeHeader {
@@ -1530,7 +1557,8 @@ pub struct DoViewChangeHeader {
     pub group: u64,
     /// View when status was last normal (key for log selection).
     pub log_view: u32,
-    pub reserved: [u8; 68],
+    pub reserved: [u8; 60],
+    pub partition_incarnation: u64,
     /// Bit `i` set means the sender proves it never prepared suffix entry `i`, so
     /// that entry never reached a replication quorum through this replica. A new
     /// primary may truncate only once `quorum_nack_prepare` senders nack an entry;
@@ -1567,9 +1595,11 @@ const _: () = {
     assert!(offset_of!(DoViewChangeHeader, present_bitset) % 16 == 0);
     assert!(
         offset_of!(DoViewChangeHeader, nack_bitset)
-            == offset_of!(DoViewChangeHeader, reserved) + size_of::<[u8; 68]>()
+            == offset_of!(DoViewChangeHeader, partition_incarnation) + size_of::<u64>()
     );
     assert!(offset_of!(DoViewChangeHeader, present_bitset) + size_of::<u128>() == HEADER_SIZE);
+    assert!(offset_of!(DoViewChangeHeader, partition_incarnation) == 216);
+    assert!(offset_of!(DoViewChangeHeader, reserved) + size_of::<[u8; 60]>() == 216);
 };
 
 /// Suffix headers a `DoViewChange` may carry: one bit per entry in each of the two
@@ -1702,7 +1732,8 @@ pub struct StartViewHeader {
     /// max(commit) from all DVCs.
     pub commit: u64,
     pub group: u64,
-    pub reserved: [u8; 88],
+    pub partition_incarnation: u64,
+    pub reserved: [u8; 80],
     /// Sender's incarnation, echoed from the `RequestStartView` this answers so a
     /// recovering replica can prove the reply post-dates its restart (see
     /// `RequestStartViewHeader::incarnation`). `0` on an unsolicited `StartView`
@@ -1725,6 +1756,11 @@ const _: () = {
     // `incarnation` is last and 16-aligned, so the struct has no padding.
     assert!(offset_of!(StartViewHeader, incarnation) + size_of::<u128>() == HEADER_SIZE);
     assert!(offset_of!(StartViewHeader, incarnation) % 16 == 0);
+    assert!(offset_of!(StartViewHeader, partition_incarnation) == 152);
+    assert!(
+        offset_of!(StartViewHeader, reserved) + size_of::<[u8; 80]>()
+            == offset_of!(StartViewHeader, incarnation)
+    );
 };
 
 impl ConsensusHeader for StartViewHeader {
@@ -1810,7 +1846,8 @@ pub struct RequestStartViewHeader {
     pub reserved_frame: [u8; 66],
 
     pub group: u64,
-    pub reserved: [u8; 104],
+    pub partition_incarnation: u64,
+    pub reserved: [u8; 96],
     /// The requester's per-boot incarnation, echoed back in the answering
     /// `StartView` so a reply from a previous incarnation is detectable.
     ///
@@ -1830,6 +1867,11 @@ const _: () = {
     // `incarnation` is last and 16-aligned, so the struct has no padding.
     assert!(offset_of!(RequestStartViewHeader, incarnation) + size_of::<u128>() == HEADER_SIZE);
     assert!(offset_of!(RequestStartViewHeader, incarnation) % 16 == 0);
+    assert!(offset_of!(RequestStartViewHeader, partition_incarnation) == 136);
+    assert!(
+        offset_of!(RequestStartViewHeader, reserved) + size_of::<[u8; 96]>()
+            == offset_of!(RequestStartViewHeader, incarnation)
+    );
 };
 
 impl ConsensusHeader for RequestStartViewHeader {
@@ -1895,7 +1937,8 @@ pub struct RequestPreparesHeader {
     pub from_op: u64,
     pub to_op: u64,
     pub group: u64,
-    pub reserved: [u8; 88],
+    pub partition_incarnation: u64,
+    pub reserved: [u8; 80],
 }
 const _: () = {
     assert!(size_of::<RequestPreparesHeader>() == HEADER_SIZE);
@@ -1903,7 +1946,8 @@ const _: () = {
         offset_of!(RequestPreparesHeader, nonce)
             == offset_of!(RequestPreparesHeader, reserved_frame) + size_of::<[u8; 66]>()
     );
-    assert!(offset_of!(RequestPreparesHeader, reserved) + size_of::<[u8; 88]>() == HEADER_SIZE);
+    assert!(offset_of!(RequestPreparesHeader, reserved) + size_of::<[u8; 80]>() == HEADER_SIZE);
+    assert!(offset_of!(RequestPreparesHeader, partition_incarnation) == 168);
 };
 
 impl ConsensusHeader for RequestPreparesHeader {
@@ -1968,7 +2012,8 @@ pub struct RepairRangeReplyHeader {
     /// `RepairDone`: last op served. `RangeEvicted`: oldest retained op.
     pub op: u64,
     pub group: u64,
-    pub reserved: [u8; 96],
+    pub partition_incarnation: u64,
+    pub reserved: [u8; 88],
 }
 const _: () = {
     assert!(size_of::<RepairRangeReplyHeader>() == HEADER_SIZE);
@@ -1976,7 +2021,8 @@ const _: () = {
         offset_of!(RepairRangeReplyHeader, nonce)
             == offset_of!(RepairRangeReplyHeader, reserved_frame) + size_of::<[u8; 66]>()
     );
-    assert!(offset_of!(RepairRangeReplyHeader, reserved) + size_of::<[u8; 96]>() == HEADER_SIZE);
+    assert!(offset_of!(RepairRangeReplyHeader, reserved) + size_of::<[u8; 88]>() == HEADER_SIZE);
+    assert!(offset_of!(RepairRangeReplyHeader, partition_incarnation) == 160);
 };
 
 impl ConsensusHeader for RepairRangeReplyHeader {
@@ -2048,7 +2094,8 @@ pub struct RequestStateTransferHeader {
 
     pub nonce: u128,
     pub group: u64,
-    pub reserved: [u8; 104],
+    pub partition_incarnation: u64,
+    pub reserved: [u8; 96],
 }
 const _: () = {
     assert!(size_of::<RequestStateTransferHeader>() == HEADER_SIZE);
@@ -2057,8 +2104,9 @@ const _: () = {
             == offset_of!(RequestStateTransferHeader, reserved_frame) + size_of::<[u8; 66]>()
     );
     assert!(
-        offset_of!(RequestStateTransferHeader, reserved) + size_of::<[u8; 104]>() == HEADER_SIZE
+        offset_of!(RequestStateTransferHeader, reserved) + size_of::<[u8; 96]>() == HEADER_SIZE
     );
+    assert!(offset_of!(RequestStateTransferHeader, partition_incarnation) == 152);
 };
 
 impl ConsensusHeader for RequestStateTransferHeader {
@@ -2164,7 +2212,8 @@ pub struct StateTransferTargetHeader {
     /// and an empty log is trivially caught up) could hand a data-holding
     /// rejoiner an empty offer that unlinks its chain.
     pub commit_max: u64,
-    pub reserved: [u8; 80],
+    pub partition_incarnation: u64,
+    pub reserved: [u8; 72],
 }
 const _: () = {
     assert!(size_of::<StateTransferTargetHeader>() == HEADER_SIZE);
@@ -2179,7 +2228,8 @@ const _: () = {
     assert!(offset_of!(StateTransferTargetHeader, available) == 160);
     assert!(offset_of!(StateTransferTargetHeader, unavailable_transient) == 161);
     assert!(offset_of!(StateTransferTargetHeader, commit_max) == 168);
-    assert!(offset_of!(StateTransferTargetHeader, reserved) + size_of::<[u8; 80]>() == HEADER_SIZE);
+    assert!(offset_of!(StateTransferTargetHeader, reserved) + size_of::<[u8; 72]>() == HEADER_SIZE);
+    assert!(offset_of!(StateTransferTargetHeader, partition_incarnation) == 176);
 };
 
 impl ConsensusHeader for StateTransferTargetHeader {
@@ -2272,7 +2322,8 @@ pub struct RequestStateChunkHeader {
     /// Index into the offered state manifest. Range-checked by the serving
     /// handler against the cached offer (the header cannot know the count).
     pub artifact: u32,
-    pub reserved: [u8; 88],
+    pub partition_incarnation: u64,
+    pub reserved: [u8; 80],
 }
 const _: () = {
     assert!(size_of::<RequestStateChunkHeader>() == HEADER_SIZE);
@@ -2280,7 +2331,8 @@ const _: () = {
         offset_of!(RequestStateChunkHeader, nonce)
             == offset_of!(RequestStateChunkHeader, reserved_frame) + size_of::<[u8; 66]>()
     );
-    assert!(offset_of!(RequestStateChunkHeader, reserved) + size_of::<[u8; 88]>() == HEADER_SIZE);
+    assert!(offset_of!(RequestStateChunkHeader, reserved) + size_of::<[u8; 80]>() == HEADER_SIZE);
+    assert!(offset_of!(RequestStateChunkHeader, partition_incarnation) == 168);
 };
 
 impl ConsensusHeader for RequestStateChunkHeader {
@@ -2356,7 +2408,8 @@ pub struct StateChunkHeader {
     /// Index into the offered state manifest. Range-checked by the receiving
     /// handler against its accepted manifest.
     pub artifact: u32,
-    pub reserved: [u8; 92],
+    pub reserved: [u8; 84],
+    pub partition_incarnation: u64,
 }
 const _: () = {
     assert!(size_of::<StateChunkHeader>() == HEADER_SIZE);
@@ -2364,7 +2417,12 @@ const _: () = {
         offset_of!(StateChunkHeader, nonce)
             == offset_of!(StateChunkHeader, reserved_frame) + size_of::<[u8; 66]>()
     );
-    assert!(offset_of!(StateChunkHeader, reserved) + size_of::<[u8; 92]>() == HEADER_SIZE);
+    assert!(
+        offset_of!(StateChunkHeader, reserved) + size_of::<[u8; 84]>()
+            == offset_of!(StateChunkHeader, partition_incarnation)
+    );
+    assert!(offset_of!(StateChunkHeader, partition_incarnation) == 248);
+    assert!(offset_of!(StateChunkHeader, partition_incarnation) + size_of::<u64>() == HEADER_SIZE);
 };
 
 impl ConsensusHeader for StateChunkHeader {
@@ -2868,10 +2926,137 @@ fn validate_forward_frame(
     Ok(())
 }
 
+// ForwardCompletionHeader - backup shard 0 -> primary shard 0
+
+/// Upper bound on the targets of one `CompleteLifecycle` batch, which keeps
+/// one metadata prepare small. A reporter with more ready targets sends the
+/// rest in its next batch.
+pub const LIFECYCLE_COMPLETIONS_MAX: usize = 1024;
+/// `metadata_op`, `stream_id` and the completion count of a lifecycle batch.
+pub const LIFECYCLE_BATCH_HEADER_SIZE: usize = size_of::<u64>() + 2 * size_of::<u32>();
+/// `topic_id`, `partition_id` and `partition_op` of one lifecycle completion.
+pub const LIFECYCLE_COMPLETION_SIZE: usize = 2 * size_of::<u32>() + size_of::<u64>();
+
+/// A backup relays the body of a partition completion to the metadata primary,
+/// which proposes it as `operation`.
+///
+/// The partition replicas prove a completion, and the primary may hold none of
+/// them, so a backup that holds one relays its evidence. No result frame comes
+/// back: the backup sees the commit through replication.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, CheckedBitPattern, NoUninit)]
+#[repr(C)]
+pub struct ForwardCompletionHeader {
+    pub checksum: u128,
+    pub checksum_body: u128,
+    pub cluster: u128,
+    pub size: u32,
+    pub view: u32,
+    pub release: u32,
+    pub command: Command,
+    pub replica: u8,
+    pub reserved_frame: [u8; 66],
+
+    /// The shard that built a lifecycle batch. Zero for a revocation.
+    pub reporter: u16,
+    pub operation: Operation,
+    pub reserved: [u8; 125],
+}
+const _: () = {
+    assert!(size_of::<ForwardCompletionHeader>() == HEADER_SIZE);
+    assert!(
+        offset_of!(ForwardCompletionHeader, reporter)
+            == offset_of!(ForwardCompletionHeader, reserved_frame) + size_of::<[u8; 66]>()
+    );
+    assert!(offset_of!(ForwardCompletionHeader, operation) == 130);
+    assert!(offset_of!(ForwardCompletionHeader, reserved) + size_of::<[u8; 125]>() == HEADER_SIZE);
+};
+
+impl ForwardCompletionHeader {
+    /// The relay names the stream and topic by number, then carries the
+    /// partition id, the installation and the partition op.
+    const REVOCATION_BODY_SIZE: usize = 2 * WireIdentifier::NUMERIC_ENCODED_SIZE
+        + size_of::<u32>()
+        + InstallConsumerGroupOwnerRequest::ENCODED_SIZE
+        + size_of::<u64>();
+}
+
+impl ConsensusHeader for ForwardCompletionHeader {
+    const FRAME_SEALED: bool = true;
+    const COMMAND: Command = Command::ForwardCompletion;
+    const OPERATION_OFFSET: Option<usize> = Some(offset_of!(Self, operation));
+
+    fn checksum(&self) -> u128 {
+        self.checksum
+    }
+
+    fn set_checksum(&mut self, checksum: u128) {
+        self.checksum = checksum;
+    }
+
+    fn operation(&self) -> Operation {
+        self.operation
+    }
+
+    fn command(&self) -> Command {
+        self.command
+    }
+
+    fn size(&self) -> u32 {
+        self.size
+    }
+
+    fn validate(&self) -> Result<(), ConsensusError> {
+        if self.command != Command::ForwardCompletion {
+            return Err(ConsensusError::InvalidCommand {
+                expected: Command::ForwardCompletion,
+                found: self.command,
+            });
+        }
+        let body_size = (self.size as usize).saturating_sub(HEADER_SIZE);
+        let body_fits = match self.operation {
+            Operation::CompleteLifecycle => body_size
+                .checked_sub(LIFECYCLE_BATCH_HEADER_SIZE)
+                .is_some_and(|completions| {
+                    completions.is_multiple_of(LIFECYCLE_COMPLETION_SIZE)
+                        && (1..=LIFECYCLE_COMPLETIONS_MAX)
+                            .contains(&(completions / LIFECYCLE_COMPLETION_SIZE))
+                }),
+            Operation::CompleteConsumerGroupRevocation => body_size == Self::REVOCATION_BODY_SIZE,
+            _ => {
+                return Err(ConsensusError::InvalidField(
+                    "forward completion operation must be a partition completion".to_string(),
+                ));
+            }
+        };
+        if !body_fits {
+            return Err(ConsensusError::InvalidField(
+                "forward completion body must have the size of its operation".to_string(),
+            ));
+        }
+        if self.operation == Operation::CompleteConsumerGroupRevocation && self.reporter != 0 {
+            return Err(ConsensusError::InvalidField(
+                "a forward revocation names no reporter".to_string(),
+            ));
+        }
+        if self.release != 0 {
+            return Err(ConsensusError::InvalidField(
+                "forward completion release must be zero".to_string(),
+            ));
+        }
+        if self.reserved_frame != [0; 66] || self.reserved != [0; 125] {
+            return Err(ConsensusError::InvalidField(
+                "forward completion reserved bytes must be zero".to_string(),
+            ));
+        }
+        Ok(())
+    }
+}
+
 // Tests
 
 #[cfg(test)]
 mod tests {
+    use std::mem::offset_of;
 
     // The byte-level twin exists for frames whose operation byte does not
     // cast; if it ever disagrees with the typed method, a genuine
@@ -2900,13 +3085,14 @@ mod tests {
     use super::prepare_identity_checksum_bytes;
     use super::{
         Command, CommitHeader, ConsensusError, ConsensusHeader, DoViewChangeHeader, EvictionHeader,
-        EvictionReason, ForwardLogoutHeader, ForwardLogoutOutcome, ForwardLogoutResultHeader,
-        ForwardRegisterHeader, ForwardRegisterOutcome, ForwardRegisterResultHeader, GenericHeader,
-        HEADER_SIZE, Operation, PrepareHeader, PrepareOkHeader, RepairPrepareHeader,
-        RepairRangeReplyHeader, ReplyHeader, RequestHeader, RequestPreparesHeader,
-        RequestStartViewHeader, RequestStateChunkHeader, RequestStateTransferHeader,
-        RoutedRequestHeader, StartViewChangeHeader, StartViewHeader, StateChunkHeader,
-        StateTransferTargetHeader,
+        EvictionReason, ForwardCompletionHeader, ForwardLogoutHeader, ForwardLogoutOutcome,
+        ForwardLogoutResultHeader, ForwardRegisterHeader, ForwardRegisterOutcome,
+        ForwardRegisterResultHeader, GenericHeader, HEADER_SIZE, LIFECYCLE_BATCH_HEADER_SIZE,
+        LIFECYCLE_COMPLETION_SIZE, LIFECYCLE_COMPLETIONS_MAX, Operation, PrepareHeader,
+        PrepareOkHeader, RepairPrepareHeader, RepairRangeReplyHeader, ReplyHeader, RequestHeader,
+        RequestPreparesHeader, RequestStartViewHeader, RequestStateChunkHeader,
+        RequestStateTransferHeader, RoutedRequestHeader, StartViewChangeHeader, StartViewHeader,
+        StateChunkHeader, StateTransferTargetHeader,
     };
     use aligned_vec::{AVec, ConstAlign};
 
@@ -2982,6 +3168,7 @@ mod tests {
             ForwardRegisterResultHeader,
             ForwardLogoutHeader,
             ForwardLogoutResultHeader,
+            ForwardCompletionHeader,
         );
     }
 
@@ -3009,6 +3196,43 @@ mod tests {
         }
     }
 
+    #[test]
+    fn given_a_sealed_partition_control_frame_when_its_incarnation_changes_should_reject() {
+        const INCARNATION: u64 = 0x0123_4567_89AB_CDEF;
+        macro_rules! check_incarnation {
+            ($($header:ty),+ $(,)?) => {$({
+                let mut header = control_header::<$header>();
+                assert_eq!(header.partition_incarnation, 0);
+                header.partition_incarnation = INCARNATION;
+                let offset = offset_of!($header, partition_incarnation);
+                assert_eq!(
+                    &bytemuck::bytes_of(&header)[offset..offset + size_of::<u64>()],
+                    &INCARNATION.to_le_bytes(),
+                );
+                for byte in offset..offset + size_of::<u64>() {
+                    assert!(
+                        matches!(tamper(header, byte), Err(ConsensusError::FrameChecksumMismatch { .. })),
+                        "{} incarnation byte {byte} must be sealed",
+                        stringify!($header),
+                    );
+                }
+            })+};
+        }
+        check_incarnation!(
+            CommitHeader,
+            StartViewChangeHeader,
+            DoViewChangeHeader,
+            StartViewHeader,
+            RequestStartViewHeader,
+            RequestPreparesHeader,
+            RepairRangeReplyHeader,
+            RequestStateTransferHeader,
+            StateTransferTargetHeader,
+            RequestStateChunkHeader,
+            StateChunkHeader,
+        );
+    }
+
     #[test]
     fn given_an_unsealed_control_header_when_verifying_should_reject() {
         // No presence-keying: a zero checksum is a corrupt frame, not an old one.
@@ -3778,4 +4002,146 @@ mod tests {
         assert_eq!(ForwardLogoutOutcome::RequestTooOld as u8, 5);
         assert_eq!(ForwardLogoutOutcome::OperationMismatch as u8, 6);
     }
+
+    /// A revocation, or else a lifecycle batch of one completion.
+    fn forward_completion(operation: Operation) -> ForwardCompletionHeader {
+        let (body_size, reporter) = if operation == Operation::CompleteConsumerGroupRevocation {
+            (ForwardCompletionHeader::REVOCATION_BODY_SIZE, 0)
+        } else {
+            (LIFECYCLE_BATCH_HEADER_SIZE + LIFECYCLE_COMPLETION_SIZE, 5)
+        };
+        let mut header = ForwardCompletionHeader {
+            checksum: 0,
+            checksum_body: 0xB0D7,
+            cluster: 7,
+            size: u32::try_from(HEADER_SIZE + body_size).expect("the frame size fits u32"),
+            view: 3,
+            release: 0,
+            command: Command::ForwardCompletion,
+            replica: 2,
+            reserved_frame: [0; 66],
+            reporter,
+            operation,
+            reserved: [0; 125],
+        };
+        header.seal();
+        header
+    }
+
+    #[test]
+    fn given_sealed_forward_completion_when_parsed_should_keep_every_field() {
+        for operation in [
+            Operation::CompleteLifecycle,
+            Operation::CompleteConsumerGroupRevocation,
+        ] {
+            let header = forward_completion(operation);
+            let mut bytes = aligned_zeroed(HEADER_SIZE);
+            bytes.copy_from_slice(bytemuck::bytes_of(&header));
+            let generic = bytemuck::checked::try_from_bytes::<GenericHeader>(&bytes)
+                .expect("a forward completion is a valid generic header");
+            assert_eq!(generic.command, Command::ForwardCompletion);
+            let parsed = bytemuck::checked::try_from_bytes::<ForwardCompletionHeader>(&bytes)
+                .expect("a forward completion parses as its own header");
+            assert_eq!(parsed, &header);
+            assert_eq!(parsed.verify_frame(), Ok(()));
+            assert_eq!(parsed.validate(), Ok(()));
+            assert_eq!(parsed.operation(), operation);
+        }
+    }
+
+    #[test]
+    fn given_forward_completion_when_malformed_should_fail_validation() {
+        let mut header = forward_completion(Operation::CompleteLifecycle);
+        header.command = Command::ForwardLogout;
+        assert!(matches!(
+            header.validate(),
+            Err(ConsensusError::InvalidCommand { .. })
+        ));
+        for operation in [
+            Operation::Reserved,
+            Operation::RemoveConsumerGroupMember,
+            Operation::TruncatePartition,
+            Operation::DeleteTopic,
+            Operation::InstallConsumerGroupOwner,
+        ] {
+            assert!(
+                matches!(
+                    forward_completion(operation).validate(),
+                    Err(ConsensusError::InvalidField(_))
+                ),
+                "{operation:?} is not a partition completion"
+            );
+        }
+        header = forward_completion(Operation::CompleteLifecycle);
+        header.size = u32::try_from(HEADER_SIZE).expect("HEADER_SIZE fits u32");
+        assert!(matches!(
+            header.validate(),
+            Err(ConsensusError::InvalidField(_))
+        ));
+        header = forward_completion(Operation::CompleteLifecycle);
+        header.reserved[0] = 1;
+        assert!(matches!(
+            header.validate(),
+            Err(ConsensusError::InvalidField(_))
+        ));
+        let lifecycle_body =
+            |completions| LIFECYCLE_BATCH_HEADER_SIZE + completions * LIFECYCLE_COMPLETION_SIZE;
+        let revocation_body = ForwardCompletionHeader::REVOCATION_BODY_SIZE;
+        for (operation, body_size, fits) in [
+            (Operation::CompleteLifecycle, lifecycle_body(1), true),
+            (
+                Operation::CompleteLifecycle,
+                lifecycle_body(LIFECYCLE_COMPLETIONS_MAX),
+                true,
+            ),
+            (Operation::CompleteLifecycle, lifecycle_body(0), false),
+            (Operation::CompleteLifecycle, lifecycle_body(1) + 1, false),
+            (
+                Operation::CompleteLifecycle,
+                lifecycle_body(LIFECYCLE_COMPLETIONS_MAX + 1),
+                false,
+            ),
+            (
+                Operation::CompleteConsumerGroupRevocation,
+                revocation_body,
+                true,
+            ),
+            (
+                Operation::CompleteConsumerGroupRevocation,
+                revocation_body - 1,
+                false,
+            ),
+            (
+                Operation::CompleteConsumerGroupRevocation,
+                revocation_body + 1,
+                false,
+            ),
+        ] {
+            header = forward_completion(operation);
+            header.size = u32::try_from(HEADER_SIZE + body_size).expect("the frame size fits u32");
+            assert_eq!(
+                header.validate().is_ok(),
+                fits,
+                "{operation:?} with a body of {body_size} bytes"
+            );
+        }
+        header = forward_completion(Operation::CompleteConsumerGroupRevocation);
+        header.reporter = 5;
+        assert!(
+            matches!(header.validate(), Err(ConsensusError::InvalidField(_))),
+            "a forward revocation names no reporter"
+        );
+        header = forward_completion(Operation::CompleteLifecycle);
+        header.release = 1;
+        assert!(
+            matches!(header.validate(), Err(ConsensusError::InvalidField(_))),
+            "a forward completion carries no release"
+        );
+        header = forward_completion(Operation::CompleteLifecycle);
+        header.reserved_frame[0] = 1;
+        assert!(
+            matches!(header.validate(), Err(ConsensusError::InvalidField(_))),
+            "the frame prefix reserved bytes must be zero"
+        );
+    }
 }
diff --git a/core/binary_protocol/src/consensus/mod.rs b/core/binary_protocol/src/consensus/mod.rs
index 1b9a44470..8e7f398b0 100644
--- a/core/binary_protocol/src/consensus/mod.rs
+++ b/core/binary_protocol/src/consensus/mod.rs
@@ -51,9 +51,10 @@ pub use consumer_session::{
 pub use error::ConsensusError;
 pub use header::{
     CHECKSUM_UNSEALED, CommitHeader, ConsensusHeader, DVC_HEADERS_MAX, DoViewChangeHeader,
-    EvictionHeader, EvictionReason, ForwardLogoutHeader, ForwardLogoutOutcome,
-    ForwardLogoutResultHeader, ForwardRegisterHeader, ForwardRegisterOutcome,
-    ForwardRegisterResultHeader, GenericHeader, HEADER_SIZE, PrepareHeader, PrepareOkHeader,
+    EvictionHeader, EvictionReason, ForwardCompletionHeader, ForwardLogoutHeader,
+    ForwardLogoutOutcome, ForwardLogoutResultHeader, ForwardRegisterHeader, ForwardRegisterOutcome,
+    ForwardRegisterResultHeader, GenericHeader, HEADER_SIZE, LIFECYCLE_BATCH_HEADER_SIZE,
+    LIFECYCLE_COMPLETION_SIZE, LIFECYCLE_COMPLETIONS_MAX, PrepareHeader, PrepareOkHeader,
     RESERVED_COMMAND_LEN, RepairPrepareHeader, RepairRangeReplyHeader, ReplyHeader, RequestHeader,
     RequestPreparesHeader, RequestStartViewHeader, RequestStateChunkHeader,
     RequestStateTransferHeader, RoutedRequestHeader, SIZE_FIELD_OFFSET, StartViewChangeHeader,
diff --git a/core/binary_protocol/src/consensus/request.rs b/core/binary_protocol/src/consensus/request.rs
index 16747cad2..48dab128e 100644
--- a/core/binary_protocol/src/consensus/request.rs
+++ b/core/binary_protocol/src/consensus/request.rs
@@ -45,12 +45,19 @@ impl RequestHeader {
     /// Builds the header a client sends for `code` with `payload`, given the
     /// identity the session assigned: `client`, `request` and `session`.
     ///
-    /// Every rule the server checks lives here so that each client, and the
-    /// fixtures other SDKs verify against, agree on the bytes: metadata and
-    /// session operations stamp `request_checksum` with the payload hash,
-    /// partition and non-replicated operations leave it zero (send batches
-    /// carry their own checksum and non-replicated requests bypass dedup),
-    /// and a non-replicated request carries its command code in `reserved`.
+    /// Every rule that depends only on the code and the payload lives here so
+    /// that each client, and the fixtures other SDKs verify against, agree on
+    /// the bytes: metadata and session operations stamp `request_checksum`
+    /// with the payload hash, partition and non-replicated operations leave
+    /// it zero (send batches carry their own checksum and non-replicated
+    /// requests bypass dedup), and a non-replicated request carries its
+    /// command code in `reserved`.
+    ///
+    /// The partition context fields stay zero. A client partition operation
+    /// or poll stamps them afterwards with
+    /// [`PartitionContext::stamp`](crate::PartitionContext::stamp). The server
+    /// refuses such a request whose incarnation is not the partition's current
+    /// one, zero included, with `HistoryUnavailable`.
     ///
     /// # Errors
     ///
diff --git a/core/binary_protocol/src/dispatch.rs b/core/binary_protocol/src/dispatch.rs
index 68d4677ac..d133dfec0 100644
--- a/core/binary_protocol/src/dispatch.rs
+++ b/core/binary_protocol/src/dispatch.rs
@@ -186,7 +186,7 @@ pub const COMMAND_TABLE: &[CommandMeta] = &[
     CommandMeta::non_replicated(GET_POLL_ROUTING_CODE, "message.poll_routing"),
     CommandMeta::non_replicated(POLL_MESSAGES_ON_PRIMARY_CODE, "message.poll_primary"),
     CommandMeta::non_replicated(GET_CONSUMER_OFFSET_ROUTING_CODE, "consumer_offset.routing"),
-    CommandMeta::non_replicated(GET_SEND_CONTEXT_CODE, "message.send_context"),
+    CommandMeta::non_replicated(GET_PARTITION_ROUTE_CODE, "partition.route"),
 ];
 
 /// Lookup command metadata by command code.
@@ -250,7 +250,7 @@ pub const fn lookup_command(code: u32) -> Option<&'static CommandMeta> {
         GET_POLL_ROUTING_CODE => 49,
         POLL_MESSAGES_ON_PRIMARY_CODE => 50,
         GET_CONSUMER_OFFSET_ROUTING_CODE => 51,
-        GET_SEND_CONTEXT_CODE => 52,
+        GET_PARTITION_ROUTE_CODE => 52,
         _ => return None,
     };
     Some(&COMMAND_TABLE[idx])
diff --git a/core/binary_protocol/src/lib.rs b/core/binary_protocol/src/lib.rs
index 968d990d3..4626a6268 100644
--- a/core/binary_protocol/src/lib.rs
+++ b/core/binary_protocol/src/lib.rs
@@ -72,15 +72,17 @@ pub use codec::{WireDecode, WireEncode};
 pub use consensus::{
     CHECKSUM_UNSEALED, Command, CommitHeader, ConsensusError, ConsensusHeader, ConsumerSession,
     ConsumerSessionHeartbeatHeader, DVC_HEADERS_MAX, DoViewChangeHeader, EvictionHeader,
-    EvictionReason, ForwardLogoutHeader, ForwardLogoutOutcome, ForwardLogoutResultHeader,
-    ForwardRegisterHeader, ForwardRegisterOutcome, ForwardRegisterResultHeader, GenericHeader,
-    HEADER_SIZE, MAX_CONSUMER_SESSIONS_PER_HEARTBEAT, Operation, PrepareHeader, PrepareOkHeader,
-    REJECTION_SECTION_LEN, RESERVED_COMMAND_LEN, RepairPrepareHeader, RepairRangeReplyHeader,
-    ReplyHeader, RequestHeader, RequestPreparesHeader, RequestStartViewHeader,
-    RequestStateChunkHeader, RequestStateTransferHeader, RoutedRequestHeader, SIZE_FIELD_OFFSET,
-    StartViewChangeHeader, StartViewHeader, StateChunkHeader, StateTransferTargetHeader,
-    frame_body, frame_checksum_bytes, prepare_identity_checksum_bytes, read_size_field,
-    result_code, result_section_len, write_rejection_section,
+    EvictionReason, ForwardCompletionHeader, ForwardLogoutHeader, ForwardLogoutOutcome,
+    ForwardLogoutResultHeader, ForwardRegisterHeader, ForwardRegisterOutcome,
+    ForwardRegisterResultHeader, GenericHeader, HEADER_SIZE, LIFECYCLE_BATCH_HEADER_SIZE,
+    LIFECYCLE_COMPLETION_SIZE, LIFECYCLE_COMPLETIONS_MAX, MAX_CONSUMER_SESSIONS_PER_HEARTBEAT,
+    Operation, PrepareHeader, PrepareOkHeader, REJECTION_SECTION_LEN, RESERVED_COMMAND_LEN,
+    RepairPrepareHeader, RepairRangeReplyHeader, ReplyHeader, RequestHeader, RequestPreparesHeader,
+    RequestStartViewHeader, RequestStateChunkHeader, RequestStateTransferHeader,
+    RoutedRequestHeader, SIZE_FIELD_OFFSET, StartViewChangeHeader, StartViewHeader,
+    StateChunkHeader, StateTransferTargetHeader, frame_body, frame_checksum_bytes,
+    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 error::WireError;
diff --git a/core/binary_protocol/src/primitives/identifier.rs b/core/binary_protocol/src/primitives/identifier.rs
index f28614eba..4cc288293 100644
--- a/core/binary_protocol/src/primitives/identifier.rs
+++ b/core/binary_protocol/src/primitives/identifier.rs
@@ -130,6 +130,9 @@ pub enum WireIdentifier {
 }
 
 impl WireIdentifier {
+    /// Kind and length bytes, then the `u32` value.
+    pub(crate) const NUMERIC_ENCODED_SIZE: usize = 2 * size_of::<u8>() + NUMERIC_VALUE_LEN as usize;
+
     #[must_use]
     pub const fn numeric(id: u32) -> Self {
         Self::Numeric(id)
diff --git a/core/binary_protocol/src/primitives/partition_history.rs b/core/binary_protocol/src/primitives/partition_history.rs
index 2f17af71a..15fcaac38 100644
--- a/core/binary_protocol/src/primitives/partition_history.rs
+++ b/core/binary_protocol/src/primitives/partition_history.rs
@@ -23,6 +23,9 @@ use crate::codec::{WireDecode, WireEncode, read_u64_le, read_u128_le};
 
 /// Captured before an operation is sent. Retries and delayed offset writes retain
 /// this value; discovering a newer incarnation or owner does not authorize old work.
+///
+/// A poll reply carries the context that served it in its body
+/// (`PollMessagesResponseHeader::context`).
 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
 pub struct PartitionContext {
     /// Metadata creation revision of the partition. It changes when the numeric
@@ -78,12 +81,14 @@ impl WireDecode for PartitionContext {
 
 /// Exact offset authority installed by a partition log.
 ///
-/// `generation` changes only when this partition's assignment changes.
 /// Zero client and session together install an unowned fence without discarding its generation.
 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
 pub struct ConsumerGroupOwner {
     pub client_id: u128,
     pub session: u64,
+    /// The metadata op that decided this owner, so it changes only when this
+    /// partition's assignment changes. A recreated group restarts its own
+    /// counter but never an op, so its owners cannot match a deleted group's.
     pub generation: u64,
 }
 
diff --git a/core/binary_protocol/src/requests/messages/get_send_context.rs b/core/binary_protocol/src/requests/messages/get_partition_route.rs
similarity index 77%
rename from core/binary_protocol/src/requests/messages/get_send_context.rs
rename to core/binary_protocol/src/requests/messages/get_partition_route.rs
index 04bf2a264..b6b74c9e9 100644
--- a/core/binary_protocol/src/requests/messages/get_send_context.rs
+++ b/core/binary_protocol/src/requests/messages/get_partition_route.rs
@@ -20,16 +20,17 @@ use bytes::{BufMut, BytesMut};
 use crate::codec::{WireDecode, WireEncode, read_u32_le};
 use crate::{WireError, WireIdentifier};
 
-/// Partition context discovery authorized by send permission, without exposing topic details.
-/// The response is a `PartitionContext` captured before a new send is admitted.
+/// Route lookup for one partition, authorized by send or poll permission on its
+/// topic. The reply includes the partition count, view and primary in a
+/// [`PartitionRouteResponse`](crate::responses::messages::PartitionRouteResponse).
 #[derive(Debug, Clone, PartialEq, Eq)]
-pub struct GetSendContextRequest {
+pub struct GetPartitionRouteRequest {
     pub stream_id: WireIdentifier,
     pub topic_id: WireIdentifier,
     pub partition_id: u32,
 }
 
-impl WireEncode for GetSendContextRequest {
+impl WireEncode for GetPartitionRouteRequest {
     fn encoded_size(&self) -> usize {
         self.stream_id.encoded_size() + self.topic_id.encoded_size() + size_of::<u32>()
     }
@@ -41,7 +42,7 @@ impl WireEncode for GetSendContextRequest {
     }
 }
 
-impl WireDecode for GetSendContextRequest {
+impl WireDecode for GetPartitionRouteRequest {
     fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
         let (stream_id, stream_size) = WireIdentifier::decode(buf)?;
         let (topic_id, topic_size) = WireIdentifier::decode(&buf[stream_size..])?;
@@ -63,7 +64,7 @@ mod tests {
     use super::*;
 
     #[test]
-    fn given_send_context_request_when_encoded_should_preserve_named_and_numeric_targets() {
+    fn given_partition_route_request_when_encoded_should_preserve_named_and_numeric_targets() {
         for (stream_id, topic_id) in [
             (WireIdentifier::numeric(3), WireIdentifier::numeric(7)),
             (
@@ -71,17 +72,20 @@ mod tests {
                 WireIdentifier::named("orders").unwrap(),
             ),
         ] {
-            let request = GetSendContextRequest {
+            let request = GetPartitionRouteRequest {
                 stream_id,
                 topic_id,
                 partition_id: 2,
             };
             let bytes = request.to_bytes();
             assert_eq!(bytes.len(), request.encoded_size());
-            assert_eq!(GetSendContextRequest::decode_from(&bytes).unwrap(), request);
+            assert_eq!(
+                GetPartitionRouteRequest::decode_from(&bytes).unwrap(),
+                request
+            );
             for length in 0..bytes.len() {
                 assert!(
-                    GetSendContextRequest::decode_from(&bytes[..length]).is_err(),
+                    GetPartitionRouteRequest::decode_from(&bytes[..length]).is_err(),
                     "accepted a request truncated at {length} bytes"
                 );
             }
diff --git a/core/binary_protocol/src/requests/messages/mod.rs b/core/binary_protocol/src/requests/messages/mod.rs
index 60ebe70a7..434c4bb16 100644
--- a/core/binary_protocol/src/requests/messages/mod.rs
+++ b/core/binary_protocol/src/requests/messages/mod.rs
@@ -15,10 +15,10 @@
 // specific language governing permissions and limitations
 // under the License.
 
-pub mod get_send_context;
+pub mod get_partition_route;
 pub mod poll_messages;
 pub mod send_messages;
 
-pub use get_send_context::GetSendContextRequest;
+pub use get_partition_route::GetPartitionRouteRequest;
 pub use poll_messages::PollMessagesRequest;
 pub use send_messages::{RawMessage, SendMessagesEncoder, SendMessagesHeader};
diff --git a/core/binary_protocol/src/requests/partitions/retire_consumer_group_owners.rs b/core/binary_protocol/src/requests/partitions/retire_consumer_group_owners.rs
index 2108f6695..a4b864cac 100644
--- a/core/binary_protocol/src/requests/partitions/retire_consumer_group_owners.rs
+++ b/core/binary_protocol/src/requests/partitions/retire_consumer_group_owners.rs
@@ -23,7 +23,13 @@ use crate::codec::{WireDecode, WireEncode, read_u32_le, read_u64_le};
 pub const CONSUMER_GROUP_OWNERS_MAX: u32 = 1 << 20;
 
 /// Committed metadata proves IDs below the allocation boundary absent from the
-/// live catalog permanently deleted. Only the newest catalog must be retained.
+/// live list permanently deleted.
+///
+/// The live list can also hold deleted groups that a primary keeps while they
+/// have a durable stranded offset, so a newer catalog can list an ID that an
+/// older one retired. A partition keeps the merge of every catalog it applies,
+/// and a store or transfer path must keep that merge, not only the newest raw
+/// catalog.
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub struct RetireConsumerGroupOwnersRequest {
     pub incarnation: u64,
diff --git a/core/binary_protocol/src/responses/messages/mod.rs b/core/binary_protocol/src/responses/messages/mod.rs
index b52b1ea02..24111cb44 100644
--- a/core/binary_protocol/src/responses/messages/mod.rs
+++ b/core/binary_protocol/src/responses/messages/mod.rs
@@ -15,10 +15,12 @@
 // specific language governing permissions and limitations
 // under the License.
 
+pub mod partition_route;
 pub mod poll_messages;
 pub mod poll_routing;
 pub mod send_messages;
 
+pub use partition_route::PartitionRouteResponse;
 pub use poll_messages::{PollMessagesResponse, PollMessagesResponseHeader};
 pub use poll_routing::PollRoutingResponse;
 pub use send_messages::{SendMessagesConfirmationResponse, SendMessagesResponse};
diff --git a/core/binary_protocol/src/responses/messages/partition_route.rs b/core/binary_protocol/src/responses/messages/partition_route.rs
new file mode 100644
index 000000000..b1a004d32
--- /dev/null
+++ b/core/binary_protocol/src/responses/messages/partition_route.rs
@@ -0,0 +1,157 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use crate::WireError;
+use crate::codec::{WireDecode, WireEncode, read_u8, read_u32_le, read_u64_le};
+use crate::primitives::partition_history::PartitionContext;
+use bytes::{BufMut, BytesMut};
+
+/// One replica's route to a partition, the reply to `GetPartitionRoute` (105).
+///
+/// The incarnation and the partition count come from metadata at
+/// `metadata_op` or later. The view and its primary come from this replica's
+/// copy of the partition group, so another replica can answer a higher view.
+///
+/// Wire format, exactly 25 bytes, little endian:
+/// ```text
+/// [incarnation:8][metadata_op:8][view:4][partitions_count:4][primary_replica:1]
+/// ```
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct PartitionRouteResponse {
+    pub incarnation: u64,
+    pub metadata_op: u64,
+    pub view: u32,
+    pub partitions_count: u32,
+    /// Configured replica id of the primary of `view`, as `GetClusterMetadata`
+    /// reports it, never a roster position.
+    pub primary_replica: u8,
+}
+
+impl PartitionRouteResponse {
+    pub const ENCODED_SIZE: usize = 2 * size_of::<u64>() + 2 * size_of::<u32>() + size_of::<u8>();
+
+    /// The context a request routed by this reply carries. A route names no
+    /// consumer, so it carries owner 0: the partition primary binds a group
+    /// request with owner 0 to its installed owner, or refuses it.
+    #[must_use]
+    pub const fn context(&self) -> PartitionContext {
+        PartitionContext {
+            incarnation: self.incarnation,
+            owner_generation: 0,
+            metadata_op: self.metadata_op,
+        }
+    }
+}
+
+impl WireEncode for PartitionRouteResponse {
+    fn encoded_size(&self) -> usize {
+        Self::ENCODED_SIZE
+    }
+
+    fn encode(&self, buf: &mut BytesMut) {
+        buf.put_u64_le(self.incarnation);
+        buf.put_u64_le(self.metadata_op);
+        buf.put_u32_le(self.view);
+        buf.put_u32_le(self.partitions_count);
+        buf.put_u8(self.primary_replica);
+    }
+}
+
+impl WireDecode for PartitionRouteResponse {
+    /// A reply of any other length comes from a mismatched build, so it fails
+    /// instead of yielding a route read from the wrong offsets.
+    fn decode_from(buf: &[u8]) -> Result<Self, WireError> {
+        let (route, consumed) = Self::decode(buf)?;
+        if consumed != buf.len() {
+            return Err(WireError::Validation(
+                "trailing partition route bytes".into(),
+            ));
+        }
+        Ok(route)
+    }
+
+    fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+        const METADATA_OP: usize = size_of::<u64>();
+        const VIEW: usize = METADATA_OP + size_of::<u64>();
+        const PARTITIONS_COUNT: usize = VIEW + size_of::<u32>();
+        const PRIMARY_REPLICA: usize = PARTITIONS_COUNT + size_of::<u32>();
+        let route = Self {
+            incarnation: read_u64_le(buf, 0)?,
+            metadata_op: read_u64_le(buf, METADATA_OP)?,
+            view: read_u32_le(buf, VIEW)?,
+            partitions_count: read_u32_le(buf, PARTITIONS_COUNT)?,
+            primary_replica: read_u8(buf, PRIMARY_REPLICA)?,
+        };
+        Ok((route, Self::ENCODED_SIZE))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    const ROUTE: PartitionRouteResponse = PartitionRouteResponse {
+        incarnation: 0x0102_0304_0506_0708,
+        metadata_op: 0x1112_1314_1516_1718,
+        view: 0x2122_2324,
+        partitions_count: 0x3132_3334,
+        primary_replica: 0x41,
+    };
+
+    #[test]
+    fn given_route_when_encoded_should_match_the_golden_bytes() {
+        let bytes = ROUTE.to_bytes();
+        assert_eq!(
+            bytes.as_ref(),
+            [
+                0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01, //
+                0x18, 0x17, 0x16, 0x15, 0x14, 0x13, 0x12, 0x11, //
+                0x24, 0x23, 0x22, 0x21, //
+                0x34, 0x33, 0x32, 0x31, //
+                0x41,
+            ]
+        );
+        assert_eq!(bytes.len(), PartitionRouteResponse::ENCODED_SIZE);
+        assert_eq!(PartitionRouteResponse::decode_from(&bytes).unwrap(), ROUTE);
+    }
+
+    #[test]
+    fn given_route_of_another_length_when_decoded_should_fail() {
+        let bytes = ROUTE.to_bytes();
+        for length in 0..bytes.len() {
+            assert!(
+                PartitionRouteResponse::decode_from(&bytes[..length]).is_err(),
+                "accepted a route truncated at {length} bytes"
+            );
+        }
+        let mut longer = bytes.to_vec();
+        longer.push(0);
+        assert!(PartitionRouteResponse::decode_from(&longer).is_err());
+    }
+
+    #[test]
+    fn given_route_when_taking_its_context_should_carry_owner_zero() {
+        assert_eq!(
+            ROUTE.context(),
+            PartitionContext {
+                incarnation: ROUTE.incarnation,
+                owner_generation: 0,
+                metadata_op: ROUTE.metadata_op,
+            }
+        );
+    }
+}
diff --git a/core/binary_protocol/src/responses/messages/poll_routing.rs b/core/binary_protocol/src/responses/messages/poll_routing.rs
index 1248c41d1..beb79565a 100644
--- a/core/binary_protocol/src/responses/messages/poll_routing.rs
+++ b/core/binary_protocol/src/responses/messages/poll_routing.rs
@@ -78,6 +78,7 @@ mod tests {
             },
             context: PartitionContext::default(),
             primary: ClusterNodeResponse {
+                replica_id: 2,
                 name: "node-1".to_owned(),
                 ip: "::1".to_owned(),
                 tcp_port: 8090,
diff --git a/core/binary_protocol/src/responses/system/get_cluster_metadata.rs b/core/binary_protocol/src/responses/system/get_cluster_metadata.rs
index 22748b4e7..c592225f3 100644
--- a/core/binary_protocol/src/responses/system/get_cluster_metadata.rs
+++ b/core/binary_protocol/src/responses/system/get_cluster_metadata.rs
@@ -35,12 +35,15 @@ pub struct ClusterMetadataResponse {
 ///
 /// Wire format:
 /// ```text
-/// [name_len:4 LE][name:N][ip_len:4 LE][ip:N]
+/// [replica_id:1][name_len:4 LE][name:N][ip_len:4 LE][ip:N]
 /// [tcp:2 LE][quic:2 LE][http:2 LE][websocket:2 LE]
 /// [role:1][status:1]
 /// ```
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub struct ClusterNodeResponse {
+    /// The node's configured VSR replica id. Clients key nodes by it: the
+    /// position in the node list is not an id.
+    pub replica_id: u8,
     pub name: String,
     pub ip: String,
     pub tcp_port: u16,
@@ -51,7 +54,8 @@ pub struct ClusterNodeResponse {
     pub status: u8,
 }
 
-const NODE_FIXED_SIZE: usize = 4 + 4 + 8 + 1 + 1; // name_len + ip_len + ports + role + status
+// replica_id + name_len + ip_len + ports + role + status
+const NODE_FIXED_SIZE: usize = 1 + 4 + 4 + 8 + 1 + 1;
 
 impl WireEncode for ClusterNodeResponse {
     fn encoded_size(&self) -> usize {
@@ -60,6 +64,7 @@ impl WireEncode for ClusterNodeResponse {
 
     #[allow(clippy::cast_possible_truncation)]
     fn encode(&self, buf: &mut BytesMut) {
+        buf.put_u8(self.replica_id);
         buf.put_u32_le(self.name.len() as u32);
         buf.put_slice(self.name.as_bytes());
         buf.put_u32_le(self.ip.len() as u32);
@@ -75,9 +80,12 @@ impl WireEncode for ClusterNodeResponse {
 
 impl WireDecode for ClusterNodeResponse {
     fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
-        let name_len = read_u32_le(buf, 0)? as usize;
-        let name = read_str(buf, 4, name_len)?;
-        let pos = 4 + name_len;
+        let replica_id = read_u8(buf, 0)?;
+        let pos = 1;
+
+        let name_len = read_u32_le(buf, pos)? as usize;
+        let name = read_str(buf, pos + 4, name_len)?;
+        let pos = pos + 4 + name_len;
 
         let ip_len = read_u32_le(buf, pos)? as usize;
         let ip = read_str(buf, pos + 4, ip_len)?;
@@ -92,6 +100,7 @@ impl WireDecode for ClusterNodeResponse {
 
         Ok((
             Self {
+                replica_id,
                 name,
                 ip,
                 tcp_port,
@@ -157,8 +166,9 @@ impl WireDecode for ClusterMetadataResponse {
 mod tests {
     use super::*;
 
-    fn sample_node(name: &str, ip: &str) -> ClusterNodeResponse {
+    fn sample_node(replica_id: u8, name: &str, ip: &str) -> ClusterNodeResponse {
         ClusterNodeResponse {
+            replica_id,
             name: name.to_string(),
             ip: ip.to_string(),
             tcp_port: 8090,
@@ -172,13 +182,26 @@ mod tests {
 
     #[test]
     fn node_roundtrip() {
-        let node = sample_node("node-1", "192.168.1.1");
+        let node = sample_node(7, "node-1", "192.168.1.1");
         let bytes = node.to_bytes();
+        assert_eq!(bytes.len(), node.encoded_size());
+        assert_eq!(bytes[0], 7, "a node must start with its replica id");
         let (decoded, consumed) = ClusterNodeResponse::decode(&bytes).unwrap();
         assert_eq!(consumed, bytes.len());
         assert_eq!(decoded, node);
     }
 
+    #[test]
+    fn truncated_node_returns_error() {
+        let bytes = sample_node(2, "n", "1.2.3.4").to_bytes();
+        for end in 0..bytes.len() {
+            assert!(
+                ClusterNodeResponse::decode(&bytes[..end]).is_err(),
+                "expected error for node truncation at byte {end}"
+            );
+        }
+    }
+
     #[test]
     fn roundtrip_no_nodes() {
         let resp = ClusterMetadataResponse {
@@ -196,9 +219,9 @@ mod tests {
         let resp = ClusterMetadataResponse {
             name: "prod-cluster".to_string(),
             nodes: vec![
-                sample_node("node-1", "10.0.0.1"),
-                sample_node("node-2", "10.0.0.2"),
-                sample_node("node-3", "10.0.0.3"),
+                sample_node(0, "node-1", "10.0.0.1"),
+                sample_node(1, "node-2", "10.0.0.2"),
+                sample_node(2, "node-3", "10.0.0.3"),
             ],
         };
         let bytes = resp.to_bytes();
@@ -211,7 +234,7 @@ mod tests {
     fn truncated_returns_error() {
         let resp = ClusterMetadataResponse {
             name: "c".to_string(),
-            nodes: vec![sample_node("n", "1.2.3.4")],
+            nodes: vec![sample_node(0, "n", "1.2.3.4")],
         };
         let bytes = resp.to_bytes();
         for i in 0..bytes.len() {
diff --git a/core/cli/src/commands/binary_cluster/get_cluster_metadata.rs b/core/cli/src/commands/binary_cluster/get_cluster_metadata.rs
index e1545f17e..d9c2514f5 100644
--- a/core/cli/src/commands/binary_cluster/get_cluster_metadata.rs
+++ b/core/cli/src/commands/binary_cluster/get_cluster_metadata.rs
@@ -73,6 +73,7 @@ impl CliCommand for GetClusterMetadataCmd {
                 let mut table = Table::new();
 
                 table.set_header(vec![
+                    "Replica ID",
                     "Name",
                     "IP",
                     "TCP",
@@ -85,6 +86,7 @@ impl CliCommand for GetClusterMetadataCmd {
 
                 cluster_metadata.nodes.iter().for_each(|node| {
                     table.add_row(vec![
+                        node.replica_id.to_string(),
                         node.name.to_string(),
                         node.ip.to_string(),
                         node.endpoints.tcp.to_string(),
@@ -101,7 +103,7 @@ impl CliCommand for GetClusterMetadataCmd {
             GetClusterMetadataOutput::List => {
                 cluster_metadata.nodes.iter().for_each(|node| {
                     event!(target: PRINT_TARGET, Level::INFO,
-                        "{}|{}|{}|{}|{}|{}|{}|{}",
+                        "{}|{}|{}|{}|{}|{}|{}|{}|{}",
                         node.name,
                         node.ip,
                         node.endpoints.tcp,
@@ -109,7 +111,8 @@ impl CliCommand for GetClusterMetadataCmd {
                         node.endpoints.http,
                         node.endpoints.websocket,
                         node.role,
-                        node.status
+                        node.status,
+                        node.replica_id
                     );
                 });
             }
diff --git a/core/common/src/error/iggy_error.rs b/core/common/src/error/iggy_error.rs
index ede228f18..909d03eca 100644
--- a/core/common/src/error/iggy_error.rs
+++ b/core/common/src/error/iggy_error.rs
@@ -18,6 +18,7 @@
 use crate::Identifier;
 use crate::utils::topic_size::MaxTopicSize;
 use crate::{IggyMessage, SendMessagesConfirmationResponse, utils::byte_size::IggyByteSize};
+use iggy_binary_protocol::requests::partitions::retire_consumer_group_owners::CONSUMER_GROUP_OWNERS_MAX;
 use std::sync::Arc;
 use strum::{EnumDiscriminants, FromRepr, IntoStaticStr};
 use thiserror::Error;
@@ -178,10 +179,21 @@ pub enum IggyError {
         "Request is below the deduplication window; outcome unknown, resending may duplicate the write"
     )]
     RequestTooOld = 85,
+    /// The request names a partition incarnation that is not the current one:
+    /// the partition was deleted, or recreated under the same id. Partition
+    /// admission and the poll gate answer this only once the node that
+    /// dispatched the request applied its `minimum_metadata_op`, and
+    /// [`Self::TransientNotAccepted`] before that. Clients must not replay
+    /// the request: they drop the captured context and any position read
+    /// under it, route again for the current context, and send new work. A
+    /// `DeleteSegments` that meets a recreated partition answers
+    /// [`Self::PartitionHistoryChanged`] instead.
     #[error("The partition history is no longer available; this request cannot execute again")]
     HistoryUnavailable = 87,
     #[error("A committed lifecycle operation prevents this mutation. Retry as a new request.")]
     LifecycleBusy = 88,
+    #[error("Request not accepted by a backup replica; retry on the primary of its group")]
+    NotPrimary = 89,
     #[error("Client with ID: {0} was not found.")]
     ClientNotFound(u32) = 100,
     #[error("Invalid client ID")]
@@ -338,6 +350,9 @@ pub enum IggyError {
     /// range frees again.
     #[error("Partition id space exhausted for this topic")]
     PartitionIdSpaceExhausted = 3013,
+    /// Committed metadata refused a `DeleteSegments` truncate: the partition
+    /// was recreated between the offset resolve on its owning shard and the
+    /// commit, so its creation revision no longer matches the resolved one.
     /// Clients must resolve the current partition state and submit a new request,
     /// rather than replay the rejected request against its previous history.
     #[error("Partition history changed after the request was resolved")]
@@ -503,7 +518,10 @@ pub enum IggyError {
         "Consumer group member with client ID: {0} does not own partition: {1} at the current generation (rebalance in progress)."
     )]
     ConsumerGroupPartitionNotOwned(u32, u32) = 5009,
-    #[error("Too many consumer groups")]
+    #[error(
+        "Too many consumer groups in this topic (limit {max})",
+        max = CONSUMER_GROUP_OWNERS_MAX
+    )]
     TooManyConsumerGroups = 5010,
     #[error("Base offset is missing")]
     MissingBaseOffsetRetainedMessageBatch = 6000,
@@ -587,8 +605,14 @@ pub enum IggyError {
     IncompatibleProtocolVersion(u32, u32, u32) = 14003,
     #[error("VSR session mismatch: requested {0}, server bound {1}")]
     SessionMismatch(u64, u64) = 14004,
-    #[error("VSR request IDs exhausted; start a new logical session")]
+    #[error("VSR request IDs exhausted; create a new client")]
     RequestIdExhausted = 14005,
+    #[error("Operation {0} has an unknown outcome")]
+    OperationUncertain(u64) = 14006,
+    #[error("Lane blocked by operation {0} with an unknown outcome")]
+    LaneBlocked(u64) = 14007,
+    #[error("Operation {0} is not retained")]
+    OperationNotRetained(u64) = 14008,
 }
 
 impl IggyError {
@@ -624,6 +648,7 @@ mod tests {
     use super::*;
 
     const GROUP_NAME_ERROR_CODE: u32 = 5005;
+    const RETAINED_OPERATION: u64 = 7;
 
     #[test]
     fn derived_sdk_error_discriminant_keeps_codes() {
@@ -684,4 +709,56 @@ mod tests {
         assert_eq!(IggyError::from_code(85), error);
         assert_eq!(IggyError::from_code_as_string(85), error.as_string());
     }
+
+    #[test]
+    fn given_pending_operation_errors_when_round_tripped_by_code_should_keep_codes_and_names() {
+        let cases = [
+            (IggyError::NotPrimary, 89, "not_primary"),
+            (
+                IggyError::OperationUncertain(RETAINED_OPERATION),
+                14006,
+                "operation_uncertain",
+            ),
+            (
+                IggyError::LaneBlocked(RETAINED_OPERATION),
+                14007,
+                "lane_blocked",
+            ),
+            (
+                IggyError::OperationNotRetained(RETAINED_OPERATION),
+                14008,
+                "operation_not_retained",
+            ),
+        ];
+        for (error, code, name) in cases {
+            assert_eq!(error.as_code(), code);
+            assert_eq!(error.as_string(), name);
+            assert_eq!(IggyError::from_code_as_string(code), name);
+        }
+
+        // `PartialEq` compares codes only, so the payload needs a pattern.
+        assert!(matches!(IggyError::from_code(89), IggyError::NotPrimary));
+        assert!(matches!(
+            IggyError::from_code(14006),
+            IggyError::OperationUncertain(0)
+        ));
+        assert!(matches!(
+            IggyError::from_code(14007),
+            IggyError::LaneBlocked(0)
+        ));
+        assert!(matches!(
+            IggyError::from_code(14008),
+            IggyError::OperationNotRetained(0)
+        ));
+    }
+
+    #[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}");
+        assert!(
+            message.contains(&CONSUMER_GROUP_OWNERS_MAX.to_string()),
+            "{message}"
+        );
+    }
 }
diff --git a/core/common/src/lib.rs b/core/common/src/lib.rs
index 33dc049f2..71e11bf16 100644
--- a/core/common/src/lib.rs
+++ b/core/common/src/lib.rs
@@ -81,7 +81,7 @@ pub use iggy_binary_protocol::responses::messages::{
 };
 pub use traits::binary_client::BinaryClient;
 pub use traits::binary_transport::BinaryTransport;
-pub use traits::binary_transport::{VsrSessionControl, VsrSessionSealed};
+pub use traits::binary_transport::{VsrSessionControl, VsrSessionSealed, decode_session_binding};
 pub use traits::client::Client;
 pub use traits::cluster_client::ClusterClient;
 pub use traits::consumer_group_client::ConsumerGroupClient;
@@ -91,7 +91,6 @@ pub use traits::message_client::MessageClient;
 pub use traits::partition_client::PartitionClient;
 pub use traits::partitioner::Partitioner;
 pub use traits::personal_access_token_client::PersonalAccessTokenClient;
-pub use traits::rust_sdk_version_info;
 pub use traits::segment_client::SegmentClient;
 pub use traits::sizeable::Sizeable;
 pub use traits::stream_client::StreamClient;
@@ -99,6 +98,7 @@ pub use traits::system_client::SystemClient;
 pub use traits::topic_client::TopicClient;
 pub use traits::user_client::UserClient;
 pub use traits::validatable::Validatable;
+pub use traits::{rust_sdk_version_info, sign_in_request};
 pub use types::args::*;
 pub use types::client::client_info::*;
 pub use types::client_state::ClientState;
diff --git a/core/common/src/traits/binary_impls/messages.rs b/core/common/src/traits/binary_impls/messages.rs
index 024a12544..a91edd71c 100644
--- a/core/common/src/traits/binary_impls/messages.rs
+++ b/core/common/src/traits/binary_impls/messages.rs
@@ -30,13 +30,14 @@ use bytes::{Bytes, BytesMut};
 use iggy_binary_protocol::codec::WireDecode;
 use iggy_binary_protocol::codec::WireEncode;
 use iggy_binary_protocol::codes::SYNC_CONSUMER_GROUP_CODE;
-use iggy_binary_protocol::codes::{GET_SEND_CONTEXT_CODE, SEND_MESSAGES_CODE};
-use iggy_binary_protocol::primitives::partition_history::PartitionContext;
+use iggy_binary_protocol::codes::{GET_PARTITION_ROUTE_CODE, SEND_MESSAGES_CODE};
 use iggy_binary_protocol::requests::consumer_groups::SyncConsumerGroupRequest;
 use iggy_binary_protocol::requests::messages::{
-    GetSendContextRequest, PollMessagesRequest, RawMessage, SendMessagesEncoder, SendMessagesHeader,
+    GetPartitionRouteRequest, PollMessagesRequest, RawMessage, SendMessagesEncoder,
+    SendMessagesHeader,
 };
 use iggy_binary_protocol::responses::consumer_groups::SyncConsumerGroupResponse;
+use iggy_binary_protocol::responses::messages::PartitionRouteResponse;
 use iggy_binary_protocol::{WireIdentifier, WirePartitioning};
 
 /// Max attempts to resolve a fenced consumer-group poll: one re-sync after the
@@ -297,7 +298,7 @@ pub(crate) async fn send_raw_messages<B: BinaryTransport + Sync + ?Sized>(
     };
     let stream_id = identifier_from_wire(&header.stream_id)?;
     let topic_id = identifier_from_wire(&header.topic_id)?;
-    let capture = GetSendContextRequest {
+    let capture = GetPartitionRouteRequest {
         stream_id: header.stream_id,
         topic_id: header.topic_id,
         partition_id,
@@ -305,14 +306,14 @@ pub(crate) async fn send_raw_messages<B: BinaryTransport + Sync + ?Sized>(
     send_with_partition_context(client, &stream_id, &topic_id, &capture, payload).await
 }
 
-/// Send under the cached send context, or one captured with `GetSendContext`.
+/// Send under the cached send context, or one captured from a `GetPartitionRoute` reply.
 /// A history refusal means the context is stale: the topic's contexts are
 /// dropped and one fresh capture is tried.
 async fn send_with_partition_context<B: BinaryTransport + Sync + ?Sized>(
     client: &B,
     stream_id: &Identifier,
     topic_id: &Identifier,
-    capture: &GetSendContextRequest,
+    capture: &GetPartitionRouteRequest,
     payload: Bytes,
 ) -> Result<Bytes, IggyError> {
     let state = client.consumer_group_state();
@@ -323,9 +324,9 @@ async fn send_with_partition_context<B: BinaryTransport + Sync + ?Sized>(
             context
         } else {
             let response = client
-                .send_raw_with_response(GET_SEND_CONTEXT_CODE, capture.to_bytes())
+                .send_raw_with_response(GET_PARTITION_ROUTE_CODE, capture.to_bytes())
                 .await?;
-            let context = super::decode_response::<PartitionContext>(&response)?;
+            let context = super::decode_response::<PartitionRouteResponse>(&response)?.context();
             state.set_partition_context(stream_id, topic_id, capture.partition_id, context);
             context
         };
@@ -485,7 +486,7 @@ impl<B: BinaryClient> MessageClient for B {
             }
             _ => IggyError::InvalidCommand,
         })?;
-        let capture = GetSendContextRequest {
+        let capture = GetPartitionRouteRequest {
             stream_id: wire_stream_id,
             topic_id: wire_topic_id,
             partition_id,
diff --git a/core/common/src/traits/binary_impls/mod.rs b/core/common/src/traits/binary_impls/mod.rs
index f5fd77c6c..dbad9bf87 100644
--- a/core/common/src/traits/binary_impls/mod.rs
+++ b/core/common/src/traits/binary_impls/mod.rs
@@ -34,9 +34,16 @@ use crate::IggyError;
 use crate::http::users::defaults::{
     MAX_PASSWORD_LENGTH, MAX_USERNAME_LENGTH, MIN_PASSWORD_LENGTH, MIN_USERNAME_LENGTH,
 };
-use crate::{BinaryClient, ClientState};
-use iggy_binary_protocol::WireDecode;
-use iggy_binary_protocol::{ClientVersionInfo, IGGY_PROTOCOL_VERSION, WireName};
+use crate::{BinaryClient, ClientState, Credentials};
+use bytes::Bytes;
+use iggy_binary_protocol::codec::WireEncode;
+use iggy_binary_protocol::codes::{LOGIN_REGISTER_CODE, LOGIN_REGISTER_WITH_PAT_CODE};
+use iggy_binary_protocol::requests::users::login_register::BindSecret;
+use iggy_binary_protocol::requests::users::{LoginRegisterRequest, LoginRegisterWithPatRequest};
+use iggy_binary_protocol::{
+    ClientVersionInfo, IGGY_PROTOCOL_VERSION, MAX_WIRE_NAME_LENGTH, WireDecode, WireName,
+};
+use secrecy::ExposeSecret;
 
 /// SDK identifier sent in the login-register version prefix. Foreign SDKs
 /// send their own (e.g. `go-sdk`) once they adopt VSR framing.
@@ -53,6 +60,41 @@ pub fn rust_sdk_version_info(sdk_version: &str) -> Result<ClientVersionInfo, Igg
     })
 }
 
+/// The code and the body of a sign-in with `credentials`. The credentials
+/// pass the bounds that the server enforces, so a long password cannot break
+/// a length prefix on the wire.
+#[doc(hidden)]
+pub fn sign_in_request(
+    credentials: &Credentials,
+    version_info: ClientVersionInfo,
+    bind_secret: BindSecret,
+) -> Result<(u32, Bytes), IggyError> {
+    match credentials {
+        Credentials::UsernamePassword(username, password) => {
+            validate_username(username)?;
+            validate_password(password.expose_secret())?;
+            let request = LoginRegisterRequest {
+                version_info,
+                bind_secret,
+                username: WireName::new(username).map_err(|_| IggyError::InvalidFormat)?,
+                password: password.clone(),
+                client_context: None,
+            };
+            Ok((LOGIN_REGISTER_CODE, request.to_bytes()))
+        }
+        Credentials::PersonalAccessToken(token) => {
+            validate_token(token.expose_secret())?;
+            let request = LoginRegisterWithPatRequest {
+                version_info,
+                bind_secret,
+                token: token.clone(),
+                client_context: None,
+            };
+            Ok((LOGIN_REGISTER_WITH_PAT_CODE, request.to_bytes()))
+        }
+    }
+}
+
 /// Release a live session before a fresh login on the same connection.
 ///
 /// The server binds a connection to one `(client, session)` and resolves the
@@ -88,6 +130,16 @@ pub(crate) fn validate_username(username: &str) -> Result<(), IggyError> {
     Ok(())
 }
 
+/// The request stores a `SecretString` rather than a `WireName`, so the
+/// `WireName` bounds are enforced here to keep the u8 length prefix
+/// consistent with the realized bytes.
+pub(crate) fn validate_token(token: &str) -> Result<(), IggyError> {
+    if token.is_empty() || token.len() > MAX_WIRE_NAME_LENGTH {
+        return Err(IggyError::InvalidFormat);
+    }
+    Ok(())
+}
+
 /// Decode a wire response, logging the error details before converting to `IggyError`.
 pub(crate) fn decode_response<T: WireDecode>(response: &[u8]) -> Result<T, IggyError> {
     T::decode_from(response).map_err(|e| {
diff --git a/core/common/src/traits/binary_impls/personal_access_tokens.rs b/core/common/src/traits/binary_impls/personal_access_tokens.rs
index 6dcaad6f6..9dfc1e3ed 100644
--- a/core/common/src/traits/binary_impls/personal_access_tokens.rs
+++ b/core/common/src/traits/binary_impls/personal_access_tokens.rs
@@ -22,10 +22,8 @@ use crate::{
     PersonalAccessTokenClient, PersonalAccessTokenExpiry, PersonalAccessTokenInfo,
     RawPersonalAccessToken,
 };
-use iggy_binary_protocol::MAX_WIRE_NAME_LENGTH;
 use iggy_binary_protocol::WireName;
 use iggy_binary_protocol::codec::WireEncode;
-use iggy_binary_protocol::codes::LOGIN_REGISTER_WITH_PAT_CODE;
 use iggy_binary_protocol::codes::{
     CREATE_PERSONAL_ACCESS_TOKEN_CODE, DELETE_PERSONAL_ACCESS_TOKEN_CODE,
     GET_PERSONAL_ACCESS_TOKENS_CODE,
@@ -34,7 +32,6 @@ use iggy_binary_protocol::requests::personal_access_tokens::{
     CreatePersonalAccessTokenRequest, DeletePersonalAccessTokenRequest,
     GetPersonalAccessTokensRequest,
 };
-use iggy_binary_protocol::requests::users::LoginRegisterWithPatRequest;
 use iggy_binary_protocol::responses::personal_access_tokens::create_personal_access_token::RawPersonalAccessTokenResponse;
 use iggy_binary_protocol::responses::personal_access_tokens::get_personal_access_tokens::GetPersonalAccessTokensResponse;
 use iggy_binary_protocol::responses::users::LoginRegisterResponse;
@@ -94,37 +91,25 @@ impl<B: BinaryClient> PersonalAccessTokenClient for B {
         token: &str,
     ) -> Result<IdentityInfo, IggyError> {
         super::logout_before_relogin(self).await?;
-        // The request stores a `SecretString` rather than a `WireName`, so the
-        // `WireName` bounds are enforced here to keep the u8 length prefix
-        // consistent with the realized bytes.
-        if token.is_empty() || token.len() > MAX_WIRE_NAME_LENGTH {
-            return Err(IggyError::InvalidFormat);
-        }
-        let response = self
-            .send_raw_with_response(
-                LOGIN_REGISTER_WITH_PAT_CODE,
-                LoginRegisterWithPatRequest {
-                    version_info: super::rust_sdk_version_info(self.sdk_version())?,
-                    bind_secret: self.session_bind_secret().await?,
-                    token: SecretString::from(token.to_string()),
-                    client_context: None,
-                }
-                .to_bytes(),
-            )
-            .await?;
+        super::validate_token(token)?;
+        let credentials = Credentials::PersonalAccessToken(SecretString::from(token.to_string()));
+        let fence = self.session_fence();
+        let (code, payload) = super::sign_in_request(
+            &credentials,
+            super::rust_sdk_version_info(self.sdk_version())?,
+            self.session_bind_secret().await?,
+        )?;
+        let response = self.send_raw_with_response(code, payload).await?;
         let wire_resp = super::decode_response::<LoginRegisterResponse>(&response)?;
-        self.bind_vsr_session(wire_resp.session).await?;
+        self.bind_vsr_session_at(fence, wire_resp.session).await?;
         tracing::debug!(
             server_version = %wire_resp.server_version,
             server_protocol_version = wire_resp.server_protocol_version,
             "authenticated against iggy server"
         );
         self.set_state(ClientState::Authenticated).await;
-        self.remember_session_credentials(
-            Credentials::PersonalAccessToken(SecretString::from(token.to_string())),
-            wire_resp.user_id,
-        )
-        .await;
+        self.remember_session_credentials(credentials, wire_resp.user_id)
+            .await;
         self.publish_event(DiagnosticEvent::SignedIn).await;
         Ok(IdentityInfo {
             user_id: wire_resp.user_id,
diff --git a/core/common/src/traits/binary_impls/users.rs b/core/common/src/traits/binary_impls/users.rs
index f3aa1bc86..06e5c496b 100644
--- a/core/common/src/traits/binary_impls/users.rs
+++ b/core/common/src/traits/binary_impls/users.rs
@@ -22,12 +22,10 @@ use crate::{
     Permissions, UserClient, UserInfo, UserInfoDetails, UserStatus, UserUpdateOptions,
 };
 use iggy_binary_protocol::codec::WireEncode;
-use iggy_binary_protocol::codes::LOGIN_REGISTER_CODE;
 use iggy_binary_protocol::codes::{
     CHANGE_PASSWORD_CODE, CREATE_USER_CODE, DELETE_USER_CODE, GET_USER_CODE, GET_USERS_CODE,
     LOGOUT_USER_CODE, UPDATE_PERMISSIONS_CODE, UPDATE_USER_CODE,
 };
-use iggy_binary_protocol::requests::users::LoginRegisterRequest;
 use iggy_binary_protocol::requests::users::{
     ChangePasswordRequest, CreateUserRequest, DeleteUserRequest, GetUserRequest, GetUsersRequest,
     LogoutUserRequest, UpdatePermissionsRequest, UpdateUserRequest,
@@ -185,36 +183,27 @@ impl<B: BinaryClient> UserClient for B {
         super::validate_username(username)?;
         super::validate_password(password)?;
         super::logout_before_relogin(self).await?;
-        let wire_name = WireName::new(username).map_err(|_| IggyError::InvalidFormat)?;
-        let response = self
-            .send_raw_with_response(
-                LOGIN_REGISTER_CODE,
-                LoginRegisterRequest {
-                    version_info: super::rust_sdk_version_info(self.sdk_version())?,
-                    bind_secret: self.session_bind_secret().await?,
-                    username: wire_name,
-                    password: SecretString::from(password.to_string()),
-                    client_context: None,
-                }
-                .to_bytes(),
-            )
-            .await?;
+        let credentials = Credentials::UsernamePassword(
+            username.to_owned(),
+            SecretString::from(password.to_string()),
+        );
+        let fence = self.session_fence();
+        let (code, payload) = super::sign_in_request(
+            &credentials,
+            super::rust_sdk_version_info(self.sdk_version())?,
+            self.session_bind_secret().await?,
+        )?;
+        let response = self.send_raw_with_response(code, payload).await?;
         let wire_resp = super::decode_response::<LoginRegisterResponse>(&response)?;
-        self.bind_vsr_session(wire_resp.session).await?;
+        self.bind_vsr_session_at(fence, wire_resp.session).await?;
         tracing::debug!(
             server_version = %wire_resp.server_version,
             server_protocol_version = wire_resp.server_protocol_version,
             "authenticated against iggy server"
         );
         self.set_state(ClientState::Authenticated).await;
-        self.remember_session_credentials(
-            Credentials::UsernamePassword(
-                username.to_owned(),
-                SecretString::from(password.to_string()),
-            ),
-            wire_resp.user_id,
-        )
-        .await;
+        self.remember_session_credentials(credentials, wire_resp.user_id)
+            .await;
         self.publish_event(DiagnosticEvent::SignedIn).await;
         Ok(IdentityInfo {
             user_id: wire_resp.user_id,
diff --git a/core/common/src/traits/binary_transport.rs b/core/common/src/traits/binary_transport.rs
index c04b5d120..3ee42f044 100644
--- a/core/common/src/traits/binary_transport.rs
+++ b/core/common/src/traits/binary_transport.rs
@@ -45,7 +45,7 @@ pub trait BinaryTransport {
     ///
     /// Partition commands carry the partition context the typed API would send:
     /// - `SendMessages` with an explicit partition id uses the cached send
-    ///   context, or captures one with `GetSendContext`. After a
+    ///   context, or captures one from `GetPartitionRoute`. After a
     ///   `HistoryUnavailable` refusal the topic's cached contexts are dropped and
     ///   one fresh capture is tried. `Balanced` and `MessagesKey` partitioning
     ///   fail with [`IggyError::FeatureUnavailable`] before anything is sent:
@@ -142,20 +142,13 @@ pub trait VsrSessionControl: vsr_session_sealed::Sealed + BinaryTransport {
         identity: SessionIdentity,
         response: &[u8],
     ) -> Result<LoginRegisterResponse, IggyError> {
-        let bound =
-            LoginRegisterResponse::decode_from(response).map_err(|_| IggyError::InvalidFormat)?;
-        if bound.session == 0 {
-            return Err(IggyError::InvalidSession(bound.session));
-        }
-        if identity.session != 0 && bound.session != identity.session {
-            return Err(IggyError::SessionMismatch(identity.session, bound.session));
-        }
-        Ok(bound)
+        decode_session_binding(&identity, response)
     }
     async fn resume_vsr_session(&self) -> Result<(), IggyError>
     where
         Self: Sync,
     {
+        let fence = self.session_fence();
         let identity = self.session_identity().await?;
         let request = BindSessionRequest {
             version_info: crate::rust_sdk_version_info(self.sdk_version())?,
@@ -169,12 +162,26 @@ pub trait VsrSessionControl: vsr_session_sealed::Sealed + BinaryTransport {
             )
             .await?;
         let bound = self.decode_session_binding(identity, &response)?;
-        self.bind_vsr_session(bound.session).await?;
+        self.bind_vsr_session_at(fence, bound.session).await?;
         self.set_state(ClientState::Authenticated).await;
         self.publish_event(DiagnosticEvent::SignedIn).await;
         Ok(())
     }
     async fn bind_vsr_session(&self, session: u64) -> Result<(), IggyError>;
+    /// The fence of the current session. A sign-in reads it before it reads
+    /// the session it signs in for, and binds its reply with
+    /// [`Self::bind_vsr_session_at`].
+    #[doc(hidden)]
+    fn session_fence(&self) -> u64 {
+        0
+    }
+    /// [`Self::bind_vsr_session`], but only while the session fence is still
+    /// `fence`. A reply that arrives after a reset, such as the reset of an
+    /// explicit disconnect, must not bind the session that replaced it.
+    #[doc(hidden)]
+    async fn bind_vsr_session_at(&self, _fence: u64, session: u64) -> Result<(), IggyError> {
+        self.bind_vsr_session(session).await
+    }
     async fn reset_vsr_session(&self) -> Result<(), IggyError>;
     /// Keep the credentials a sign-in succeeded with, so a transport that
     /// loses its connection can re-establish the session -- on this node or,
@@ -206,21 +213,40 @@ pub trait VsrSessionControl: vsr_session_sealed::Sealed + BinaryTransport {
 
 pub use vsr_session_sealed::Sealed as VsrSessionSealed;
 
+/// Decodes the reply to a session bind. The reply must name the session that
+/// `identity` asked for.
+#[doc(hidden)]
+pub fn decode_session_binding(
+    identity: &SessionIdentity,
+    response: &[u8],
+) -> Result<LoginRegisterResponse, IggyError> {
+    let bound =
+        LoginRegisterResponse::decode_from(response).map_err(|_| IggyError::InvalidFormat)?;
+    if bound.session == 0 {
+        return Err(IggyError::InvalidSession(bound.session));
+    }
+    if identity.session != 0 && bound.session != identity.session {
+        return Err(IggyError::SessionMismatch(identity.session, bound.session));
+    }
+    Ok(bound)
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
     use crate::ConsumerGroupClientState;
     use bytes::BytesMut;
     use iggy_binary_protocol::codes::{
-        GET_CONSUMER_OFFSET_ROUTING_CODE, GET_POLL_ROUTING_CODE, GET_SEND_CONTEXT_CODE, PING_CODE,
-        POLL_MESSAGES_ON_PRIMARY_CODE,
+        GET_CONSUMER_OFFSET_ROUTING_CODE, GET_PARTITION_ROUTE_CODE, GET_POLL_ROUTING_CODE,
+        PING_CODE, POLL_MESSAGES_ON_PRIMARY_CODE,
     };
     use iggy_binary_protocol::requests::consumer_offsets::{
         DeleteConsumerOffsetRequest, StoreConsumerOffsetRequest,
     };
     use iggy_binary_protocol::requests::messages::{
-        GetSendContextRequest, RawMessage, SendMessagesEncoder,
+        GetPartitionRouteRequest, RawMessage, SendMessagesEncoder,
     };
+    use iggy_binary_protocol::responses::messages::PartitionRouteResponse;
     use iggy_binary_protocol::{
         AckLevel, WireConsumer, WireIdentifier, WirePartitioning, WirePollingStrategy,
     };
@@ -277,8 +303,15 @@ mod tests {
             context: PartitionContext,
         ) -> Result<Bytes, IggyError> {
             self.record(Call::Raw(code, payload, context));
-            if code == GET_SEND_CONTEXT_CODE {
-                return Ok(CAPTURED.to_bytes());
+            if code == GET_PARTITION_ROUTE_CODE {
+                return Ok(PartitionRouteResponse {
+                    incarnation: CAPTURED.incarnation,
+                    metadata_op: CAPTURED.metadata_op,
+                    view: 0,
+                    partitions_count: PARTITION + 1,
+                    primary_replica: 0,
+                }
+                .to_bytes());
             }
             Ok(Bytes::from_static(RECEIPT))
         }
@@ -349,7 +382,7 @@ mod tests {
                 .unwrap();
             assert_eq!(response, Bytes::from_static(RECEIPT));
         }
-        let capture = GetSendContextRequest {
+        let capture = GetPartitionRouteRequest {
             stream_id: WireIdentifier::numeric(STREAM),
             topic_id: wire_topic(),
             partition_id: PARTITION,
@@ -358,7 +391,7 @@ mod tests {
             transport.take_calls(),
             vec![
                 Call::Raw(
-                    GET_SEND_CONTEXT_CODE,
+                    GET_PARTITION_ROUTE_CODE,
                     capture.to_bytes(),
                     PartitionContext::default()
                 ),
@@ -454,7 +487,7 @@ mod tests {
         let payload = Bytes::from_static(b"opaque");
         for code in [
             PING_CODE,
-            GET_SEND_CONTEXT_CODE,
+            GET_PARTITION_ROUTE_CODE,
             GET_POLL_ROUTING_CODE,
             POLL_MESSAGES_ON_PRIMARY_CODE,
             GET_CONSUMER_OFFSET_ROUTING_CODE,
diff --git a/core/common/src/traits/client.rs b/core/common/src/traits/client.rs
index 76c48fb94..a93a8addd 100644
--- a/core/common/src/traits/client.rs
+++ b/core/common/src/traits/client.rs
@@ -23,6 +23,7 @@ use crate::{DiagnosticEvent, IggyError};
 use async_broadcast::Receiver;
 use async_trait::async_trait;
 use std::fmt::Debug;
+use std::time::Instant;
 
 /// The client trait which is the main interface to the Iggy server.
 /// It consists of multiple modules, each of which is responsible for a specific set of commands.
@@ -79,4 +80,31 @@ pub trait Client:
     ///
     /// HTTP keeps no connection, so it publishes no events.
     async fn subscribe_events(&self) -> Receiver<DiagnosticEvent>;
+
+    /// Replays the retained operation `op` until it commits or `deadline`
+    /// passes. `op` is the request id that `OperationUncertain` or
+    /// `LaneBlocked` carries.
+    ///
+    /// `Ok(())` means that the operation committed exactly once. After
+    /// `OperationUncertain(op)` the outcome is still unknown, and the
+    /// operation stays retained. An operation that is not retained gives
+    /// `OperationNotRetained(op)`. A client that retains no operations
+    /// returns `FeatureUnavailable`.
+    ///
+    /// A session reset drops retained operations without settling them.
+    /// `OperationNotRetained(op)` after a reset means the outcome is still
+    /// unknown and can no longer be recovered through this client.
+    #[doc(hidden)]
+    async fn resolve_operation(&self, _op: u64, _deadline: Instant) -> Result<(), IggyError> {
+        Err(IggyError::FeatureUnavailable)
+    }
+
+    /// Frees the lane that the retained operation `op` blocks, and the outcome
+    /// of `op` stays unknown. An operation that is not retained gives
+    /// `OperationNotRetained(op)`. A client that retains no operations
+    /// returns `FeatureUnavailable`.
+    #[doc(hidden)]
+    async fn abandon_operation(&self, _op: u64) -> Result<(), IggyError> {
+        Err(IggyError::FeatureUnavailable)
+    }
 }
diff --git a/core/common/src/traits/mod.rs b/core/common/src/traits/mod.rs
index e624f2158..9c200766d 100644
--- a/core/common/src/traits/mod.rs
+++ b/core/common/src/traits/mod.rs
@@ -36,4 +36,4 @@ pub(crate) mod user_client;
 pub(crate) mod validatable;
 
 pub use binary_impls::decode_send_confirmations;
-pub use binary_impls::rust_sdk_version_info;
+pub use binary_impls::{rust_sdk_version_info, sign_in_request};
diff --git a/core/common/src/types/cluster/node.rs b/core/common/src/types/cluster/node.rs
index ed47b8d73..3b81553bc 100644
--- a/core/common/src/types/cluster/node.rs
+++ b/core/common/src/types/cluster/node.rs
@@ -23,6 +23,10 @@ use std::fmt::Display;
 
 #[derive(Debug, Deserialize, Serialize, Clone)]
 pub struct ClusterNode {
+    /// The node's configured VSR replica id. Key nodes by it: the position in
+    /// [`crate::ClusterMetadata::nodes`] is not an id. Required on
+    /// deserialization, since a defaulted 0 would name the wrong replica.
+    pub replica_id: u8,
     pub name: String,
     pub ip: String,
     pub endpoints: TransportEndpoints,
@@ -34,8 +38,8 @@ impl Display for ClusterNode {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         write!(
             f,
-            "ClusterNode {{ name: {}, ip: {}, endpoints: {}, role: {}, status: {} }}",
-            self.name, self.ip, self.endpoints, self.role, self.status
+            "ClusterNode {{ replica_id: {}, name: {}, ip: {}, endpoints: {}, role: {}, status: {} }}",
+            self.replica_id, self.name, self.ip, self.endpoints, self.role, self.status
         )
     }
 }
diff --git a/core/common/src/wire_conversions.rs b/core/common/src/wire_conversions.rs
index 139768a20..52fa6f39a 100644
--- a/core/common/src/wire_conversions.rs
+++ b/core/common/src/wire_conversions.rs
@@ -486,6 +486,7 @@ impl From<StatsResponse> for Stats {
 impl From<ClusterNode> for ClusterNodeResponse {
     fn from(node: ClusterNode) -> Self {
         Self {
+            replica_id: node.replica_id,
             name: node.name,
             ip: node.ip,
             tcp_port: node.endpoints.tcp,
@@ -503,6 +504,7 @@ impl TryFrom<ClusterNodeResponse> for ClusterNode {
 
     fn try_from(w: ClusterNodeResponse) -> Result<Self, Self::Error> {
         Ok(Self {
+            replica_id: w.replica_id,
             name: w.name,
             ip: w.ip,
             endpoints: TransportEndpoints::new(
@@ -1110,4 +1112,22 @@ mod tests {
         assert_eq!(options.len(), 1);
         assert!(options.contains_key(&HeaderKey::from_str("segment_size").unwrap()));
     }
+
+    #[test]
+    fn given_cluster_node_when_converted_to_wire_and_back_should_keep_its_replica_id() {
+        let node = ClusterNode {
+            replica_id: 2,
+            name: "node-2".to_owned(),
+            ip: "10.0.0.2".to_owned(),
+            endpoints: TransportEndpoints::new(8090, 8080, 3000, 8092),
+            role: ClusterNodeRole::Follower,
+            status: ClusterNodeStatus::Healthy,
+        };
+
+        let wire = ClusterNodeResponse::from(node);
+        assert_eq!(wire.replica_id, 2);
+
+        let node = ClusterNode::try_from(wire).unwrap();
+        assert_eq!(node.replica_id, 2);
+    }
 }
diff --git a/core/configs/src/server_config/cluster.rs b/core/configs/src/server_config/cluster.rs
index b0c787b53..ae773577c 100644
--- a/core/configs/src/server_config/cluster.rs
+++ b/core/configs/src/server_config/cluster.rs
@@ -309,10 +309,13 @@ pub struct ClusterConfig {
     /// wedged the group is already fenced quorum-invisible and peers elect around
     /// it; this converts the log-only limp into a distinct exit status a
     /// supervisor can act on. It is also the deadline of each running
-    /// partition file job (`sharding.partition_io_capacity`). Zero (and the `0` /
-    /// `disabled` / `unlimited` sentinels, which all parse to zero) disables
-    /// the fail-stop; nonzero values below
-    /// `MIN_SUPERBLOCK_WEDGED_FATAL_TIMEOUT` are rejected at boot.
+    /// partition file job (`sharding.partition_io_capacity`), counted in pump
+    /// ticks. Expiry fences the partition that owns the job and stops the
+    /// node, and the timed-out job keeps its slot and allocation charge until
+    /// shutdown. Zero (and the `0` / `disabled` / `unlimited` sentinels, which
+    /// all parse to zero) disables both the fail-stop and the file-job
+    /// deadline; nonzero values below `MIN_SUPERBLOCK_WEDGED_FATAL_TIMEOUT` are
+    /// rejected at boot.
     #[serde(default = "default_superblock_wedged_fatal_timeout")]
     #[serde_as(as = "DisplayFromStr")]
     #[config_env(leaf)]
diff --git a/core/consensus/src/client_table.rs b/core/consensus/src/client_table.rs
index c23f3cffe..7f1858729 100644
--- a/core/consensus/src/client_table.rs
+++ b/core/consensus/src/client_table.rs
@@ -500,13 +500,14 @@ pub struct DedupWatermark {
 /// [`IggyError::RequestTooOld`] because its outcome is unknown.
 ///
 /// The width is in the CLIENT's request-id space, not in this group's writes:
-/// `ConsensusSession` mints from one counter across every partition, stream and
-/// metadata op, so a slice only ever sees the subset of those ids routed to it.
-/// Coverage in a client's own writes to one group is this width divided by the
-/// number of groups it interleaves, so 128 ids is around 16 writes per group
-/// across 8 partitions, and a replay held back longer than that can be refused
-/// with an unknown outcome. A wider bitmap divides by the same fanout: closing
-/// the gap needs per-group request numbering, which waits on the clients-table follow-up.
+/// `ConsensusSession` uses one counter for mutations, bound reads and pings
+/// across all groups. Reads bypass dedup and do not advance this watermark,
+/// but a later committed mutation brings their skipped ids into the window.
+/// Thus 128 ids is not a guarantee of retaining 128 writes: even 16 writes per
+/// group across 8 partitions assumes no intervening reads or metadata calls.
+/// A delayed replay below that window returns an unknown outcome. A wider
+/// bitmap retains the same limitation; different numbering needs a separate
+/// contract that preserves unique request ids for multiplexed replies.
 pub const COMMITTED_WINDOW_BITS: u64 = 128;
 
 /// VSR client table: per-session fence epoch + request-watermark dedup.
diff --git a/core/consensus/src/impls.rs b/core/consensus/src/impls.rs
index cf8b881b9..fd96e580a 100644
--- a/core/consensus/src/impls.rs
+++ b/core/consensus/src/impls.rs
@@ -18,11 +18,11 @@
 use crate::oneshot::{self, Receiver, Sender};
 use crate::vsr_timeout::{TimeoutKind, TimeoutManager};
 use crate::{
-    AckLogEvent, Consensus, ControlActionLogEvent, DvcQuorumArray, DvcSuffix, IgnoreReason,
-    MergeOutcome, MergeQuorums, MergedLog, Pipeline, PlaneKind, PrepareLogEvent, Project,
-    ReplicaLogContext, SimEventKind, StoredDvc, ViewChangeLogEvent, ViewChangeReason, VsrState,
-    dvc_count, dvc_iter, dvc_quorum_array_empty, dvc_record, dvc_reset, dvc_suffix_decode,
-    emit_replica_event, emit_sim_event, merge_dvc_quorum, seal_prepare_checksum,
+    AckLogEvent, Consensus, ControlActionLogEvent, DvcHeaderKind, DvcQuorumArray, DvcSuffix,
+    IgnoreReason, MergeOutcome, MergeQuorums, MergedLog, Pipeline, PlaneKind, PrepareLogEvent,
+    Project, ReplicaLogContext, SimEventKind, StoredDvc, ViewChangeLogEvent, ViewChangeReason,
+    VsrState, dvc_count, dvc_header_kind, dvc_iter, dvc_quorum_array_empty, dvc_record, dvc_reset,
+    dvc_suffix_decode, emit_replica_event, emit_sim_event, merge_dvc_quorum, seal_prepare_checksum,
 };
 use bit_set::BitSet;
 use clock::{Clock, IggySystemClock};
@@ -1180,6 +1180,8 @@ where
     replica: u8,
     replica_count: u8,
     group: u64,
+    /// Metadata creation revision for a partition group; zero for metadata.
+    partition_incarnation: Cell<u64>,
 
     view: Cell<u32>,
 
@@ -1214,6 +1216,11 @@ where
     /// on [`Self::recovery_barrier`] gives up. Armed together with the barrier;
     /// `ZERO` when no suffix is pending (barrier `0`), which no waiter reads.
     recovery_deadline: Cell<Duration>,
+    /// Op head of the last view this replica started as primary, `0` before
+    /// the first start. The merged log can hold ops above the merged commit
+    /// that an older view committed and acknowledged, so a read of applied
+    /// state misses them until `commit_min` reaches this head.
+    view_start_head: Cell<u64>,
     /// True while this replica declines the primaryship its (stale) recovered
     /// view assigns it (see `init_as_backup`). `is_primary()` is pure view
     /// math, so without this flag a restarted view-N primary would still pass
@@ -1310,6 +1317,13 @@ where
     /// this replica prepares and announces nothing. Cleared by
     /// [`VsrConsensus::start_pending_view`], or by `reset_view_change_state`.
     pending_view_log: RefCell<Option<MergedLog>>,
+    /// The installed view must prove the local suffix before it can be applied.
+    /// Survives election resets until the plane successfully reconciles its journal.
+    view_log_unproven: Cell<bool>,
+    /// The prefix below a repair gap is unknown; entries above it can be proven.
+    view_log_unproven_through: Cell<u64>,
+    /// Set only after adoption has removed the superseded tail.
+    view_log_missing_op: Cell<Option<u64>>,
 
     /// Tracks DVC messages received (only used by primary candidate)
     /// Stores metadata; actual log comes from message
@@ -1645,8 +1659,10 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
             view_durable: Cell::new(0),
             log_view_durable: Cell::new(0),
             incarnation: Cell::new(0),
+            partition_incarnation: Cell::new(0),
             recovery_barrier: Cell::new(0),
             recovery_deadline: Cell::new(Duration::ZERO),
+            view_start_head: Cell::new(0),
             ceded_primaryship: Cell::new(false),
             status: Cell::new(Status::Recovering),
             state_transfer_stage: Cell::new(StateTransferStage::Idle),
@@ -1668,6 +1684,9 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
             local_dvc_suffix: RefCell::new(None),
             journal_mutations: Cell::new(0),
             pending_view_log: RefCell::new(None),
+            view_log_unproven: Cell::new(false),
+            view_log_unproven_through: Cell::new(u64::MAX),
+            view_log_missing_op: Cell::new(None),
             do_view_change_from_all_replicas: RefCell::new(dvc_quorum_array_empty()),
             do_view_change_quorum: Cell::new(false),
             sent_own_start_view_change: Cell::new(false),
@@ -2027,6 +2046,13 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
         self.recovery_deadline.set(deadline);
     }
 
+    /// Whether `commit_min` reached the op head of the view this replica last
+    /// started as primary. See `view_start_head`.
+    #[must_use]
+    pub const fn applied_view_start(&self) -> bool {
+        self.commit_min.get() >= self.view_start_head.get()
+    }
+
     pub fn set_view(&mut self, view: u32) {
         self.view.set(view);
     }
@@ -2350,6 +2376,16 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
         self.group
     }
 
+    /// Set the partition's metadata creation revision before serving its group.
+    pub fn set_partition_incarnation(&self, partition_incarnation: u64) {
+        self.partition_incarnation.set(partition_incarnation);
+    }
+
+    #[must_use]
+    pub const fn partition_incarnation(&self) -> u64 {
+        self.partition_incarnation.get()
+    }
+
     #[must_use]
     pub const fn last_prepare_checksum(&self) -> u128 {
         self.last_prepare_checksum.get()
@@ -3515,10 +3551,9 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
             }
         };
         let headers = suffix.headers();
-        if headers.is_empty() {
+        if headers.is_empty() && !self.view_log_unproven() {
             return header.op;
         }
-
         *self.pending_view_log.borrow_mut() = Some(MergedLog {
             op_head: header.op,
             commit_max: header.commit,
@@ -3614,7 +3649,10 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
         // drop the answer to its own probe, and its election would push the
         // discarded suffix back.
         let head = self.sequencer.current_sequence();
-        if self.status.get() == Status::Normal && msg_view == self.log_view.get() && msg_op <= head
+        if self.status.get() == Status::Normal
+            && !self.view_log_unproven()
+            && msg_view == self.log_view.get()
+            && msg_op <= head
         {
             if msg_commit > self.commit_max() {
                 self.advance_commit_max(msg_commit);
@@ -3666,6 +3704,9 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
         // it: the new primary's numbers are at least as fresh as any probe
         // answer.
         self.finish_view_probe();
+        let needs_reconciliation = self.view_log_unproven.get()
+            || self.status.get() != Status::Normal
+            || self.log_view.get() != msg_view;
         self.view.set(msg_view);
         self.log_view.set(msg_view);
         self.status.set(Status::Normal);
@@ -3674,6 +3715,9 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
         self.reset_view_change_state();
 
         // Stale pipeline entries from the old view must be discarded
+        self.view_log_unproven.set(needs_reconciliation);
+        self.view_log_unproven_through.set(u64::MAX);
+        self.view_log_missing_op.set(None);
         self.pipeline.borrow_mut().clear();
 
         // Cross-check the announced head against the headers published with it: a
@@ -3894,6 +3938,9 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
         let new_op = merged.op_head;
         self.advance_commit_max(max_commit);
         *self.pending_view_log.borrow_mut() = Some(merged);
+        self.view_log_unproven.set(true);
+        self.view_log_unproven_through.set(u64::MAX);
+        self.view_log_missing_op.set(None);
 
         // Stale pipeline entries are invalid in new view; reconciliation
         // replays from journal.
@@ -3955,6 +4002,65 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
         self.pending_view_log.borrow().clone()
     }
 
+    #[must_use]
+    pub const fn view_log_unproven(&self) -> bool {
+        self.view_log_unproven.get()
+    }
+
+    pub fn settle_view_log_proof(&self) {
+        self.view_log_unproven.set(false);
+        self.invalidate_local_dvc_suffix();
+    }
+
+    pub fn fence_view_log(&self, op: u64, unproven_through: u64) {
+        self.view_log_unproven.set(true);
+        self.view_log_unproven_through.set(unproven_through);
+        self.view_log_missing_op.set(Some(op));
+        self.invalidate_local_dvc_suffix();
+    }
+
+    #[must_use]
+    pub const fn view_log_entry_proven(&self, op: u64) -> bool {
+        !self.view_log_unproven()
+            || op <= self.commit_min()
+            || op > self.view_log_unproven_through.get()
+    }
+
+    #[must_use]
+    pub const fn view_log_unproven_range(&self) -> std::ops::RangeInclusive<u64> {
+        self.commit_min().saturating_add(1)..=if self.view_log_unproven() {
+            self.view_log_unproven_through.get()
+        } else {
+            0
+        }
+    }
+
+    #[must_use]
+    pub const fn view_log_repair_op(&self) -> Option<u64> {
+        if self.view_log_unproven() {
+            self.view_log_missing_op.get()
+        } else {
+            None
+        }
+    }
+
+    pub fn note_repaired_view_header(&self, header: PrepareHeader) {
+        if let Some(pending) = self.pending_view_log.borrow_mut().as_mut()
+            && pending.op_head == header.op
+            && pending
+                .headers
+                .first()
+                .is_none_or(|head| dvc_header_kind(head) == DvcHeaderKind::Blank)
+        {
+            if let Some(head) = pending.headers.first_mut() {
+                *head = header;
+            } else {
+                pending.headers.push(header);
+            }
+            self.invalidate_local_dvc_suffix();
+        }
+    }
+
     /// Whether a merge is parked, without cloning it.
     #[must_use]
     pub fn view_log_is_pending(&self) -> bool {
@@ -4049,6 +4155,7 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
         self.status.set(Status::Normal);
         self.ceded_primaryship.set(false);
         self.sequencer.set_sequence(new_op);
+        self.view_start_head.set(new_op);
         // Only place a promotion learns what the merge did to a gated suffix.
         self.redecide_recovery_barrier(new_op);
         if let Some(head) = merged.headers.first() {
@@ -4429,7 +4536,7 @@ where
                 // ops (and the authenticated user on Register), so the in-apply
                 // RBAC gate resolves the same identity on every backup.
                 user_id: old.user_id,
-                partition_incarnation: old.partition_incarnation,
+                partition_incarnation: consensus.partition_incarnation(),
                 ..Default::default()
             }
         });
@@ -5062,7 +5169,8 @@ mod timestamp_clamp_tests {
     use super::*;
     use crate::LocalPipeline;
 
-    use crate::test_bus::{NoopBus, make_start_view};
+    use crate::test_bus::{NoopBus, make_commit, make_start_view};
+    use iggy_binary_protocol::CommitHeader;
 
     #[test]
     fn observed_log_timestamp_floors_new_primary_stamps() {
@@ -5329,6 +5437,8 @@ mod timestamp_clamp_tests {
                     )
                     .is_empty()
             );
+            assert!(consensus.view_log_unproven());
+            consensus.settle_view_log_proof();
             // Journaled and acked above the adopted head, as `on_replicate` does.
             consensus.sequencer().set_sequence(HEAD);
             consensus.advance_commit_max(COMMIT);
@@ -5362,6 +5472,7 @@ mod timestamp_clamp_tests {
                     "a duplicate StartView with a newer commit must apply it without adopting its head"
                 );
                 assert_eq!(consensus.commit_max(), commit);
+                assert!(!consensus.view_log_unproven());
                 assert_eq!(consensus.sequencer().current_sequence(), HEAD);
                 assert_eq!(consensus.view(), VIEW);
                 assert_eq!(consensus.status(), Status::Normal);
@@ -5378,7 +5489,105 @@ mod timestamp_clamp_tests {
                 "a backup behind the primary must still adopt its head",
             );
             assert_eq!(consensus.sequencer().current_sequence(), HEAD + 1);
+            assert!(!consensus.view_log_unproven());
+        }
+    }
+
+    #[test]
+    fn given_unproven_same_view_when_commit_and_start_view_arrive_should_refresh_proof() {
+        const VIEW: u32 = 4;
+        const HEAD: u64 = 8;
+        let consensus = VsrConsensus::with_clock(
+            1,
+            0,
+            3,
+            METADATA_GROUP,
+            NoopBus,
+            LocalPipeline::new(),
+            ConsensusClock::system(),
+        );
+        let primary = u8::try_from(VIEW % 3).unwrap();
+        let start = make_start_view(VIEW, HEAD, 0, primary, 0);
+        consensus.handle_start_view(PlaneKind::Metadata, start.header(), &[]);
+        consensus.fence_view_log(HEAD - 1, HEAD - 1);
+        let commit = make_commit(VIEW, primary, 1).transmute_header(
+            |original, header: &mut CommitHeader| {
+                *header = original;
+                header.commit = HEAD;
+                header.seal();
+            },
+        );
+        assert!(matches!(
+            consensus.handle_commit(commit.header()),
+            CommitOutcome::Advanced
+        ));
+        assert_eq!(consensus.commit_max(), HEAD);
+        assert_eq!(consensus.commit_min(), 0);
+        assert!(consensus.view_log_unproven());
+        let mut canonical = PrepareHeader {
+            command: Command::Prepare,
+            operation: Operation::CreateStream,
+            size: u32::try_from(size_of::<PrepareHeader>()).unwrap(),
+            op: HEAD,
+            ..PrepareHeader::default()
+        };
+        canonical.checksum = canonical.identity_checksum();
+
+        let actions = consensus.handle_start_view(
+            PlaneKind::Metadata,
+            make_start_view(VIEW, HEAD, HEAD, primary, 0).header(),
+            bytemuck::bytes_of(&canonical),
+        );
+
+        assert!(!actions.is_empty());
+        assert_eq!(
+            consensus.pending_view_log().unwrap().headers,
+            vec![canonical]
+        );
+        assert!(consensus.view_log_unproven());
+        assert!(!consensus.view_log_entry_proven(1));
+        assert_eq!(consensus.commit_min(), 0);
+    }
+
+    #[test]
+    fn given_unreconciled_start_view_when_head_grows_and_times_out_should_keep_commit_fenced() {
+        const VIEW: u32 = 4;
+        const FIRST_HEAD: u64 = 20;
+        const NEXT_HEAD: u64 = FIRST_HEAD + 1;
+        let consensus = VsrConsensus::with_clock(
+            1,
+            0,
+            3,
+            METADATA_GROUP,
+            NoopBus,
+            LocalPipeline::new(),
+            ConsensusClock::system(),
+        );
+        let primary = u8::try_from(VIEW % 3).unwrap();
+
+        for head in [FIRST_HEAD, NEXT_HEAD] {
+            let start_view = make_start_view(VIEW, head, head, primary, 0);
+            assert!(
+                !consensus
+                    .handle_start_view(PlaneKind::Metadata, start_view.header(), &[])
+                    .is_empty()
+            );
+            assert!(consensus.view_log_unproven());
+            assert_eq!(consensus.commit_max(), head);
         }
+
+        assert!(
+            !consensus
+                .handle_normal_heartbeat_timeout(PlaneKind::Metadata)
+                .is_empty()
+        );
+        assert_eq!(consensus.status(), Status::ViewChange);
+        assert_eq!(consensus.view(), VIEW + 1);
+        assert!(consensus.view_log_unproven());
+        assert_eq!(consensus.commit_max(), NEXT_HEAD);
+
+        consensus.settle_view_log_proof();
+        assert!(!consensus.view_log_unproven());
     }
 
     /// The split-brain gate's predicate: `view` and `log_view` each independently
@@ -6497,7 +6706,8 @@ mod probe_answer_tests {
             replica: PROBER,
             reserved_frame: [0; 66],
             group: METADATA_GROUP,
-            reserved: [0; 104],
+            partition_incarnation: 0,
+            reserved: [0; 96],
             incarnation: 0,
         }
     }
diff --git a/core/consensus/src/lib.rs b/core/consensus/src/lib.rs
index 1de069145..0524d337c 100644
--- a/core/consensus/src/lib.rs
+++ b/core/consensus/src/lib.rs
@@ -132,6 +132,13 @@ pub trait Consensus: Sized {
     fn is_follower(&self) -> bool;
     fn is_normal(&self) -> bool;
     fn is_transferring(&self) -> bool;
+
+    /// A backup that serves its view: the primary of that view can take the
+    /// request at once, so the refusal is `NotPrimary`, not a retry later.
+    /// A ceded primary is still the primary of its view and stays out.
+    fn is_normal_backup(&self) -> bool {
+        self.is_normal() && !self.is_transferring() && self.is_follower()
+    }
 }
 
 /// Shared consensus lifecycle interface for control/data planes.
diff --git a/core/consensus/src/plane_helpers.rs b/core/consensus/src/plane_helpers.rs
index d846c9914..006da97a6 100644
--- a/core/consensus/src/plane_helpers.rs
+++ b/core/consensus/src/plane_helpers.rs
@@ -16,8 +16,8 @@
 // under the License.
 
 use crate::{
-    Consensus, IgnoreReason, Pipeline, PipelineEntry, PlaneKind, PrepareOkOutcome, Sequencer,
-    Status, VsrConsensus,
+    Consensus, DvcHeaderKind, IgnoreReason, Pipeline, PipelineEntry, PlaneKind, PrepareOkOutcome,
+    Sequencer, Status, VsrConsensus, dvc_header_kind,
 };
 use iggy_binary_protocol::consensus::{REJECTION_SECTION_LEN, write_rejection_section};
 use iggy_binary_protocol::{
@@ -853,7 +853,7 @@ where
         return false;
     }
 
-    if consensus.is_transferring() {
+    if consensus.is_transferring() || consensus.view_log_unproven() {
         return false;
     }
 
@@ -903,6 +903,90 @@ where
         .await
 }
 
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum ViewLogProof {
+    Proven,
+    /// Fetch `op`; retained entries above `unproven_through` match the view.
+    Missing {
+        op: u64,
+        unproven_through: u64,
+    },
+    Divergent(u64),
+}
+
+/// Prove held entries against the view's hash chain. Canonical headers can
+/// bridge missing bodies; a gap with no header leaves its lower prefix unknown.
+#[must_use]
+pub fn view_log_proof(
+    commit_floor: u64,
+    canonical: &[PrepareHeader],
+    op_head: u64,
+    header_at: impl Fn(u64) -> Option<PrepareHeader>,
+) -> ViewLogProof {
+    let proven = locally_proven_commit(commit_floor, &header_at);
+    if op_head <= proven {
+        return ViewLogProof::Proven;
+    }
+    let Some(head) = canonical
+        .first()
+        .filter(|head| head.op == op_head && dvc_header_kind(head) != DvcHeaderKind::Blank)
+    else {
+        return ViewLogProof::Missing {
+            op: op_head,
+            unproven_through: op_head,
+        };
+    };
+    let mut expected = head.checksum;
+    let mut missing = None;
+    for op in (proven + 1..=op_head).rev() {
+        let header = if let Some(local) = header_at(op) {
+            local
+        } else if let Some(header) = canonical
+            .iter()
+            .find(|header| header.op == op && dvc_header_kind(header) != DvcHeaderKind::Blank)
+        {
+            missing.get_or_insert(op);
+            *header
+        } else {
+            return ViewLogProof::Missing {
+                op,
+                unproven_through: op,
+            };
+        };
+        if header.checksum == CHECKSUM_UNSEALED || header.checksum != expected {
+            return ViewLogProof::Divergent(op);
+        }
+        expected = header.parent;
+    }
+    missing.map_or(ViewLogProof::Proven, |op| ViewLogProof::Missing {
+        op,
+        unproven_through: 0,
+    })
+}
+
+fn locally_proven_commit(
+    commit_floor: u64,
+    header_at: &impl Fn(u64) -> Option<PrepareHeader>,
+) -> u64 {
+    let mut proven = commit_floor;
+    let mut op = commit_floor;
+    let mut parent = None;
+    while let Some(next_op) = op.checked_add(1) {
+        let Some(local) = header_at(next_op) else {
+            break;
+        };
+        if local.checksum == CHECKSUM_UNSEALED
+            || parent.is_some_and(|checksum| local.parent != checksum)
+        {
+            break;
+        }
+        proven = proven.max(local.commit.min(op));
+        parent = Some(local.checksum);
+        op = next_op;
+    }
+    proven
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -1105,7 +1189,8 @@ mod tests {
             commit,
             group: 0,
             log_view: 0,
-            reserved: [0; 68],
+            reserved: [0; 60],
+            partition_incarnation: 0,
             nack_bitset: 0,
             present_bitset: 0,
         };
@@ -1503,7 +1588,8 @@ mod tests {
             commit,
             group: 0,
             log_view,
-            reserved: [0; 68],
+            reserved: [0; 60],
+            partition_incarnation: 0,
             nack_bitset: 0,
             present_bitset: present,
         };
@@ -1538,7 +1624,8 @@ mod tests {
             commit,
             group: 0,
             log_view,
-            reserved: [0; 68],
+            reserved: [0; 60],
+            partition_incarnation: 0,
             nack_bitset: 0,
             present_bitset: 0,
         };
@@ -1593,7 +1680,8 @@ mod tests {
             replica,
             reserved_frame: [0; 66],
             group: 0,
-            reserved: [0; 120],
+            partition_incarnation: 0,
+            reserved: [0; 112],
         }
     }
 
@@ -1904,6 +1992,34 @@ mod tests {
         );
     }
 
+    #[test]
+    fn given_a_started_view_when_its_head_is_unapplied_should_report_the_view_start_unapplied() {
+        let consensus = VsrConsensus::new(1, 0, 3, 0, NoopBus, LocalPipeline::new());
+        consensus.init();
+        assert!(
+            consensus.applied_view_start(),
+            "a replica that started no view has nothing to apply"
+        );
+        consensus.restore_commit_state(2, 2);
+        consensus.sequencer().set_sequence(3);
+        install_local_suffix(&consensus, 3, 2, 0);
+        let _ = consensus.handle_start_view_change(PlaneKind::Partitions, &svc_header(1, 3));
+        let (dvc, body) = dvc_with_full_suffix(2, 3, 0, 3, 2);
+        let _ = consensus.handle_do_view_change(PlaneKind::Partitions, &dvc, &body);
+        let _ = consensus.start_pending_view(PlaneKind::Partitions);
+        assert_eq!(consensus.status(), Status::Normal);
+        assert_eq!((consensus.commit_min(), consensus.commit_max()), (2, 2));
+        assert!(
+            !consensus.applied_view_start(),
+            "op 3 can be a write that the old view acknowledged, and \
+             commit_min == commit_max does not cover it"
+        );
+
+        consensus.advance_commit_max(3);
+        consensus.advance_commit_min(3);
+        assert!(consensus.applied_view_start());
+    }
+
     #[test]
     fn loopback_cleared_on_complete_view_change_as_primary() {
         // 3 replicas, replica 0 is primary for view 0 (and view 3: 3 % 3 = 0).
@@ -1997,7 +2113,8 @@ mod tests {
             op,
             commit,
             group: 0,
-            reserved: [0; 88],
+            partition_incarnation: 0,
+            reserved: [0; 80],
             incarnation: 0,
         };
         (header, body)
@@ -2106,25 +2223,19 @@ mod tests {
     }
 
     #[test]
-    fn given_start_view_without_suffix_when_adopted_should_trust_the_announced_op() {
-        // Probe answers and stale-view corrections carry numbers only: a backup must
-        // still adopt, and record nothing it could mistake for the view's decision.
+    fn given_start_view_without_suffix_when_adopted_should_keep_proof_pending() {
         let consensus = VsrConsensus::new(1, 0, 3, 0, NoopBus, LocalPipeline::new());
         consensus.init();
-
         let (header, body) = start_view_with_suffix(1, 1, 5, 3, false);
         assert!(body.is_empty());
         let actions = consensus.handle_start_view(PlaneKind::Metadata, &header, &body);
 
-        assert!(
-            !actions.is_empty(),
-            "a numbers-only StartView must still adopt"
-        );
+        assert!(!actions.is_empty());
         assert_eq!(consensus.sequencer().current_sequence(), 5);
-        assert!(
-            consensus.pending_view_log().is_none(),
-            "no suffix means no canonical headers to verify against"
-        );
+        let pending = consensus.pending_view_log().unwrap();
+        assert_eq!(pending.op_head, 5);
+        assert_eq!(pending.headers, [] as [PrepareHeader; 0]);
+        assert!(consensus.view_log_unproven());
     }
 
     #[test]
@@ -2493,4 +2604,138 @@ mod tests {
 
         assert_eq!(update, Some((172, 1720)));
     }
+
+    #[test]
+    fn given_view_suffix_when_proving_local_history_should_distinguish_gaps_from_divergence() {
+        let local: Vec<_> = (3..=6)
+            .map(|op| PrepareHeader {
+                op,
+                parent: u128::from(op - 1),
+                commit: 2,
+                checksum: u128::from(op),
+                ..Default::default()
+            })
+            .collect();
+        let canonical = [*local.last().unwrap()];
+        let proof = |floor, entries: &[PrepareHeader], canonical: &[PrepareHeader]| {
+            let held: BTreeMap<_, _> = entries.iter().map(|header| (header.op, *header)).collect();
+            view_log_proof(floor, canonical, 6, |op| held.get(&op).copied())
+        };
+        assert_eq!(proof(2, &local, &canonical), ViewLogProof::Proven);
+        let mut gap = local.clone();
+        gap.remove(2);
+        assert_eq!(
+            proof(2, &gap, &canonical),
+            ViewLogProof::Missing {
+                op: 5,
+                unproven_through: 5
+            }
+        );
+        let bridged = [local[3], local[2]];
+        assert_eq!(
+            proof(2, &gap, &bridged),
+            ViewLogProof::Missing {
+                op: 5,
+                unproven_through: 0,
+            },
+            "a canonical header proves the prefix even while its body is missing"
+        );
+        gap[1].checksum += 10;
+        assert_eq!(proof(2, &gap, &bridged), ViewLogProof::Divergent(4));
+        assert_eq!(
+            proof(2, &gap, &canonical),
+            ViewLogProof::Missing {
+                op: 5,
+                unproven_through: 5
+            },
+            "a gap cannot prove the held prefix divergent"
+        );
+        gap.insert(2, local[2]);
+        assert_eq!(proof(2, &gap, &canonical), ViewLogProof::Divergent(4));
+        assert_eq!(
+            proof(2, &local, &[]),
+            ViewLogProof::Missing {
+                op: 6,
+                unproven_through: 6
+            }
+        );
+        assert_eq!(proof(6, &local, &[]), ViewLogProof::Proven);
+    }
+
+    #[test]
+    fn given_blank_view_slots_when_proving_history_should_wait_for_evidence() {
+        const HEAD: u64 = 4;
+        let blank = crate::dvc_blank(HEAD);
+        let local = PrepareHeader {
+            op: HEAD,
+            parent: 3,
+            checksum: 4,
+            ..Default::default()
+        };
+        for held in [None, Some(local)] {
+            assert_eq!(
+                view_log_proof(HEAD - 1, &[blank], HEAD, |_| held),
+                ViewLogProof::Missing {
+                    op: HEAD,
+                    unproven_through: HEAD,
+                },
+                "a blank head cannot prove either a missing or held prepare divergent"
+            );
+        }
+        assert_eq!(
+            view_log_proof(HEAD - 2, &[local, crate::dvc_blank(HEAD - 1)], HEAD, |op| {
+                (op == HEAD).then_some(local)
+            }),
+            ViewLogProof::Missing {
+                op: HEAD - 1,
+                unproven_through: HEAD - 1,
+            },
+            "a blank predecessor cannot bridge a proof gap"
+        );
+    }
+
+    #[test]
+    fn given_commit_stamps_when_proving_history_should_require_a_sealed_contiguous_chain() {
+        const FLOOR: u64 = 2;
+        let headers: Vec<_> = (FLOOR + 1..=6)
+            .map(|op| PrepareHeader {
+                op,
+                parent: u128::from(op - 1),
+                checksum: u128::from(op),
+                commit: op - 1,
+                ..Default::default()
+            })
+            .collect();
+        let proof = |headers: &[PrepareHeader]| {
+            view_log_proof(FLOOR, &[], 5, |op| {
+                headers.iter().find(|header| header.op == op).copied()
+            })
+        };
+        assert_eq!(proof(&headers), ViewLogProof::Proven);
+        for damaged in [0, 1] {
+            let mut broken = headers.clone();
+            if damaged == 0 {
+                broken[1].checksum = CHECKSUM_UNSEALED;
+            } else {
+                broken[1].parent += 1;
+            }
+            assert_eq!(
+                proof(&broken),
+                ViewLogProof::Missing {
+                    op: 5,
+                    unproven_through: 5
+                },
+                "a later commit stamp must not bridge an unsealed or disconnected prefix"
+            );
+        }
+        let mut gap = headers;
+        gap.remove(1);
+        assert_eq!(
+            proof(&gap),
+            ViewLogProof::Missing {
+                op: 5,
+                unproven_through: 5
+            }
+        );
+    }
 }
diff --git a/core/integration/tests/cli/system/test_cluster_metadata_command.rs b/core/integration/tests/cli/system/test_cluster_metadata_command.rs
index 236bf00ca..dc42d60ef 100644
--- a/core/integration/tests/cli/system/test_cluster_metadata_command.rs
+++ b/core/integration/tests/cli/system/test_cluster_metadata_command.rs
@@ -63,6 +63,7 @@ impl IggyCmdTestCase for TestClusterMetadataCmd {
                         "Executing get cluster metadata in table mode\n",
                     ))
                     .stdout(contains("Cluster name:"))
+                    .stdout(contains("Replica ID"))
                     .stdout(contains("Name"))
                     .stdout(contains("IP"))
                     .stdout(contains("TCP"))
@@ -79,8 +80,7 @@ impl IggyCmdTestCase for TestClusterMetadataCmd {
                     .success()
                     .stdout(starts_with("Executing get cluster metadata in list mode\n"))
                     .stdout(contains("Cluster name:"))
-                    .stdout(contains("leader"))
-                    .stdout(contains("healthy"));
+                    .stdout(contains("|leader|healthy|0\n"));
             }
         }
     }
diff --git a/core/integration/tests/cluster/client_table_adversarial.rs b/core/integration/tests/cluster/client_table_adversarial.rs
index fc956f170..603076f22 100644
--- a/core/integration/tests/cluster/client_table_adversarial.rs
+++ b/core/integration/tests/cluster/client_table_adversarial.rs
@@ -599,4 +599,5 @@ async fn exchange(stream: &mut TcpStream, header: &RequestHeader, body: &Bytes)
 fn is_transient(code: u32) -> bool {
     code == IggyError::TransientNotCommitted.as_code()
         || code == IggyError::TransientNotAccepted.as_code()
+        || code == IggyError::NotPrimary.as_code()
 }
diff --git a/core/integration/tests/cluster/client_table_restart.rs b/core/integration/tests/cluster/client_table_restart.rs
index 8a3dcc1bf..fb44f268c 100644
--- a/core/integration/tests/cluster/client_table_restart.rs
+++ b/core/integration/tests/cluster/client_table_restart.rs
@@ -683,6 +683,7 @@ async fn exchange(stream: &mut TcpStream, header: &RequestHeader, body: &Bytes)
 fn is_transient(code: u32) -> bool {
     code == IggyError::TransientNotCommitted.as_code()
         || code == IggyError::TransientNotAccepted.as_code()
+        || code == IggyError::NotPrimary.as_code()
 }
 
 const LIVENESS_STREAM: &str = "session-liveness";
diff --git a/core/integration/tests/cluster/partition_dedup.rs b/core/integration/tests/cluster/partition_dedup.rs
index 84a644afd..2b44bc306 100644
--- a/core/integration/tests/cluster/partition_dedup.rs
+++ b/core/integration/tests/cluster/partition_dedup.rs
@@ -1290,4 +1290,5 @@ async fn login_on(stream: &mut TcpStream, client: u128) -> Option<u64> {
 fn is_transient(code: u32) -> bool {
     code == IggyError::TransientNotCommitted.as_code()
         || code == IggyError::TransientNotAccepted.as_code()
+        || code == IggyError::NotPrimary.as_code()
 }
diff --git a/core/integration/tests/cluster/partition_primary_routing.rs b/core/integration/tests/cluster/partition_primary_routing.rs
index 861d9d457..a9f27da0d 100644
--- a/core/integration/tests/cluster/partition_primary_routing.rs
+++ b/core/integration/tests/cluster/partition_primary_routing.rs
@@ -28,38 +28,54 @@ use super::register_forwarding::connect_without_login;
 use futures::StreamExt;
 use iggy::prelude::*;
 use iggy_binary_protocol::codes::{
-    BIND_SESSION_CODE, GET_CONSUMER_GROUP_CODE, GET_POLL_ROUTING_CODE,
+    BIND_SESSION_CODE, GET_CONSUMER_GROUP_CODE, GET_PARTITION_ROUTE_CODE, GET_POLL_ROUTING_CODE,
     POLL_MESSAGES_ON_PRIMARY_CODE, STORE_CONSUMER_OFFSET_CODE,
 };
 use iggy_binary_protocol::requests::consumer_groups::GetConsumerGroupRequest;
 use iggy_binary_protocol::requests::consumer_offsets::StoreConsumerOffsetRequest;
-use iggy_binary_protocol::requests::messages::PollMessagesRequest;
+use iggy_binary_protocol::requests::messages::{GetPartitionRouteRequest, PollMessagesRequest};
 use iggy_binary_protocol::requests::system::BindSessionRequest;
 use iggy_binary_protocol::requests::users::login_register::BindSecret;
 use iggy_binary_protocol::responses::consumer_groups::ConsumerGroupDetailsResponse;
-use iggy_binary_protocol::responses::messages::PollRoutingResponse;
+use iggy_binary_protocol::responses::messages::{PartitionRouteResponse, PollRoutingResponse};
 use iggy_binary_protocol::{
     ClientVersionInfo, IGGY_PROTOCOL_VERSION, WireDecode, WireEncode, WireName,
 };
 use iggy_common::locking::IggyRwLockFn;
+use iggy_common::store_consumer_offset::StoreConsumerOffset;
 use iggy_common::wire_conversions::{
     consumer_to_wire, identifier_to_wire, polling_strategy_to_wire,
 };
-use iggy_common::{BinaryTransport, RESYNC_REQUIRED_PARTITION_SENTINEL, VsrSessionControl};
-use integration::harness::TestHarness;
+use iggy_common::{
+    BinaryTransport, ConsumerOffsetInfo, IggyMessagesBatch, RESYNC_REQUIRED_PARTITION_SENTINEL,
+    VsrSessionControl,
+};
 use integration::harness::disk::{
     leader_node_index_via, read_metadata_superblock_state, read_partition_superblock_state,
 };
+use integration::harness::{TestBinary, TestHarness};
 use integration::iggy_harness;
 use journal::superblock::{PingPongSuperblock, SuperblockStore};
+use reqwest::StatusCode;
+use reqwest::header::RETRY_AFTER;
 use tokio::time::{Instant, sleep, timeout};
 
+use crate::server::http_client::{HttpClient, applied_op, node_url, until_primary_resolved};
+
 const STREAM_NAME: &str = "partition-routing-stream";
 const TOPIC_NAME: &str = "partition-routing-topic";
 const PARTITION_ID: u32 = 0;
 const GROUP_NAME: &str = "partition-routing-group";
 const OFFSET_GROUP_NAME: &str = "offset-routing-group";
 const GROUP_PAYLOADS: [&str; 2] = ["group-poll-first", "group-poll-second"];
+const RELAY_CONSUMER_ID: u32 = 7;
+const RELAYED_PAYLOADS: [&str; 2] = ["relayed-acked", "relayed-unacked"];
+/// The marker and the route of an HTTP request that one node relays to another.
+const FORWARDED_HEADER: &str = "iggy-forwarded";
+const PARTITION_ROUTE_HEADER: &str = "iggy-partition-route";
+/// Partition 0 at incarnation 1, owner generation 0 and metadata op 0, with
+/// 5 s left. Every replica applied op 0, so the metadata hold ends at once.
+const ROUTE_AT_FIRST_OP: &str = "0,1,0,0,5000";
 const GROUP_POLL_BUDGET: Duration = Duration::from_secs(20);
 // QUIC may use the full 30-second response budget before a dead data connection
 // reports an uncertain outcome; recovery then needs a fresh routing attempt.
@@ -85,6 +101,18 @@ fn message(payload: &str) -> IggyMessage {
     IggyMessage::from_str(payload).expect("build message")
 }
 
+fn send_body(payload: &str) -> SendMessages {
+    let messages = vec![message(payload)];
+    SendMessages {
+        metadata_length: 0,
+        stream_id: Identifier::default(),
+        topic_id: Identifier::default(),
+        partitioning: Partitioning::partition_id(PARTITION_ID),
+        batch: IggyMessagesBatch::from(&messages),
+        context: None,
+    }
+}
+
 #[iggy_harness(cluster_nodes = 3)]
 async fn given_metadata_view_moved_when_producing_to_a_fresh_topic_should_reach_the_advertised_leader(
     harness: &mut TestHarness,
@@ -189,6 +217,155 @@ async fn given_metadata_view_moved_when_producing_to_a_fresh_topic_should_reach_
     );
 }
 
+/// Any node answers a partition route, so a client that reaches one node
+/// learns where every partition is served. The nodes agree on the partition's
+/// incarnation, count and primary. Send or poll permission is enough, and a
+/// user with neither learns nothing.
+#[iggy_harness(cluster_nodes = 3)]
+async fn given_any_node_when_routing_a_partition_should_name_the_same_primary(
+    harness: &mut TestHarness,
+) {
+    const PARTITIONS_COUNT: u32 = 2;
+    const PASSWORD: &str = "route-password";
+    const SENDER: &str = "route-sender";
+    const POLLER: &str = "route-poller";
+    const STRANGER: &str = "route-stranger";
+    let leader = harness.node(0).tcp_addr().unwrap();
+    let root = connect_without_login(leader).await;
+    root.login_user(DEFAULT_ROOT_USERNAME, DEFAULT_ROOT_PASSWORD)
+        .await
+        .unwrap();
+    root.create_stream(STREAM_NAME).await.unwrap();
+    let stream = Identifier::named(STREAM_NAME).unwrap();
+    root.create_topic(
+        &stream,
+        TOPIC_NAME,
+        &TopicCreateOptions {
+            partitions_count: Some(PARTITIONS_COUNT),
+            message_expiry: Some(IggyExpiry::NeverExpire),
+            ..TopicCreateOptions::default()
+        },
+    )
+    .await
+    .unwrap();
+    let topic = Identifier::named(TOPIC_NAME).unwrap();
+    for (name, global) in [
+        (
+            SENDER,
+            GlobalPermissions {
+                send_messages: true,
+                ..GlobalPermissions::default()
+            },
+        ),
+        (
+            POLLER,
+            GlobalPermissions {
+                poll_messages: true,
+                ..GlobalPermissions::default()
+            },
+        ),
+        (STRANGER, GlobalPermissions::default()),
+    ] {
+        root.create_user(
+            name,
+            PASSWORD,
+            UserStatus::Active,
+            Some(Permissions {
+                global,
+                streams: None,
+            }),
+        )
+        .await
+        .unwrap();
+    }
+    let route_request = GetPartitionRouteRequest {
+        stream_id: identifier_to_wire(&stream).unwrap(),
+        topic_id: identifier_to_wire(&topic).unwrap(),
+        partition_id: PARTITION_ID,
+    }
+    .to_bytes();
+
+    let stranger = connect_without_login(leader).await;
+    stranger.login_user(STRANGER, PASSWORD).await.unwrap();
+    let refused = stranger
+        .send_raw_with_response(GET_PARTITION_ROUTE_CODE, route_request.clone())
+        .await;
+    assert!(
+        matches!(refused, Err(IggyError::Unauthorized)),
+        "a user without send or poll permission must not learn the route, got {refused:?}"
+    );
+
+    let mut routes = Vec::new();
+    for name in [SENDER, POLLER] {
+        let member = connect_without_login(leader).await;
+        member.login_user(name, PASSWORD).await.unwrap();
+        let binding = BindSessionRequest {
+            version_info: ClientVersionInfo {
+                protocol_version: IGGY_PROTOCOL_VERSION,
+                sdk_name: WireName::new("routing-test").unwrap(),
+                sdk_version: WireName::new("1.0.0").unwrap(),
+            },
+            identity: member.session_identity().await.unwrap(),
+            bind_secret: member.session_bind_secret().await.unwrap(),
+        };
+        for node in 0..harness.cluster_size() {
+            let data = connect_without_login(harness.node(node).tcp_addr().unwrap()).await;
+            data.send_raw_with_response(BIND_SESSION_CODE, binding.to_bytes())
+                .await
+                .unwrap();
+            data.bind_vsr_session(binding.identity.session)
+                .await
+                .unwrap();
+            // A node creates its replica of a new partition after it applies
+            // the topic, and answers transient until then.
+            let route = timeout(PRECONDITION_BUDGET, async {
+                loop {
+                    match data
+                        .send_raw_with_response(GET_PARTITION_ROUTE_CODE, route_request.clone())
+                        .await
+                    {
+                        Ok(reply) => break PartitionRouteResponse::decode_from(&reply).unwrap(),
+                        Err(IggyError::TransientNotAccepted) => sleep(PRECONDITION_POLL).await,
+                        Err(error) => panic!("node {node} refused the route for {name}: {error:?}"),
+                    }
+                }
+            })
+            .await
+            .unwrap_or_else(|_| panic!("node {node} never routed the partition for {name}"));
+            routes.push((node, name, route));
+        }
+    }
+
+    let (_, _, first) = &routes[0];
+    assert_ne!(
+        first.incarnation, 0,
+        "a created partition has an incarnation"
+    );
+    assert_eq!(first.partitions_count, PARTITIONS_COUNT);
+    assert!(usize::from(first.primary_replica) < harness.cluster_size());
+    for (node, name, route) in &routes {
+        assert_eq!(
+            (
+                route.incarnation,
+                route.partitions_count,
+                route.view,
+                route.primary_replica
+            ),
+            (
+                first.incarnation,
+                first.partitions_count,
+                first.view,
+                first.primary_replica
+            ),
+            "node {node} answered {name} with a different route"
+        );
+        assert!(
+            route.metadata_op > 0,
+            "node {node} applied the topic, so its route reports a metadata op"
+        );
+    }
+}
+
 #[iggy_harness(cluster_nodes = 3, server(
     metadata.journal_slots = "256",
     consumer_group.rebalancing_timeout = "1s",
@@ -262,6 +439,368 @@ async fn given_split_primaries_when_http_auto_commits_on_a_backup_should_replica
     assert_replicated_offset(harness, &consumer, Some((GROUP_PAYLOADS.len() - 1) as u64)).await;
 }
 
+/// Every partition request an HTTP caller sends to a backup runs on the
+/// partition primary: the backup chooses the partition and relays the request.
+#[iggy_harness(cluster_nodes = 3, server(
+    metadata.journal_slots = "256",
+    http.jwt.encoding_secret = "0123456789abcdef0123456789abcdef",
+    http.jwt.decoding_secret = "0123456789abcdef0123456789abcdef"
+))]
+async fn given_split_primaries_when_http_calls_a_partition_backup_should_serve_on_the_primary(
+    harness: &mut TestHarness,
+) {
+    let (partition_primary, backup, _) = seed_split_primaries(harness).await;
+    assert_ne!(partition_primary, backup);
+    let http = HttpClient::login_root_no_redirect(node_url(harness, backup)).await;
+    let messages = http.url(&format!(
+        "/streams/{STREAM_NAME}/topics/{TOPIC_NAME}/messages"
+    ));
+    // The relay copies the partition primary's applied op onto each response.
+    let mut primary_applied_op = 0;
+    for (payload, query, status) in [
+        (RELAYED_PAYLOADS[0], "", StatusCode::CREATED),
+        (RELAYED_PAYLOADS[1], "?ack=none", StatusCode::ACCEPTED),
+    ] {
+        let response = http
+            .client
+            .post(format!("{messages}{query}"))
+            .bearer_auth(&http.token)
+            .json(&send_body(payload))
+            .send()
+            .await
+            .expect("send request");
+        assert_eq!(response.status(), status, "send{query} on a backup");
+        primary_applied_op = primary_applied_op.max(applied_op(&response));
+    }
+
+    let expected: Vec<&str> = GROUP_PAYLOADS.into_iter().chain(RELAYED_PAYLOADS).collect();
+    let poll = format!(
+        "{messages}?consumer_id={RELAY_CONSUMER_ID}&partition_id={PARTITION_ID}\
+         &kind=offset&value=0&count={}&auto_commit=false",
+        expected.len() + 1
+    );
+    let polled = timeout(GROUP_POLL_BUDGET, async {
+        loop {
+            let response = http
+                .client
+                .get(&poll)
+                .bearer_auth(&http.token)
+                .send()
+                .await
+                .expect("poll request");
+            assert_eq!(response.status(), StatusCode::OK, "poll on a backup");
+            let polled: PolledMessages = response.json().await.expect("decode PolledMessages");
+            if polled.messages.len() >= expected.len() {
+                break polled;
+            }
+            sleep(PRECONDITION_POLL).await;
+        }
+    })
+    .await
+    .expect("the unacknowledged send must commit on the partition primary");
+    let payloads: Vec<&[u8]> = polled
+        .messages
+        .iter()
+        .map(|message| message.payload.as_ref())
+        .collect();
+    let expected: Vec<&[u8]> = expected.iter().map(|payload| payload.as_bytes()).collect();
+    assert_eq!(payloads, expected);
+    assert!(
+        polled.context.metadata_op >= primary_applied_op,
+        "the relayed poll context {:?} must cover the primary's applied op {primary_applied_op}",
+        polled.context
+    );
+
+    let offsets = http.url(&format!(
+        "/streams/{STREAM_NAME}/topics/{TOPIC_NAME}/consumer-offsets"
+    ));
+    let offset = format!("{offsets}?consumer_id={RELAY_CONSUMER_ID}&partition_id={PARTITION_ID}");
+    let store = StoreConsumerOffset {
+        context: None,
+        consumer: Consumer::new(Identifier::numeric(RELAY_CONSUMER_ID).unwrap()),
+        partition_id: Some(PARTITION_ID),
+        offset: 1,
+    };
+    let response = http
+        .client
+        .put(&offsets)
+        .bearer_auth(&http.token)
+        .json(&store)
+        .send()
+        .await
+        .expect("store offset request");
+    assert_eq!(
+        response.status(),
+        StatusCode::NO_CONTENT,
+        "store on a backup"
+    );
+    pause_node(harness, partition_primary, true);
+    let (response, poll_response, ()) = tokio::join!(
+        http.client.get(&offset).bearer_auth(&http.token).send(),
+        http.client.get(&poll).bearer_auth(&http.token).send(),
+        async {
+            wait_for_queued_relays(harness, partition_primary, 2).await;
+            pause_node(harness, partition_primary, false);
+        },
+    );
+    let poll_response = poll_response.expect("relayed poll request");
+    assert_eq!(poll_response.status(), StatusCode::OK);
+    let relayed: PolledMessages = poll_response.json().await.expect("decode relayed poll");
+    assert_eq!(relayed.messages.len(), expected.len());
+    let response = response.expect("relayed get offset request");
+    assert_eq!(response.status(), StatusCode::OK, "get on a backup");
+    let info: ConsumerOffsetInfo = response.json().await.expect("decode ConsumerOffsetInfo");
+    assert_eq!(info.stored_offset, store.offset);
+    let response = http
+        .client
+        .delete(format!(
+            "{offsets}/{RELAY_CONSUMER_ID}?partition_id={PARTITION_ID}"
+        ))
+        .bearer_auth(&http.token)
+        .send()
+        .await
+        .expect("delete offset request");
+    assert_eq!(
+        response.status(),
+        StatusCode::NO_CONTENT,
+        "delete on a backup"
+    );
+    let response = http
+        .client
+        .get(&offset)
+        .bearer_auth(&http.token)
+        .send()
+        .await
+        .expect("get offset request after delete");
+    assert_eq!(response.status(), StatusCode::NOT_FOUND, "deleted offset");
+}
+
+/// A relayed request carries the partition the contacted node chose. A backup
+/// refuses it with `NotPrimary` instead of relaying it again, a malformed route
+/// is the caller's error, and a route without the relay marker is ignored.
+#[iggy_harness(cluster_nodes = 3, server(
+    metadata.journal_slots = "256",
+    http.jwt.encoding_secret = "0123456789abcdef0123456789abcdef",
+    http.jwt.decoding_secret = "0123456789abcdef0123456789abcdef"
+))]
+async fn given_split_primaries_when_a_relayed_request_reaches_a_backup_should_refuse_not_primary(
+    harness: &mut TestHarness,
+) {
+    let (partition_primary, backup, _) = seed_split_primaries(harness).await;
+    assert_ne!(partition_primary, backup);
+    let http = HttpClient::login_root_no_redirect(node_url(harness, backup)).await;
+    let messages = http.url(&format!(
+        "/streams/{STREAM_NAME}/topics/{TOPIC_NAME}/messages"
+    ));
+    let poll = format!(
+        "{messages}?consumer_id={RELAY_CONSUMER_ID}&partition_id={PARTITION_ID}\
+         &kind=offset&value=0&count=1&auto_commit=false"
+    );
+    let send = |route: &'static str| {
+        http.client
+            .post(&messages)
+            .bearer_auth(&http.token)
+            .header(PARTITION_ROUTE_HEADER, route)
+            .json(&send_body(RELAYED_PAYLOADS[0]))
+    };
+
+    for (request, name) in [
+        (
+            send(ROUTE_AT_FIRST_OP).header(FORWARDED_HEADER, "1"),
+            "send",
+        ),
+        (
+            http.client
+                .get(&poll)
+                .bearer_auth(&http.token)
+                .header(FORWARDED_HEADER, "1")
+                .header(PARTITION_ROUTE_HEADER, ROUTE_AT_FIRST_OP),
+            "poll",
+        ),
+    ] {
+        let response = request.send().await.expect("relayed request");
+        assert_eq!(
+            response.status(),
+            StatusCode::SERVICE_UNAVAILABLE,
+            "relayed {name} on a backup"
+        );
+        assert!(response.headers().contains_key(RETRY_AFTER));
+        let body: serde_json::Value = response.json().await.expect("decode the error body");
+        assert_eq!(
+            body["id"],
+            IggyError::NotPrimary.as_code(),
+            "relayed {name}"
+        );
+    }
+
+    let response = send("0,1")
+        .header(FORWARDED_HEADER, "1")
+        .send()
+        .await
+        .expect("malformed relayed send");
+    assert_eq!(response.status(), StatusCode::BAD_REQUEST);
+
+    let response = send(ROUTE_AT_FIRST_OP)
+        .send()
+        .await
+        .expect("send with a route and no relay marker");
+    assert_eq!(response.status(), StatusCode::CREATED);
+}
+
+/// A read changes no state, so a backup resends a relayed read whose partition
+/// primary died before it answered, and the caller gets the answer of the next
+/// primary. A poll with `auto_commit` may have moved its offset, so its outcome
+/// stays unknown.
+#[iggy_harness(cluster_nodes = 3, server(
+    metadata.journal_slots = "256",
+    http.jwt.encoding_secret = "0123456789abcdef0123456789abcdef",
+    http.jwt.decoding_secret = "0123456789abcdef0123456789abcdef"
+))]
+async fn given_split_primaries_when_a_relayed_read_loses_its_primary_should_resend_it(
+    harness: &mut TestHarness,
+) {
+    let (partition_primary, backup, _) = seed_split_primaries(harness).await;
+    assert_ne!(partition_primary, backup);
+    let http = HttpClient::login_root_no_redirect(node_url(harness, backup)).await;
+    let offsets = http.url(&format!(
+        "/streams/{STREAM_NAME}/topics/{TOPIC_NAME}/consumer-offsets"
+    ));
+    let store = StoreConsumerOffset {
+        context: None,
+        consumer: Consumer::new(Identifier::numeric(RELAY_CONSUMER_ID).unwrap()),
+        partition_id: Some(PARTITION_ID),
+        offset: 1,
+    };
+    let response = http
+        .client
+        .put(&offsets)
+        .bearer_auth(&http.token)
+        .json(&store)
+        .send()
+        .await
+        .expect("store offset request");
+    assert_eq!(response.status(), StatusCode::NO_CONTENT);
+    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 get = |url: String| http.client.get(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)),
+        async {
+            wait_for_queued_relays(harness, partition_primary, 3).await;
+            harness
+                .kill_node(partition_primary)
+                .expect("kill the frozen partition primary");
+        },
+    );
+
+    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
+    } else {
+        response
+    };
+    let (status, body) = status_and_body(response).await;
+    assert_eq!(status, StatusCode::OK, "poll without auto_commit: {body}");
+    let polled: PolledMessages = serde_json::from_str(&body).expect("decode PolledMessages");
+    assert_eq!(polled.messages.len(), GROUP_PAYLOADS.len());
+    let (status, body) = status_and_body(auto_commit.expect("poll with auto_commit")).await;
+    assert_eq!(
+        status,
+        StatusCode::BAD_GATEWAY,
+        "poll with auto_commit: {body}"
+    );
+    let body: serde_json::Value = serde_json::from_str(&body).expect("decode the error body");
+    assert_eq!(body["code"], "forward_failed");
+    let (status, body) = status_and_body(offset_read.expect("offset read")).await;
+    // A new primary refuses to read a stored offset until it applied its view
+    // start. That refusal admits nothing, so the caller resends the read.
+    let (status, body) = if status == StatusCode::SERVICE_UNAVAILABLE {
+        status_and_body(
+            until_primary_resolved(|| async { get(offset.clone()).await.expect("offset read") })
+                .await,
+        )
+        .await
+    } else {
+        (status, body)
+    };
+    assert_eq!(status, StatusCode::OK, "offset read: {body}");
+    let info: ConsumerOffsetInfo = serde_json::from_str(&body).expect("decode ConsumerOffsetInfo");
+    assert_eq!(info.stored_offset, store.offset);
+}
+
+fn pause_node(harness: &TestHarness, node: usize, paused: bool) {
+    let pid = harness.node(node).pid().expect("the node is running");
+    let signal = if paused { libc::SIGSTOP } else { libc::SIGCONT };
+    // SAFETY: the signal targets a child this harness owns and has not reaped.
+    assert_eq!(unsafe { libc::kill(pid as libc::pid_t, signal) }, 0);
+}
+
+/// The stopped HTTP listener cannot consume bytes. Nonempty receive queues
+/// prove the relay requests reached it before it is resumed or killed.
+async fn wait_for_queued_relays(harness: &TestHarness, node: usize, expected: usize) {
+    const TCP_ESTABLISHED: &str = "01";
+    let port = harness
+        .node(node)
+        .http_addr()
+        .expect("node HTTP address")
+        .port();
+    timeout(PRECONDITION_BUDGET, async {
+        loop {
+            let queued = ["/proc/net/tcp", "/proc/net/tcp6"]
+                .into_iter()
+                .map(|path| {
+                    std::fs::read_to_string(path)
+                        .expect("read the kernel TCP connection table")
+                        .lines()
+                        .skip(1)
+                        .filter(|line| {
+                            let fields: Vec<_> = line.split_whitespace().collect();
+                            fields.get(3) == Some(&TCP_ESTABLISHED)
+                                && fields
+                                    .get(1)
+                                    .and_then(|address| address.rsplit_once(':'))
+                                    .and_then(|(_, port)| u16::from_str_radix(port, 16).ok())
+                                    == Some(port)
+                                && fields
+                                    .get(4)
+                                    .and_then(|queues| queues.split_once(':'))
+                                    .and_then(|(_, received)| {
+                                        usize::from_str_radix(received, 16).ok()
+                                    })
+                                    .is_some_and(|received| received > 0)
+                        })
+                        .count()
+                })
+                .sum::<usize>();
+            if queued >= expected {
+                break;
+            }
+            sleep(Duration::from_millis(10)).await;
+        }
+    })
+    .await
+    .expect("every HTTP relay must reach the paused partition primary");
+}
+
+async fn status_and_body(response: reqwest::Response) -> (StatusCode, String) {
+    let status = response.status();
+    (status, response.text().await.expect("response body"))
+}
+
 #[iggy_harness(cluster_nodes = 3, server(metadata.journal_slots = "256"))]
 #[ignore = "requires Go; run this test explicitly with --ignored"]
 async fn given_split_primaries_when_go_group_auto_commits_should_preserve_membership(
@@ -898,6 +1437,18 @@ async fn assert_data_session_fences(
     data.send_raw_with_context(POLL_MESSAGES_ON_PRIMARY_CODE, poll.clone(), route.context)
         .await
         .unwrap();
+    // A caller without the owner generation sends 0, and the primary binds it
+    // to the owner it installed for this session.
+    let mut unbound = route.context;
+    unbound.owner_generation = 0;
+    let response = data
+        .send_raw_with_context(POLL_MESSAGES_ON_PRIMARY_CODE, poll.clone(), unbound)
+        .await
+        .expect("owner 0 must bind to the owner installed for this session");
+    assert_eq!(
+        PolledMessages::from_bytes(response).unwrap().partition_id,
+        PARTITION_ID
+    );
 
     member
         .leave_consumer_group(stream, topic, &consumer.id)
@@ -934,6 +1485,15 @@ async fn assert_data_session_fences(
         "the attached session must respect the member leaving"
     );
     assert!(polled.messages.is_empty());
+    let response = data
+        .send_raw_with_context(POLL_MESSAGES_ON_PRIMARY_CODE, poll.clone(), unbound)
+        .await
+        .unwrap();
+    assert_eq!(
+        PolledMessages::from_bytes(response).unwrap().partition_id,
+        RESYNC_REQUIRED_PARTITION_SENTINEL,
+        "owner 0 must not outlive the membership"
+    );
     let offset_write = StoreConsumerOffsetRequest {
         consumer: consumer_to_wire(consumer).unwrap(),
         stream_id: identifier_to_wire(stream).unwrap(),
diff --git a/core/integration/tests/cluster/partition_state_transfer.rs b/core/integration/tests/cluster/partition_state_transfer.rs
index d09616d68..ef16db51f 100644
--- a/core/integration/tests/cluster/partition_state_transfer.rs
+++ b/core/integration/tests/cluster/partition_state_transfer.rs
@@ -224,45 +224,27 @@ async fn given_evicted_ring_when_fresh_node_joins_late_should_state_transfer_par
 async fn given_evicted_ring_when_node_restarts_with_data_should_state_transfer_partition(
     harness: &mut TestHarness,
 ) {
-    // Node 2 holds a durable prefix, then misses enough traffic that the
-    // survivors' ring moves past its durable end: its repaired window cannot
-    // connect, which is exactly the refusal-site trigger.
-    let client = harness
-        .root_client_for_node(0)
-        .await
-        .expect("connect a root client to the node");
-    seed_topic(&client, None).await;
-    produce(&client, 40).await;
-    sleep(Duration::from_secs(1)).await;
-    harness.stop_node(2).expect("stop node 2");
-
-    produce(&client, MESSAGES_COUNT).await;
-    let _seed_client = client;
-
-    harness.restart_node(2).expect("restart node 2 with data");
-
-    await_marker(harness, 2, CONVERSION_MARKER).await;
-    await_marker(harness, 2, INSTALL_MARKER).await;
-    // The serving side proves the pull actually ran (not a vacuous install).
-    // Retried like every other marker check here: stdout goes through the
-    // buffered non-blocking appender, so a survivor's line can trail node 2's
-    // install by more than one poll.
-    await_marker_any(harness, &[0, 1], FULLY_SERVED_MARKER).await;
+    restart_lagging_node_and_await_install(harness).await;
+}
 
-    // The markers only say the machinery ran. Read node 2's OWN segment bytes
-    // and check every produced payload is there, in ascending file order: a
-    // truncated, short or reordered install fails here and passes above.
-    //
-    // Read off disk rather than polled: the SDK is leader-aware and redirects
-    // a poll to the primary, so no client-side read can be pinned to the
-    // rejoined node.
-    // Two produce runs, each numbering its payloads from 0, so the expected
-    // chain is 0..40 followed by 0..MESSAGES_COUNT.
-    let expected: Vec<String> = (0..40)
-        .chain(0..MESSAGES_COUNT)
-        .map(|sequence| format!("message-{sequence}"))
-        .collect();
-    await_installed_payloads(harness, 2, &expected).await;
+/// The survivors' ring holds more than one repair chunk (`repair_chunk_max`,
+/// 128 ops) above node 2's floor, yet fewer than the 200 ops node 2 misses, so
+/// the floor is still evicted. Nothing is written after the restart, so the
+/// ring never slides the window down to one chunk. The chunk is pinned: one of
+/// 150 ops or more covers the window in one round, and a refusal that waits
+/// for the whole window would then pass too.
+#[iggy_harness(
+    cluster_nodes = 3,
+    server(
+        sharding.cpu_allocation = "0..1",
+        partition.evicted_ring_capacity = "150",
+        cluster.repair_chunk_max = "128"
+    )
+)]
+async fn given_evicted_ring_wider_than_repair_chunk_when_node_restarts_with_data_should_state_transfer_partition(
+    harness: &mut TestHarness,
+) {
+    restart_lagging_node_and_await_install(harness).await;
 }
 
 #[iggy_harness(
@@ -371,6 +353,48 @@ async fn given_transfer_peer_dies_when_stalled_should_leave_dead_peer_and_recove
     }
 }
 
+async fn restart_lagging_node_and_await_install(harness: &mut TestHarness) {
+    // Node 2 holds a durable prefix, then misses enough traffic that the
+    // survivors' ring moves past its durable end: its repaired window cannot
+    // connect, which is exactly the refusal-site trigger.
+    let client = harness
+        .root_client_for_node(0)
+        .await
+        .expect("connect a root client to the node");
+    seed_topic(&client, None).await;
+    produce(&client, 40).await;
+    sleep(Duration::from_secs(1)).await;
+    harness.stop_node(2).expect("stop node 2");
+
+    produce(&client, MESSAGES_COUNT).await;
+    let _seed_client = client;
+
+    harness.restart_node(2).expect("restart node 2 with data");
+
+    await_marker(harness, 2, CONVERSION_MARKER).await;
+    await_marker(harness, 2, INSTALL_MARKER).await;
+    // The serving side proves the pull actually ran (not a vacuous install).
+    // Retried like every other marker check here: stdout goes through the
+    // buffered non-blocking appender, so a survivor's line can trail node 2's
+    // install by more than one poll.
+    await_marker_any(harness, &[0, 1], FULLY_SERVED_MARKER).await;
+
+    // The markers only say the machinery ran. Read node 2's OWN segment bytes
+    // and check every produced payload is there, in ascending file order: a
+    // truncated, short or reordered install fails here and passes above.
+    //
+    // Read off disk rather than polled: the SDK is leader-aware and redirects
+    // a poll to the primary, so no client-side read can be pinned to the
+    // rejoined node.
+    // Two produce runs, each numbering its payloads from 0, so the expected
+    // chain is 0..40 followed by 0..MESSAGES_COUNT.
+    let expected: Vec<String> = (0..40)
+        .chain(0..MESSAGES_COUNT)
+        .map(|sequence| format!("message-{sequence}"))
+        .collect();
+    await_installed_payloads(harness, 2, &expected).await;
+}
+
 async fn connect_any(harness: &TestHarness, nodes: &[usize]) -> Option<IggyClient> {
     for &node in nodes {
         if let Ok(builder) = harness.node(node).tcp_client()
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 346ed6dee..c1459d06a 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
@@ -39,7 +39,7 @@ use std::collections::HashSet;
 use std::str::FromStr;
 use std::sync::Arc;
 use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
-use tokio::time::{Duration, sleep};
+use tokio::time::{Duration, Instant, sleep};
 
 const STREAM_NAME: &str = "cg-partition-test-stream";
 const TOPIC_NAME: &str = "cg-partition-test-topic";
@@ -50,6 +50,10 @@ const PARTITIONS_COUNT: u32 = 3;
 /// `consumer_group.rebalancing_timeout`, which outlasts this bound, so the
 /// deadline cannot complete the handoff for them.
 const MEMBERS_CONVERGENCE_TIMEOUT: Duration = Duration::from_secs(15);
+/// Covers two passes of the default 1 s `reconcile_periodic_interval` with
+/// margin, and stays far below the default `consumer_group.rebalancing_timeout`.
+const LEAVE_OBSERVATION_WINDOW: Duration = Duration::from_secs(3);
+const MEMBER_POLL_INTERVAL: Duration = Duration::from_millis(100);
 /// Slices the slab-reuse wait so the surviving consumer can prove liveness
 /// inside the server's staleness window. Product of the two is the 3s the
 /// spec waits for the freed slab to become reusable.
@@ -2376,6 +2380,9 @@ async fn should_not_return_same_message_to_two_consumers_during_rebalance(harnes
         .await
         .unwrap();
     join_cg(&client2).await;
+    // The join moves a partition client1 was never served, so its handoff
+    // needs no commit. Before that install client2 owns nothing and polls empty.
+    await_members_count(&root_client, 2).await;
 
     // 4. Consumer2 polls - it must NOT get the same partition as consumer1's
     //    in-flight message. Due to cooperative rebalance, the partition with
@@ -2393,23 +2400,27 @@ async fn should_not_return_same_message_to_two_consumers_during_rebalance(harnes
         .await
         .unwrap();
 
-    if !polled2.messages.is_empty() {
-        assert_ne!(
-            polled2.partition_id,
-            partition_polled_by_client1,
-            "Consumer2 got partition {} which consumer1 is still processing! \
-             Message ID from consumer1: offset={}, partition={}. \
-             Message ID from consumer2: offset={}, partition={}. \
-             This is the duplicate processing bug!",
-            polled2.partition_id,
-            message_from_client1.header.offset,
-            partition_polled_by_client1,
-            polled2.messages[0].header.offset,
-            polled2.partition_id,
-        );
-    }
+    assert_eq!(
+        polled2.messages.len(),
+        1,
+        "Consumer2 must get the message of the partition it was handed, got partition {}",
+        polled2.partition_id
+    );
+    assert_ne!(
+        polled2.partition_id,
+        partition_polled_by_client1,
+        "Consumer2 got partition {} which consumer1 is still processing! \
+         Message ID from consumer1: offset={}, partition={}. \
+         Message ID from consumer2: offset={}, partition={}. \
+         This is the duplicate processing bug!",
+        polled2.partition_id,
+        message_from_client1.header.offset,
+        partition_polled_by_client1,
+        polled2.messages[0].header.offset,
+        polled2.partition_id,
+    );
 
-    // 5. Now consumer1 commits the offset
+    // 5. Consumer1 commits its in-flight message on the partition it kept
     client1
         .store_consumer_offset(
             &consumer,
@@ -2421,10 +2432,7 @@ async fn should_not_return_same_message_to_two_consumers_during_rebalance(harnes
         .await
         .unwrap();
 
-    // 6. Give the server a moment to process the revocation completion
-    sleep(Duration::from_millis(100)).await;
-
-    // 7. After commit, the partition should have transferred. Verify final state.
+    // 6. The commit hands off nothing, so the assignments stay unique
     let cg = await_members_count(&root_client, 2).await;
     assert_eq!(cg.members_count, 2);
     assert_unique_partition_assignments(&cg);
@@ -3202,7 +3210,6 @@ async fn should_not_assign_partition_to_wrong_member_after_slab_reuse(harness: &
 }
 
 #[iggy_harness(test_client_transport = [Tcp, WebSocket, Quic], server(
-    consumer_group.rebalancing_timeout = "1s",
     heartbeat.enabled = true,
     heartbeat.interval = "60s",
     consumer_group.heartbeat_interval = "500ms",
@@ -3223,8 +3230,9 @@ async fn should_not_complete_other_members_revocations_on_leave(harness: &TestHa
     join_cg(&client1).await;
 
     let consumer = Consumer::group(Identifier::named(CONSUMER_GROUP_NAME).unwrap());
+    let mut polled_offsets = Vec::with_capacity(PARTITIONS_COUNT as usize);
     for _ in 0..PARTITIONS_COUNT {
-        client1
+        let polled = client1
             .poll_messages(
                 &Identifier::named(STREAM_NAME).unwrap(),
                 &Identifier::named(TOPIC_NAME).unwrap(),
@@ -3236,6 +3244,8 @@ async fn should_not_complete_other_members_revocations_on_leave(harness: &TestHa
             )
             .await
             .unwrap();
+        assert_eq!(polled.messages.len(), 1);
+        polled_offsets.push((polled.partition_id, polled.messages[0].header.offset));
     }
 
     // 2. Consumer2 joins — triggers pending revocation for 1 partition
@@ -3264,6 +3274,39 @@ async fn should_not_complete_other_members_revocations_on_leave(harness: &TestHa
     client3.shutdown().await.unwrap();
     drop(client3);
 
+    // Client1 committed nothing and the default deadline outlasts this window,
+    // so only the leave could complete client2's revocation here.
+    let deadline = Instant::now() + MEMBERS_CONVERGENCE_TIMEOUT;
+    while get_consumer_group(&root_client).await.members_count != 2 {
+        assert!(
+            Instant::now() < deadline,
+            "Client3 did not leave the group within {MEMBERS_CONVERGENCE_TIMEOUT:?}"
+        );
+        sleep(MEMBER_POLL_INTERVAL).await;
+    }
+    sleep(LEAVE_OBSERVATION_WINDOW).await;
+    let cg = get_consumer_group(&root_client).await;
+    assert!(
+        cg.members
+            .iter()
+            .any(|member| member.partitions_count == PARTITIONS_COUNT),
+        "The leave completed a revocation that client1 never drained. Members: {:?}",
+        cg.members
+    );
+
+    for (partition_id, offset) in &polled_offsets {
+        client1
+            .store_consumer_offset(
+                &consumer,
+                &Identifier::named(STREAM_NAME).unwrap(),
+                &Identifier::named(TOPIC_NAME).unwrap(),
+                Some(*partition_id),
+                *offset,
+            )
+            .await
+            .unwrap();
+    }
+
     let cg = await_members_count(&root_client, 2).await;
     assert_eq!(cg.members_count, 2);
     assert_unique_partition_assignments(&cg);
diff --git a/core/integration/tests/sdk/consumer_group_membership.rs b/core/integration/tests/sdk/consumer_group_membership.rs
index 79f2218b7..3eef81c91 100644
--- a/core/integration/tests/sdk/consumer_group_membership.rs
+++ b/core/integration/tests/sdk/consumer_group_membership.rs
@@ -633,13 +633,18 @@ async fn given_weak_offset_durability_when_owner_changes_should_preserve_progres
     let recovered = harness.tcp_root_client().await.unwrap();
     timeout(RESOLVE_TIMEOUT, async {
         loop {
-            let offset = recovered
+            match recovered
                 .get_consumer_offset(&consumer, &stream_id, &topic_id, Some(0))
                 .await
-                .unwrap();
-            if let Some(offset) = offset {
-                assert_eq!(offset.stored_offset, STORED_OFFSET);
-                break;
+            {
+                Ok(Some(offset)) => {
+                    assert_eq!(offset.stored_offset, STORED_OFFSET);
+                    break;
+                }
+                // Right after a whole-cluster restart, every node can refuse
+                // the read while it recovers the partition.
+                Ok(None) | Err(IggyError::TransientNotAccepted) => {}
+                Err(error) => panic!("reading the recovered offset failed: {error}"),
             }
             sleep(ASSIGNMENT_POLL_INTERVAL).await;
         }
diff --git a/core/integration/tests/server/http_client.rs b/core/integration/tests/server/http_client.rs
index 8e51eb942..7543bbed7 100644
--- a/core/integration/tests/server/http_client.rs
+++ b/core/integration/tests/server/http_client.rs
@@ -40,6 +40,8 @@ pub const LOGIN_TIMEOUT: Duration = Duration::from_secs(15);
 pub const LOGIN_RETRY_INTERVAL: Duration = Duration::from_millis(50);
 pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
 
+const APPLIED_OP_HEADER: &str = "iggy-applied-op";
+
 /// One authenticated HTTP session against the test server's shard-0 listener: a
 /// `reqwest` client, the listener base URL, and the bearer to send. The bearer
 /// is either a login JWT or a raw personal access token (the server resolves
@@ -269,3 +271,18 @@ pub async fn access_token(response: Response) -> String {
         .expect("login must return an access token")
         .token
 }
+
+/// The serving node's applied metadata op, as stamped on every authenticated
+/// success. A response without it is a contract violation, not an absence: the
+/// relay records this value as the caller's read-your-writes floor, so a
+/// missing header would silently reopen the stale read it exists to close.
+pub fn applied_op(response: &Response) -> u64 {
+    response
+        .headers()
+        .get(APPLIED_OP_HEADER)
+        .expect("every authenticated success carries iggy-applied-op")
+        .to_str()
+        .expect("iggy-applied-op must be ASCII")
+        .parse()
+        .expect("iggy-applied-op must be an op number")
+}
diff --git a/core/integration/tests/server/http_read_your_writes.rs b/core/integration/tests/server/http_read_your_writes.rs
index f428f79ad..dcdd61039 100644
--- a/core/integration/tests/server/http_read_your_writes.rs
+++ b/core/integration/tests/server/http_read_your_writes.rs
@@ -61,11 +61,11 @@
 
 use iggy::prelude::*;
 use integration::iggy_harness;
-use reqwest::{Response, StatusCode};
+use reqwest::StatusCode;
 use serde_json::{Value, json};
 
 use crate::server::http_client::{
-    HttpClient, leader_and_follower, node_url, until_primary_resolved,
+    HttpClient, applied_op, leader_and_follower, node_url, until_primary_resolved,
 };
 
 /// Seed / read-back rounds. More than one because the lag is a race the test
@@ -73,21 +73,6 @@ use crate::server::http_client::{
 /// different position relative to the epoch it just handed out.
 const ROUNDS: u32 = 4;
 
-/// The serving node's applied metadata op, as stamped on every authenticated
-/// success. A response without it is a contract violation, not an absence: the
-/// relay records this value as the caller's read-your-writes floor, so a
-/// missing header would silently reopen the stale read it exists to close.
-fn applied_op(response: &Response) -> u64 {
-    response
-        .headers()
-        .get("iggy-applied-op")
-        .expect("every authenticated success carries iggy-applied-op")
-        .to_str()
-        .expect("iggy-applied-op must be ASCII")
-        .parse()
-        .expect("iggy-applied-op must be an op number")
-}
-
 /// The `name` of every stream in a `GET /streams` list body.
 fn stream_names(body: &Value) -> Vec<String> {
     body.as_array()
diff --git a/core/integration/tests/server/http_vsr.rs b/core/integration/tests/server/http_vsr.rs
index da9702bb6..7bdbe0562 100644
--- a/core/integration/tests/server/http_vsr.rs
+++ b/core/integration/tests/server/http_vsr.rs
@@ -21,7 +21,7 @@
 //! `iggy-durability` header, the body-size cap, and cross-request isolation
 //! of concurrent produces on one login session.
 
-use crate::server::http_client::HttpClient;
+use crate::server::http_client::{HttpClient, applied_op, leader_and_follower, node_url};
 use futures::future::join_all;
 use iggy::prelude::*;
 use iggy_common::create_stream::CreateStream;
@@ -57,7 +57,8 @@ const ASYNC_COMMIT_RETRY_INTERVAL: Duration = Duration::from_millis(100);
 /// exercises the listener's produce / poll / consumer-offset behavior, so its
 /// helpers decode `PolledMessages` and drive multi-partition produces.
 trait HttpSessionExt {
-    async fn create_stream_and_topic(&self, stream: &str, topic: &str, partitions: u32);
+    /// Returns the applied op of the topic create.
+    async fn create_stream_and_topic(&self, stream: &str, topic: &str, partitions: u32) -> u64;
     async fn produce(
         &self,
         stream: &str,
@@ -93,7 +94,7 @@ trait HttpSessionExt {
 }
 
 impl HttpSessionExt for HttpClient {
-    async fn create_stream_and_topic(&self, stream: &str, topic: &str, partitions: u32) {
+    async fn create_stream_and_topic(&self, stream: &str, topic: &str, partitions: u32) -> u64 {
         let response = self
             .client
             .post(self.url("/streams"))
@@ -136,15 +137,11 @@ impl HttpSessionExt for HttpClient {
         // Body included on failure: a 500 from a serialization fault names
         // itself only there, and status alone sent this down a long detour.
         let status = response.status();
-        let body = if status.is_success() {
-            String::new()
-        } else {
-            response.text().await.unwrap_or_default()
-        };
-        assert!(
-            status.is_success(),
-            "create topic failed: {status} body={body}"
-        );
+        if status.is_success() {
+            return applied_op(&response);
+        }
+        let body = response.text().await.unwrap_or_default();
+        panic!("create topic failed: {status} body={body}");
     }
 
     async fn produce(
@@ -387,6 +384,41 @@ async fn given_http_session_when_producing_and_polling_should_round_trip(harness
     );
 }
 
+/// A fresh partition that only plain consumers read keeps a zero metadata
+/// frontier. The served context must still cover the topic create: below it,
+/// a later poll on a node that has not applied the create passes the frontier
+/// check and drops its position on `HistoryUnavailable`.
+#[iggy_harness]
+async fn given_fresh_topic_when_polling_over_http_should_serve_a_context_at_or_above_the_topic_create(
+    harness: &TestHarness,
+) {
+    const STREAM: &str = "http-context-stream";
+    const TOPIC: &str = "http-context-topic";
+    let http = HttpClient::login_root(harness).await;
+    let topic_create_op = http.create_stream_and_topic(STREAM, TOPIC, 1).await;
+    let response = http
+        .produce(
+            STREAM,
+            TOPIC,
+            PARTITION_ID,
+            vec![text_message(1, "served-context".to_string())],
+        )
+        .await;
+    assert_eq!(
+        response.status(),
+        StatusCode::CREATED,
+        "produce must commit"
+    );
+
+    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,
+        "the served context {:?} must cover the topic create at op {topic_create_op}",
+        polled.context
+    );
+}
+
 const CONCURRENT_PRODUCES: usize = 16;
 const CONCURRENT_PARTITIONS: u32 = 4;
 
@@ -1287,3 +1319,29 @@ async fn given_http_sdk_when_sending_should_expose_the_advertised_durability(
     assert_eq!(durability, Some(Durability::Persisted));
     assert_eq!(response.confirmations.len(), 1);
 }
+
+/// Without cluster-wide bearer key material, forwarding is off and a follower
+/// cannot relay a control-plane write. Its consensus answers `NotPrimary`, and
+/// replaying on a backup cannot reach the primary, so the follower refuses with
+/// 58 at once instead of replaying until the transient budget ends.
+#[iggy_harness(cluster_nodes = 3, server(sharding.cpu_allocation = "0..1"))]
+async fn given_forwarding_off_when_a_follower_takes_a_write_should_refuse_at_once(
+    harness: &TestHarness,
+) {
+    /// Far below the 30 s transient replay budget of the submit path.
+    const REFUSAL_BUDGET: Duration = Duration::from_secs(5);
+    let (_, follower) = leader_and_follower(harness).await;
+    let http = HttpClient::login_root_no_redirect(node_url(harness, follower)).await;
+    let started = Instant::now();
+    let response = http
+        .post_json("/streams", &json!({ "name": "follower-write" }))
+        .await;
+    let elapsed = started.elapsed();
+    assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
+    let body: serde_json::Value = response.json().await.expect("decode the error body");
+    assert_eq!(body["id"], IggyError::TransientNotAccepted.as_code());
+    assert!(
+        elapsed < REFUSAL_BUDGET,
+        "a follower refused after {elapsed:?}, the replay budget is not for backups"
+    );
+}
diff --git a/core/integration/tests/server/mod.rs b/core/integration/tests/server/mod.rs
index e747305cd..aed696bd1 100644
--- a/core/integration/tests/server/mod.rs
+++ b/core/integration/tests/server/mod.rs
@@ -29,6 +29,9 @@ mod legacy_login_vsr;
 // arm) must deny typed (InvalidCommand) at the read gate, and an undeclared
 // operation byte at the funnel, not stall or reply empty-ok.
 mod unknown_code_vsr;
+// A read held for the metadata frontier runs off the connection drain, so a
+// frame pipelined behind it gets its reply first.
+mod pipelined_frames_vsr;
 // A failed credential login must report the credential failure, not the
 // payload shape it fell through to.
 mod login_credentials_vsr;
diff --git a/core/integration/tests/server/pipelined_frames_vsr.rs b/core/integration/tests/server/pipelined_frames_vsr.rs
new file mode 100644
index 000000000..05ada2fdc
--- /dev/null
+++ b/core/integration/tests/server/pipelined_frames_vsr.rs
@@ -0,0 +1,303 @@
+// 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.
+
+//! Frames a client pipelines on one connection, against the server (vsr). A
+//! read held for the metadata frontier runs in its own task, so a frame sent
+//! behind it on the same connection gets its reply first.
+//!
+//! The frames are hand-crafted on a raw TCP socket, because the SDK waits for
+//! 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::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::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 integration::harness::{TestBinary, TestHarness};
+use integration::iggy_harness;
+
+use crate::server::raw_tcp::{
+    connect, non_replicated_header, partition_context, read_reply, register_root, request_header,
+    write_frame,
+};
+
+/// The header validator requires a nonzero client id; the value is otherwise
+/// free since nothing here reconnects.
+const CLIENT_ID: u128 = 0x9_1BE;
+
+/// No commit reaches this op, so a read that waits for it is held for its
+/// whole budget and then fails retryable.
+const UNREACHED_OP: u64 = u64::MAX;
+
+const READ_REQUEST: u64 = 1;
+const PING_REQUEST: u64 = 2;
+
+#[iggy_harness]
+async fn given_a_held_read_when_a_ping_follows_on_one_tcp_connection_should_answer_the_ping_first(
+    harness: &TestHarness,
+) {
+    let mut stream = connect(harness).await;
+    let session = register_root(&mut stream, CLIENT_ID).await;
+
+    let mut read = non_replicated_header(CLIENT_ID, session, READ_REQUEST, GET_ME_CODE);
+    read.minimum_metadata_op = UNREACHED_OP;
+    write_frame(&mut stream, &read, &[]).await;
+    let ping = non_replicated_header(CLIENT_ID, session, PING_REQUEST, PING_CODE);
+    write_frame(&mut stream, &ping, &[]).await;
+
+    let first = reply_header(&read_reply(&mut stream).await.0);
+    assert_eq!(
+        first.request, PING_REQUEST,
+        "the ping must not wait for the held read"
+    );
+    assert_eq!(first.status, 0, "a served ping succeeds");
+    let second = reply_header(&read_reply(&mut stream).await.0);
+    assert_eq!(second.request, READ_REQUEST);
+    assert_eq!(
+        second.status,
+        IggyError::TransientNotAccepted.as_code(),
+        "a read held past its budget must fail retryable"
+    );
+}
+
+fn reply_header(header: &[u8]) -> ReplyHeader {
+    bytemuck::checked::try_pod_read_unaligned::<ReplyHeader>(header)
+        .expect("a Reply header decodes")
+}
+
+#[iggy_harness(cluster_nodes = 3)]
+async fn given_a_pending_metadata_write_when_a_read_and_ping_follow_should_order_the_read_and_serve_ping(
+    harness: &TestHarness,
+) {
+    const NAME: &str = "pipelined-created-stream";
+    const CREATE: u64 = 1;
+    const READ: u64 = 2;
+    const PING: u64 = 3;
+    let mut socket = connect(harness).await;
+    let session = register_root(&mut socket, CLIENT_ID).await;
+    for node in 1..harness.cluster_size() {
+        signal_node(harness, node, libc::SIGSTOP);
+    }
+    let create = CreateStreamRequest {
+        name: WireName::new(NAME).unwrap(),
+        options: WireOptions::empty(),
+    }
+    .to_bytes();
+    let header = request_header(
+        Operation::CreateStream,
+        CLIENT_ID,
+        session,
+        CREATE,
+        create.len(),
+    );
+    write_frame(&mut socket, &header, &create).await;
+    let read = GetStreamRequest {
+        stream_id: WireIdentifier::named(NAME).unwrap(),
+    }
+    .to_bytes();
+    let mut header = non_replicated_header(CLIENT_ID, session, READ, GET_STREAM_CODE);
+    header.size = u32::try_from(HEADER_SIZE + read.len()).unwrap();
+    write_frame(&mut socket, &header, &read).await;
+    write_frame(
+        &mut socket,
+        &non_replicated_header(CLIENT_ID, session, PING, PING_CODE),
+        &[],
+    )
+    .await;
+    let first = reply_header(&read_reply(&mut socket).await.0);
+    for node in 1..harness.cluster_size() {
+        signal_node(harness, node, libc::SIGCONT);
+    }
+    assert_eq!(
+        first.request, PING,
+        "PING must bypass both the blocked write and its dependent read"
+    );
+    assert_eq!(first.status, 0);
+    for _ in 0..2 {
+        let (header, body) = read_reply(&mut socket).await;
+        let reply = reply_header(&header);
+        assert_eq!(reply.status, 0);
+        match reply.request {
+            CREATE => assert_eq!(result_code(&body), Some(0)),
+            READ => assert_eq!(
+                GetStreamResponse::decode_from(&body)
+                    .unwrap()
+                    .stream
+                    .name
+                    .as_str(),
+                NAME
+            ),
+            request => panic!("unexpected reply {request}"),
+        }
+    }
+}
+
+#[iggy_harness(cluster_nodes = 1)]
+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 mut socket = connect(harness).await;
+    let session = register_root(&mut socket, CLIENT_ID).await;
+    let body = next_poll().to_bytes();
+    for request in 1..=2 {
+        let mut header = non_replicated_header(CLIENT_ID, session, request, POLL_MESSAGES_CODE);
+        header.size = u32::try_from(HEADER_SIZE + body.len()).unwrap();
+        header.partition_incarnation = context.incarnation;
+        header.minimum_metadata_op = context.metadata_op;
+        write_frame(&mut socket, &header, &body).await;
+    }
+    for offset in 0..2 {
+        let (header, body) = read_reply(&mut socket).await;
+        let reply = reply_header(&header);
+        assert_eq!(reply.status, 0);
+        assert_eq!(reply.request, offset + 1);
+        let polled = PolledMessages::from_bytes(body.into()).unwrap();
+        assert_eq!(polled.messages.len(), 1);
+        assert_eq!(polled.messages[0].header.offset, offset);
+    }
+}
+
+#[iggy_harness(cluster_nodes = 1)]
+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 mut socket = connect(harness).await;
+    let session = register_root(&mut socket, CLIENT_ID).await;
+    let poll = next_poll();
+    let body = poll.to_bytes();
+    let mut header = non_replicated_header(CLIENT_ID, session, 1, POLL_MESSAGES_CODE);
+    header.size = u32::try_from(HEADER_SIZE + body.len()).unwrap();
+    header.partition_incarnation = context.incarnation;
+    header.minimum_metadata_op = context.metadata_op;
+    write_frame(&mut socket, &header, &body).await;
+    let store = StoreConsumerOffsetRequest {
+        consumer: poll.consumer,
+        stream_id: poll.stream_id,
+        topic_id: poll.topic_id,
+        partition_id: Some(0),
+        offset: 1,
+        ack: AckLevel::Quorum,
+    }
+    .to_bytes();
+    let mut header = request_header(
+        Operation::StoreConsumerOffset,
+        CLIENT_ID,
+        session,
+        2,
+        store.len(),
+    );
+    header.partition_incarnation = context.incarnation;
+    header.minimum_metadata_op = 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, 1,
+        "the poll must finish before its following store"
+    );
+    assert_eq!(reply.status, 0);
+    let polled = PolledMessages::from_bytes(body.into()).unwrap();
+    assert_eq!(polled.messages.len(), 1);
+    assert_eq!(polled.messages[0].header.offset, 0);
+    let (header, _) = read_reply(&mut socket).await;
+    let reply = reply_header(&header);
+    assert_eq!(reply.request, 2);
+    assert_eq!(reply.status, 0);
+    let client = harness.tcp_root_client().await.unwrap();
+    let stored = client
+        .get_consumer_offset(
+            &Consumer::new(Identifier::numeric(1).unwrap()),
+            &Identifier::named(STREAM).unwrap(),
+            &Identifier::named(TOPIC).unwrap(),
+            Some(0),
+        )
+        .await
+        .unwrap()
+        .unwrap();
+    assert_eq!(stored.stored_offset, 1);
+}
+
+const STREAM: &str = "pipelined-stream";
+const TOPIC: &str = "pipelined-topic";
+
+async fn seed_poll_messages(harness: &mut TestHarness) -> PartitionContext {
+    let client = harness.tcp_root_client().await.unwrap();
+    let stream = Identifier::named(STREAM).unwrap();
+    let topic = Identifier::named(TOPIC).unwrap();
+    client.create_stream(STREAM).await.unwrap();
+    client
+        .create_topic(
+            &stream,
+            TOPIC,
+            &TopicCreateOptions {
+                partitions_count: Some(1),
+                messages_required_to_save: Some(1),
+                durability: Durability::Persisted,
+                ..Default::default()
+            },
+        )
+        .await
+        .unwrap();
+    let mut messages = (0..3)
+        .map(|value| {
+            IggyMessage::builder()
+                .payload(format!("message-{value}").into())
+                .build()
+                .unwrap()
+        })
+        .collect::<Vec<_>>();
+    client
+        .send_messages(
+            &stream,
+            &topic,
+            &Partitioning::partition_id(0),
+            &mut messages,
+        )
+        .await
+        .unwrap();
+    drop(client);
+    // Recovery leaves the messages on disk, so polling must yield for I/O.
+    harness.restart_server().await.unwrap();
+    let client = harness.tcp_root_client().await.unwrap();
+    partition_context(&client, &stream, &topic, 0).await
+}
+
+fn next_poll() -> PollMessagesRequest {
+    PollMessagesRequest {
+        consumer: WireConsumer::consumer(WireIdentifier::numeric(1)),
+        stream_id: WireIdentifier::named(STREAM).unwrap(),
+        topic_id: WireIdentifier::named(TOPIC).unwrap(),
+        partition_id: Some(0),
+        strategy: WirePollingStrategy::next(),
+        count: 1,
+        auto_commit: true,
+    }
+}
+
+fn signal_node(harness: &TestHarness, node: usize, signal: libc::c_int) {
+    let pid = harness.node(node).pid().expect("the node is running");
+    // SAFETY: the signal targets a child owned by the harness and not yet reaped.
+    assert_eq!(unsafe { libc::kill(pid as libc::pid_t, signal) }, 0);
+}
diff --git a/core/integration/tests/server/raw_tcp.rs b/core/integration/tests/server/raw_tcp.rs
index b65a3fd4c..48972075f 100644
--- a/core/integration/tests/server/raw_tcp.rs
+++ b/core/integration/tests/server/raw_tcp.rs
@@ -154,6 +154,12 @@ pub(crate) async fn exchange_raw(
     body: &[u8],
 ) -> ([u8; HEADER_SIZE], Vec<u8>) {
     write_raw_frame(stream, header, body).await;
+    read_reply(stream).await
+}
+
+/// Read the next server frame, which must be a Reply, with the body sized by
+/// the reply's size field.
+pub(crate) async fn read_reply(stream: &mut TcpStream) -> ([u8; HEADER_SIZE], Vec<u8>) {
     let reply_header = read_frame_header(stream).await;
     let command = frame_command(&reply_header);
     assert_eq!(
@@ -245,4 +251,5 @@ pub(crate) async fn register_root(stream: &mut TcpStream, client: u128) -> u64 {
 fn is_transient(code: u32) -> bool {
     code == IggyError::TransientNotCommitted.as_code()
         || code == IggyError::TransientNotAccepted.as_code()
+        || code == IggyError::NotPrimary.as_code()
 }
diff --git a/core/integration/tests/server/scenarios/authentication_scenario.rs b/core/integration/tests/server/scenarios/authentication_scenario.rs
index 7e05aa8e1..78ad83895 100644
--- a/core/integration/tests/server/scenarios/authentication_scenario.rs
+++ b/core/integration/tests/server/scenarios/authentication_scenario.rs
@@ -33,7 +33,7 @@ use iggy_binary_protocol::WireEncode;
 use iggy_binary_protocol::codes::*;
 use iggy_binary_protocol::dispatch::COMMAND_TABLE;
 use iggy_binary_protocol::requests::consumer_offsets::GetConsumerOffsetRequest;
-use iggy_binary_protocol::requests::messages::{GetSendContextRequest, PollMessagesRequest};
+use iggy_binary_protocol::requests::messages::{GetPartitionRouteRequest, PollMessagesRequest};
 use iggy_binary_protocol::requests::system::{BindSessionRequest, SessionIdentity};
 use iggy_binary_protocol::requests::users::login_register::BindSecret;
 use iggy_binary_protocol::{ClientVersionInfo, IGGY_PROTOCOL_VERSION, WireName};
@@ -277,10 +277,10 @@ async fn test_all_commands_require_auth(client: &IggyClient, binding_identity: S
             }
 
             // Messages
-            GET_SEND_CONTEXT_CODE => client
+            GET_PARTITION_ROUTE_CODE => client
                 .send_binary_request(
                     code,
-                    GetSendContextRequest {
+                    GetPartitionRouteRequest {
                         stream_id: identifier_to_wire(&ctx.stream_id).unwrap(),
                         topic_id: identifier_to_wire(&ctx.topic_id).unwrap(),
                         partition_id: 0,
diff --git a/core/journal/src/lib.rs b/core/journal/src/lib.rs
index 823fce418..28342442b 100644
--- a/core/journal/src/lib.rs
+++ b/core/journal/src/lib.rs
@@ -56,13 +56,18 @@ pub trait Journal {
     /// it removed, which would mark the removed ops evictable when a suffix
     /// truncation needs them refillable.
     ///
-    /// Required, not defaulted: an `Unsupported` default hides a missing impl until
-    /// mid-view-change, where the caller can only wedge or start a view over a log it
-    /// cannot serve.
+    /// # Errors
+    /// I/O error if the rewrite fails.
+    fn truncate_from(&self, from_op: u64) -> impl Future<Output = io::Result<usize>> {
+        self.remove_range(from_op..=u64::MAX)
+    }
+
+    /// Remove only the specified operations without advancing the snapshot floor.
+    /// View repair must preserve held entries on the other side of a gap.
     ///
     /// # Errors
     /// I/O error if the rewrite fails.
-    fn truncate_from(&self, from_op: u64) -> impl Future<Output = io::Result<usize>>;
+    fn remove_range(&self, ops: RangeInclusive<u64>) -> impl Future<Output = io::Result<usize>>;
 
     /// Highest op the index holds. Not derivable from [`Self::header`]: a caller
     /// looking for a suffix ABOVE some op has no bound to probe up to.
diff --git a/core/journal/src/prepare_journal.rs b/core/journal/src/prepare_journal.rs
index 91a9d3f2c..d344d9580 100644
--- a/core/journal/src/prepare_journal.rs
+++ b/core/journal/src/prepare_journal.rs
@@ -953,31 +953,20 @@ impl Journal for PrepareJournal {
         self.last_op.get()
     }
 
-    /// Remove every entry at or above `from_op`, leaving the snapshot floor where
-    /// it is. Returns how many entries went.
-    ///
-    /// Deliberately not `drain`, which compacts a committed prefix and advances
-    /// `snapshot_op` past its range. Doing that to a suffix would declare everything
-    /// below the head snapshotted, letting `append` evict live entries repair cannot
-    /// put back, when those ops are exactly the ones that must stay refillable.
-    ///
-    /// For the one caller that needs it: a backup whose uncommitted entries disagree
-    /// with the log a view change settled on. They cannot be corrected in place, and
-    /// journal repair skips their ops as already-present, so dropping them is what
-    /// lets the primary's retransmission refill the range.
+    /// Preserve entries outside the repair range and leave removed ops refillable.
     ///
     /// # Errors
     /// I/O error if the rewrite fails. Past the rename the journal is poisoned on any
     /// failure, as in `drain`: serving a pre-truncation offset or appending at a stale
-    /// `write_offset` is worse than a hard stop. `from_op` must be at least 1.
-    async fn truncate_from(&self, from_op: u64) -> io::Result<usize> {
+    /// `write_offset` is worse than a hard stop. Ops are 1-based.
+    async fn remove_range(&self, ops: RangeInclusive<u64>) -> io::Result<usize> {
         if let Some(state) = self.poisoned.get() {
             return Err(Self::poisoned_io_error(state));
         }
-        if from_op == 0 {
+        if *ops.start() == 0 {
             return Err(io::Error::new(
                 io::ErrorKind::InvalidInput,
-                "truncate_from: ops are 1-based, so 0 would discard the whole journal",
+                "journal removal requires 1-based operations",
             ));
         }
         // Shares the drain guard: both rewrite the same WAL
@@ -997,7 +986,7 @@ impl Journal for PrepareJournal {
             let offsets = self.offsets.borrow();
             for slot in 0..self.slot_count {
                 if let (Some(header), Some(offset)) = (&headers[slot], offsets[slot]) {
-                    if header.op >= from_op {
+                    if ops.contains(&header.op) {
                         removed += 1;
                     } else {
                         live.push((*header, offset));
@@ -1024,7 +1013,7 @@ impl Journal for PrepareJournal {
         }
         for slot in 0..self.slot_count {
             if let Some(header) = &headers[slot]
-                && header.op >= from_op
+                && ops.contains(&header.op)
             {
                 headers[slot] = None;
                 offsets[slot] = None;
@@ -1463,6 +1452,56 @@ mod tests {
         assert!(journal.header(4).is_some());
     }
 
+    #[compio::test]
+    async fn given_a_repair_gap_when_rewriting_and_reopening_should_preserve_and_refill_both_sides()
+    {
+        const HEAD: u64 = 5;
+        const GAP: u64 = 3;
+        const BODY: usize = 64;
+        let dir = tempdir().unwrap();
+        let path = dir.path().join("journal.wal");
+        let mut parent = 0;
+        let entries: Vec<_> = (1..=HEAD)
+            .map(|op| {
+                let entry = make_identity_sealed_prepare(op, BODY, parent);
+                parent = entry.header().checksum;
+                entry
+            })
+            .collect();
+        {
+            let journal = PrepareJournal::open(&path, 0).await.unwrap();
+            for entry in &entries {
+                journal.append(entry.deep_copy()).await.unwrap();
+            }
+            assert_eq!(journal.remove_range(GAP..=GAP).await.unwrap(), 1);
+        }
+        {
+            let journal = PrepareJournal::open(&path, 0).await.unwrap();
+            assert_eq!(journal.snapshot_op(), 0);
+            assert_eq!(journal.last_op(), Some(HEAD));
+            assert!(journal.header(GAP as usize).is_none());
+            for entry in entries.iter().filter(|entry| entry.header().op != GAP) {
+                let header = *journal.header(entry.header().op as usize).unwrap();
+                assert_eq!(
+                    journal.entry(&header).await.unwrap().as_slice(),
+                    entry.as_slice()
+                );
+            }
+            journal
+                .append(entries[GAP as usize - 1].deep_copy())
+                .await
+                .unwrap();
+        }
+        let journal = PrepareJournal::open(&path, 0).await.unwrap();
+        for entry in &entries {
+            let header = *journal.header(entry.header().op as usize).unwrap();
+            assert_eq!(
+                journal.entry(&header).await.unwrap().as_slice(),
+                entry.as_slice()
+            );
+        }
+    }
+
     #[compio::test]
     async fn truncate_from_survives_reopen() {
         // The rewrite has to be durable, not just reflected in the index.
diff --git a/core/message_bus/src/config.rs b/core/message_bus/src/config.rs
index aefaf5624..991a21cd2 100644
--- a/core/message_bus/src/config.rs
+++ b/core/message_bus/src/config.rs
@@ -70,7 +70,8 @@ pub struct QuicTuning {
     /// Maximum number of concurrent bidirectional streams per
     /// connection. Each SDK command opens a fresh bidi stream, so
     /// this caps how many commands one client connection may have
-    /// in flight; 1 disables pipelining.
+    /// in flight; 1 disables pipelining. Defaults to
+    /// [`QUIC_MAX_CONCURRENT_STREAMS`].
     pub max_concurrent_bidi_streams: u32,
 
     /// Initial path MTU advertised to the peer, in bytes.
@@ -83,10 +84,10 @@ pub struct QuicTuning {
     pub receive_window: u32,
 
     /// Receive-flow control window per stream, in bytes. Never above
-    /// [`Self::receive_window`], and equal to it under the shipped
-    /// single-stream default. Setting it strictly below is what keeps
-    /// one unread stream from pinning the whole connection window, so
-    /// it only buys anything once several streams share a connection.
+    /// [`Self::receive_window`]. A value strictly below keeps one
+    /// unread stream from pinning the whole connection window. The
+    /// default is equal, because the server reads each stream as soon
+    /// as it opens.
     pub stream_receive_window: u32,
 
     /// Interval between QUIC keep-alive PINGs. `Duration::ZERO`
@@ -265,18 +266,24 @@ fn build_quic_tuning(quic: &configs::quic::QuicConfig) -> QuicTuning {
     }
 }
 
+/// Default stream cap of a client QUIC connection. Equal to the default
+/// `message_bus.client_queue_capacity`, the most requests the server runs
+/// at once for one connection.
+pub const QUIC_MAX_CONCURRENT_STREAMS: u32 = 256;
+
 impl Default for QuicTuning {
     /// Mirrors the `[quic]` defaults in
     /// `core/server/config.toml`: 64 MiB send/receive windows, a per-stream
     /// window equal to the connection receive window, 30 s idle timeout, 10 s
-    /// keep-alive, 1200 B initial MTU, one in-flight command per connection.
+    /// keep-alive, 1200 B initial MTU, [`QUIC_MAX_CONCURRENT_STREAMS`]
+    /// in-flight commands per connection.
     ///
     /// Intended for tests and direct callers; production builds
     /// derive the field from [`ServerConfig`] so the values stay in
     /// lock-step with the on-disk schema.
     fn default() -> Self {
         Self {
-            max_concurrent_bidi_streams: 1,
+            max_concurrent_bidi_streams: QUIC_MAX_CONCURRENT_STREAMS,
             initial_mtu: 1200,
             send_window: 64 * 1024 * 1024,
             receive_window: 64 * 1024 * 1024,
diff --git a/core/message_bus/src/lib.rs b/core/message_bus/src/lib.rs
index 506f5711f..0662eeff6 100644
--- a/core/message_bus/src/lib.rs
+++ b/core/message_bus/src/lib.rs
@@ -1504,6 +1504,21 @@ pub const fn client_id_owning_shard(client_id: u128) -> u16 {
 /// so it still satisfies the wire header's `client != 0` validation.
 pub const AUTO_COMMIT_CLIENT_ID: u128 = u128::MAX;
 
+/// Internal clients cannot collide with minted ids, whose shard is below `u16::MAX`.
+const SERVER_INTERNAL_CLIENT_HIGH: u128 = u128::MAX << u64::BITS;
+
+/// Keep completions for one key on the same internal client across retries.
+#[must_use]
+pub const fn server_internal_client_id(key: u64) -> u128 {
+    SERVER_INTERNAL_CLIENT_HIGH | key as u128
+}
+
+/// Recognize the reserved high half shared by completions and auto-commits.
+#[must_use]
+pub const fn is_server_internal_client(client_id: u128) -> bool {
+    client_id & SERVER_INTERNAL_CLIENT_HIGH == SERVER_INTERNAL_CLIENT_HIGH
+}
+
 /// Whether `client_id` is the reserved [`AUTO_COMMIT_CLIENT_ID`] sentinel.
 #[must_use]
 pub const fn is_auto_commit_client(client_id: u128) -> bool {
@@ -1544,6 +1559,18 @@ mod tests {
     use iggy_binary_protocol::{Command, HEADER_SIZE};
     use std::cell::RefCell;
 
+    #[test]
+    fn given_internal_client_keys_when_encoded_should_remain_reserved() {
+        for key in [0, 1, u64::MAX / 2, u64::MAX] {
+            let client_id = server_internal_client_id(key);
+            assert!(is_server_internal_client(client_id));
+            assert_eq!(client_id & u128::from(u64::MAX), u128::from(key));
+        }
+        assert!(is_server_internal_client(AUTO_COMMIT_CLIENT_ID));
+        assert!(!is_server_internal_client(0));
+        assert!(!is_server_internal_client(SERVER_INTERNAL_CLIENT_HIGH - 1));
+    }
+
     #[allow(clippy::cast_possible_truncation)]
     fn dummy_message() -> Message<GenericHeader> {
         Message::<GenericHeader>::new(HEADER_SIZE).transmute_header(|_, h: &mut GenericHeader| {
diff --git a/core/message_bus/src/transports/mod.rs b/core/message_bus/src/transports/mod.rs
index 7a703884e..a3b074e28 100644
--- a/core/message_bus/src/transports/mod.rs
+++ b/core/message_bus/src/transports/mod.rs
@@ -53,12 +53,14 @@
 //!       ONE trailing `flush()` per batch. This avoids per-frame TCP/TLS
 //!       record overhead while staying inside the per-record API
 //!       constraints of those transports.
-//!     * **QUIC (per-frame)** uses one bidirectional stream per peer
-//!       and writes each `BusMessage` fragment with a separate
+//!     * **QUIC (per-frame)** answers each request on its own
+//!       bidirectional stream, and serves the streams of a peer at once.
+//!       It writes each `BusMessage` fragment with a separate
 //!       `SendStream::write_all` call; quinn coalesces at the datagram
 //!       layer.
 //!
-//!   In all four cases, any write failure tears the connection down.
+//!   On TCP, TCP-TLS and WS / WSS, any write failure tears the connection
+//!   down. On QUIC, it ends only the stream of that request.
 //! - **Zero-copy `Frozen` ownership**: outbound frames are handed to
 //!   the kernel without intermediate copies on plaintext TCP. WS / QUIC
 //!   / TLS planes pay structural copies in their record layers; see the
diff --git a/core/message_bus/src/transports/quic.rs b/core/message_bus/src/transports/quic.rs
index da9296cb6..77548aa15 100644
--- a/core/message_bus/src/transports/quic.rs
+++ b/core/message_bus/src/transports/quic.rs
@@ -25,36 +25,19 @@
 //!
 //! One bidirectional stream **per request** (matches the iggy SDK QUIC
 //! client, which opens a fresh `open_bi` per command and `send.finish()`-s
-//! after writing the request). The transport's [`QuicTransportConn::run`]
-//! runs an outer `accept_bi` loop on the connection and serves one bidi
-//! at a time:
+//! after writing the request). [`QuicTransportConn::run`] serves the
+//! streams of one connection at once, as many as the stream credit of
+//! `[quic] max_concurrent_bidi_streams` lets the client open. Each stream:
 //!
-//! 1. accept the next bidi (or break on shutdown / connection close);
-//! 2. read exactly one inbound frame from the `RecvStream`, note its
-//!    `request` id, and forward it to [`ActorContext::in_tx`];
-//! 3. wait on [`ActorContext::rx`] for the reply whose `request` id matches,
-//!    discarding any stale/duplicate reply left by an earlier attempt's bidi
-//!    (the SDK replays an explicit transient with the SAME request id); write
-//!    the matched reply, then `finish()` to deliver pending data + FIN;
-//! 4. drop the bidi pair and loop to the next `accept_bi`.
+//! 1. reads exactly one inbound frame, registers a waiter under its
+//!    `request` id, and forwards the frame to [`ActorContext::in_tx`];
+//! 2. gets the frame that answers it from a router that reads
+//!    [`ActorContext::rx`] (see `ReplyRouter`);
+//! 3. writes that frame, then `finish()`-es to deliver pending data + FIN.
 //!
-//! The `accept_bi` loop is serial (the iggy SDK serialises per connection via
-//! an internal `RwLock`, so one bidi at a time is a semantic match) and the
-//! reply wait is `request_id`-KEYED. The wait is NOT bounded by a short
-//! abandon-timeout: every transient submit is answered with an explicit
-//! `TransientNotCommitted` rejection frame these days, so an unanswered bidi
-//! means the dispatch truly dropped the request (both partition queues full)
-//! or the op is pathologically slow -- and abandoning the bidi while the op
-//! can still commit would strand its reply. All partition ops on a
-//! connection share ONE request id (only metadata ops advance the dedup
-//! counter), so a stranded reply would be consumed by the NEXT partition
-//! op's bidi and shift the connection's data-plane stream off by one,
-//! permanently. Instead, `REPLY_WAIT_BACKSTOP` (longer than the SDK's
-//! whole-request deadline, so the client always gives up first and
-//! reconnects) closes the CONNECTION, which drops the mailbox and every
-//! pending reply with it -- nothing can strand or cross request ids. A
-//! future pipelining upgrade would additionally run per-bidi handlers
-//! concurrently (and would then require a real per-op correlation id).
+//! A stream whose client stops it, or whose reply does not come within
+//! `REPLY_WAIT_BACKSTOP`, drops its waiter and resets. The other streams
+//! and the connection go on.
 //!
 //! The handshake itself is driven by [`accept_handshake`] (no bidi
 //! captured). To avoid a single slow / hostile peer wedging the entire
@@ -83,16 +66,25 @@ use super::{ActorContext, TransportConn, TransportListener};
 use crate::config::QuicTuning;
 use crate::framing;
 use crate::lifecycle::connection_registry::BusMessage;
+use async_channel::Sender;
 use compio::BufResult;
 use compio::io::AsyncWriteExt;
 use compio_quic::{
-    Connection, Endpoint, IdleTimeout, Incoming, VarInt, congestion,
+    Connection, Endpoint, IdleTimeout, Incoming, RecvStream, SendStream, VarInt, congestion,
     crypto::rustls::QuicServerConfig,
 };
 use futures::FutureExt;
-use iggy_binary_protocol::{Command, HEADER_SIZE, ReplyHeader, RequestHeader};
+use futures::channel::oneshot;
+use futures::stream::{FuturesUnordered, StreamExt};
+use iggy_binary_protocol::{Command, GenericHeader, ReplyHeader, RequestHeader};
+use server_common::Message;
+use std::cell::RefCell;
+use std::collections::{HashMap, VecDeque};
 use std::io;
+use std::mem::offset_of;
 use std::net::SocketAddr;
+use std::ops::ControlFlow;
+use std::pin::pin;
 use std::sync::Arc;
 use std::time::Duration;
 use tracing::{debug, warn};
@@ -286,38 +278,36 @@ async fn accept_handshake_inner(incoming: Incoming) -> Option<(QuicTransportConn
 /// using `MessageBusConfig::close_grace`.
 pub(crate) const DEFAULT_CLOSE_GRACE: Duration = Duration::from_secs(2);
 
-/// How long a bidi waits for its keyed reply before abandoning the stream.
-/// A replicated submit the server cannot yet commit is answered with silence
-/// (the server expects the SDK to replay), so without a bound the per-bidi
-/// wait - and, since `accept_bi` is serial, the whole connection - would wedge:
-/// Backstop for a bidi whose dispatch never produced a reply (a true drop:
-/// both partition queues full, or a pathologically slow commit). Longer than
-/// the SDK's whole-request deadline (30s), so the client always times out
-/// first, surfaces `Disconnected`, and reconnects. On firing the CONNECTION
-/// is closed rather than the bidi abandoned: partition ops all share one
-/// request id, so a reply stranded by an abandoned bidi would be consumed by
-/// the next data-plane op and shift every subsequent reply off by one.
-/// Closing the connection drops the mailbox and any late reply with it.
+/// How long a stream waits for its reply before it resets. A request whose
+/// dispatch never answers (both partition queues full, or a pathologically
+/// slow commit) would otherwise hold its stream credit for the life of the
+/// connection. Longer than the SDK's whole-request deadline (30s), so the
+/// client always gives up first. A reply that comes after the reset is an
+/// unclaimed frame (see `ReplyRouter::register`).
 const REPLY_WAIT_BACKSTOP: Duration = Duration::from_mins(1);
 
+/// Application error code that resets a stream ending without its reply.
+const QUIC_NO_REPLY: u32 = 0x1005;
+
 /// `Command` byte offset shared by every consensus header (after the
 /// fixed checksum/cluster/size/view/release prefix).
 const COMMAND_OFFSET: usize = 60;
 
-/// How an outbound frame routes to a waiting bidi.
+/// How an outbound frame routes to the waiting streams.
 enum ReplyRoute {
-    /// A request-keyed `Reply`: deliver only to the bidi whose request id
-    /// matches; a non-matching one is a stale/duplicate from an abandoned bidi.
+    /// A `Reply`, for the oldest stream that waits under this request id.
     Keyed(u64),
-    /// A terminal/control frame (`Eviction`, etc.) with no `request` field, or
-    /// an undecodable header: deliver to the in-flight bidi unconditionally.
-    /// Filtering these out would silently swallow session-terminal signals
-    /// (e.g. `NoSession` -> `Unauthenticated`), leaving the client to time out.
+    /// A terminal/control frame (`Eviction`, etc.) with no `request` field:
+    /// deliver to every waiting stream. Filtering these out would silently
+    /// swallow session-terminal signals (e.g. `NoSession` ->
+    /// `Unauthenticated`), leaving the client to time out.
     Unkeyed,
 }
 
 /// Decide how to route an outbound frame. Only `Command::Reply` carries a
-/// `request` id; everything else must pass through unfiltered.
+/// `request` id; everything else must pass through unfiltered. The id is
+/// read as raw bytes, because the deny of an operation this build does not
+/// declare echoes that byte, and a checked `ReplyHeader` cast refuses it.
 fn reply_route(frame: &BusMessage) -> ReplyRoute {
     let bytes = frame.first().as_slice();
     let is_reply = bytes
@@ -326,12 +316,205 @@ fn reply_route(frame: &BusMessage) -> ReplyRoute {
     if !is_reply {
         return ReplyRoute::Unkeyed;
     }
+    read_u64(bytes, offset_of!(ReplyHeader, request)).map_or(ReplyRoute::Unkeyed, ReplyRoute::Keyed)
+}
+
+/// 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> {
+    read_u64(request.as_slice(), offset_of!(RequestHeader, request))
+}
+
+fn read_u64(bytes: &[u8], offset: usize) -> Option<u64> {
     bytes
-        .get(..HEADER_SIZE)
-        .and_then(|header| bytemuck::checked::try_from_bytes::<ReplyHeader>(header).ok())
-        .map_or(ReplyRoute::Unkeyed, |header| {
-            ReplyRoute::Keyed(header.request)
-        })
+        .get(offset..offset + size_of::<u64>())
+        .and_then(|field| field.try_into().ok())
+        .map(u64::from_le_bytes)
+}
+
+/// Hands the outbound frames of one connection to the streams that wait
+/// for them.
+///
+/// A stream registers its waiter before it forwards its request, so a reply
+/// never comes before the stream it answers.
+struct ReplyRouter {
+    /// Waiting streams by request id, oldest first.
+    waiters: HashMap<u64, VecDeque<oneshot::Sender<BusMessage>>>,
+    /// Frames that came while no stream waited for them, oldest first.
+    unclaimed: VecDeque<BusMessage>,
+    /// When `unclaimed` holds this many frames, it drops its oldest one.
+    unclaimed_max: usize,
+}
+
+impl ReplyRouter {
+    fn new(unclaimed_max: usize) -> Self {
+        Self {
+            waiters: HashMap::new(),
+            unclaimed: VecDeque::new(),
+            unclaimed_max,
+        }
+    }
+
+    /// Register a stream that waits for the reply to `request_id`.
+    ///
+    /// The stream first takes the oldest unclaimed frame that has no request
+    /// id or has its own. The SDK replays an explicit transient with the same
+    /// request id on a new stream, so a late reply of the first attempt
+    /// answers the replay. The replies for other ids ahead of that frame are
+    /// dropped, because their streams already ended.
+    fn register(&mut self, request_id: u64) -> oneshot::Receiver<BusMessage> {
+        let (waiter, reply) = oneshot::channel();
+        while let Some(frame) = self.unclaimed.pop_front() {
+            if !matches!(reply_route(&frame), ReplyRoute::Keyed(id) if id != request_id) {
+                // `reply` is alive, so the send cannot fail.
+                let _ = waiter.send(frame);
+                return reply;
+            }
+        }
+        self.waiters
+            .entry(request_id)
+            .or_default()
+            .push_back(waiter);
+        reply
+    }
+
+    /// Hand a reply to the oldest waiter of its request id, and a frame
+    /// without one to every waiter, because an eviction ends every request
+    /// of the connection. A frame no stream takes stays unclaimed.
+    fn route(&mut self, frame: BusMessage) {
+        let unclaimed = match reply_route(&frame) {
+            ReplyRoute::Keyed(request_id) => self.deliver(request_id, frame),
+            ReplyRoute::Unkeyed => self.broadcast(frame),
+        };
+        if let Some(frame) = unclaimed {
+            if self.unclaimed.len() == self.unclaimed_max {
+                self.unclaimed.pop_front();
+            }
+            self.unclaimed.push_back(frame);
+        }
+    }
+
+    /// Drop the waiters of `request_id` whose streams ended without a reply.
+    fn release(&mut self, request_id: u64) {
+        if let Some(waiters) = self.waiters.get_mut(&request_id) {
+            waiters.retain(|waiter| !waiter.is_canceled());
+            if waiters.is_empty() {
+                self.waiters.remove(&request_id);
+            }
+        }
+    }
+
+    /// Returns the frame if no waiter took it.
+    fn deliver(&mut self, request_id: u64, mut frame: BusMessage) -> Option<BusMessage> {
+        let Some(waiters) = self.waiters.get_mut(&request_id) else {
+            return Some(frame);
+        };
+        let unclaimed = loop {
+            let Some(waiter) = waiters.pop_front() else {
+                break Some(frame);
+            };
+            match waiter.send(frame) {
+                Ok(()) => break None,
+                Err(returned) => frame = returned,
+            }
+        };
+        if waiters.is_empty() {
+            self.waiters.remove(&request_id);
+        }
+        unclaimed
+    }
+
+    /// Returns the frame if no waiter took it.
+    fn broadcast(&mut self, frame: BusMessage) -> Option<BusMessage> {
+        let mut delivered = false;
+        for waiter in self.waiters.drain().flat_map(|(_, waiters)| waiters) {
+            delivered |= waiter.send(frame.clone()).is_ok();
+        }
+        (!delivered).then_some(frame)
+    }
+}
+
+/// What the stream futures of [`QuicTransportConn::run`] borrow from it.
+struct StreamContext<'a> {
+    in_tx: &'a Sender<Message<GenericHeader>>,
+    router: &'a RefCell<ReplyRouter>,
+    max_message_size: usize,
+    reply_backstop: Duration,
+    label: &'static str,
+    peer: &'a str,
+}
+
+impl StreamContext<'_> {
+    /// Serve one request stream. Breaks when the inbound queue is gone,
+    /// because the connection then has no dispatch.
+    #[allow(clippy::future_not_send)]
+    async fn serve(&self, mut send: SendStream, mut recv: RecvStream) -> ControlFlow<()> {
+        let Self {
+            in_tx,
+            router,
+            max_message_size,
+            reply_backstop,
+            label,
+            peer,
+        } = *self;
+        // The SDK `finish()`-es after its request, so the stream carries one
+        // frame.
+        let request = match framing::read_message(&mut recv, max_message_size).await {
+            Ok(request) => request,
+            Err(e) => {
+                debug!(%label, %peer, error = ?e, "quic: bidi read error");
+                return ControlFlow::Continue(());
+            }
+        };
+        let Some(request_id) = request_id(&request) else {
+            debug!(%label, %peer, "quic: frame shorter than a request header");
+            return ControlFlow::Continue(());
+        };
+        let mut reply = router.borrow_mut().register(request_id);
+        if in_tx.send(request).await.is_err() {
+            debug!(%label, %peer, "quic: inbound queue dropped");
+            return ControlFlow::Break(());
+        }
+        let replied = {
+            let mut stopped = pin!(send.stopped().fuse());
+            let mut backstop = pin!(compio::time::sleep(reply_backstop).fuse());
+            futures::select_biased! {
+                frame = reply => frame.ok(),
+                _ = stopped => {
+                    debug!(%label, %peer, request_id, "quic: client stopped the stream before its reply");
+                    None
+                }
+                () = backstop => {
+                    warn!(%label, %peer, request_id, "quic: no reply within the backstop; resetting the stream");
+                    None
+                }
+            }
+        };
+        let Some(frame) = replied else {
+            drop(reply);
+            router.borrow_mut().release(request_id);
+            if let Err(e) = send.reset(VarInt::from_u32(QUIC_NO_REPLY)) {
+                debug!(%label, %peer, error = ?e, "quic: stream reset failed");
+            }
+            return ControlFlow::Continue(());
+        };
+        for fragment in frame.into_fragments() {
+            let BufResult(result, _frozen) = send.write_all(fragment).await;
+            if let Err(e) = result {
+                debug!(%label, %peer, error = ?e, "quic: write failed");
+                return ControlFlow::Continue(());
+            }
+        }
+        // `finish()` flushes pending data + a FIN, which tells the SDK the
+        // reply is complete.
+        if let Err(e) = send.finish() {
+            debug!(%label, %peer, error = ?e, "quic: send.finish() failed");
+        }
+        // Keep the reply alive through graceful shutdown until the peer has
+        // acknowledged it. Closing the connection after FIN alone can discard it.
+        let _ = send.stopped().await;
+        ControlFlow::Continue(())
+    }
 }
 
 /// A single accepted QUIC connection.
@@ -343,6 +526,8 @@ fn reply_route(frame: &BusMessage) -> ReplyRoute {
 pub struct QuicTransportConn {
     connection: Connection,
     close_grace: Duration,
+    /// [`REPLY_WAIT_BACKSTOP`]. Tests shorten it.
+    reply_backstop: Duration,
 }
 
 impl QuicTransportConn {
@@ -352,6 +537,7 @@ impl QuicTransportConn {
         Self {
             connection,
             close_grace: DEFAULT_CLOSE_GRACE,
+            reply_backstop: REPLY_WAIT_BACKSTOP,
         }
     }
 
@@ -375,9 +561,13 @@ impl QuicTransportConn {
 }
 
 impl TransportConn for QuicTransportConn {
-    #[allow(clippy::future_not_send, clippy::too_many_lines)]
+    #[allow(clippy::future_not_send)]
     async fn run(self, ctx: ActorContext) {
-        let connection = self.connection;
+        let Self {
+            connection,
+            reply_backstop,
+            close_grace,
+        } = self;
         let ActorContext {
             in_tx,
             rx,
@@ -389,139 +579,74 @@ impl TransportConn for QuicTransportConn {
             peer,
         } = ctx;
         let mut shutdown_fut = Box::pin(shutdown.wait().fuse());
+        // Unclaimed frames never outnumber what the outbound queue holds.
+        let router = RefCell::new(ReplyRouter::new(rx.capacity().unwrap_or(usize::MAX)));
+        let context = StreamContext {
+            in_tx: &in_tx,
+            router: &router,
+            max_message_size,
+            reply_backstop,
+            label,
+            peer: &peer,
+        };
+        // The stream credit of the connection bounds this set.
+        let mut streams = FuturesUnordered::new();
 
-        // Outer accept loop: one bidirectional stream per request.
-        loop {
-            let (mut send, mut recv) = futures::select! {
+        let drain = loop {
+            futures::select! {
                 () = shutdown_fut.as_mut() => {
-                    debug!(%label, %peer, "quic: shutdown observed at accept_bi");
-                    break;
+                    debug!(%label, %peer, "quic: shutdown observed");
+                    break true;
                 }
-                res = connection.accept_bi().fuse() => match res {
-                    Ok(streams) => streams,
+                accepted = connection.accept_bi().fuse() => match accepted {
+                    Ok((send, recv)) => {
+                        // Defense-in-depth 0-RTT check (matches the previous
+                        // handshake-path guard now that bidi accept is deferred
+                        // to here).
+                        if recv.is_0rtt() {
+                            warn!(%label, %peer, "quic: bidi accepted in 0-RTT window; refusing");
+                            connection.close(
+                                VarInt::from_u32(QUIC_PROTOCOL_VIOLATION),
+                                b"0-RTT not permitted",
+                            );
+                            break false;
+                        }
+                        streams.push(context.serve(send, recv));
+                    }
                     Err(e) => {
                         debug!(%label, %peer, error = ?e, "quic: accept_bi failed (connection closed)");
-                        break;
+                        break false;
                     }
                 },
-            };
-
-            // Defense-in-depth 0-RTT check (matches the previous handshake-
-            // path guard now that bidi accept is deferred to here).
-            if recv.is_0rtt() {
-                warn!(%label, %peer, "quic: bidi accepted in 0-RTT window; refusing");
-                connection.close(
-                    VarInt::from_u32(QUIC_PROTOCOL_VIOLATION),
-                    b"0-RTT not permitted",
-                );
-                break;
-            }
-
-            // Read exactly one inbound frame from this bidi. The SDK
-            // `send.finish()`-s after writing the request, so further
-            // reads on this RecvStream would return ConnectionClosed.
-            let req = match framing::read_message(&mut recv, max_message_size).await {
-                Ok(m) => m,
-                Err(e) => {
-                    debug!(%label, %peer, error = ?e, "quic: bidi read error");
-                    continue;
-                }
-            };
-            // Request id this bidi is waiting on. Used to match its reply and
-            // to discard stale/duplicate replies left by an abandoned bidi.
-            // `None` (unparsable) degrades to accepting the first reply.
-            let request_id = req
-                .as_slice()
-                .get(..HEADER_SIZE)
-                .and_then(|bytes| bytemuck::checked::try_from_bytes::<RequestHeader>(bytes).ok())
-                .map(|header| header.request);
-
-            if in_tx.send(req).await.is_err() {
-                debug!(%label, %peer, "quic: inbound queue dropped");
-                break;
+                frame = rx.recv().fuse() => if let Ok(frame) = frame {
+                    router.borrow_mut().route(frame);
+                } else {
+                    debug!(%label, %peer, "quic: mailbox closed");
+                    break false;
+                },
+                served = streams.select_next_some() => if served.is_break() {
+                    break false;
+                },
             }
+        };
 
-            // Wait for THIS bidi's reply, keyed by request id. The bus `rx` is
-            // a single unkeyed per-connection channel, so filter by id and
-            // discard replies belonging to an earlier attempt's bidi (an
-            // explicit-transient replay reuses the SAME request id, so a late
-            // reply matches and is consumed by the replay's bidi - never
-            // misrouted to a later request). There is no short abandon-timeout:
-            // transient submits are answered with explicit rejection frames, so
-            // silence here means the dispatch dropped the request or the op is
-            // pathologically slow. `REPLY_WAIT_BACKSTOP` (see its doc) closes
-            // the whole connection instead of abandoning the bidi, so a late
-            // reply can never strand and shift onto the next data-plane op.
-            let mut matched: Option<BusMessage> = None;
-            let mut connection_gone = false;
-            {
-                let mut reply_deadline =
-                    std::pin::pin!(compio::time::sleep(REPLY_WAIT_BACKSTOP).fuse());
-                loop {
+        if drain {
+            let pending = async {
+                while !streams.is_empty() {
                     futures::select! {
-                        () = shutdown_fut.as_mut() => {
-                            debug!(%label, %peer, "quic: shutdown during reply wait");
-                            connection_gone = true;
-                            break;
-                        }
-                        () = reply_deadline.as_mut() => {
-                            warn!(%label, %peer, ?request_id, "quic: no reply within backstop; closing connection so no reply can strand");
-                            connection_gone = true;
-                            break;
-                        }
-                        res = rx.recv().fuse() => if let Ok(m) = res {
-                            let deliver = match reply_route(&m) {
-                                // Eviction / control frame: always deliver.
-                                ReplyRoute::Unkeyed => true,
-                                // Keyed reply: only this bidi's; else it's a
-                                // stale/duplicate from an abandoned bidi -> drop.
-                                ReplyRoute::Keyed(id) => {
-                                    request_id.is_none() || Some(id) == request_id
-                                }
-                            };
-                            if deliver {
-                                matched = Some(m);
-                                break;
-                            }
-                        } else {
-                            debug!(%label, %peer, "quic: mailbox closed");
-                            connection_gone = true;
+                        frame = rx.recv().fuse() => match frame {
+                            Ok(frame) => router.borrow_mut().route(frame),
+                            Err(_) => break,
+                        },
+                        served = streams.select_next_some() => if served.is_break() {
                             break;
                         },
                     }
                 }
-            }
-            if connection_gone {
-                break;
-            }
-            let Some(first) = matched else {
-                // Unreachable in practice: every no-reply path above sets
-                // `connection_gone`. Close the send half defensively.
-                if let Err(e) = send.finish() {
-                    debug!(%label, %peer, error = ?e, "quic: send.finish() failed with no reply");
-                }
-                continue;
             };
-
-            // Write the reply fragment by fragment, then `finish()` so
-            // quinn-proto flushes pending data + a FIN, signalling the SDK
-            // that the reply is complete.
-            let mut write_failed = false;
-            for fragment in first.into_fragments() {
-                let BufResult(result, _frozen) = send.write_all(fragment).await;
-                if let Err(e) = result {
-                    debug!(%label, %peer, error = ?e, "quic: write failed");
-                    write_failed = true;
-                    break;
-                }
+            if compio::time::timeout(close_grace, pending).await.is_err() {
+                warn!(%label, %peer, "quic: close grace expired with streams in flight");
             }
-            if write_failed {
-                continue;
-            }
-            if let Err(e) = send.finish() {
-                debug!(%label, %peer, error = ?e, "quic: send.finish() failed");
-            }
-            // (send, recv) drop here -> stream fully closed.
         }
 
         connection.close(VarInt::from_u32(QUIC_SHUTDOWN), b"shutdown");
@@ -562,12 +687,30 @@ mod tests {
     use async_channel::bounded;
     use compio::io::AsyncWrite;
     use compio_quic::ClientBuilder;
-    use iggy_binary_protocol::{Command, GenericHeader, HEADER_SIZE, SIZE_FIELD_OFFSET};
+    use iggy_binary_protocol::{Command, GenericHeader, HEADER_SIZE, Operation, SIZE_FIELD_OFFSET};
     use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
     use server_common::iobuf::Frozen;
     use server_common::{MESSAGE_ALIGN, Message};
     use std::time::Duration;
 
+    const TEST_CLIENT: u128 = 1;
+    const ONE_STREAM: u32 = 1;
+    const TWO_STREAMS: u32 = 2;
+    const FIRST_REQUEST: u64 = 1;
+    const SECOND_REQUEST: u64 = 2;
+    /// The commit of a reply that no test tells apart from another.
+    const ANY_COMMIT: u64 = 0;
+    const FIRST_COMMIT: u64 = 1;
+    const SECOND_COMMIT: u64 = 2;
+    /// Bounds every wait of a test, so a frame that never comes fails it.
+    const TEST_WAIT: Duration = Duration::from_secs(5);
+    const SHORT_BACKSTOP: Duration = Duration::from_millis(200);
+    /// The server only sees that the client stopped, whatever the code.
+    const CLIENT_STOP: u32 = 0;
+    /// No `Operation` has this discriminant.
+    const UNDECLARED_OPERATION: u8 = u8::MAX;
+    const UNCLAIMED_MAX: usize = 16;
+
     fn install_crypto_provider() {
         // Idempotent. Tests in the same process race on
         // `install_default`, so swallow the second-installer error.
@@ -596,9 +739,9 @@ mod tests {
     async fn server_endpoint(
         cert: CertificateDer<'static>,
         key: PrivateKeyDer<'static>,
+        tuning: &QuicTuning,
     ) -> Endpoint {
-        let server_cfg = server_config_with_cert(vec![cert], key, &QuicTuning::default())
-            .expect("server config");
+        let server_cfg = server_config_with_cert(vec![cert], key, tuning).expect("server config");
         Endpoint::server("127.0.0.1:0", server_cfg)
             .await
             .expect("bind")
@@ -641,17 +784,126 @@ mod tests {
         (out_tx, in_rx, shutdown, handle)
     }
 
-    /// End-to-end frame delivery via the new accept_bi-per-bidi server
-    /// loop: client opens N bidis sequentially, writing one request per
-    /// bidi; server's loop reads each request, awaits a reply from
-    /// `rx`, writes it, and `finish()`-es the bidi. Verifies inbound
-    /// ordering matches what the client sent.
+    fn tuning(max_concurrent_bidi_streams: u32) -> QuicTuning {
+        QuicTuning {
+            max_concurrent_bidi_streams,
+            ..QuicTuning::default()
+        }
+    }
+
+    /// A served connection whose client can open `max_concurrent_bidi_streams`
+    /// streams at once, the client side of it, and the two endpoints, which
+    /// must outlive both.
+    #[allow(clippy::future_not_send)]
+    async fn connect(
+        max_concurrent_bidi_streams: u32,
+    ) -> (
+        QuicTransportConn,
+        Connection,
+        (QuicTransportListener, Endpoint),
+    ) {
+        install_crypto_provider();
+        let (cert, key) = self_signed();
+        let server = server_endpoint(cert.clone(), key, &tuning(max_concurrent_bidi_streams)).await;
+        let server_addr = server.local_addr().unwrap();
+        let listener = QuicTransportListener::new(server);
+        let client = client_endpoint(cert).await;
+        let connecting = client
+            .connect(server_addr, "localhost", None)
+            .expect("connect");
+        let (accepted, connection) = futures::join!(listener.accept(), connecting);
+        let (conn, _peer) = accepted.expect("accept");
+        (
+            conn,
+            connection.expect("client handshake"),
+            (listener, client),
+        )
+    }
+
+    #[allow(clippy::cast_possible_truncation)]
+    fn request(request_id: u64) -> Frozen<MESSAGE_ALIGN> {
+        Message::<RequestHeader>::new(HEADER_SIZE)
+            .transmute_header(|_, h: &mut RequestHeader| {
+                h.command = Command::Request;
+                h.size = HEADER_SIZE as u32;
+                h.client = TEST_CLIENT;
+                // Sessionless, so the header needs no bound session.
+                h.operation = Operation::NonReplicated;
+                h.request = request_id;
+            })
+            .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> {
+        Message::<ReplyHeader>::new(HEADER_SIZE)
+            .transmute_header(|_, h: &mut ReplyHeader| {
+                h.command = Command::Reply;
+                h.size = HEADER_SIZE as u32;
+                h.request = request_id;
+                h.commit = commit;
+            })
+            .into_generic()
+    }
+
+    fn reply(request_id: u64, commit: u64) -> BusMessage {
+        reply_message(request_id, commit).into_frozen().into()
+    }
+
+    fn reply_header(frame: &[u8]) -> ReplyHeader {
+        *bytemuck::checked::from_bytes::<ReplyHeader>(&frame[..HEADER_SIZE])
+    }
+
+    /// Open a stream and send one request on it. The returned half reads the
+    /// answer.
+    #[allow(clippy::future_not_send)]
+    async fn send_request(client: &Connection, request_id: u64) -> RecvStream {
+        let (mut send, recv) = client.open_bi_wait().await.expect("open_bi");
+        let BufResult(result, _) = send.write_all(request(request_id)).await;
+        result.expect("write");
+        send.finish().expect("finish");
+        recv
+    }
+
+    #[allow(clippy::future_not_send)]
+    async fn read_answer(recv: &mut RecvStream) -> Message<GenericHeader> {
+        compio::time::timeout(
+            TEST_WAIT,
+            framing::read_message(recv, framing::MAX_MESSAGE_SIZE),
+        )
+        .await
+        .expect("answer within the test wait")
+        .expect("answer frame")
+    }
+
+    /// The request id of the next frame the server forwards.
+    #[allow(clippy::future_not_send)]
+    async fn forwarded(inbound: &async_channel::Receiver<Message<GenericHeader>>) -> u64 {
+        let frame = compio::time::timeout(TEST_WAIT, inbound.recv())
+            .await
+            .expect("frame forwarded within the test wait")
+            .expect("inbound queue open");
+        request_id(&frame).expect("whole request header")
+    }
+
+    /// The commit of the reply the router handed to `waiter`, if it got one.
+    fn routed_commit(waiter: &mut oneshot::Receiver<BusMessage>) -> Option<u64> {
+        let frame = waiter.try_recv().expect("waiter registered")?;
+        Some(reply_header(frame.first().as_slice()).commit)
+    }
+
+    /// End-to-end frame delivery with one stream at a time: the client
+    /// opens N bidis in turn, writing one request per bidi; the server
+    /// reads each request, takes its reply from `rx`, writes it, and
+    /// `finish()`-es the bidi. Verifies inbound ordering matches what the
+    /// client sent.
     #[compio::test]
     #[allow(clippy::future_not_send)]
     async fn loopback_round_trip_three_frames() {
         install_crypto_provider();
         let (cert, key) = self_signed();
-        let server = server_endpoint(cert.clone(), key).await;
+        let server = server_endpoint(cert.clone(), key, &tuning(ONE_STREAM)).await;
         let server_addr = server.local_addr().unwrap();
 
         let listener = QuicTransportListener::new(server);
@@ -714,7 +966,7 @@ mod tests {
     async fn read_message_reports_oversize_via_run() {
         install_crypto_provider();
         let (cert, key) = self_signed();
-        let server = server_endpoint(cert.clone(), key).await;
+        let server = server_endpoint(cert.clone(), key, &QuicTuning::default()).await;
         let server_addr = server.local_addr().unwrap();
 
         let listener = QuicTransportListener::new(server);
@@ -760,6 +1012,178 @@ mod tests {
         );
     }
 
+    /// Streams of one connection run at once, and each reply reaches the
+    /// stream that sent its request id.
+    #[compio::test]
+    #[allow(clippy::future_not_send)]
+    async fn given_two_streams_when_the_second_reply_is_ready_first_should_answer_it_first() {
+        let (conn, client, _endpoints) = connect(TWO_STREAMS).await;
+        let (outbound, inbound, shutdown, server) = drive(conn);
+        let mut first = send_request(&client, FIRST_REQUEST).await;
+        let mut second = send_request(&client, SECOND_REQUEST).await;
+        let mut requests = [forwarded(&inbound).await, forwarded(&inbound).await];
+        requests.sort_unstable();
+        assert_eq!(requests, [FIRST_REQUEST, SECOND_REQUEST]);
+
+        outbound
+            .send(reply(SECOND_REQUEST, ANY_COMMIT))
+            .await
+            .unwrap();
+        let answer = read_answer(&mut second).await;
+        assert_eq!(reply_header(answer.as_slice()).request, SECOND_REQUEST);
+        outbound
+            .send(reply(FIRST_REQUEST, ANY_COMMIT))
+            .await
+            .unwrap();
+        let answer = read_answer(&mut first).await;
+        assert_eq!(reply_header(answer.as_slice()).request, FIRST_REQUEST);
+
+        shutdown.trigger();
+        let _ = server.await;
+    }
+
+    /// An eviction ends every request of the connection, so it reaches
+    /// every waiting stream.
+    #[compio::test]
+    #[allow(clippy::future_not_send)]
+    async fn given_two_pending_streams_when_an_eviction_arrives_should_deliver_it_to_both() {
+        let (conn, client, _endpoints) = connect(TWO_STREAMS).await;
+        let (outbound, inbound, shutdown, server) = drive(conn);
+        let mut first = send_request(&client, FIRST_REQUEST).await;
+        let mut second = send_request(&client, SECOND_REQUEST).await;
+        forwarded(&inbound).await;
+        forwarded(&inbound).await;
+
+        outbound
+            .send(header_only(Command::Eviction).into())
+            .await
+            .unwrap();
+        for stream in [&mut first, &mut second] {
+            assert_eq!(
+                read_answer(stream).await.header().command,
+                Command::Eviction
+            );
+        }
+
+        shutdown.trigger();
+        let _ = server.await;
+    }
+
+    /// A stream that its client stops gives back its stream credit, so the
+    /// next stream does not wait for a reply that never comes.
+    #[compio::test]
+    #[allow(clippy::future_not_send)]
+    async fn given_a_stopped_stream_when_its_reply_never_comes_should_still_serve_the_next_stream()
+    {
+        let (conn, client, _endpoints) = connect(ONE_STREAM).await;
+        let (outbound, inbound, shutdown, server) = drive(conn);
+        let mut stopped = send_request(&client, FIRST_REQUEST).await;
+        assert_eq!(forwarded(&inbound).await, FIRST_REQUEST);
+        stopped
+            .stop(VarInt::from_u32(CLIENT_STOP))
+            .expect("stop the stream");
+
+        let mut next = compio::time::timeout(TEST_WAIT, send_request(&client, SECOND_REQUEST))
+            .await
+            .expect("the stopped stream gives back its credit");
+        assert_eq!(forwarded(&inbound).await, SECOND_REQUEST);
+        outbound
+            .send(reply(SECOND_REQUEST, ANY_COMMIT))
+            .await
+            .unwrap();
+        let answer = read_answer(&mut next).await;
+        assert_eq!(reply_header(answer.as_slice()).request, SECOND_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]
+    #[allow(clippy::future_not_send)]
+    async fn given_a_stream_without_a_reply_when_the_backstop_passes_should_reset_only_that_stream()
+    {
+        let (mut conn, client, _endpoints) = connect(ONE_STREAM).await;
+        conn.reply_backstop = SHORT_BACKSTOP;
+        let (outbound, inbound, shutdown, server) = drive(conn);
+        let mut unanswered = send_request(&client, FIRST_REQUEST).await;
+        assert_eq!(forwarded(&inbound).await, FIRST_REQUEST);
+        let reset = compio::time::timeout(TEST_WAIT, unanswered.received_reset())
+            .await
+            .expect("the backstop ends the stream")
+            .expect("reset by the server");
+        assert_eq!(reset, Some(VarInt::from_u32(QUIC_NO_REPLY)));
+
+        let mut next = compio::time::timeout(TEST_WAIT, send_request(&client, SECOND_REQUEST))
+            .await
+            .expect("the reset stream gives back its credit");
+        assert_eq!(forwarded(&inbound).await, SECOND_REQUEST);
+        outbound
+            .send(reply(SECOND_REQUEST, ANY_COMMIT))
+            .await
+            .unwrap();
+        let answer = read_answer(&mut next).await;
+        assert_eq!(reply_header(answer.as_slice()).request, SECOND_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()
+     {
+        let mut router = ReplyRouter::new(UNCLAIMED_MAX);
+        let mut first = router.register(FIRST_REQUEST);
+        let mut second = router.register(FIRST_REQUEST);
+        router.route(reply(FIRST_REQUEST, FIRST_COMMIT));
+        router.route(reply(FIRST_REQUEST, SECOND_COMMIT));
+        assert_eq!(routed_commit(&mut first), Some(FIRST_COMMIT));
+        assert_eq!(routed_commit(&mut second), Some(SECOND_COMMIT));
+    }
+
+    /// A late reply waits for the stream that replays its request id. The
+    /// replies for other ids ahead of it are dropped, so a later stream with
+    /// such an id gets only its own reply.
+    #[test]
+    fn given_a_reply_without_a_waiting_stream_when_a_stream_with_its_id_registers_should_deliver_it()
+     {
+        let mut router = ReplyRouter::new(UNCLAIMED_MAX);
+        router.route(reply(SECOND_REQUEST, FIRST_COMMIT));
+        router.route(reply(FIRST_REQUEST, FIRST_COMMIT));
+        let mut replay = router.register(FIRST_REQUEST);
+        assert_eq!(routed_commit(&mut replay), Some(FIRST_COMMIT));
+
+        let mut later = router.register(SECOND_REQUEST);
+        assert_eq!(routed_commit(&mut later), None);
+        router.route(reply(SECOND_REQUEST, SECOND_COMMIT));
+        assert_eq!(routed_commit(&mut later), Some(SECOND_COMMIT));
+    }
+
+    #[test]
+    fn given_a_full_unclaimed_queue_when_another_frame_comes_should_drop_the_oldest() {
+        let mut router = ReplyRouter::new(1);
+        router.route(reply(FIRST_REQUEST, FIRST_COMMIT));
+        router.route(reply(FIRST_REQUEST, SECOND_COMMIT));
+        let mut kept = router.register(FIRST_REQUEST);
+        assert_eq!(routed_commit(&mut kept), Some(SECOND_COMMIT));
+        let mut next = router.register(FIRST_REQUEST);
+        assert_eq!(routed_commit(&mut next), None);
+    }
+
+    /// The deny of an operation this build does not declare goes to its own
+    /// stream only.
+    #[test]
+    fn given_a_reply_with_an_undeclared_operation_when_routed_should_key_it_by_request() {
+        let mut deny = reply_message(FIRST_REQUEST, ANY_COMMIT);
+        deny.as_mut_slice()[offset_of!(ReplyHeader, operation)] = UNDECLARED_OPERATION;
+        let deny: BusMessage = deny.into_frozen().into();
+        assert!(matches!(
+            reply_route(&deny),
+            ReplyRoute::Keyed(FIRST_REQUEST)
+        ));
+    }
+
     #[test]
     fn transport_config_from_default_builds() {
         // Lightweight smoke: confirm the helper builds without panicking
@@ -781,23 +1205,55 @@ mod tests {
         let _cfg = transport_config_from(&tuning);
     }
 
-    /// `with_close_grace` plumbs the override into the field that
-    /// `run`'s joint drain timeout consumes. The full timeout-bound
-    /// behaviour is exercised end-to-end by the existing
-    /// `request_reply_round_trip` (which goes through the default
-    /// `close_grace` path); deterministic wedge testing of the elapsed
-    /// arm requires a non-cancel-safe QUIC read state that cannot be
-    /// reliably constructed in-process.
-    #[test]
-    fn with_close_grace_overrides_default() {
-        // `QuicTransportConn` requires an actual `Connection`; constructing
-        // one outside an async context is not feasible here, so we exercise
-        // the builder via direct field replacement on a default-constructed
-        // tuning struct that mirrors the field type.
-        let custom = Duration::from_millis(123);
-        // Sanity: the default is non-zero (would otherwise immediately
-        // elapse every close drain) and our override differs from it.
-        assert_ne!(DEFAULT_CLOSE_GRACE, custom);
-        assert!(DEFAULT_CLOSE_GRACE > Duration::ZERO);
+    #[compio::test]
+    #[allow(clippy::future_not_send)]
+    async fn given_pending_reply_when_shutdown_starts_should_deliver_it_before_closing() {
+        let (conn, client, _endpoints) = connect(ONE_STREAM).await;
+        let (outbound, inbound, shutdown, mut server) = drive(conn.with_close_grace(TEST_WAIT));
+        let mut response = send_request(&client, FIRST_REQUEST).await;
+        assert_eq!(forwarded(&inbound).await, FIRST_REQUEST);
+        shutdown.trigger();
+        assert!(
+            compio::time::timeout(SHORT_BACKSTOP, &mut server)
+                .await
+                .is_err(),
+            "shutdown must keep an admitted request alive during the grace"
+        );
+        outbound
+            .send(reply(FIRST_REQUEST, ANY_COMMIT))
+            .await
+            .unwrap();
+        assert_eq!(
+            read_answer(&mut response).await.header().command,
+            Command::Reply
+        );
+        compio::time::timeout(TEST_WAIT, server)
+            .await
+            .expect("the acknowledged reply releases the drain")
+            .expect("the server exits normally");
+    }
+
+    #[compio::test]
+    #[allow(clippy::future_not_send)]
+    async fn given_missing_reply_when_shutdown_grace_expires_should_close_within_the_bound() {
+        let (conn, client, _endpoints) = connect(ONE_STREAM).await;
+        let (_outbound, inbound, shutdown, server) = drive(conn.with_close_grace(SHORT_BACKSTOP));
+        let mut response = send_request(&client, FIRST_REQUEST).await;
+        assert_eq!(forwarded(&inbound).await, FIRST_REQUEST);
+        shutdown.trigger();
+        compio::time::timeout(TEST_WAIT, server)
+            .await
+            .expect("shutdown must not wait for the 60-second reply backstop")
+            .expect("the server exits normally");
+        let result = compio::time::timeout(
+            TEST_WAIT,
+            framing::read_message(&mut response, framing::MAX_MESSAGE_SIZE),
+        )
+        .await
+        .expect("the abandoned stream closes");
+        assert!(
+            result.is_err(),
+            "a request without a reply must not succeed"
+        );
     }
 }
diff --git a/core/metadata/src/impls/metadata.rs b/core/metadata/src/impls/metadata.rs
index 2ea96e4b9..f6ca866ee 100644
--- a/core/metadata/src/impls/metadata.rs
+++ b/core/metadata/src/impls/metadata.rs
@@ -899,17 +899,22 @@ fn apply_user_revocation<M: StreamsFrontend>(
     }
     // A transferred table may already contain this end marker while the
     // streams snapshot still needs the matching membership release.
-    let table = client_table.borrow();
-    for identity in table.ended_sessions().filter(|identity| {
-        identity.metadata_watermark == header.op
-            && table.get_user_id(identity.client_id) == Some(user_id)
-    }) {
-        mux_stm.streams().remove_consumer_group_member(
-            identity.client_id,
-            iggy_common::IggyTimestamp::from(header.timestamp),
-            header.op,
-        );
-    }
+    let client_ids: Vec<u128> = {
+        let table = client_table.borrow();
+        table
+            .ended_sessions()
+            .filter(|identity| {
+                identity.metadata_watermark == header.op
+                    && table.get_user_id(identity.client_id) == Some(user_id)
+            })
+            .map(|identity| identity.client_id)
+            .collect()
+    };
+    mux_stm.streams().remove_consumer_group_members(
+        &client_ids,
+        iggy_common::IggyTimestamp::from(header.timestamp),
+        header.op,
+    );
 }
 
 /// Late-bound callback invoked after every committed op on shard 0's metadata
@@ -2852,9 +2857,11 @@ where
     }
 
     /// Submit a server-originated `CompleteConsumerGroupRevocation` through the
-    /// metadata consensus group (shard 0). The partition reconciler calls this
-    /// to complete a cooperative revocation once the source has drained the
-    /// partition (or it timed out).
+    /// metadata consensus group (shard 0). A partition reconciler, on this node
+    /// or on a relaying backup, asks for this to complete a cooperative
+    /// revocation once the source has drained the partition (or it timed out).
+    /// `namespace` is the partition that `request` completes, and keys its
+    /// internal client.
     ///
     /// Unlike a client op there is no session: a reserved internal client id
     /// (never coordinator-minted) carries it, `request_preflight` is skipped
@@ -2874,27 +2881,12 @@ where
     #[allow(clippy::future_not_send)]
     pub async fn submit_complete_revocation_in_process(
         &self,
-        stream_id: u32,
-        topic_id: u32,
-        partition_id: u32,
-        installation: iggy_binary_protocol::requests::partitions::InstallConsumerGroupOwnerRequest,
-        partition_op: u64,
+        request: CompleteConsumerGroupRevocationRequest,
+        namespace: server_common::sharding::IggyNamespace,
     ) -> Result<u64, MetadataSubmitError> {
-        let request = CompleteConsumerGroupRevocationRequest {
-            stream_id: WireIdentifier::numeric(stream_id),
-            topic_id: WireIdentifier::numeric(topic_id),
-            partition_id,
-            installation,
-            partition_op,
-        };
         self.submit_partition_completion(
             Operation::CompleteConsumerGroupRevocation,
-            server_common::sharding::IggyNamespace::new(
-                stream_id as usize,
-                topic_id as usize,
-                partition_id as usize,
-            )
-            .inner(),
+            namespace.inner(),
             &request.to_bytes(),
         )
         .await
@@ -2937,7 +2929,7 @@ where
     ) -> Result<Message<ReplyHeader>, MetadataSubmitError> {
         // One internal client per key serializes the completions of that key.
         const INTERNAL_REQUEST_ID: u64 = u64::MAX;
-        let internal_client_id = (u128::from(u64::MAX) << 64) | u128::from(internal_client);
+        let internal_client_id = message_bus::server_internal_client_id(internal_client);
         let consensus = self
             .consensus
             .as_ref()
@@ -3107,20 +3099,22 @@ where
             .as_ref()
             .expect("submit_request_in_process: consensus only exists on shard 0");
 
-        // Not-primary is transient: the same request replayed against the
-        // current primary commits fine. Reply with the explicit transient
-        // frame (relayed to the socket by the home shard) so the client
-        // replays immediately rather than waiting out its read-timeout.
-        // `TransientNotAccepted` specifically: the request never entered the
-        // pipeline here, so the client may re-issue it ANYWHERE -- including
-        // under a fresh session after failing over to the current leader --
-        // without double-apply risk. (`TransientNotCommitted` conversely means
-        // the outcome is unknown and only a same-session replay is safe.)
+        // Reply before admission so the client can retry without a read timeout.
+        // This refusal covers only this attempt; earlier copies may still commit.
+        // Retrying with their session and request id preserves deduplication.
         //
         // No catch-up gate: a non-register op admitted mid-commit-window
         // simply pipelines behind the in-flight batch (the wire path has
         // always done this); the register-specific invariant is guarded in
         // `submit_register_in_process` / `register_preflight`.
+        if consensus.is_normal_backup() {
+            return Ok(build_result_rejection_reply(
+                &request_header,
+                consensus.commit_max(),
+                IggyError::NotPrimary.as_code(),
+            )
+            .into_generic());
+        }
         if !(consensus.is_primary() && consensus.is_normal() && !consensus.is_transferring()) {
             return Ok(build_result_rejection_reply(
                 &request_header,
@@ -3168,9 +3162,8 @@ where
         // Prepare queue full: backpressure, not failure. Absorb into the
         // request queue with this caller's reply subscriber; the commit
         // path promotes it as slots free up and the await below resolves
-        // with the committed reply. Only a full request queue is terminal
-        // (`TransientNotAccepted`, re-issuable anywhere: the request never
-        // entered a queue).
+        // with the committed reply. A full request queue refuses this attempt
+        // before admission, without settling the outcome of earlier copies.
         if consensus.pipeline_is_full() {
             let (entry, receiver) = consensus::RequestEntry::with_subscriber(message);
             if consensus.push_queued_request(entry).is_err() {
@@ -3501,7 +3494,7 @@ where
             // a send to the reserved id only fails with a false error.
             if !had_in_process_subscriber
                 && prepare_header.client != RESERVED_CLIENT_ID
-                && !message_bus::is_auto_commit_client(prepare_header.client)
+                && !message_bus::is_server_internal_client(prepare_header.client)
             {
                 wire_replies.push((event, reply));
             }
@@ -4231,6 +4224,9 @@ where
     #[allow(clippy::future_not_send)]
     pub async fn commit_journal(&self) {
         let consensus = self.consensus.as_ref().unwrap();
+        if consensus.view_log_unproven() {
+            return;
+        }
         let journal = self.journal.as_ref().unwrap();
 
         let mut applied = 0usize;
@@ -4755,6 +4751,7 @@ mod tests {
     use crate::stm::StateHandler;
     use crate::stm::authz::GatedApply;
     use crate::stm::consumer_group::JoinConsumerGroupRequest;
+    use crate::stm::lifecycle::{CompleteLifecycleRequest, LifecycleCompletion};
     use crate::stm::stream::{Streams, StreamsInner};
     use crate::stm::user::{Users, UsersInner};
     use consensus::LocalPipeline;
@@ -4775,6 +4772,9 @@ mod tests {
     use server_common::iobuf::Frozen;
     use std::cell::RefCell;
     use std::rc::Rc;
+    use std::sync::mpsc;
+    use std::thread;
+    use std::time::{Duration, Instant};
 
     #[test]
     fn given_cancelled_snapshot_writer_when_replaced_should_keep_the_new_snapshot() {
@@ -5527,8 +5527,7 @@ mod tests {
     /// delete without its in-process sender. Committing it must not send a
     /// reply to the reserved client id, which no connection owns.
     #[compio::test]
-    async fn given_repipelined_server_originated_delete_when_committed_should_send_no_client_reply()
-    {
+    async fn given_repipelined_server_operations_when_committed_should_send_no_client_reply() {
         const USER: u32 = 7;
         const TOKEN: &str = "expired";
 
@@ -5591,6 +5590,40 @@ mod tests {
             consensus.message_bus().client_sends.borrow().is_empty(),
             "no connection owns the reserved client id, so nothing may be sent to it"
         );
+
+        let body = CompleteLifecycleRequest::new(
+            1,
+            1,
+            vec![LifecycleCompletion {
+                topic_id: 1,
+                partition_id: 0,
+                partition_op: 1,
+            }],
+        )
+        .unwrap()
+        .to_bytes();
+        let header = RoutedRequestHeader {
+            client: message_bus::server_internal_client_id(server_common::sharding::METADATA_GROUP),
+            ..header
+        };
+        let prepare = consensus::seal_prepare_capacity(
+            build_prepare_message(consensus, &header, Operation::CompleteLifecycle, &body),
+            consensus.retry_capacity(&md.client_table.borrow()),
+        );
+        consensus.pipeline_message(PlaneKind::Metadata, &prepare);
+        md.on_replicate(prepare).await;
+        consensus.drain_loopback_into(&mut loopback);
+        let ack = loopback
+            .pop()
+            .unwrap()
+            .try_into_typed::<PrepareOkHeader>()
+            .unwrap();
+        md.on_ack(ack).await;
+        assert_eq!(consensus.commit_min(), 2, "the completion must commit");
+        assert!(
+            consensus.message_bus().client_sends.borrow().is_empty(),
+            "a completion without its in-process sender must never send a wire reply"
+        );
     }
 
     /// A replayed `CreatePersonalAccessToken` must be refused, not served from
@@ -5729,6 +5762,64 @@ mod tests {
         );
     }
 
+    /// A normal backup names the primary of its view as the place to retry,
+    /// and a backup in transfer cannot. Neither admits the mutation.
+    #[compio::test]
+    async fn given_backup_when_mutation_is_submitted_should_refuse_by_role() {
+        const CLIENT: u128 = 1;
+        const USER: u32 = TOPIC_MANAGER;
+        const BACKUP_REPLICA: u8 = 1;
+        const REPLICA_COUNT: u8 = 3;
+        for (transferring, expected) in [
+            (false, IggyError::NotPrimary),
+            (true, IggyError::TransientNotAccepted),
+        ] {
+            let consensus = VsrConsensus::new(
+                1,
+                BACKUP_REPLICA,
+                REPLICA_COUNT,
+                server_common::sharding::METADATA_GROUP,
+                NoopBus,
+                LocalPipeline::new(),
+            );
+            consensus.init();
+            if transferring {
+                consensus.begin_state_transfer_await();
+            }
+            let metadata: IggyMetadata<_, PrepareJournal, (), TestMux> = IggyMetadata::new(
+                Some(consensus),
+                None,
+                None,
+                None,
+                TestMux::new((
+                    users_with_topic_manager(),
+                    (stream_with_one_partition().into(), ()),
+                )),
+                None,
+            );
+            metadata
+                .client_table
+                .borrow_mut()
+                .commit_register(CLIENT, USER, [0x5a; 32], register_reply(CLIENT, 1))
+                .unwrap();
+
+            let reply = metadata
+                .submit_request_in_process(create_topic_request(CLIENT, USER))
+                .await
+                .expect("a refusal is a reply, not a submit error");
+            assert_eq!(
+                iggy_binary_protocol::result_code(&reply.as_slice()[size_of::<ReplyHeader>()..]),
+                Some(expected.as_code()),
+                "transferring {transferring}"
+            );
+            assert_eq!(
+                metadata.consensus.as_ref().unwrap().pipeline_len(),
+                0,
+                "a backup must not prepare the mutation"
+            );
+        }
+    }
+
     #[test]
     fn given_no_partitions_cap_when_admitting_create_topic_should_admit() {
         let mut metadata = metadata_plane();
@@ -7416,6 +7507,92 @@ mod tests {
         }
     }
 
+    /// All owner decisions of one revocation share its op as generation, and a
+    /// reconciler on another shard installs what it reads. A reader that holds
+    /// a guard across the revocation stalls any second publish, so a racing
+    /// reader would then see one removal alone.
+    #[compio::test]
+    async fn given_two_sessions_of_one_user_when_revoked_should_publish_both_removals_at_once() {
+        const FIRST: u128 = 1;
+        const SECOND: u128 = 2;
+        const PUBLISH_TIMEOUT: Duration = Duration::from_secs(10);
+        let (_directory, metadata) = metadata_with_group_member(FIRST).await;
+        join_group_member(&metadata, SECOND).await;
+        let header = PrepareHeader {
+            op: metadata.consensus.as_ref().unwrap().commit_min() + 1,
+            operation: Operation::DeleteUser,
+            ..Default::default()
+        };
+        let factory = metadata.mux_stm.streams().factory();
+        // One guard for both members: two reads could straddle a publish.
+        let memberships = |streams: &Streams| {
+            streams.read(|inner| {
+                [FIRST, SECOND].map(|client_id| {
+                    inner
+                        .items
+                        .iter()
+                        .flat_map(|(_, stream)| stream.topics.iter())
+                        .flat_map(|(_, topic)| topic.consumer_groups.values())
+                        .filter(|group| {
+                            group
+                                .members
+                                .iter()
+                                .any(|(_, member)| member.client_id == client_id)
+                        })
+                        .count()
+                })
+            })
+        };
+        let (held_sender, held_receiver) = mpsc::channel();
+        let (release_sender, release_receiver) = mpsc::channel::<()>();
+
+        let seen = thread::scope(|scope| {
+            let holder_factory = factory.clone();
+            let holder = scope.spawn(move || {
+                Streams::from_factory(holder_factory)
+                    .read(|_| {
+                        held_sender.send(()).unwrap();
+                        release_receiver.recv()
+                    })
+                    .unwrap();
+            });
+            held_receiver.recv().unwrap();
+            let observer = scope.spawn(move || {
+                let streams = Streams::from_factory(factory);
+                let deadline = Instant::now() + PUBLISH_TIMEOUT;
+                let seen = loop {
+                    let seen = memberships(&streams);
+                    if seen != [1, 1] {
+                        break seen;
+                    }
+                    assert!(
+                        Instant::now() < deadline,
+                        "the revocation never reached readers"
+                    );
+                    thread::yield_now();
+                };
+                release_sender.send(()).unwrap();
+                seen
+            });
+            apply_user_revocation(
+                &*metadata.mux_stm,
+                &metadata.client_table,
+                true,
+                GROUP_MEMBER_USER,
+                &header,
+            );
+            let seen = observer.join().unwrap();
+            holder.join().unwrap();
+            seen
+        });
+
+        assert_eq!(
+            seen,
+            [0, 0],
+            "a reader saw one member removal of the revocation without the other"
+        );
+    }
+
     #[test]
     fn given_rejected_or_older_user_deletion_when_applying_should_preserve_newer_sessions() {
         const CLIENT: u128 = 1;
@@ -7488,6 +7665,9 @@ mod tests {
         }
     }
 
+    /// The user of every session that [`join_group_member`] registers.
+    const GROUP_MEMBER_USER: u32 = 7;
+
     #[allow(clippy::future_not_send)]
     async fn metadata_with_group_member(
         client_id: u128,
@@ -7495,7 +7675,6 @@ mod tests {
         tempfile::TempDir,
         IggyMetadata<VsrConsensus<NoopBus>, PrepareJournal, (), TestMux>,
     ) {
-        const USER: u32 = 7;
         let mut inner = StreamsInner::new();
         let timestamp = IggyTimestamp::now();
         let _ = StateHandler::apply(
@@ -7541,11 +7720,22 @@ mod tests {
                 .unwrap(),
         );
         metadata.mux_stm = Rc::new(TestMux::new((Users::default(), (inner.into(), ()))));
+        metadata.mux_stm.users().ensure_root_user("root", "hash");
+        join_group_member(&metadata, client_id).await;
+        (dir, metadata)
+    }
+
+    /// Registers `client_id` as a session of `GROUP_MEMBER_USER` and commits
+    /// its join to the group of [`metadata_with_group_member`].
+    #[allow(clippy::future_not_send)]
+    async fn join_group_member(
+        metadata: &IggyMetadata<VsrConsensus<NoopBus>, PrepareJournal, (), TestMux>,
+        client_id: u128,
+    ) {
         metadata
-            .submit_register_in_process(client_id, USER, [0x5a; 32])
+            .submit_register_in_process(client_id, GROUP_MEMBER_USER, [0x5a; 32])
             .await
             .unwrap();
-        metadata.mux_stm.users().ensure_root_user("root", "hash");
         let consensus = metadata.consensus.as_ref().unwrap();
         let join = consensus::seal_prepare_capacity(
             build_prepare_message(
@@ -7584,7 +7774,6 @@ mod tests {
                 .len(),
             1
         );
-        (dir, metadata)
     }
 
     /// Register is the one op that still honors the catch-up gate (its
diff --git a/core/metadata/src/stm/consumer_group.rs b/core/metadata/src/stm/consumer_group.rs
index 935880ec7..33022cd14 100644
--- a/core/metadata/src/stm/consumer_group.rs
+++ b/core/metadata/src/stm/consumer_group.rs
@@ -73,7 +73,6 @@ pub struct ConsumerGroupOwnershipTransition {
     pub stream_id: u32,
     pub topic_id: u32,
     pub partition_id: u32,
-    pub source: Option<ConsumerGroupOwner>,
     pub installation: InstallConsumerGroupOwnerRequest,
     pub created_at: u64,
     pub skip_drain: bool,
@@ -132,7 +131,7 @@ pub struct ConsumerGroup {
     pub id: u64,
     /// Bumped on every `rebalance_members`. The client caches the generation it
     /// synced at; the coordinator fences stale polls and the heartbeat re-syncs
-    /// when it advances.
+    /// when it advances. Owner generations are metadata ops, not this counter.
     pub generation: u64,
     pub name: Arc<str>,
     pub members: IdSlab<ConsumerGroupMember>,
@@ -182,7 +181,7 @@ impl ConsumerGroup {
             let desired = ConsumerGroupOwner {
                 client_id,
                 session,
-                generation: self.generation,
+                generation: metadata_op,
             };
             let assignment =
                 self.assignments
@@ -200,8 +199,14 @@ impl ConsumerGroup {
             let same_member = |owner: ConsumerGroupOwner| {
                 owner.client_id == client_id && owner.session == session
             };
-            if let Some(pending) = assignment.pending {
+            let owner_left = assignment
+                .owner
+                .is_none_or(|owner| !members.contains(&(owner.client_id, owner.session)));
+            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;
                     continue;
                 }
             } else if assignment.owner.is_some_and(same_member)
@@ -209,14 +214,14 @@ impl ConsumerGroup {
             {
                 continue;
             }
-            let skip_drain = assignment
-                .owner
-                .is_none_or(|owner| !members.contains(&(owner.client_id, owner.session)));
+            // The installed owner drains the same work for any successor, so a
+            // new target keeps the deadline of the first handoff.
+            let created_at = assignment.pending.map_or(now, |pending| pending.created_at);
             assignment.pending = Some(PendingOwnership {
                 owner: desired,
                 metadata_op,
-                created_at: now,
-                skip_drain,
+                created_at,
+                skip_drain: owner_left,
             });
         }
         self.rebuild_member_assignments();
@@ -295,7 +300,6 @@ impl ConsumerGroup {
                     stream_id: stream_id as u32,
                     topic_id: topic_id as u32,
                     partition_id: partition_id as u32,
-                    source: assignment.owner,
                     installation: InstallConsumerGroupOwnerRequest {
                         incarnation: assignment.incarnation,
                         group_id: self.id,
@@ -492,8 +496,9 @@ impl WireDecode for LeaveConsumerGroupRequest {
     }
 }
 
-/// Owner retirement sends every live group id of a topic in one catalog, and
-/// decode refuses a catalog above `CONSUMER_GROUP_OWNERS_MAX`.
+/// Owner retirement sends the live group ids of a topic plus the kept ids of a
+/// partition in one catalog, and decode refuses a catalog above
+/// `CONSUMER_GROUP_OWNERS_MAX`. This guard bounds only the live ids.
 const fn admits_consumer_group(live_groups: usize) -> bool {
     live_groups < CONSUMER_GROUP_OWNERS_MAX as usize
 }
@@ -611,8 +616,8 @@ impl StateHandler for DeleteConsumerGroupRequest {
             return ApplyReply::err(DeleteConsumerGroupResult::ConsumerGroupNotFound);
         };
         topic.consumer_group_index.remove(&group.name);
-        // Finalization runs under the intent's op, and the lifecycle completion
-        // then records the op that actually removed the group.
+        // A finalized deletion applies at its completion op. A sibling group
+        // created after the intent can hold a later op than the intent.
         topic.consumer_group_catalog_op = state.apply_context.metadata_op;
         // Bump the partition-shaping revision so the reconciler's fast-skip
         // doesn't pass over the delete: it reclaims the group's leftover
@@ -1220,6 +1225,261 @@ mod tests {
         }
     }
 
+    #[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);
+        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, SUCCESSOR));
+        group.rebalance_members(&partitions, 14, 200);
+        let decided = group.assignments[&1].pending.unwrap();
+        assert!(!decided.skip_drain);
+
+        group.members.remove(0);
+        group.rebalance_members(&partitions, 16, 400);
+        assert_eq!(
+            group.assignments[&1].pending,
+            Some(PendingOwnership {
+                skip_drain: true,
+                ..decided
+            }),
+            "a departed owner cannot commit, so its successor must not wait for a drain"
+        );
+        let mut transitions = BTreeMap::new();
+        group.index_pending_revocations(0, 0, &mut transitions);
+        assert!(transitions[&(0, 0, group.id, 1)].skip_drain);
+
+        let handoff = group.assignments[&0].pending.unwrap();
+        assert!(handoff.skip_drain);
+        group.members.insert(member(0, OWNER));
+        group.rebalance_members(&partitions, 18, 600);
+        assert_eq!(
+            group.assignments[&0].pending,
+            Some(PendingOwnership {
+                skip_drain: false,
+                ..handoff
+            }),
+            "a rejoined owner can commit again, so the handoff waits for its drain"
+        );
+    }
+
+    #[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);
+        const THIRD: (u128, u64) = (9, 15);
+        const FIRST_HANDOFF_AT: u64 = 300;
+        let member = |id, (client_id, session)| {
+            let mut member = ConsumerGroupMember::new(id, client_id);
+            member.session = Some(session);
+            member
+        };
+        let partitions: Vec<_> = (0..3)
+            .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..3 {
+            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);
+        group.members.insert(member(2, THIRD));
+        group.rebalance_members(&partitions, 15, FIRST_HANDOFF_AT);
+        let first = group.assignments[&1].pending.unwrap();
+        assert_eq!(
+            (first.owner.client_id, first.created_at),
+            (THIRD.0, FIRST_HANDOFF_AT)
+        );
+
+        group.members.remove(2);
+        group.rebalance_members(&partitions, 16, 400);
+        assert_eq!(
+            group.assignments[&1].pending,
+            Some(PendingOwnership {
+                owner: ConsumerGroupOwner {
+                    client_id: OWNER.0,
+                    session: OWNER.1,
+                    generation: 16,
+                },
+                metadata_op: 16,
+                created_at: FIRST_HANDOFF_AT,
+                skip_drain: false,
+            }),
+            "a new target must not restart the drain deadline of the installed owner"
+        );
+        let mut transitions = BTreeMap::new();
+        group.index_pending_revocations(0, 0, &mut transitions);
+        assert_eq!(
+            transitions[&(0, 0, group.id, 1)].created_at,
+            FIRST_HANDOFF_AT
+        );
+    }
+
+    #[test]
+    fn given_membership_change_when_rebalancing_should_mint_the_deciding_op_as_owner_generation() {
+        const FIRST_OP: u64 = 12;
+        const SECOND_OP: u64 = 31;
+        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"));
+        let mut first = ConsumerGroupMember::new(0, 7);
+        first.session = Some(11);
+        group.members.insert(first);
+        group.rebalance_members(&partitions, FIRST_OP, 100);
+        let mut second = ConsumerGroupMember::new(1, 8);
+        second.session = Some(13);
+        group.members.insert(second);
+        group.rebalance_members(&partitions, SECOND_OP, 200);
+
+        let decisions: Vec<_> = group
+            .assignments
+            .values()
+            .map(|assignment| {
+                let pending = assignment.pending.unwrap();
+                assert_eq!(pending.owner.generation, pending.metadata_op);
+                (pending.owner.client_id, pending.owner.generation)
+            })
+            .collect();
+        assert_eq!(decisions, [(7, FIRST_OP), (8, SECOND_OP)]);
+        assert_eq!(
+            group.generation, 2,
+            "the synced counter keeps its own sequence"
+        );
+    }
+
+    /// A user revocation ends several sessions in one op, so a group can
+    /// rebalance twice under that op.
+    #[test]
+    fn given_two_rebalances_in_one_op_when_indexing_should_leave_one_decision_per_partition() {
+        const FIRST_OP: u64 = 10;
+        const REVOCATION_OP: u64 = 20;
+        const SURVIVOR: u128 = 3;
+        let partitions: Vec<_> = (0..3)
+            .map(|id| Partition::new(id, id as u64 + 1, IggyTimestamp::from(1), 1, 0))
+            .collect();
+        let mut group = ConsumerGroup::new(0, Arc::from("group"));
+        for id in 0..3 {
+            let mut member = ConsumerGroupMember::new(id, id as u128 + 1);
+            member.session = Some(1);
+            group.members.insert(member);
+        }
+        group.rebalance_members(&partitions, FIRST_OP, 100);
+        for key in [0, 1] {
+            group.members.remove(key);
+            group.rebalance_members(&partitions, REVOCATION_OP, 200);
+        }
+
+        let mut transitions = BTreeMap::new();
+        group.index_pending_revocations(0, 0, &mut transitions);
+        let decisions: Vec<_> = transitions
+            .values()
+            .map(|transition| {
+                let installation = transition.installation;
+                assert_eq!(installation.owner.client_id, SURVIVOR);
+                assert_eq!(installation.owner.generation, installation.metadata_op);
+                (transition.partition_id, installation.metadata_op)
+            })
+            .collect();
+        assert_eq!(
+            decisions,
+            [(0, REVOCATION_OP), (1, REVOCATION_OP), (2, FIRST_OP)]
+        );
+    }
+
+    #[test]
+    fn given_two_revoked_sessions_when_removed_together_should_match_sequential_removals() {
+        const REVOKED: [u128; 2] = [1, 2];
+        const SURVIVOR: u128 = 3;
+        const ABSENT: u128 = 99;
+        const REVOCATION_OP: u64 = 50;
+        let timestamp = IggyTimestamp::from(1_000);
+        let mut state = streams_with_topic();
+        assert_eq!(create_partitions(&mut state, 2).code, 0);
+        assert_eq!(create_group(&mut state, "group").code, 0);
+        for client_id in [REVOKED[0], REVOKED[1], SURVIVOR] {
+            assert_eq!(join(&mut state, 0, 0, 0, client_id).code, 0);
+        }
+        complete_pending_revocations(&mut state);
+        let observe = |streams: &Streams| {
+            (
+                rmp_serde::to_vec(&streams.to_snapshot()).unwrap(),
+                streams.consumer_group_pending_revocations(),
+            )
+        };
+        let sequential = Streams::from(state.clone());
+        let batched = Streams::from(state);
+        for streams in [&sequential, &batched] {
+            // A left-right absorbs its first command in place, so the
+            // removals below go through the op log of both copies.
+            streams.remove_consumer_group_member(ABSENT, timestamp, REVOCATION_OP - 1);
+        }
+
+        sequential.remove_consumer_group_member(REVOKED[0], timestamp, REVOCATION_OP);
+        assert!(
+            sequential
+                .consumer_group_pending_revocations()
+                .iter()
+                .any(|transition| transition.installation.owner.client_id == REVOKED[1]),
+            "the first removal alone decides an owner that the second one replaces"
+        );
+        sequential.remove_consumer_group_member(REVOKED[1], timestamp, REVOCATION_OP);
+        batched.remove_consumer_group_members(&REVOKED, timestamp, REVOCATION_OP);
+
+        let decisions: Vec<_> = batched
+            .consumer_group_pending_revocations()
+            .iter()
+            .map(|transition| {
+                (
+                    transition.partition_id,
+                    transition.installation.owner.client_id,
+                    transition.installation.metadata_op,
+                )
+            })
+            .collect();
+        assert_eq!(
+            decisions,
+            [(0, SURVIVOR, REVOCATION_OP), (2, SURVIVOR, REVOCATION_OP)]
+        );
+        assert_eq!(observe(&batched), observe(&sequential));
+        for streams in [&sequential, &batched] {
+            // The next publish exposes the copy that replayed the removals.
+            streams.remove_consumer_group_member(ABSENT, timestamp, REVOCATION_OP + 1);
+        }
+        assert_eq!(observe(&batched), observe(&sequential));
+    }
+
     #[test]
     fn given_session_refresh_when_rebalancing_should_index_revocations_like_full_rebuild() {
         const CLIENT: u128 = 7;
@@ -1327,6 +1587,49 @@ mod tests {
         }
     }
 
+    #[test]
+    fn given_recreated_group_when_same_session_rejoins_should_mint_generations_above_the_deleted_group()
+     {
+        const CLIENT: u128 = 7;
+        let mut state = streams_with_topic();
+        assert_eq!(create_group(&mut state, "group").code, 0);
+        assert_eq!(join(&mut state, 0, 0, 0, CLIENT).code, 0);
+        let deleted_owners = complete_pending_revocations(&mut state);
+        assert_eq!(deleted_owners.len(), 1, "the topic has one partition");
+        assert_eq!(
+            crate::stm::lifecycle::apply_with_lifecycle_completion(
+                &DeleteConsumerGroupRequest {
+                    stream_id: WireIdentifier::numeric(0),
+                    topic_id: WireIdentifier::numeric(0),
+                    group_id: WireIdentifier::numeric(0),
+                },
+                &mut state,
+                IggyTimestamp::now(),
+            )
+            .code,
+            0
+        );
+        // Bounds every generation the deleted group minted, its fences included.
+        let deleted_through = state.apply_context.metadata_op;
+        assert_eq!(create_group(&mut state, "group").code, 0);
+        assert_eq!(join(&mut state, 0, 0, 1, CLIENT).code, 0);
+
+        let recreated: Vec<_> = state
+            .pending_revocations
+            .values()
+            .map(|transition| transition.installation.owner)
+            .collect();
+        assert_eq!(recreated.len(), deleted_owners.len());
+        for (deleted, recreated) in deleted_owners.iter().zip(&recreated) {
+            assert_eq!(
+                (recreated.client_id, recreated.session),
+                (deleted.client_id, deleted.session)
+            );
+            assert!(recreated.generation > deleted_through);
+            assert_ne!(recreated, deleted, "a deleted group's owner must not match");
+        }
+    }
+
     #[test]
     fn session_refresh_preserves_other_clients_after_boot_and_in_place_restore() {
         const CLIENT: u128 = 7;
@@ -1606,9 +1909,11 @@ mod tests {
         )
     }
 
-    fn complete_pending_revocations(state: &mut StreamsInner) {
+    /// Installs every pending owner, one op each, and returns the installed owners.
+    fn complete_pending_revocations(state: &mut StreamsInner) -> Vec<ConsumerGroupOwner> {
         let transitions: Vec<_> = state.pending_revocations.values().copied().collect();
-        for transition in transitions {
+        for transition in &transitions {
+            state.apply_context.metadata_op += 1;
             let completion = CompleteConsumerGroupRevocationRequest {
                 stream_id: WireIdentifier::numeric(transition.stream_id),
                 topic_id: WireIdentifier::numeric(transition.topic_id),
@@ -1622,6 +1927,10 @@ mod tests {
             );
         }
         assert!(state.pending_revocations.is_empty());
+        transitions
+            .iter()
+            .map(|transition| transition.installation.owner)
+            .collect()
     }
 
     #[test]
@@ -1638,8 +1947,9 @@ mod tests {
     }
 
     /// A full topic costs hundreds of megabytes to materialise, so this pins
-    /// the guard to the catalog bound instead: the largest admitted topic must
-    /// still fit one retirement catalog.
+    /// the guard to the catalog bound instead: the live ids of the largest
+    /// admitted topic must still fit one retirement catalog. A partition adds
+    /// its kept ids to them, which this bound does not cover.
     #[test]
     fn given_the_owner_catalog_bound_when_creating_one_more_group_should_reject() {
         let largest = CONSUMER_GROUP_OWNERS_MAX as usize;
diff --git a/core/metadata/src/stm/lifecycle.rs b/core/metadata/src/stm/lifecycle.rs
index 29d3dc8d3..5f33161ba 100644
--- a/core/metadata/src/stm/lifecycle.rs
+++ b/core/metadata/src/stm/lifecycle.rs
@@ -20,6 +20,7 @@ use std::collections::{BTreeMap, BTreeSet};
 
 use ahash::AHashMap;
 use bytes::{BufMut, Bytes, BytesMut};
+pub use iggy_binary_protocol::LIFECYCLE_COMPLETIONS_MAX;
 use iggy_binary_protocol::codec::{read_u32_le, read_u64_le};
 use iggy_binary_protocol::primitives::partition_history::ConsumerGroupOwner;
 use iggy_binary_protocol::requests::consumer_groups::DeleteConsumerGroupRequest;
@@ -28,7 +29,10 @@ use iggy_binary_protocol::requests::partitions::{
 };
 use iggy_binary_protocol::requests::streams::DeleteStreamRequest;
 use iggy_binary_protocol::requests::topics::DeleteTopicRequest;
-use iggy_binary_protocol::{Operation, WireDecode, WireEncode, WireError, WireIdentifier};
+use iggy_binary_protocol::{
+    LIFECYCLE_BATCH_HEADER_SIZE, LIFECYCLE_COMPLETION_SIZE, Operation, WireDecode, WireEncode,
+    WireError, WireIdentifier,
+};
 use iggy_common::{IggyError, IggyTimestamp};
 use serde::{Deserialize, Serialize};
 
@@ -85,11 +89,11 @@ pub struct LifecycleIntent {
 pub(crate) type LifecycleScope = (usize, Option<usize>, u64);
 
 impl LifecycleIntent {
-    pub(crate) fn scope(&self, metadata_op: u64) -> LifecycleScope {
+    pub(crate) fn scope(&self) -> LifecycleScope {
         (
             self.stream_id as usize,
             self.topic_id.map(|id| id as usize),
-            metadata_op,
+            self.context.metadata_op,
         )
     }
 
@@ -113,14 +117,6 @@ impl LifecycleIntent {
 /// incarnation offline completes it instead.
 pub const RETIRED_PARTITION_OP: u64 = u64::MAX;
 
-/// Upper bound on the targets of one [`CompleteLifecycleRequest`], which keeps
-/// one metadata prepare small. A reporter with more ready targets sends the
-/// rest in its next batch.
-pub const LIFECYCLE_COMPLETIONS_MAX: usize = 1024;
-
-const COMPLETION_BATCH_HEADER_SIZE: usize = size_of::<u64>() + 2 * size_of::<u32>();
-const COMPLETION_SIZE: usize = 2 * size_of::<u32>() + size_of::<u64>();
-
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 pub struct LifecycleCompletion {
     pub topic_id: u32,
@@ -172,7 +168,7 @@ impl CompleteLifecycleRequest {
 
 impl WireEncode for CompleteLifecycleRequest {
     fn encoded_size(&self) -> usize {
-        COMPLETION_BATCH_HEADER_SIZE + self.completions.len() * COMPLETION_SIZE
+        LIFECYCLE_BATCH_HEADER_SIZE + self.completions.len() * LIFECYCLE_COMPLETION_SIZE
     }
 
     fn encode(&self, buf: &mut BytesMut) {
@@ -207,7 +203,7 @@ impl WireDecode for CompleteLifecycleRequest {
         let count = read_u32_le(buf, 12)? as usize;
         let mut completions = Vec::with_capacity(count.min(LIFECYCLE_COMPLETIONS_MAX));
         for index in 0..count {
-            let offset = COMPLETION_BATCH_HEADER_SIZE + index * COMPLETION_SIZE;
+            let offset = LIFECYCLE_BATCH_HEADER_SIZE + index * LIFECYCLE_COMPLETION_SIZE;
             completions.push(LifecycleCompletion {
                 topic_id: read_u32_le(buf, offset)?,
                 partition_id: read_u32_le(buf, offset + size_of::<u32>())?,
@@ -238,15 +234,6 @@ impl Streams {
         self.read(|state| state.lifecycle_blocks(stream_id, topic_id))
     }
 
-    pub fn lifecycle_blocks_consumer(
-        &self,
-        stream_id: usize,
-        topic_id: usize,
-        group_id: Option<u64>,
-    ) -> bool {
-        self.read(|state| state.lifecycle_blocks_consumer(stream_id, topic_id, group_id))
-    }
-
     pub fn request_lifecycle_blocked(&self, operation: Operation, body: &[u8]) -> bool {
         let topic_scoped = match operation {
             Operation::UpdateStream | Operation::DeleteStream | Operation::CreateTopic => false,
@@ -343,15 +330,18 @@ impl StreamsInner {
         self.lifecycle_scopes
             .range(stream_wide)
             .chain(self.lifecycle_scopes.range(topic_scoped))
-            .filter(move |_| !self.finalizing_lifecycle)
             .filter_map(move |(_, _, op)| self.lifecycle_intents.get(op))
     }
 
-    /// Keeps `lifecycle_scopes` exact, also when `op` already holds an intent.
-    fn insert_lifecycle(&mut self, op: u64, intent: LifecycleIntent) {
-        let scope = intent.scope(op);
-        if let Some(replaced) = self.lifecycle_intents.insert(op, intent) {
-            self.lifecycle_scopes.remove(&replaced.scope(op));
+    /// Keeps `lifecycle_scopes` exact, also when the op of `intent` already
+    /// holds an intent.
+    fn insert_lifecycle(&mut self, intent: LifecycleIntent) {
+        let scope = intent.scope();
+        if let Some(replaced) = self
+            .lifecycle_intents
+            .insert(intent.context.metadata_op, intent)
+        {
+            self.lifecycle_scopes.remove(&replaced.scope());
         }
         self.lifecycle_scopes.insert(scope);
     }
@@ -392,17 +382,18 @@ impl StreamsInner {
             for partition in &topic.partitions[first..] {
                 let incarnation = partition.created_revision;
                 let fence = if let LifecycleAction::DeleteConsumerGroup { group_id } = action {
-                    let group = topic.consumer_groups.get(&u64::from(group_id))?;
-                    let Some(generation) = group.generation.checked_add(1) else {
-                        return Some(ApplyReply::err(IggyError::InvalidCommand.as_code()));
-                    };
+                    if !topic.consumer_groups.contains_key(&u64::from(group_id)) {
+                        return None;
+                    }
+                    // A partition skips an install below its owner's generation.
+                    // The delete op exceeds every op that decided an owner before it.
                     LifecycleFence::Owner(InstallConsumerGroupOwnerRequest {
                         incarnation,
                         group_id: u64::from(group_id),
                         owner: ConsumerGroupOwner {
                             client_id: 0,
                             session: 0,
-                            generation,
+                            generation: self.apply_context.metadata_op,
                         },
                         metadata_op: self.apply_context.metadata_op,
                     })
@@ -441,7 +432,7 @@ impl StreamsInner {
             remaining_partitions: 0,
         };
         intent.rebuild_partition_index();
-        self.insert_lifecycle(self.apply_context.metadata_op, intent);
+        self.insert_lifecycle(intent);
         self.revision = self.revision.wrapping_add(1);
         Some(ApplyReply::ok(Bytes::new()))
     }
@@ -488,13 +479,11 @@ impl StateHandler for CompleteLifecycleRequest {
             return ApplyReply::ok(Bytes::new());
         }
         let mut intent = entry.remove();
-        state
-            .lifecycle_scopes
-            .remove(&intent.scope(self.metadata_op));
+        state.lifecycle_scopes.remove(&intent.scope());
         let stream_id = WireIdentifier::numeric(intent.stream_id);
         let topic_id = WireIdentifier::numeric(intent.topic_id.unwrap_or_default());
-        let context = state.apply_context;
-        state.apply_context = intent.context;
+        // The deferred request applies at this op, not at the intent op: an
+        // owner its rebalance decides must outrank every owner decided since.
         state.finalizing_lifecycle = true;
         let reply = match intent.action {
             LifecycleAction::DeleteStream => {
@@ -519,7 +508,6 @@ impl StateHandler for CompleteLifecycleRequest {
             .apply(state, timestamp),
         };
         state.finalizing_lifecycle = false;
-        state.apply_context = context;
         if reply.code != 0 {
             // Keep the final report pending so reconciliation can retry finalization.
             for &(index, _) in &recorded {
@@ -528,19 +516,7 @@ impl StateHandler for CompleteLifecycleRequest {
                 }
             }
             intent.remaining_partitions += recorded.len();
-            state.insert_lifecycle(self.metadata_op, intent);
-        } else if matches!(intent.action, LifecycleAction::DeleteConsumerGroup { .. })
-            && let Some(topic) = intent.topic_id.and_then(|topic_id| {
-                state
-                    .items
-                    .get_mut(intent.stream_id as usize)?
-                    .topics
-                    .get_mut(topic_id as usize)
-            })
-        {
-            // A sibling group created after the intent may already hold a later
-            // op, and the label must keep growing with the catalog.
-            topic.consumer_group_catalog_op = context.metadata_op;
+            state.insert_lifecycle(intent);
         }
         reply
     }
@@ -605,7 +581,7 @@ impl StreamsSnapshot {
                                 || owner.client_id == 0
                                 || owner.session == 0
                                 || owner.generation == 0
-                                || owner.generation > group.generation
+                                || owner.generation > metadata_op
                                 || (assignment.pending.is_none()
                                     && !members.contains(&(owner.client_id, Some(owner.session))))
                         }) {
@@ -616,7 +592,7 @@ impl StreamsSnapshot {
                         if let Some(pending) = assignment.pending
                             && ((pending.owner.client_id == 0) != (pending.owner.session == 0)
                                 || pending.owner.generation == 0
-                                || pending.owner.generation > group.generation
+                                || pending.owner.generation != pending.metadata_op
                                 || pending.metadata_op == 0
                                 || pending.metadata_op > metadata_op
                                 || (!pending.owner.is_unassigned()
@@ -710,20 +686,21 @@ impl StreamsSnapshot {
                         LifecycleAction::DeleteConsumerGroup { group_id },
                         LifecycleFence::Owner(installation),
                     ) => {
-                        let group = topic
+                        if !topic
                             .consumer_groups
                             .iter()
-                            .find(|(id, _)| *id == u64::from(group_id))
-                            .map(|(_, group)| group)
-                            .ok_or(SnapshotError::InvalidState(
+                            .any(|(id, _)| *id == u64::from(group_id))
+                        {
+                            return Err(SnapshotError::InvalidState(
                                 "deleted consumer group is absent",
-                            ))?;
+                            ));
+                        }
                         if installation.incarnation != partition.created_revision
                             || installation.metadata_op != op
                             || installation.group_id != u64::from(group_id)
                             || !installation.owner.is_unassigned()
                             || installation.owner.generation == 0
-                            || installation.owner.generation > group.generation.saturating_add(1)
+                            || installation.owner.generation != op
                         {
                             return Err(SnapshotError::InvalidState(
                                 "incorrect consumer group deletion fence",
@@ -796,8 +773,8 @@ mod tests {
     use super::*;
     use crate::stm::State;
     use crate::stm::consumer_group::{
-        CompleteConsumerGroupRevocationRequest, ConsumerGroup, ConsumerGroupMember,
-        ConsumerGroupSnapshot,
+        CompleteConsumerGroupRevocationRequest, ConsumerGroup, ConsumerGroupAssignment,
+        ConsumerGroupMember, ConsumerGroupOwnershipTransition, ConsumerGroupSnapshot,
     };
     use crate::stm::snapshot::Snapshotable;
     use iggy_binary_protocol::primitives::partition_assignment::CreatedPartitionAssignment;
@@ -890,6 +867,186 @@ mod tests {
         assert_eq!(reply.code, 0);
     }
 
+    /// One member owns partition 0 and awaits partition 1, both decided at
+    /// `owner_op`, and the group's deletion is pending at `INTENT_OP`.
+    fn pending_owned_group_deletion(owner_op: u64) -> Streams {
+        let streams = streams();
+        let partitions = streams.read(|inner| inner.items[0].topics[0].partitions.clone());
+        let mut group = ConsumerGroup::new(0, Arc::from("group"));
+        let mut member = ConsumerGroupMember::new(0, 17);
+        member.session = Some(5);
+        group.members.insert(member);
+        group.rebalance_members(&partitions, owner_op, 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, 12));
+        let mut snapshot = streams.to_snapshot();
+        let topic = &mut snapshot.items[0].1.topics[0].1;
+        topic.next_consumer_group_id = 1;
+        topic
+            .consumer_groups
+            .push((0, ConsumerGroupSnapshot::from_group(&group)));
+        let streams = Streams::from_snapshot(snapshot).unwrap();
+        let reply = apply(
+            &streams,
+            Operation::DeleteConsumerGroup,
+            &DeleteConsumerGroupRequest {
+                stream_id: WireIdentifier::numeric(0),
+                topic_id: WireIdentifier::numeric(0),
+                group_id: WireIdentifier::numeric(0),
+            },
+            context(),
+        );
+        assert_eq!(reply.code, 0);
+        assert!(streams.lifecycle_pending(INTENT_OP));
+        streams
+    }
+
+    fn snapshot_assignment(
+        snapshot: &mut StreamsSnapshot,
+        partition_id: usize,
+    ) -> &mut ConsumerGroupAssignment {
+        snapshot.items[0].1.topics[0].1.consumer_groups[0]
+            .1
+            .assignments
+            .get_mut(&partition_id)
+            .unwrap()
+    }
+
+    fn snapshot_deletion_fence(snapshot: &mut StreamsSnapshot) -> &mut ConsumerGroupOwner {
+        let intent = snapshot.lifecycle_intents.get_mut(&INTENT_OP).unwrap();
+        let LifecycleFence::Owner(installation) = &mut intent.partitions[0].fence else {
+            panic!("a group deletion fences owners");
+        };
+        &mut installation.owner
+    }
+
+    #[test]
+    fn given_group_with_owners_when_deleting_should_fence_partitions_at_the_delete_op() {
+        let streams = pending_owned_group_deletion(INTENT_OP - 1);
+        let decided = streams.read(|inner| {
+            inner.items[0].topics[0].consumer_groups[&0]
+                .assignments
+                .values()
+                .flat_map(|assignment| {
+                    assignment
+                        .owner
+                        .into_iter()
+                        .chain(assignment.pending.map(|pending| pending.owner))
+                })
+                .map(|owner| owner.generation)
+                .max()
+                .unwrap()
+        });
+        let intents = streams.pending_lifecycles();
+        assert_eq!(intents.len(), 1);
+        assert_eq!(intents[0].partitions.len(), 2);
+        for target in &intents[0].partitions {
+            let LifecycleFence::Owner(installation) = target.fence else {
+                panic!("a group deletion fences owners, got {:?}", target.fence);
+            };
+            assert!(installation.owner.is_unassigned());
+            assert_eq!(installation.owner.generation, INTENT_OP);
+            assert_eq!(installation.metadata_op, INTENT_OP);
+            assert!(installation.owner.generation > decided);
+        }
+    }
+
+    /// The finalization removes the group's transitions, so a read under one
+    /// guard never offers a decision that the deletion made obsolete.
+    #[test]
+    fn given_pending_group_deletion_when_a_member_reregisters_should_offer_no_transition_for_the_group()
+     {
+        const REGISTER_OP: u64 = INTENT_OP + 1;
+        const NEW_SESSION: u64 = 6;
+        const NO_TRANSITIONS: [ConsumerGroupOwnershipTransition; 0] = [];
+        let streams = pending_owned_group_deletion(INTENT_OP - 1);
+        streams.refresh_consumer_group_session(
+            17,
+            NEW_SESSION,
+            REGISTER_OP,
+            IggyTimestamp::default(),
+        );
+        let decided = streams.consumer_group_pending_revocations();
+        assert_eq!(decided.len(), 2);
+        for transition in &decided {
+            assert_eq!(transition.installation.owner.session, NEW_SESSION);
+            assert_eq!(transition.installation.owner.generation, REGISTER_OP);
+        }
+        assert_eq!(
+            streams.consumer_group_unblocked_revocations(),
+            NO_TRANSITIONS
+        );
+
+        let reply = complete_batch(
+            &streams,
+            vec![completion(0, 12), completion(1, 23)],
+            REGISTER_OP + 1,
+        );
+        assert_eq!(reply.code, 0);
+        assert!(!streams.lifecycle_pending(INTENT_OP));
+        assert_eq!(streams.consumer_group_pending_revocations(), NO_TRANSITIONS);
+        assert_eq!(
+            streams.consumer_group_unblocked_revocations(),
+            NO_TRANSITIONS
+        );
+    }
+
+    #[test]
+    fn given_op_minted_ownership_when_restoring_snapshot_should_enforce_strict_op_bounds() {
+        const OWNER_OP: u64 = INTENT_OP - 1;
+        let valid = pending_owned_group_deletion(OWNER_OP).to_snapshot();
+        let restore = |streams: StreamsSnapshot| {
+            let mut snapshot = crate::stm::snapshot::MetadataSnapshot::new(INTENT_OP);
+            snapshot.streams = Some(streams);
+            crate::stm::snapshot::MetadataSnapshot::decode(&snapshot.encode().unwrap())
+        };
+        assert!(restore(valid.clone()).is_ok());
+        #[allow(clippy::type_complexity)]
+        let mutations: &[(&str, fn(&mut StreamsSnapshot))] = &[
+            ("owner decided after the snapshot", |snapshot| {
+                snapshot_assignment(snapshot, 0)
+                    .owner
+                    .as_mut()
+                    .unwrap()
+                    .generation = INTENT_OP + 1;
+            }),
+            ("pending owner minted below its op", |snapshot| {
+                snapshot_assignment(snapshot, 1)
+                    .pending
+                    .as_mut()
+                    .unwrap()
+                    .owner
+                    .generation = OWNER_OP - 1;
+            }),
+            ("pending owner minted above its op", |snapshot| {
+                snapshot_assignment(snapshot, 1)
+                    .pending
+                    .as_mut()
+                    .unwrap()
+                    .owner
+                    .generation = OWNER_OP + 1;
+            }),
+            ("deletion fence below its op", |snapshot| {
+                snapshot_deletion_fence(snapshot).generation = INTENT_OP - 1;
+            }),
+            ("deletion fence above its op", |snapshot| {
+                snapshot_deletion_fence(snapshot).generation = INTENT_OP + 1;
+            }),
+        ];
+        for (label, mutate) in mutations {
+            let mut invalid = valid.clone();
+            mutate(&mut invalid);
+            assert!(restore(invalid).is_err(), "{label}");
+        }
+    }
+
     #[test]
     fn given_invalid_lifecycle_snapshot_when_restoring_should_reject_without_mutating_state() {
         let streams = streams();
@@ -1132,6 +1289,93 @@ mod tests {
         assert_eq!(restored.consumer_group_pending_revocations().len(), 3);
     }
 
+    /// Each of three members owns one partition. While the deletion of the last
+    /// partition waits, a logout moves partition 1 to another member, and that
+    /// owner installs. The finalization moves partition 1 again, and a
+    /// partition ignores an install below its installed generation.
+    #[test]
+    fn given_owner_installed_during_partition_deletion_when_finalizing_should_decide_above_it() {
+        const OWNER_OP: u64 = 10;
+        const LOGOUT_OP: u64 = INTENT_OP + 1;
+        let streams: Streams = StreamsInner::new().into();
+        streams.seed_namespace(IggyNamespace::new(0, 0, 2), 3, 0);
+        let partitions = streams.read(|inner| inner.items[0].topics[0].partitions.clone());
+        let mut group = ConsumerGroup::new(0, Arc::from("group"));
+        for (member_id, client_id, session) in [(0, 17, 5), (1, 18, 6), (2, 19, 7)] {
+            let mut member = ConsumerGroupMember::new(member_id, client_id);
+            member.session = Some(session);
+            group.members.insert(member);
+        }
+        group.rebalance_members(&partitions, OWNER_OP, 100);
+        for partition in &partitions {
+            let assignment = &group.assignments[&partition.id];
+            let pending = assignment.pending.unwrap();
+            let installation = InstallConsumerGroupOwnerRequest {
+                incarnation: assignment.incarnation,
+                group_id: 0,
+                owner: pending.owner,
+                metadata_op: pending.metadata_op,
+            };
+            assert!(group.complete_revocation(partition.id, &installation, 12));
+        }
+        let mut snapshot = streams.to_snapshot();
+        let topic = &mut snapshot.items[0].1.topics[0].1;
+        topic.next_consumer_group_id = 1;
+        topic
+            .consumer_groups
+            .push((0, ConsumerGroupSnapshot::from_group(&group)));
+        let streams = Streams::from_snapshot(snapshot).unwrap();
+        let delete = DeletePartitionsRequest {
+            stream_id: WireIdentifier::numeric(0),
+            topic_id: WireIdentifier::numeric(0),
+            partitions_count: 1,
+        };
+        assert_eq!(
+            apply(&streams, Operation::DeletePartitions, &delete, context()).code,
+            0
+        );
+        assert!(streams.lifecycle_pending(INTENT_OP));
+
+        streams.remove_consumer_group_member(18, IggyTimestamp::default(), LOGOUT_OP);
+        let moved = streams.consumer_group_pending_revocations();
+        assert_eq!(moved.len(), 1);
+        assert_eq!(moved[0].partition_id, 1);
+        assert_eq!(moved[0].installation.owner.client_id, 17);
+        assert_eq!(moved[0].installation.owner.generation, LOGOUT_OP);
+        let install = CompleteConsumerGroupRevocationRequest {
+            stream_id: WireIdentifier::numeric(0),
+            topic_id: WireIdentifier::numeric(0),
+            partition_id: 1,
+            installation: moved[0].installation,
+            partition_op: 13,
+        };
+        assert_eq!(
+            apply(
+                &streams,
+                Operation::CompleteConsumerGroupRevocation,
+                &install,
+                ApplyContext {
+                    metadata_op: LOGOUT_OP + 1,
+                    ..context()
+                },
+            )
+            .code,
+            0
+        );
+
+        complete(&streams, 2, 14);
+        assert!(!streams.lifecycle_pending(INTENT_OP));
+        let decided = streams.consumer_group_pending_revocations();
+        assert_eq!(decided.len(), 1);
+        assert_eq!(decided[0].partition_id, 1);
+        assert_eq!(decided[0].installation.owner.client_id, 19);
+        assert!(
+            decided[0].installation.owner.generation > LOGOUT_OP,
+            "generation {} must outrank the installed owner from op {LOGOUT_OP}",
+            decided[0].installation.owner.generation
+        );
+    }
+
     #[test]
     fn given_pending_group_deletion_when_other_consumers_arrive_should_isolate_the_group() {
         let streams = streams();
@@ -1162,9 +1406,9 @@ mod tests {
             0
         );
         assert!(streams.lifecycle_pending(INTENT_OP));
-        assert!(streams.lifecycle_blocks_consumer(0, 0, Some(0)));
-        assert!(!streams.lifecycle_blocks_consumer(0, 0, Some(1)));
-        assert!(!streams.lifecycle_blocks_consumer(0, 0, None));
+        assert!(streams.read(|inner| inner.lifecycle_blocks_consumer(0, 0, Some(0))));
+        assert!(!streams.read(|inner| inner.lifecycle_blocks_consumer(0, 0, Some(1))));
+        assert!(!streams.read(|inner| inner.lifecycle_blocks_consumer(0, 0, None)));
         for group_id in 0..2 {
             let request = JoinConsumerGroupRequest {
                 stream_id: WireIdentifier::numeric(0),
@@ -1189,9 +1433,9 @@ mod tests {
             )
         );
         complete(&streams, 0, 12);
-        assert!(streams.lifecycle_blocks_consumer(0, 0, Some(0)));
+        assert!(streams.read(|inner| inner.lifecycle_blocks_consumer(0, 0, Some(0))));
         complete(&streams, 1, 23);
-        assert!(!streams.lifecycle_blocks_consumer(0, 0, Some(0)));
+        assert!(!streams.read(|inner| inner.lifecycle_blocks_consumer(0, 0, Some(0))));
         assert!(!streams.lifecycle_pending(INTENT_OP));
         assert_eq!(
             streams.read(|inner| inner.items[0].topics[0]
@@ -1585,7 +1829,7 @@ mod tests {
         let mut replacement = reused.lifecycle_intents[&STREAM_DELETE_OP].clone();
         replacement.stream_id = 0;
         replacement.topic_id = Some(0);
-        reused.insert_lifecycle(STREAM_DELETE_OP, replacement);
+        reused.insert_lifecycle(replacement);
         assert_eq!(
             reused.lifecycle_scopes,
             BTreeSet::from([(0, Some(0), STREAM_DELETE_OP)]),
@@ -1656,16 +1900,13 @@ mod tests {
         for ((stream_id, topic_id, expected), found) in cases.into_iter().zip(found) {
             assert_eq!(found, expected, "stream {stream_id} topic {topic_id:?}");
         }
-        assert!(!streams.lifecycle_blocks_consumer(0, 0, None));
+        assert!(!streams.read(|inner| inner.lifecycle_blocks_consumer(0, 0, None)));
         let topic_creation = streams.read(|inner| {
             [0, 1, 2].map(|stream_id| inner.lifecycle_blocks_topic_creation(stream_id))
         });
         assert_eq!(topic_creation, [false, true, false]);
 
-        let mut inner = StreamsInner::inner_from_snapshot(streams.to_snapshot(), Arc::default());
+        let inner = StreamsInner::inner_from_snapshot(streams.to_snapshot(), Arc::default());
         assert_eq!(blockers(&inner, 1, Some(0)), [STREAM_OP]);
-        inner.finalizing_lifecycle = true;
-        assert_eq!(blockers(&inner, 0, None), [] as [u64; 0]);
-        assert_eq!(blockers(&inner, 1, Some(0)), [] as [u64; 0]);
     }
 }
diff --git a/core/metadata/src/stm/mod.rs b/core/metadata/src/stm/mod.rs
index d525ab686..e3c8c0e15 100644
--- a/core/metadata/src/stm/mod.rs
+++ b/core/metadata/src/stm/mod.rs
@@ -84,13 +84,24 @@ where
     /// Panics if the inner `UnsafeCell` pointer is null (should never happen
     /// unless the writer was not properly initialized).
     pub fn apply(&self, cmd: O) {
+        self.apply_all(std::iter::once(cmd));
+    }
+
+    /// Applies `cmds` in order and publishes once, so readers see all of them
+    /// or none.
+    ///
+    /// # Panics
+    /// Panics if the inner `UnsafeCell` pointer is null (should never happen
+    /// unless the writer was not properly initialized).
+    pub fn apply_all(&self, cmds: impl IntoIterator<Item = O>) {
         let hdl = unsafe {
             self.inner
                 .get()
                 .as_mut()
                 .expect("[apply]: called on uninit writer")
         };
-        hdl.append(cmd).publish();
+        hdl.extend(cmds);
+        hdl.publish();
     }
 }
 
@@ -240,6 +251,21 @@ where
         cell.apply(cmd);
         Ok(())
     }
+
+    /// Apply `cmds` in order with one publish, see [`WriteCell::apply_all`].
+    ///
+    /// # Errors
+    ///
+    /// Returns [`ApplyOnReaderError::ReaderOnly`] like [`Self::try_apply`],
+    /// with no command applied.
+    pub fn try_apply_all(
+        &self,
+        cmds: impl IntoIterator<Item = <T as Command>::Cmd>,
+    ) -> Result<(), ApplyOnReaderError> {
+        let cell = self.write.as_ref().ok_or(ApplyOnReaderError::ReaderOnly)?;
+        cell.apply_all(cmds);
+        Ok(())
+    }
 }
 
 /// Public interface for state handlers.
@@ -550,6 +576,8 @@ macro_rules! collect_handlers {
 mod tests {
     use super::*;
     use left_right::Absorb;
+    use std::cell::RefCell;
+    use std::rc::Rc;
 
     #[derive(Debug, Default, Clone)]
     struct Counter(u64);
@@ -609,4 +637,54 @@ mod tests {
             "reader state must be unchanged after a rejected try_apply"
         );
     }
+
+    /// `absorb_first` gets the copy that readers see, so each first absorb
+    /// logs how many commands readers saw then. Both copies share the log.
+    #[derive(Debug, Default, Clone)]
+    struct PublishProbe {
+        applied: u64,
+        visible_at_absorb: Rc<RefCell<Vec<u64>>>,
+    }
+
+    impl Absorb<CounterCmd> for PublishProbe {
+        fn absorb_first(&mut self, _cmd: &mut CounterCmd, other: &Self) {
+            self.visible_at_absorb.borrow_mut().push(other.applied);
+            self.applied += 1;
+        }
+
+        fn absorb_second(&mut self, _cmd: CounterCmd, _other: &Self) {
+            self.applied += 1;
+        }
+
+        fn sync_with(&mut self, first: &Self) {
+            *self = first.clone();
+        }
+    }
+
+    impl Command for PublishProbe {
+        type Cmd = CounterCmd;
+        type Input = ();
+        type Error = ();
+
+        fn parse(_input: Self::Input) -> Result<Either<Self::Cmd, Self::Input>, Self::Error> {
+            Ok(Either::Left(CounterCmd::Inc))
+        }
+    }
+
+    #[test]
+    fn given_two_commands_when_applied_together_should_publish_them_at_once() {
+        let lr: LeftRight<PublishProbe, CounterCmd> = PublishProbe::default().into();
+        // Until its first publish, left-right absorbs in place, past `absorb_first`.
+        lr.try_apply(CounterCmd::Inc)
+            .expect("writer must accept apply");
+        lr.try_apply_all([CounterCmd::Inc, CounterCmd::Inc])
+            .expect("writer must accept apply");
+        let visible_at_absorb = lr.read(|probe| Rc::clone(&probe.visible_at_absorb));
+        assert_eq!(
+            *visible_at_absorb.borrow(),
+            [1, 1],
+            "readers must see no command of the batch before all of them apply"
+        );
+        assert_eq!(lr.read(|probe| probe.applied), 3);
+    }
 }
diff --git a/core/metadata/src/stm/result.rs b/core/metadata/src/stm/result.rs
index 235bc124f..f83066f2c 100644
--- a/core/metadata/src/stm/result.rs
+++ b/core/metadata/src/stm/result.rs
@@ -286,20 +286,25 @@ const LIFECYCLE_BUSY_CODE: u32 = 88;
 /// `IggyError::InvalidCommand`, committed by the lifecycle apply paths.
 const INVALID_COMMAND_CODE: u32 = 3;
 
-/// True if `code` is one this op's result enum declares (`Ok = 0` included), or
-/// one of the global codes `UNAUTHORIZED_CODE`, `LIFECYCLE_BUSY_CODE` and
-/// `INVALID_COMMAND_CODE`.
+/// True if the state machine can commit `code` for this op.
+///
+/// That is a code of the op's result enum (`Ok = 0` included), the global
+/// `UNAUTHORIZED_CODE`, `LIFECYCLE_BUSY_CODE` for an op behind a lifecycle gate,
+/// or `INVALID_COMMAND_CODE` for an op that begins a lifecycle.
 ///
 /// The state machine only commits codes from the op's own enum, plus the
-/// global in-apply denials of the authorization and lifecycle gates, so an
+/// in-apply denials of the authorization and lifecycle gates, so an
 /// unrecognized one is a server bug, not a race (a race still yields a declared
 /// code). Enriched internal ops map to their client-op enum; ops with no result
 /// section (partition plane) return `true`.
 #[must_use]
 pub const fn result_code_recognized(operation: Operation, code: u32) -> bool {
-    // The in-apply RBAC and lifecycle gates can deny any control-plane op,
-    // committing a no-op, so accept their codes globally rather than per-op enum.
-    if code == UNAUTHORIZED_CODE || code == LIFECYCLE_BUSY_CODE || code == INVALID_COMMAND_CODE {
+    // The in-apply RBAC gate can deny any control-plane op, committing a no-op,
+    // so accept its code globally rather than per-op enum.
+    if code == UNAUTHORIZED_CODE
+        || (code == LIFECYCLE_BUSY_CODE && lifecycle_gated(operation))
+        || (code == INVALID_COMMAND_CODE && begins_lifecycle(operation))
+    {
         return true;
     }
     match operation {
@@ -335,6 +340,40 @@ pub const fn result_code_recognized(operation: Operation, code: u32) -> bool {
     }
 }
 
+/// Ops whose apply commits `LIFECYCLE_BUSY_CODE` for a target that a pending
+/// lifecycle covers, in their client and enriched forms.
+const fn lifecycle_gated(operation: Operation) -> bool {
+    matches!(
+        operation,
+        Operation::UpdateStream
+            | Operation::DeleteStream
+            | Operation::CreateTopic
+            | Operation::CreateTopicWithAssignments
+            | Operation::UpdateTopic
+            | Operation::DeleteTopic
+            | Operation::CreatePartitions
+            | Operation::CreatePartitionsWithAssignments
+            | Operation::DeletePartitions
+            | Operation::TruncatePartition
+            | Operation::CreateConsumerGroup
+            | Operation::DeleteConsumerGroup
+            | Operation::JoinConsumerGroup
+            | Operation::LeaveConsumerGroup
+    )
+}
+
+/// Ops that begin a lifecycle, whose apply commits `INVALID_COMMAND_CODE` for a
+/// target it cannot encode.
+const fn begins_lifecycle(operation: Operation) -> bool {
+    matches!(
+        operation,
+        Operation::DeleteStream
+            | Operation::DeleteTopic
+            | Operation::DeletePartitions
+            | Operation::DeleteConsumerGroup
+    )
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -439,6 +478,39 @@ mod tests {
         ));
     }
 
+    #[test]
+    fn given_an_op_without_a_lifecycle_gate_when_lifecycle_busy_should_not_be_recognized() {
+        for operation in [
+            Operation::CreateUser,
+            Operation::CreatePersonalAccessToken,
+            Operation::CreateStream,
+        ] {
+            assert!(
+                !result_code_recognized(operation, IggyError::LifecycleBusy.as_code()),
+                "{operation:?} has no lifecycle gate"
+            );
+        }
+    }
+
+    #[test]
+    fn given_an_op_that_begins_no_lifecycle_when_invalid_command_should_not_be_recognized() {
+        for operation in [
+            Operation::CreateUser,
+            Operation::CreateStream,
+            Operation::UpdateTopic,
+            Operation::JoinConsumerGroup,
+        ] {
+            assert!(
+                !result_code_recognized(operation, IggyError::InvalidCommand.as_code()),
+                "{operation:?} begins no lifecycle"
+            );
+        }
+        assert!(result_code_recognized(
+            Operation::DeleteConsumerGroup,
+            IggyError::InvalidCommand.as_code()
+        ));
+    }
+
     // Pins every result-enum discriminant to the `IggyError` variant its doc
     // claims, via `IggyError::as_code()` (the real wire discriminant the SDKs
     // decode). The discriminants are bare literals with no compile-time tie to
@@ -481,6 +553,10 @@ mod tests {
             u32::from(DeletePartitionsResult::StreamNotFound),
             stream_not_found
         );
+        assert_eq!(
+            u32::from(TruncatePartitionResult::StreamNotFound),
+            stream_not_found
+        );
         assert_eq!(
             u32::from(CreateConsumerGroupResult::StreamNotFound),
             stream_not_found
@@ -509,6 +585,10 @@ mod tests {
             u32::from(DeletePartitionsResult::TopicNotFound),
             topic_not_found
         );
+        assert_eq!(
+            u32::from(TruncatePartitionResult::TopicNotFound),
+            topic_not_found
+        );
         assert_eq!(
             u32::from(CreateConsumerGroupResult::TopicNotFound),
             topic_not_found
@@ -534,6 +614,58 @@ mod tests {
             u32::from(CreateTopicResult::InvalidPartitionsCount),
             IggyError::InvalidPartitionsCount.as_code(),
         );
+        assert_eq!(
+            u32::from(DeletePartitionsResult::InvalidPartitionsCount),
+            IggyError::InvalidPartitionsCount.as_code(),
+        );
+
+        // PartitionResizeDisabled (2023).
+        let partition_resize_disabled = IggyError::PartitionResizeDisabled.as_code();
+        assert_eq!(
+            u32::from(CreatePartitionsResult::PartitionResizeDisabled),
+            partition_resize_disabled,
+        );
+        assert_eq!(
+            u32::from(DeletePartitionsResult::PartitionResizeDisabled),
+            partition_resize_disabled,
+        );
+
+        // PartitionNotFound (3007).
+        assert_eq!(
+            u32::from(TruncatePartitionResult::PartitionNotFound),
+            IggyError::PartitionNotFound(0, id(), id()).as_code(),
+        );
+
+        // UnsupportedOptionKey (4041) / InvalidOptionValue (4042).
+        assert_eq!(
+            u32::from(UpdateTopicResult::UnsupportedOptionKey),
+            IggyError::UnsupportedOptionKey(String::new()).as_code(),
+        );
+        let invalid_option_value = IggyError::InvalidOptionValue(String::new()).as_code();
+        assert_eq!(
+            u32::from(CreateStreamResult::InvalidOptionValue),
+            invalid_option_value
+        );
+        assert_eq!(
+            u32::from(UpdateStreamResult::InvalidOptionValue),
+            invalid_option_value
+        );
+        assert_eq!(
+            u32::from(CreateTopicResult::InvalidOptionValue),
+            invalid_option_value
+        );
+        assert_eq!(
+            u32::from(UpdateTopicResult::InvalidOptionValue),
+            invalid_option_value
+        );
+        assert_eq!(
+            u32::from(CreateUserResult::InvalidOptionValue),
+            invalid_option_value
+        );
+        assert_eq!(
+            u32::from(UpdateUserResult::InvalidOptionValue),
+            invalid_option_value
+        );
 
         // UserAlreadyExists (46) - also stands in for username-already-exists.
         let user_exists = IggyError::UserAlreadyExists.as_code();
@@ -615,6 +747,11 @@ mod tests {
             u32::from(CreateConsumerGroupResult::NameAlreadyExists),
             IggyError::ConsumerGroupNameAlreadyExists(String::new(), id()).as_code(),
         );
+        // TooManyConsumerGroups (5010).
+        assert_eq!(
+            u32::from(CreateConsumerGroupResult::TooManyConsumerGroups),
+            IggyError::TooManyConsumerGroups.as_code(),
+        );
         let consumer_group_not_found = IggyError::ConsumerGroupIdNotFound(id(), id()).as_code();
 
         // Delete/Join/Leave mirror the legacy `resolve_consumer_group` error
@@ -692,7 +829,7 @@ mod tests {
 
         // Unauthorized (41) - the global in-apply RBAC denial code.
         assert_eq!(UNAUTHORIZED_CODE, IggyError::Unauthorized.as_code());
-        // LifecycleBusy (88) and InvalidCommand (3) - the global lifecycle gate codes.
+        // LifecycleBusy (88) and InvalidCommand (3) - the lifecycle gate codes.
         assert_eq!(LIFECYCLE_BUSY_CODE, IggyError::LifecycleBusy.as_code());
         assert_eq!(INVALID_COMMAND_CODE, IggyError::InvalidCommand.as_code());
     }
diff --git a/core/metadata/src/stm/snapshot.rs b/core/metadata/src/stm/snapshot.rs
index 51935e998..dcb7cf9ac 100644
--- a/core/metadata/src/stm/snapshot.rs
+++ b/core/metadata/src/stm/snapshot.rs
@@ -24,9 +24,9 @@ use crate::stm::user::UsersSnapshot;
 
 /// The snapshot format version written by this build.
 ///
-/// Each version identifies a serialized shape. Bump it when fields change,
-/// including nested fields: `MessagePack` encodes structs positionally, so a
-/// layout change can reinterpret bytes without a deserialization error.
+/// Each version identifies a serialized shape and its invariants. Bump it when
+/// fields change, including nested fields: `MessagePack` encodes structs
+/// positionally, so a layout change can reinterpret bytes without an error.
 ///
 /// [`MetadataSnapshot::decode`] accepts versions from
 /// [`MIN_READABLE_SNAPSHOT_FORMAT_VERSION`] through this version. Older accepted
@@ -56,7 +56,10 @@ use crate::stm::user::UsersSnapshot;
 ///
 /// Version 11: streams also persist pending lifecycle intents, and each
 /// `TopicSnapshot` the op of its last consumer group catalog change.
-pub const SNAPSHOT_FORMAT_VERSION: u32 = 11;
+///
+/// Version 12: pending consumer group owner generations equal their metadata
+/// operation, replacing independently incremented generations.
+pub const SNAPSHOT_FORMAT_VERSION: u32 = 12;
 
 /// Oldest format version [`MetadataSnapshot::decode`] still reads.
 ///
@@ -604,7 +607,7 @@ mod tests {
         // operator's boot reading one field's bytes as another's. Changing either
         // number is the reminder to change the other.
         const FIELD_COUNT: u32 = 7;
-        const PINNED_VERSION: u32 = 11;
+        const PINNED_VERSION: u32 = 12;
 
         let encoded = MetadataSnapshot::new(0).encode().unwrap();
         let mut cursor = encoded.as_slice();
@@ -615,7 +618,7 @@ mod tests {
         );
         assert_eq!(
             SNAPSHOT_FORMAT_VERSION, PINNED_VERSION,
-            "SNAPSHOT_FORMAT_VERSION moved; confirm the shape moved with it"
+            "SNAPSHOT_FORMAT_VERSION moved; confirm the shape or invariants changed with it"
         );
     }
 
@@ -797,6 +800,19 @@ mod tests {
         }
     }
 
+    #[test]
+    fn given_independent_owner_generation_format_when_decoding_should_refuse_before_restore() {
+        const INDEPENDENT_OWNER_GENERATION_FORMAT: u32 = 11;
+        let mut snapshot = MetadataSnapshot::new(9);
+        snapshot.version = INDEPENDENT_OWNER_GENERATION_FORMAT;
+        let encoded = snapshot.encode().unwrap();
+        assert!(matches!(
+            MetadataSnapshot::decode(&encoded),
+            Err(SnapshotError::UnsupportedFormatVersion { found, expected })
+                if found == INDEPENDENT_OWNER_GENERATION_FORMAT && expected == SNAPSHOT_FORMAT_VERSION
+        ));
+    }
+
     #[test]
     fn decode_refuses_any_other_format_version() {
         // Newer and older alike: there is no accepted window, so both directions are
diff --git a/core/metadata/src/stm/stream.rs b/core/metadata/src/stm/stream.rs
index a6d425c4d..c3e0a4e8d 100644
--- a/core/metadata/src/stm/stream.rs
+++ b/core/metadata/src/stm/stream.rs
@@ -216,7 +216,7 @@ pub struct Topic {
     /// group's offset key.
     pub next_consumer_group_id: u64,
     /// Op of the last apply that changed `next_consumer_group_id` or the group
-    /// set. A partition expects one owner retirement catalog per op, so a reader
+    /// set. A partition expects one allocation boundary per op, so a reader
     /// takes the catalog and this op from the same published state.
     pub consumer_group_catalog_op: u64,
 }
@@ -397,7 +397,8 @@ impl Stream {
 /// * `WriteCell::apply` is `append(cmd).publish()`, so a batch is one op. Batch
 ///   two and a delete could be absorbed on the second buffer AFTER a create
 ///   that re-minted the same id, and the delete would then evict the new
-///   partition's entry.
+///   partition's entry. The one multi-op batch,
+///   `Streams::remove_consumer_group_members`, touches no entry here.
 /// * `left_right` drains the previous batch's `absorb_second` before the
 ///   current batch's `absorb_first` (0.11.8 `write.rs`), which is an
 ///   implementation detail, not a contract -- which is why the workspace
@@ -1048,6 +1049,11 @@ impl PollMetadata {
         }
     }
 
+    #[must_use]
+    pub const fn client_id(&self) -> u128 {
+        self.client_id
+    }
+
     #[must_use]
     pub fn matches_partition(&self, created_revision: Option<u64>) -> bool {
         created_revision == Some(self.created_revision)
@@ -1229,6 +1235,27 @@ impl Streams {
         })
     }
 
+    /// A partition's incarnation and its topic's partition count, read under
+    /// one guard so a recreated topic never pairs an old incarnation with a
+    /// new count. `None` while a lifecycle operation blocks the topic, as for
+    /// [`Self::poll_metadata`]. The count matches `GetTopic`, so a keyed send
+    /// hashes over the same partitions either way.
+    #[must_use]
+    pub fn partition_route(&self, namespace: IggyNamespace) -> Option<(u64, usize)> {
+        self.read(|inner| {
+            if inner.lifecycle_blocks_consumer(namespace.stream_id(), namespace.topic_id(), None) {
+                return None;
+            }
+            let topic = inner
+                .items
+                .get(namespace.stream_id())?
+                .topics
+                .get(namespace.topic_id())?;
+            let partition = find_partition(&topic.partitions, namespace.partition_id())?;
+            Some((partition.created_revision, topic.partitions.len()))
+        })
+    }
+
     /// A topic's current partition count, or `None` if the stream or topic is
     /// unknown. Update admission reads it to size the floor a `max_topic_size`
     /// has to clear across the whole topic; the update request itself carries
@@ -1390,9 +1417,9 @@ impl Streams {
             .read(|inner| !inner.pending_revocations.is_empty())
     }
 
-    /// The topic's current partition ids, for the join-time in-flight gather
-    /// (the home shard reads each partition's poll/commit state to classify the
-    /// cooperative handoff). `None` if the stream/topic does not resolve.
+    /// The topic's current partition ids, or `None` if the stream or topic
+    /// does not resolve. The server's `resolve_partition_namespace` checks
+    /// only for `None`, to tell a missing partition from a missing topic.
     #[must_use]
     #[allow(clippy::cast_possible_truncation)]
     pub fn topic_partition_ids(
@@ -1415,6 +1442,47 @@ impl Streams {
             .read(|inner| inner.pending_revocations.values().copied().collect())
     }
 
+    /// The pending revocation of `group_id` on the partition `namespace` names.
+    #[must_use]
+    pub fn consumer_group_pending_revocation(
+        &self,
+        namespace: IggyNamespace,
+        group_id: u64,
+    ) -> Option<ConsumerGroupOwnershipTransition> {
+        self.inner.read(|inner| {
+            inner
+                .pending_revocations
+                .get(&(
+                    namespace.stream_id(),
+                    namespace.topic_id(),
+                    group_id,
+                    namespace.partition_id(),
+                ))
+                .copied()
+        })
+    }
+
+    /// Pending transitions that no lifecycle intent blocks, read under one
+    /// guard. A gate read after the copy can miss an intent that finished in
+    /// between and pass a decision that the finished intent made obsolete.
+    #[must_use]
+    pub fn consumer_group_unblocked_revocations(&self) -> Vec<ConsumerGroupOwnershipTransition> {
+        self.inner.read(|inner| {
+            inner
+                .pending_revocations
+                .values()
+                .filter(|transition| {
+                    !inner.lifecycle_blocks_consumer(
+                        transition.stream_id as usize,
+                        transition.topic_id as usize,
+                        Some(transition.installation.group_id),
+                    )
+                })
+                .copied()
+                .collect()
+        })
+    }
+
     /// The group's id (the consumer-group offset key) if `client_id` currently
     /// owns `partition_id` in it -- the poll/commit fence. `None` for a stale
     /// client whose partition was reassigned, prompting a re-sync.
@@ -1556,19 +1624,37 @@ impl Streams {
         timestamp: IggyTimestamp,
         metadata_op: u64,
     ) {
-        let cmd = StreamsCommand::RemoveConsumerGroupMember(
-            RemoveConsumerGroupMemberRequest { client_id },
-            timestamp,
-            ApplyContext {
-                metadata_op,
-                ..ApplyContext::default()
-            },
-        );
-        if let Err(error) = self.inner.try_apply(cmd) {
+        self.remove_consumer_group_members(&[client_id], timestamp, metadata_op);
+    }
+
+    /// Remove `client_ids` in order, as [`Self::remove_consumer_group_member`]
+    /// would, with one publish. The removals of one op decide owners at the
+    /// same generation, so a reader that installed an owner between two of
+    /// them could never install the final one.
+    pub fn remove_consumer_group_members(
+        &self,
+        client_ids: &[u128],
+        timestamp: IggyTimestamp,
+        metadata_op: u64,
+    ) {
+        if client_ids.is_empty() {
+            return;
+        }
+        let cmds = client_ids.iter().map(|&client_id| {
+            StreamsCommand::RemoveConsumerGroupMember(
+                RemoveConsumerGroupMemberRequest { client_id },
+                timestamp,
+                ApplyContext {
+                    metadata_op,
+                    ..ApplyContext::default()
+                },
+            )
+        });
+        if let Err(error) = self.inner.try_apply_all(cmds) {
             tracing::error!(
-                client_id,
+                ?client_ids,
                 %error,
-                "remove_consumer_group_member dispatched to reader-only Streams STM"
+                "remove_consumer_group_members dispatched to reader-only Streams STM"
             );
         }
     }
@@ -1743,6 +1829,21 @@ impl Streams {
         })
     }
 
+    /// Name of the stream that [`Self::seed_namespace`] creates at `slab`.
+    #[cfg(any(test, feature = "simulator"))]
+    #[must_use]
+    pub fn seeded_stream_name(slab: usize) -> String {
+        format!("sim-stream-{slab}")
+    }
+
+    /// Name of the topic that [`Self::seed_namespace`] creates at `slab` under
+    /// stream slab `stream_slab`.
+    #[cfg(any(test, feature = "simulator"))]
+    #[must_use]
+    pub fn seeded_topic_name(stream_slab: usize, slab: usize) -> String {
+        format!("sim-topic-{stream_slab}-{slab}")
+    }
+
     /// Create stream slabs `0..=stream_slab`, skipping those already present.
     #[cfg(any(test, feature = "simulator"))]
     fn seed_stream_slabs(&self, stream_slab: usize) {
@@ -1753,7 +1854,7 @@ impl Streams {
             self.inner
                 .try_apply(StreamsCommand::CreateStream(
                     CreateStreamRequest {
-                        name: WireName::new(format!("sim-stream-{slab}"))
+                        name: WireName::new(Self::seeded_stream_name(slab))
                             .expect("sim stream name is valid"),
                         options: WireOptions::empty(),
                     },
@@ -1798,7 +1899,7 @@ impl Streams {
                             stream_id: stream_wire.clone(),
                             partitions_count: u32::try_from(partitions.len())
                                 .expect("sim partition count fits u32"),
-                            name: WireName::new(format!("sim-topic-{stream_slab}-{slab}"))
+                            name: WireName::new(Self::seeded_topic_name(stream_slab, slab))
                                 .expect("sim topic name is valid"),
                             options: iggy_common::TopicCreateOptions {
                                 durability: iggy_common::Durability::Persisted,
@@ -2841,8 +2942,8 @@ impl StreamsInner {
             intent.rebuild_partition_index();
         }
         let lifecycle_scopes = lifecycle_intents
-            .iter()
-            .map(|(&op, intent)| intent.scope(op))
+            .values()
+            .map(LifecycleIntent::scope)
             .collect();
         let mut inner = Self {
             index,
diff --git a/core/partitions/src/consumer_offset_capacity.rs b/core/partitions/src/consumer_offset_capacity.rs
index bce7a8155..098f24124 100644
--- a/core/partitions/src/consumer_offset_capacity.rs
+++ b/core/partitions/src/consumer_offset_capacity.rs
@@ -36,6 +36,9 @@ pub struct DurableConsumerOffsets {
     groups: RefCell<HashMap<u32, DurableOffsetState>>,
     external_groups: RefCell<HashMap<u32, DurableOffsetState>>,
     membership_epoch: Cell<u64>,
+    /// The op of the committed delete that left every kind empty, `0` when
+    /// none is known. Any entry the table gains resets it.
+    emptied_at: Cell<u64>,
 }
 
 impl DurableConsumerOffsets {
@@ -64,6 +67,7 @@ impl DurableConsumerOffsets {
         committed_offset: u64,
         persisted_high_water: u64,
     ) -> bool {
+        self.emptied_at.set(0);
         let created = self
             .entries(kind)
             .borrow_mut()
@@ -88,6 +92,7 @@ impl DurableConsumerOffsets {
         committed_offset: u64,
         persisted_high_water: u64,
     ) {
+        self.emptied_at.set(0);
         let mut entries = self.entries(kind).borrow_mut();
         match entries.entry(id) {
             Entry::Occupied(mut entry) => {
@@ -118,9 +123,18 @@ impl DurableConsumerOffsets {
         self.consumers.borrow_mut().clear();
         self.groups.borrow_mut().clear();
         self.external_groups.borrow_mut().clear();
+        self.emptied_at.set(0);
         self.bump_membership_epoch();
     }
 
+    pub(crate) const fn emptied_at(&self) -> u64 {
+        self.emptied_at.get()
+    }
+
+    pub(crate) fn set_emptied_at(&self, op: u64) {
+        self.emptied_at.set(op);
+    }
+
     #[cfg(any(test, feature = "simulator"))]
     pub(crate) fn committed_entries(&self, kind: ConsumerKind) -> Vec<(u32, u64)> {
         self.entries(kind)
diff --git a/core/partitions/src/iggy_partition.rs b/core/partitions/src/iggy_partition.rs
index 5a7785788..24cb51351 100644
--- a/core/partitions/src/iggy_partition.rs
+++ b/core/partitions/src/iggy_partition.rs
@@ -43,11 +43,11 @@ use consensus::Pipeline;
 use consensus::client_table::{CachedReply, ClientTableWireError};
 use consensus::{
     AutoCommitRequestContext, ClientTable, ClientTableMode, CommitLogEvent, Consensus,
-    LocalRequestOrder, PartitionDiagEvent, PipelineEntry, PlaneKind, Project, ReplicaLogContext,
-    RequestLogEvent, Sequencer, SimEventKind, VsrConsensus, ack_preflight, ack_quorum_reached,
-    build_deny_reply_from_request, build_reply_from_request, build_reply_message,
-    emit_namespace_progress_event, emit_partition_diag, emit_sim_event,
-    fence_old_prepare_by_commit, repair_session_live, repaired_frontier_update,
+    DvcHeaderKind, LocalRequestOrder, PartitionDiagEvent, PipelineEntry, PlaneKind, Project,
+    ReplicaLogContext, RequestLogEvent, Sequencer, SimEventKind, VsrConsensus, ack_preflight,
+    ack_quorum_reached, build_deny_reply_from_request, build_reply_from_request,
+    build_reply_message, dvc_header_kind, emit_namespace_progress_event, emit_partition_diag,
+    emit_sim_event, fence_old_prepare_by_commit, repair_session_live, repaired_frontier_update,
     replicate_frozen_to_next_in_chain, replicate_preflight, restamp_prepare_view,
     send_prepare_ok as send_prepare_ok_common, verify_prepare_integrity,
 };
@@ -154,7 +154,8 @@ where
     pub(crate) pending_owner_install: Cell<Option<InstallConsumerGroupOwnerRequest>>,
     /// Op of the last unapplied install the latest log scan found. A view
     /// change can leave several, and the latch holds only the last one, so
-    /// group reads and offset writes wait until every one of them has applied.
+    /// group reads, offset writes and retirement catalogs wait until every one
+    /// of them has applied.
     pub(crate) owner_install_barrier: u64,
     pending_history_transition: Cell<Option<TransitionPartitionHistoryRequest>>,
     pub(crate) history_transition: Option<InstalledPartitionHistory>,
@@ -286,13 +287,6 @@ where
     pub(crate) required_metadata_frontier: u64,
     #[cfg(feature = "simulator")]
     ideal_storage: bool,
-    /// `Partition::created_revision` of the metadata row this partition was
-    /// built for (the reconciler's "epoch"). It keys the on-disk history
-    /// (`prepares-{created_revision}`): a delete whose on-disk cleanup failed
-    /// leaves the directory behind, and the recreated partition must not adopt
-    /// what the dead incarnation left there. `0` for partitions built without
-    /// a metadata row (tests, in-memory storage).
-    pub(crate) created_revision: u64,
     /// Durable superblock for this partition's consensus group, recording
     /// `(view, log_view)` across a crash so this replica can never
     /// re-participate in a view older than one it advertised. `None` for
@@ -353,10 +347,21 @@ where
     /// [`Self::note_transfer_rearm_scheduled`]; livelock across attempts is
     /// bounded by [`Self::transfer_failures`] and its exponential backoff.
     transfer_attempts: u32,
-    /// Consecutive stalled re-requests on the live repair session, against
-    /// [`crate::types::REPAIR_MAX_STALL_RETRIES`]. Survives the session, so rotating
-    /// the peer cannot reset it; cleared by real progress.
+    /// Repair stall rounds without progress in [`Self::repair_charged_view`],
+    /// against [`crate::types::REPAIR_MAX_STALL_RETRIES`]. Restarts once a
+    /// session spends it, so each peer that the shard rotates to gets the
+    /// whole budget; cleared by real progress.
     repair_attempts: u32,
+    /// Rotations since the last progress in [`Self::repair_charged_view`]
+    /// that opened a session against another peer. Survives the sessions, so
+    /// the shard can give up on the ring; cleared by real progress.
+    repair_rotations: u32,
+    /// `commit_min` at the last repair stall round. A walk past it is real
+    /// progress, see [`Self::burn_repair_attempt`].
+    repair_walked_to: u64,
+    /// The view of the last repair stall round. A round in another view
+    /// starts a fresh count.
+    repair_charged_view: u32,
     /// CONSECUTIVE transfer failures of any class (decode, spill, install,
     /// peer-unavailable, stall exhaustion). Deliberately NOT keyed on the
     /// offered generation: a committing primary advances its generation
@@ -523,7 +528,7 @@ pub enum PendingPartitionTransition {
         drain: Option<crate::PersistenceDrain>,
     },
     Truncate {
-        from_op: u64,
+        ops: std::ops::RangeInclusive<u64>,
         drain: Option<crate::PersistenceDrain>,
         actions: Vec<consensus::VsrAction>,
         peer: Option<u8>,
@@ -550,6 +555,8 @@ struct ConsumerOffsetPlan {
     tracked: Option<DurableOffsetState>,
     persisted: bool,
     action: ConsumerOffsetAction,
+    /// The emptied-at op this effect leaves when it changes it, `0` for none.
+    emptied_at: Option<u64>,
 }
 
 #[derive(Clone, Copy)]
@@ -569,6 +576,11 @@ struct PendingCommit {
     owner_install: Option<InstallConsumerGroupOwnerRequest>,
     offset_effects: Vec<ConsumerOffsetEffect>,
     offset_batch: Vec<(usize, ConsumerOffsetPlan)>,
+    /// The offset table's entry count and emptied-at op once the accepted
+    /// effects publish.
+    offset_count: usize,
+    initial_emptied_at: u64,
+    emptied_at: Option<u64>,
     incarnation: crate::PartitionIncarnation,
     poll_history: PollHistoryId,
     applied_floor: u64,
@@ -893,7 +905,6 @@ where
             required_metadata_frontier: 0,
             #[cfg(feature = "simulator")]
             ideal_storage: false,
-            created_revision: 0,
             superblock: None,
             superblock_lock: Rc::new(LocalGate::new()),
             superblock_intent: Cell::new(None),
@@ -908,6 +919,9 @@ where
             transfer: None,
             transfer_attempts: 0,
             repair_attempts: 0,
+            repair_rotations: 0,
+            repair_walked_to: 0,
+            repair_charged_view: 0,
             transfer_failures: 0,
             transfer_refusals: 0,
             transfer_rearm: None,
@@ -923,17 +937,19 @@ where
     }
 
     /// Record the metadata incarnation this partition was built for.
-    pub const fn set_created_revision(&mut self, created_revision: u64) {
-        self.created_revision = created_revision;
+    pub fn set_created_revision(&mut self, created_revision: u64) {
+        self.consensus.set_partition_incarnation(created_revision);
     }
 
+    /// Metadata creation revision that isolates this incarnation's on-disk
+    /// history and consensus frames. Zero only without a metadata row.
     #[must_use]
     pub const fn created_revision(&self) -> u64 {
-        self.created_revision
+        self.consensus.partition_incarnation()
     }
 
-    /// A report covers only an applied barrier on a serving replica. WAL replay
-    /// or the paired checkpoint must restore this floor before admission.
+    /// A report covers an applied retirement barrier or terminal history fence
+    /// on a serving replica. Replay or a checkpoint must restore that proof.
     /// Retirement barriers require persistence at every durability level.
     #[must_use]
     pub fn session_retired(&self, identity: SessionIdentity) -> bool {
@@ -942,8 +958,11 @@ where
             && self.consensus.is_normal()
             && !self.consensus.is_transferring()
             && self.consensus.pending_request(identity.client_id).is_none()
-            && self.required_metadata_frontier >= identity.metadata_watermark
-            && self.dedup.get_epoch(identity.client_id) != Some(identity.session)
+            && (self
+                .history_transition
+                .is_some_and(|history| history.partition_op <= self.consensus.commit_min())
+                || (self.required_metadata_frontier >= identity.metadata_watermark
+                    && self.dedup.get_epoch(identity.client_id) != Some(identity.session)))
     }
 
     fn pending_session_retirement(&self, client_id: u128, session: u64) -> bool {
@@ -1020,10 +1039,33 @@ where
     pub fn consumer_group_owner(&self, group_id: u64) -> Option<ConsumerGroupOwner> {
         self.consumer_group_owners
             .get(&group_id)
-            .filter(|installed| installed.installation.incarnation == self.created_revision)
+            .filter(|installed| installed.installation.incarnation == self.created_revision())
             .map(|installed| installed.installation.owner)
     }
 
+    #[must_use]
+    pub fn consumer_group_retired(&self, group_id: u64) -> bool {
+        self.owner_retirement
+            .as_ref()
+            .is_some_and(|catalog| catalog.retires(group_id))
+    }
+
+    /// The installed generation that a group offset write with owner 0 binds
+    /// to under rule H1: the installed owner is the writer's client and
+    /// session, and it was decided no later than the metadata op the writer
+    /// routed by. A later decision can have moved the partition away and back,
+    /// so the writer's offset can predate it, and `None` leaves the write to
+    /// the owner match, which refuses it.
+    fn captured_offset_owner(&self, group_id: u64, header: &RoutedRequestHeader) -> Option<u64> {
+        let installation = self.consumer_group_owners.get(&group_id)?.installation;
+        (installation.incarnation == self.created_revision()
+            && !installation.owner.is_unassigned()
+            && installation.owner.client_id == header.client
+            && installation.owner.session == header.session
+            && installation.metadata_op <= header.minimum_metadata_op)
+            .then_some(installation.owner.generation)
+    }
+
     fn matches_consumer_owner(
         &self,
         kind: ConsumerKind,
@@ -1036,21 +1078,31 @@ where
         if kind != ConsumerKind::ConsumerGroup {
             return owner_generation == 0;
         }
-        self.consumer_group_owner(u64::from(consumer_id))
-            .is_some_and(|owner| {
-                owner.generation == owner_generation
-                    && (!owner.is_unassigned()
-                        || (operation == Operation::DeleteConsumerOffset
-                            && is_auto_commit_client(client_id)))
-                    && (is_auto_commit_client(client_id)
-                        || (owner.client_id == client_id && owner.session == session))
-            })
+        let group_id = u64::from(consumer_id);
+        let Some(owner) = self.consumer_group_owner(group_id) else {
+            // Group ids are never reused, so no owner follows a retired group.
+            // A replica can still hold its offset when the primary that retired
+            // it had none, and a delete admitted under the fence can commit
+            // after the catalog, so this cleanup passes at any generation.
+            return operation == Operation::DeleteConsumerOffset
+                && is_auto_commit_client(client_id)
+                && self.consumer_group_retired(group_id);
+        };
+        owner.generation == owner_generation
+            && (!owner.is_unassigned()
+                || (operation == Operation::DeleteConsumerOffset
+                    && is_auto_commit_client(client_id)))
+            && (is_auto_commit_client(client_id)
+                || (owner.client_id == client_id && owner.session == session))
     }
 
+    /// Judges the installed owner, not the one metadata names: a successor
+    /// can commit its install here before a later rebalance supersedes it,
+    /// and metadata then still names its predecessor.
     #[must_use]
-    pub fn consumer_group_drained(&self, group_id: u64, source: ConsumerGroupOwner) -> bool {
+    pub fn consumer_group_drained(&self, group_id: u64) -> bool {
         if self.observed_view != self.consensus.view()
-            || self.consumer_group_owner(group_id) != Some(source)
+            || self.consumer_group_owner(group_id).is_none()
             || self
                 .consumer_group_polls
                 .get(&group_id)
@@ -1086,7 +1138,7 @@ where
     ) -> Option<u64> {
         let installed = self.history_transition?;
         (installed.transition == *transition
-            && transition.incarnation == self.created_revision
+            && transition.incarnation == self.created_revision()
             && installed.partition_op <= self.consensus.commit_min()
             && self.fatal.is_none()
             && !self.materialization_missing
@@ -1106,7 +1158,7 @@ where
         transition: TransitionPartitionHistoryRequest,
         partition_op: u64,
     ) -> Result<(), IggyError> {
-        if transition.incarnation != self.created_revision {
+        if transition.incarnation != self.created_revision() {
             return Err(IggyError::HistoryUnavailable);
         }
         if let Some(installed) = self.history_transition {
@@ -1132,7 +1184,7 @@ where
         installation: InstallConsumerGroupOwnerRequest,
         partition_op: u64,
     ) -> Result<(), IggyError> {
-        if installation.incarnation != self.created_revision {
+        if installation.incarnation != self.created_revision() {
             return Ok(());
         }
         if self
@@ -1146,19 +1198,12 @@ where
             return Err(IggyError::HistoryUnavailable);
         }
         if let Some(installed) = self.consumer_group_owners.get(&installation.group_id) {
-            if installation.owner.generation < installed.installation.owner.generation {
-                return Ok(());
-            }
-            if installation.owner.generation == installed.installation.owner.generation {
-                return if installation == installed.installation {
-                    Ok(())
-                } else {
-                    Err(IggyError::InvalidCommand)
-                };
-            }
-            if installation.metadata_op <= installed.installation.metadata_op {
+            if self.conflicts_with_installed_owner(&installation) {
                 return Err(IggyError::InvalidCommand);
             }
+            if installation.owner.generation <= installed.installation.owner.generation {
+                return Ok(());
+            }
         } else if self.consumer_group_owners.len()
             >= crate::state_transfer::CONSUMER_OFFSETS_ENTRIES_MAX as usize
         {
@@ -1178,35 +1223,86 @@ where
         );
         self.consumer_group_polls
             .insert(installation.group_id, Arc::new(AtomicBool::new(true)));
-        self.last_polled_offsets.pin().remove(&ConsumerGroupId(
-            usize::try_from(group_id).map_err(|_| IggyError::InvalidCommand)?,
-        ));
+        self.last_polled_offsets
+            .pin()
+            .remove(&ConsumerGroupId(group_id as usize));
         self.mark_consumer_group_offsets_need_reconcile();
         self.fence_published.set(true);
         Ok(())
     }
 
+    /// Another owner at the installed generation, or a higher generation
+    /// decided no later than the installed owner. The apply refuses both, and
+    /// admission refuses them before they are prepared.
+    fn conflicts_with_installed_owner(
+        &self,
+        installation: &InstallConsumerGroupOwnerRequest,
+    ) -> bool {
+        self.consumer_group_owners
+            .get(&installation.group_id)
+            .is_some_and(|installed| {
+                let installed = installed.installation;
+                if installation.owner.generation == installed.owner.generation {
+                    *installation != installed
+                } else {
+                    installation.owner.generation > installed.owner.generation
+                        && installation.metadata_op <= installed.metadata_op
+                }
+            })
+    }
+
     /// Retirement follows committed metadata deletion and ordered offset cleanup.
     /// Older owner fences cannot resurrect IDs absent from the retained catalog.
-    pub fn has_retirable_consumer_group_owners(&self, is_live: impl Fn(u64) -> bool) -> bool {
+    ///
+    /// Returns the deleted groups that a catalog keeps beside the live ones, or
+    /// `None` while it would retire no row or must wait. A deleted group whose
+    /// durable offset is stranded is kept: a catalog waits for a durable offset,
+    /// and a stranded key gets no delete while the mark holds. A stranded key
+    /// with no durable entry does not keep its group: the cleanup of a retired
+    /// group deletes the offset once a primary that holds it leads. Until that
+    /// delete, a file that every boot fails to read, or fails to remove when
+    /// torn or corrupt, stays on disk with no row.
+    #[must_use]
+    pub fn retirement_kept_groups(&self, is_live: impl Fn(u64) -> bool) -> Option<Vec<u64>> {
+        let capacity = self.consumer_offset_capacity_for(ConsumerKind::ConsumerGroup);
+        let (kept, retired): (Vec<u64>, Vec<u64>) = self
+            .consumer_group_owners
+            .keys()
+            .copied()
+            .filter(|&group_id| !is_live(group_id))
+            .partition(|&group_id| {
+                u32::try_from(group_id).is_ok_and(|id| {
+                    capacity.is_stranded(id)
+                        && self
+                            .durable_consumer_offsets
+                            .contains(ConsumerKind::ConsumerGroup, id)
+                })
+            });
+        (!retired.is_empty()
+            && !self.retirement_waits(|group_id| retired.binary_search(&group_id).is_ok()))
+        .then_some(kept)
+    }
+
+    /// A catalog waits until each group it retires is unassigned and has no
+    /// durable offset, so its apply never depends on local offset files.
+    fn retirement_waits(&self, retires: impl Fn(u64) -> bool) -> bool {
         self.consumer_group_owners
             .iter()
             .any(|(&group_id, installed)| {
-                !is_live(group_id)
-                    && installed.installation.owner.is_unassigned()
-                    && u32::try_from(group_id).is_ok_and(|id| {
-                        !self
-                            .durable_consumer_offsets
-                            .contains(ConsumerKind::ConsumerGroup, id)
-                    })
+                retires(group_id)
+                    && (!installed.installation.owner.is_unassigned()
+                        || u32::try_from(group_id).is_ok_and(|id| {
+                            self.durable_consumer_offsets
+                                .contains(ConsumerKind::ConsumerGroup, id)
+                        }))
             })
     }
 
     fn apply_consumer_group_owner_retirement(
         &mut self,
-        catalog: RetireConsumerGroupOwnersRequest,
+        mut catalog: RetireConsumerGroupOwnersRequest,
     ) -> Result<(), IggyError> {
-        if catalog.incarnation != self.created_revision {
+        if catalog.incarnation != self.created_revision() {
             return Ok(());
         }
         if let Some(installed) = &self.owner_retirement {
@@ -1214,14 +1310,17 @@ where
                 return Ok(());
             }
             if catalog.next_group_id < installed.next_group_id
-                || (catalog.metadata_op == installed.metadata_op && catalog != *installed)
-                || catalog
-                    .live_group_ids
-                    .iter()
-                    .any(|id| installed.retires(*id))
+                || (catalog.metadata_op == installed.metadata_op
+                    && catalog.next_group_id != installed.next_group_id)
             {
                 return Err(IggyError::InvalidCommand);
             }
+            // Each primary keeps the deleted groups it saw with a durable
+            // stranded offset, so a catalog can keep a group that an earlier
+            // one retired.
+            catalog
+                .live_group_ids
+                .retain(|&group_id| !installed.retires(group_id));
         }
         if self.consumer_group_owners.iter().any(|(&id, installed)| {
             catalog.retires(id) && !installed.installation.owner.is_unassigned()
@@ -1230,12 +1329,7 @@ where
         }
         let mut removed = Vec::new();
         self.consumer_group_owners.retain(|&group_id, _| {
-            let prune = catalog.retires(group_id)
-                && u32::try_from(group_id).is_ok_and(|id| {
-                    !self
-                        .durable_consumer_offsets
-                        .contains(ConsumerKind::ConsumerGroup, id)
-                });
+            let prune = catalog.retires(group_id);
             if prune {
                 removed.push(group_id);
             }
@@ -1243,9 +1337,12 @@ where
         });
         for group_id in removed {
             self.consumer_group_polls.remove(&group_id);
-            self.last_polled_offsets.pin().remove(&ConsumerGroupId(
-                usize::try_from(group_id).map_err(|_| IggyError::InvalidCommand)?,
-            ));
+            // Last-polled keys come from u32 group ids only.
+            if let Ok(id) = u32::try_from(group_id) {
+                self.last_polled_offsets
+                    .pin()
+                    .remove(&ConsumerGroupId(id as usize));
+            }
         }
         self.required_metadata_frontier = self.required_metadata_frontier.max(catalog.metadata_op);
         self.owner_retirement = Some(catalog);
@@ -1475,12 +1572,12 @@ where
             .as_ref()
             .ok_or(IggyError::CannotReadFile)?;
         let directory = std::path::Path::new(partition_directory)
-            .join(format!("prepares-{}", self.created_revision));
+            .join(format!("prepares-{}", self.created_revision()));
         if recovered.is_none()
             && self.consensus.replica_count() > 1
             && !self.consensus.recovery_election_allowed()
         {
-            PartitionPersistence::fence_missing_history(&directory, self.created_revision)
+            PartitionPersistence::fence_missing_history(&directory, self.created_revision())
                 .await
                 .map_err(|error| {
                     warn!(%error, "cannot fence missing partition prepare history");
@@ -1489,7 +1586,7 @@ where
         }
         self.materialization_missing = crate::state_transfer::materialization_is_missing(
             partition_directory,
-            self.created_revision,
+            self.created_revision(),
         )
         .await
         .map_err(|_| IggyError::CannotReadFile)?;
@@ -1501,7 +1598,7 @@ where
             PartitionPersistence::open_with_capacity(
                 &directory,
                 self.namespace().inner(),
-                self.created_revision,
+                self.created_revision(),
                 journal::durable_storage::DiskStorage,
                 capacity,
                 self.runtime_options
@@ -2247,7 +2344,10 @@ where
             return;
         };
         while let Some(op) = persistence.head().checked_add(1) {
-            if op > through || op > self.consensus.sequencer().current_sequence() {
+            if op > through
+                || op > self.consensus.sequencer().current_sequence()
+                || !self.consensus.view_log_entry_proven(op)
+            {
                 break;
             }
             let Some(prepare) = self.log.journal().inner.repair_entry(op) else {
@@ -2720,12 +2820,13 @@ where
             consumer_offsets: self.retained_consumer_offsets(ConsumerKind::Consumer),
             consumer_group_offsets: self.retained_consumer_offsets(ConsumerKind::ConsumerGroup),
             external_group_offsets: self.retained_consumer_offsets(ConsumerKind::ExternalGroup),
+            emptied_at: self.durable_consumer_offsets.emptied_at(),
             consumer_group_owners: std::mem::take(&mut self.consumer_group_owners),
             history_transition: self.history_transition,
             owner_retirement: self.owner_retirement.take(),
             durable_offset: self.offset.load(Ordering::Acquire),
             write_offset: self.dirty_offset.load(Ordering::Relaxed),
-            offset_space_used: self.offset_space_used(),
+            offset_space: self.offset_space,
             log: std::mem::take(&mut self.log),
         }
     }
@@ -2754,10 +2855,11 @@ where
             required_metadata_frontier,
             durable_offset,
             write_offset,
-            offset_space_used,
+            offset_space,
             consumer_offsets,
             consumer_group_offsets,
             external_group_offsets,
+            emptied_at,
             consumer_group_owners,
             history_transition,
             owner_retirement,
@@ -2809,18 +2911,11 @@ where
                 offset,
             );
         }
-        // Empty carry-over: the previous incarnation never took a write, so there
-        // is no offset space to restore and claiming one would make the next
-        // prepare mint from a base no peer agrees on.
-        //
-        // Keyed on the RETIRED incarnation's flag, never on `(0, 0)` or on this
-        // partition's own `append_live`. One message at offset 0 reports
-        // the same two zeroes as an empty partition, and this instance is freshly
-        // built so its own flag is always false. The arithmetic test would therefore
-        // adopt the log, skip the counters, and let the next write stamp
-        // `base_offset = 0` where peers stamp 1, with `batch_checksum` over it: two
-        // logs, different bytes at the same op, silently.
-        if !offset_space_used {
+        self.durable_consumer_offsets.set_emptied_at(emptied_at);
+        // Zero counters can describe an empty partition or its first batch.
+        // Preserve both bits so an uncommitted first batch does not become a
+        // committed offset that view truncation is forbidden to discard.
+        if !offset_space.append_live {
             return;
         }
         let durable = durable_offset.max(self.offset.load(Ordering::Acquire));
@@ -2829,11 +2924,8 @@ where
             .max(self.dirty_offset.load(Ordering::Relaxed));
         self.offset.store(durable, Ordering::Release);
         self.dirty_offset.store(dirty, Ordering::Relaxed);
-        self.set_offset_space_used(true);
-        // Everything carried over is already persisted as far as this replica is
-        // concerned, so the flush and commit paths must not re-persist or re-count
-        // it, the same contract boot gives a partition recovered from segments.
-        self.recovered_durable_offset = Some(durable);
+        self.offset_space = offset_space;
+        self.recovered_durable_offset = offset_space.committed_seeded.then_some(durable);
     }
 
     #[cfg(any(test, feature = "simulator"))]
@@ -3243,23 +3335,57 @@ where
         self.injected_commit_failure = true;
     }
 
-    /// Burn one repair stall round; `true` once the budget is exhausted and the
-    /// session should be re-armed against a different peer.
+    /// Burn one repair stall round; `true` once the session spent the budget.
+    /// The count then restarts, so the session that the shard opens in its
+    /// place gets the whole budget.
     ///
     /// On the PARTITION, not the session, for the same reason the transfer
-    /// budget is: the rotation mints a new session, which would otherwise reset
-    /// the count and re-target forever without ever giving up on the ring.
+    /// budget is: the rotation mints a new session, so only a count outside
+    /// it can add up the rotations ([`Self::note_repair_rotation`]) until the
+    /// shard gives up on the ring.
+    ///
+    /// A walk since the last round restarts both counts like a new frame does:
+    /// while the WAL drains, the peer re-serves frames this journal holds. A
+    /// new view restarts them too, as on the metadata plane: the rounds of an
+    /// earlier view say nothing about the ring of this one.
     #[must_use = "the bool is the rotate verdict; dropping it disables the stall budget"]
     pub const fn burn_repair_attempt(&mut self) -> bool {
+        let commit_min = self.consensus.commit_min();
+        if commit_min > self.repair_walked_to {
+            self.repair_walked_to = commit_min;
+            self.restart_repair_budget();
+        }
+        let view = self.consensus.view();
+        if view != self.repair_charged_view {
+            self.repair_charged_view = view;
+            self.restart_repair_budget();
+        }
         self.repair_attempts += 1;
-        self.repair_attempts > crate::types::REPAIR_MAX_STALL_RETRIES
+        let spent = self.repair_attempts > crate::types::REPAIR_MAX_STALL_RETRIES;
+        if spent {
+            self.repair_attempts = 0;
+        }
+        spent
+    }
+
+    /// Count a rotation that opened a repair session against another peer.
+    /// The shard gives up on the ring once these rotations, with the session
+    /// that just spent its budget, reach the other replicas.
+    pub const fn note_repair_rotation(&mut self) {
+        self.repair_rotations = self.repair_rotations.saturating_add(1);
+    }
+
+    /// Rotations since the last progress, for the shard's ring check and its
+    /// log.
+    #[must_use]
+    pub const fn repair_rotations(&self) -> u32 {
+        self.repair_rotations
     }
 
-    /// Real repair progress (any in-window frame from the serving peer): reset
-    /// the stall budget, so it bounds CONSECUTIVE stalls rather than the ones a
-    /// long healthy stream accumulates.
-    pub const fn note_repair_progress(&mut self) {
+    /// Restart both stall counts when progress or a transfer supersedes repair.
+    pub const fn restart_repair_budget(&mut self) {
         self.repair_attempts = 0;
+        self.repair_rotations = 0;
     }
 
     /// Burn one transfer stall round; `true` once the budget is exhausted.
@@ -3436,6 +3562,11 @@ where
         false
     }
 
+    /// Restore the op of the committed delete that emptied the recovered table.
+    pub(crate) fn seed_offsets_emptied_at(&self, op: u64) {
+        self.durable_consumer_offsets.set_emptied_at(op);
+    }
+
     #[must_use]
     pub fn durable_consumer_offset_count(&self, kind: ConsumerKind) -> usize {
         self.durable_consumer_offsets.count(kind)
@@ -3598,6 +3729,7 @@ where
             owner,
             file,
             permit,
+            retired_marker: None,
         }
     }
 
@@ -3655,6 +3787,7 @@ where
         self.retire_consumer_offset_file(path);
         crate::io::OffsetDeleteIoJob {
             path: path.to_owned(),
+            emptied_marker: None,
         }
     }
 
@@ -3679,7 +3812,11 @@ where
         pending: PendingConsumerOffsetCommit,
         tracked: Option<DurableOffsetState>,
     ) -> Result<ConsumerOffsetEffect, IggyError> {
-        let plan = self.plan_consumer_offset_effect(pending, tracked);
+        let plan = self.plan_consumer_offset_effect(
+            pending,
+            tracked,
+            self.durable_consumer_offsets.emptied_at(),
+        );
         if let ConsumerOffsetAction::Ready(effect) = plan.action {
             return Ok(effect);
         }
@@ -3687,10 +3824,12 @@ where
         self.accept_consumer_offset_effect(plan, job.execute().await)
     }
 
+    /// `table_emptied_at` is the emptied-at op of the table this effect lands in.
     fn plan_consumer_offset_effect(
         &self,
         pending: PendingConsumerOffsetCommit,
         tracked: Option<DurableOffsetState>,
+        table_emptied_at: u64,
     ) -> ConsumerOffsetPlan {
         let has_path = self.consumer_offsets_dir(pending.kind).is_some();
         let action = match pending.mutation {
@@ -3734,6 +3873,9 @@ where
             persisted: self.consumer_offset_durability().is_persisted()
                 && self.persistence.is_none(),
             action,
+            emptied_at: (matches!(pending.mutation, PendingConsumerOffsetMutation::Upsert(_))
+                && table_emptied_at != 0)
+                .then_some(0),
         }
     }
 
@@ -3744,15 +3886,21 @@ where
         let path = self
             .persisted_offset_path(plan.pending.kind, plan.pending.consumer_id)
             .ok_or(IggyError::CannotWriteToFile)?;
+        let marker = plan
+            .emptied_at
+            .and_then(|emptied_at| Some((self.emptied_marker_path()?, emptied_at)));
         match plan.action {
             ConsumerOffsetAction::Write { offset, fold_max } => {
-                Ok(crate::io::PartitionIoJob::OffsetWrite(
-                    self.prepare_consumer_offset_write(&path, offset, plan.persisted, fold_max),
-                ))
+                let mut job =
+                    self.prepare_consumer_offset_write(&path, offset, plan.persisted, fold_max);
+                job.retired_marker = marker.map(|(marker, _)| marker);
+                Ok(crate::io::PartitionIoJob::OffsetWrite(job))
+            }
+            ConsumerOffsetAction::Delete => {
+                let mut job = self.prepare_consumer_offset_delete(&path);
+                job.emptied_marker = marker;
+                Ok(crate::io::PartitionIoJob::OffsetDelete(job))
             }
-            ConsumerOffsetAction::Delete => Ok(crate::io::PartitionIoJob::OffsetDelete(
-                self.prepare_consumer_offset_delete(&path),
-            )),
             ConsumerOffsetAction::Ready(_) => Err(IggyError::CannotWriteToFile),
         }
     }
@@ -3785,7 +3933,9 @@ where
                             state: None,
                         })
                     }
-                    Err(error) => {
+                    // Only a failed unlink reports this error. The marker write
+                    // after a successful one leaves no file behind to strand.
+                    Err(error @ IggyError::CannotDeleteConsumerOffsetFile(_)) => {
                         self.consumer_offset_capacity_for(pending.kind)
                             .record_stranded(pending.consumer_id);
                         warn!(
@@ -3795,6 +3945,7 @@ where
                         );
                         Err(error)
                     }
+                    Err(error) => Err(error),
                 }
             }
             _ => Err(IggyError::CannotWriteToFile),
@@ -3921,7 +4072,7 @@ where
         }
         Ok(PollCompletion {
             context: iggy_binary_protocol::primitives::partition_history::PartitionContext {
-                incarnation: self.created_revision,
+                incarnation: self.created_revision(),
                 owner_generation: result.context.owner.map_or(0, |owner| owner.generation),
                 metadata_op: self.required_metadata_frontier,
             },
@@ -3994,6 +4145,9 @@ where
         // poll would report progress the group never agreed, and a later
         // read on the primary would hand the same messages out again.
         // Refusing keeps the outcome retriable on a replica that can commit.
+        if consensus.is_normal_backup() {
+            return Err(IggyError::NotPrimary);
+        }
         if !consensus.is_primary()
             || !consensus.is_normal()
             || consensus.is_transferring()
@@ -4015,16 +4169,9 @@ where
         let request = self.build_poll_auto_commit_request(kind, consumer_id, offset)?;
         // Automatic commits bypass the request handler, so check journal
         // capacity here before queueing or assigning an operation.
-        if self
-            .persistence
-            .as_ref()
-            .is_some_and(|persistence| {
-                !persistence.has_capacity(
-                    request.header().size as usize
-                        + iggy_binary_protocol::requests::consumer_offsets::PREPARED_OWNER_GENERATION_SIZE,
-                )
-            })
-        {
+        if self.persistence.as_ref().is_some_and(|persistence| {
+            !persistence.has_capacity(Self::prepared_request_size(&request))
+        }) {
             return Err(IggyError::TransientNotAccepted);
         }
         let order = self.next_local_request_order()?;
@@ -4280,7 +4427,7 @@ where
                     session: 1,
                     request: 1,
                     group: namespace.inner(),
-                    partition_incarnation: self.created_revision,
+                    partition_incarnation: self.created_revision(),
                     owner_generation: if kind == ConsumerKind::ConsumerGroup {
                         self.consumer_group_owner(u64::from(consumer_id))
                             .map_or(0, |owner| owner.generation)
@@ -4566,7 +4713,11 @@ where
                 let tracked = self
                     .durable_consumer_offsets
                     .get(pending.kind, pending.consumer_id);
-                let plan = self.plan_consumer_offset_effect(pending, tracked);
+                let plan = self.plan_consumer_offset_effect(
+                    pending,
+                    tracked,
+                    self.durable_consumer_offsets.emptied_at(),
+                );
                 if let ConsumerOffsetAction::Ready(effect) = plan.action {
                     self.publish_no_ack_effect(effect);
                 } else {
@@ -4712,6 +4863,23 @@ where
         self.consumer_offset_dirs[kind.index()].as_deref()
     }
 
+    /// The marker beside the offset directories that names the committed
+    /// delete which emptied every kind. `None` unless every kind has a
+    /// directory under one parent, which is where boot reads it.
+    pub(crate) fn emptied_marker_path(&self) -> Option<String> {
+        let mut parents = ConsumerKind::ALL.into_iter().map(|kind| {
+            self.consumer_offsets_dir(kind)
+                .and_then(|dir| std::path::Path::new(dir).parent())
+        });
+        let parent = parents.next().flatten()?;
+        parents.all(|other| other == Some(parent)).then(|| {
+            parent
+                .join(crate::offset_storage::EMPTIED_MARKER_FILE)
+                .to_string_lossy()
+                .into_owned()
+        })
+    }
+
     pub(crate) const fn consumer_offset_capacity_for(
         &self,
         kind: ConsumerKind,
@@ -5442,12 +5610,12 @@ where
 
     pub fn defer_view_truncation(
         &mut self,
-        from_op: u64,
+        ops: std::ops::RangeInclusive<u64>,
         actions: Vec<consensus::VsrAction>,
         peer: Option<u8>,
     ) {
         self.transition = Some(PendingPartitionTransition::Truncate {
-            from_op,
+            ops,
             drain: None,
             actions,
             peer,
@@ -5455,6 +5623,14 @@ where
         self.notify_io();
     }
 
+    #[must_use]
+    pub const fn view_truncation_pending(&self) -> bool {
+        matches!(
+            self.transition,
+            Some(PendingPartitionTransition::Truncate { .. })
+        )
+    }
+
     #[must_use]
     pub fn retains_io_reservation(&self, identity: crate::PartitionIoIdentity) -> bool {
         identity.incarnation == self.incarnation
@@ -5645,12 +5821,11 @@ where
             let message = self.deferred_views.swap_remove(next);
             return Ok(crate::PartitionIoStep::Transition(message));
         }
-        if let Some(PendingPartitionTransition::Truncate { from_op, drain, .. }) =
-            &mut self.transition
+        if let Some(PendingPartitionTransition::Truncate { ops, drain, .. }) = &mut self.transition
         {
             if let Some(persistence) = &self.persistence {
                 if drain.is_none() {
-                    persistence.truncate_from(*from_op);
+                    persistence.truncate_from(*ops.start());
                     if persistence.start() {
                         self.consensus
                             .message_bus()
@@ -5668,15 +5843,12 @@ where
                 persistence.finish_drain(drain);
             }
             let Some(PendingPartitionTransition::Truncate {
-                from_op,
-                actions,
-                peer,
-                ..
+                ops, actions, peer, ..
             }) = self.transition.take()
             else {
                 unreachable!("truncate transition exists");
             };
-            self.truncate_resident_from(from_op).await?;
+            self.remove_resident_range(ops).await?;
             return Ok(crate::PartitionIoStep::ViewApplied { actions, peer });
         }
         if matches!(
@@ -5849,7 +6021,7 @@ where
                         .partition_dir
                         .clone()
                         .ok_or(IggyError::CannotDeleteFile)?,
-                    revision: self.created_revision,
+                    revision: self.created_revision(),
                     replicated: self.consensus.replica_count() > 1,
                 })
             }
@@ -6607,8 +6779,21 @@ where
             // State-dependent admission belongs to the primary. A backup can
             // lag the committed offset table or message frontier and would
             // otherwise turn a routing artifact into a terminal 404 or 400.
-            // Reject it first with the only response that proves the request
-            // was never admitted, so the caller may safely retry elsewhere.
+            // Reject it first with a response that proves the request was
+            // never admitted, so the caller may safely retry elsewhere. A
+            // normal backup names the cause, so the caller goes to the
+            // primary at once instead of backing off.
+            if consensus.is_normal_backup() {
+                Self::send_partition_deny_or_log(
+                    consensus,
+                    message.header(),
+                    IggyError::NotPrimary.as_code(),
+                    "not-primary reply send failed",
+                    reply.take(),
+                )
+                .await;
+                return;
+            }
             if self.fatal.is_some()
                 || self.maintenance.shutdown.is_some()
                 || self.io_active.is_retiring()
@@ -6669,13 +6854,14 @@ where
                 return;
             }
             if message.header().operation == Operation::RetireConsumerGroupOwners {
-                let valid = RetireConsumerGroupOwnersRequest::decode_from(message.body())
-                    .is_ok_and(|catalog| {
-                        catalog.incarnation == self.created_revision
+                let Some(catalog) = RetireConsumerGroupOwnersRequest::decode_from(message.body())
+                    .ok()
+                    .filter(|catalog| {
+                        catalog.incarnation == self.created_revision()
                             && catalog.metadata_op <= message.header().metadata_watermark
                             && !self.history_deleted()
-                    });
-                if !valid {
+                    })
+                else {
                     Self::send_partition_deny_or_log(
                         consensus,
                         message.header(),
@@ -6685,13 +6871,32 @@ where
                     )
                     .await;
                     return;
+                };
+                // An install admitted ahead of the catalog commits first and can
+                // assign a retired row again, which the catalog apply refuses.
+                if self.retirement_waits(|group_id| catalog.retires(group_id))
+                    || self
+                        .pending_owner_install
+                        .get()
+                        .is_some_and(|pending| catalog.retires(pending.group_id))
+                    || consensus.commit_min() < self.owner_install_barrier
+                {
+                    Self::send_partition_deny_or_log(
+                        consensus,
+                        message.header(),
+                        IggyError::TransientNotAccepted.as_code(),
+                        "owner retirement awaits offset cleanup or an owner install",
+                        reply.take(),
+                    )
+                    .await;
+                    return;
                 }
             }
             if message.header().operation == Operation::TransitionPartitionHistory {
                 let transition =
                     match TransitionPartitionHistoryRequest::decode_from(message.body()) {
                         Ok(transition)
-                            if transition.incarnation == self.created_revision
+                            if transition.incarnation == self.created_revision()
                                 && !self.history_deleted() =>
                         {
                             transition
@@ -6757,7 +6962,7 @@ where
                     let installation =
                         match InstallConsumerGroupOwnerRequest::decode_from(message.body()) {
                             Ok(installation)
-                                if installation.incarnation == self.created_revision
+                                if installation.incarnation == self.created_revision()
                                     && !self.history_deleted() =>
                             {
                                 installation
@@ -6829,6 +7034,17 @@ where
                         .await;
                         return;
                     }
+                    if self.conflicts_with_installed_owner(&installation) {
+                        Self::send_partition_deny_or_log(
+                            consensus,
+                            message.header(),
+                            IggyError::InvalidCommand.as_code(),
+                            "conflicting ownership installation",
+                            reply.take(),
+                        )
+                        .await;
+                        return;
+                    }
                     Some(installation)
                 } else {
                     None
@@ -6973,7 +7189,7 @@ where
                 ) {
                     consensus::client_table::RequestStatus::New => {}
                     consensus::client_table::RequestStatus::Duplicate(cached) => {
-                        if cached.header().partition_incarnation != self.created_revision {
+                        if cached.header().partition_incarnation != self.created_revision() {
                             Self::send_partition_deny_or_log(
                                 consensus,
                                 message.header(),
@@ -7062,6 +7278,26 @@ where
                 .await;
                 return;
             }
+            // A group offset write without a position carries owner 0. It
+            // takes the installed generation before the owner match, so
+            // promotion, the prepare and apply all see the bound owner.
+            let message = match consumer_offset {
+                Some((ConsumerKind::ConsumerGroup, consumer_id, _, _))
+                    if message.header().owner_generation == 0
+                        && !is_auto_commit_client(client_id) =>
+                {
+                    match self.captured_offset_owner(u64::from(consumer_id), message.header()) {
+                        Some(generation) => message.transmute_header(
+                            |mut header, captured: &mut RoutedRequestHeader| {
+                                header.owner_generation = generation;
+                                *captured = header;
+                            },
+                        ),
+                        None => message,
+                    }
+                }
+                _ => message,
+            };
             if let Some((kind, consumer_id, _, _)) = consumer_offset
                 && !self.matches_consumer_owner(
                     kind,
@@ -7374,11 +7610,12 @@ where
                 }
                 continue;
             }
-            if matches!(
+            let offset_request = matches!(
                 header.operation,
                 Operation::StoreConsumerOffset | Operation::DeleteConsumerOffset
-            ) && let Ok((kind, consumer_id, _, _)) =
-                Self::parse_consumer_offset_request(header.operation, &request.message)
+            )
+            .then(|| Self::parse_consumer_offset_request(header.operation, &request.message));
+            if let Some(Ok((kind, consumer_id, _, _))) = offset_request
                 && !self.matches_consumer_owner(
                     kind,
                     consumer_id,
@@ -7445,12 +7682,7 @@ where
                 }
                 continue;
             }
-            if matches!(
-                header.operation,
-                Operation::StoreConsumerOffset | Operation::DeleteConsumerOffset
-            ) {
-                let parsed =
-                    Self::parse_consumer_offset_request(header.operation, &request.message);
+            if let Some(parsed) = offset_request {
                 let (kind, consumer_id, offset, _) = match parsed {
                     Ok(parsed) => parsed,
                     Err(error) => {
@@ -7611,16 +7843,19 @@ where
         }
     }
 
+    /// Bytes of the prepare that `project` builds. An offset prepare is sized
+    /// from `header.size`, because an auto-commit buffer already reserves the
+    /// owner trailer past it.
     fn prepared_request_size(message: &Message<RoutedRequestHeader>) -> usize {
-        message.as_slice().len()
-            + if matches!(
-                message.header().operation,
-                Operation::StoreConsumerOffset | Operation::DeleteConsumerOffset
-            ) {
-                iggy_binary_protocol::requests::consumer_offsets::PREPARED_OWNER_GENERATION_SIZE
-            } else {
-                0
-            }
+        if matches!(
+            message.header().operation,
+            Operation::StoreConsumerOffset | Operation::DeleteConsumerOffset
+        ) {
+            message.header().size as usize
+                + iggy_binary_protocol::requests::consumer_offsets::PREPARED_OWNER_GENERATION_SIZE
+        } else {
+            message.as_slice().len()
+        }
     }
 
     async fn deny_queued_request(
@@ -8125,6 +8360,7 @@ where
         if self.fatal.is_some()
             || self.materialization_missing
             || self.commit_waits_for_checkpoint()
+            || self.consensus.view_log_unproven()
         {
             return;
         }
@@ -8195,7 +8431,9 @@ where
                 let Some(entry) = pipeline.entry_by_op(op) else {
                     break;
                 };
-                if !self.prepare_body_is_ready(&entry.header, materialize) {
+                if !self.consensus.view_log_entry_proven(entry.header.op)
+                    || !self.prepare_body_is_ready(&entry.header, materialize)
+                {
                     break;
                 }
                 if self.no_ack.front().is_some_and(|head| {
@@ -8206,9 +8444,6 @@ where
                     break;
                 }
                 selected.push(entry.header);
-                if entry.header.operation == Operation::TransitionPartitionHistory {
-                    break;
-                }
             }
             selected
         })
@@ -8272,24 +8507,16 @@ where
             .filter(|head| head.op >= from_op)
             .map_or(commit_max, |head| commit_max.min(head.op - 1));
         let materialize = self.should_persist_messages(config);
-        let mut drained = Vec::new();
-        for header in self
-            .log
+        self.log
             .journal()
             .inner
             .committed_headers_from(from_op, commit_max, max_ops)
-        {
-            if !self.prepare_body_is_ready(&header, materialize) {
-                break;
-            }
-            // Ops after the delete fence are validated only once it applies.
-            let fence = header.operation == Operation::TransitionPartitionHistory;
-            drained.push(header);
-            if fence {
-                break;
-            }
-        }
-        drained
+            .into_iter()
+            .take_while(|header| {
+                self.consensus.view_log_entry_proven(header.op)
+                    && self.prepare_body_is_ready(header, materialize)
+            })
+            .collect()
     }
 
     #[allow(clippy::too_many_lines)]
@@ -8542,11 +8769,47 @@ where
     }
 
     async fn truncate_resident_from(&mut self, from_op: u64) -> Result<usize, IggyError> {
+        self.remove_resident_range(from_op..=u64::MAX).await
+    }
+
+    /// Replace a proven mismatch while retaining the canonical tail across gaps.
+    ///
+    /// # Errors
+    /// Returns an error if the WAL or resident indexes cannot be rewritten.
+    pub async fn remove_divergent_prepare(&mut self, op: u64) -> Result<(), IggyError> {
+        if op
+            <= self
+                .consensus
+                .commit_min()
+                .max(self.log.journal().inner.evicted_through())
+        {
+            return Err(IggyError::InvalidCommand);
+        }
+        if let Some(persistence) = &self.persistence {
+            // The durable WAL is contiguous. A canonical tail beyond a divergent
+            // entry is repair-held and must be persisted again after the gap fills.
+            // Truncation invalidates the head certificate. A crash before refill
+            // requires state transfer; the old certificate cannot vouch for a prefix.
+            persistence.truncate_from(op);
+            self.start_persistence();
+            persistence
+                .drain_with_timeout()
+                .await
+                .map_err(|_| IggyError::CannotSyncFile)?;
+        }
+        self.remove_resident_range(op..=op).await?;
+        Ok(())
+    }
+
+    async fn remove_resident_range(
+        &mut self,
+        ops: std::ops::RangeInclusive<u64>,
+    ) -> Result<usize, IggyError> {
         let mut entries = self.log.journal().inner.resident_entries();
         entries.sort_unstable_by_key(peek_op);
         let mut retained_info = JournalInfo::default();
         let mut retained_next_offset = 0;
-        let mut rewind_next_offset = None;
+        let mut removed_messages = false;
         for entry in &entries {
             if peek_operation(entry) != Operation::SendMessages {
                 continue;
@@ -8556,12 +8819,8 @@ where
             if batch.message_count() == 0 {
                 continue;
             }
-            if peek_op(entry) >= from_op {
-                rewind_next_offset = Some(
-                    rewind_next_offset.map_or(batch.header.base_offset, |offset: u64| {
-                        offset.min(batch.header.base_offset)
-                    }),
-                );
+            if ops.contains(&peek_op(entry)) {
+                removed_messages = true;
                 continue;
             }
             accumulate_committed_info(
@@ -8600,14 +8859,14 @@ where
             .log
             .journal()
             .inner
-            .truncate_from(from_op)
+            .remove_range(ops.clone())
             .await
             .map_err(|_| IggyError::CannotAppendMessage)?;
 
         self.log.journal_mut().info = retained_info;
         self.log.active_segment_mut().current_position = restored_position;
-        if let Some(next_offset) = rewind_next_offset {
-            let next_offset = next_offset.max(minimum_next_offset);
+        if removed_messages {
+            let next_offset = minimum_next_offset;
             self.dirty_offset
                 .store(next_offset.saturating_sub(1), Ordering::Relaxed);
             // The APPEND bit follows the rewound counter. The committed bit does
@@ -8621,9 +8880,8 @@ where
             }
         }
         self.consensus.note_journal_mutation();
-        let commit_max = self.consensus.commit_max();
         self.pending_consumer_offset_commits
-            .retain(|op, _| *op < from_op || *op <= commit_max);
+            .retain(|op, _| !ops.contains(op));
         self.offset_reservations_need_resync.set(true);
         self.truncated_since_scan.set(true);
         Ok(removed)
@@ -9474,10 +9732,14 @@ where
         }
         let plan = self.plan_committed_offset(&header, pending)?;
         if let ConsumerOffsetAction::Ready(effect) = plan.action {
-            self.accept_committed_offset_effect(effect)?;
+            self.accept_committed_offset_effect(&plan, effect)?;
         } else {
             let mut batch = Vec::new();
-            if matches!(plan.action, ConsumerOffsetAction::Write { .. }) {
+            // A batch writes concurrently, so a store that retires the marker
+            // runs alone to keep the removal ahead of every write.
+            if matches!(plan.action, ConsumerOffsetAction::Write { .. })
+                && plan.emptied_at.is_none()
+            {
                 for (entry, header) in pending
                     .headers
                     .iter()
@@ -9579,7 +9841,27 @@ where
                 },
                 |effect| effect.state,
             );
-        Ok(self.plan_consumer_offset_effect(offset, tracked))
+        let emptied_at = pending.emptied_at.unwrap_or(pending.initial_emptied_at);
+        let mut plan = self.plan_consumer_offset_effect(offset, tracked, emptied_at);
+        if matches!(offset.mutation, PendingConsumerOffsetMutation::Delete) {
+            // Only a delete of the last key the table holds empties it. An
+            // absent key may be one whose file boot lost, and a stranded file
+            // may hold an offset the table never loaded.
+            //
+            // Accepted limit: a donor whose boot lost offsets (one file, every
+            // file or a kind directory) still marks the table emptied when it
+            // deletes the last key it holds, even one stored after the boot.
+            // A receiver that missed that delete then drops the lost keys too.
+            // A non-empty offer from that donor already drops them the same way.
+            plan.emptied_at = (emptied_at == 0
+                && tracked.is_some()
+                && pending.offset_count == 1
+                && ConsumerKind::ALL
+                    .into_iter()
+                    .all(|kind| self.consumer_offset_capacity_for(kind).stranded_count() == 0))
+            .then_some(header.op);
+        }
+        Ok(plan)
     }
 
     fn capture_commit_phase(
@@ -9684,7 +9966,7 @@ where
                                 plan,
                                 crate::io::PartitionIoResult::OffsetWrite(result),
                             )
-                            .and_then(|effect| self.accept_committed_offset_effect(effect));
+                            .and_then(|effect| self.accept_committed_offset_effect(&plan, effect));
                         if let Err(error) = outcome {
                             first_error.get_or_insert((entry, error));
                         }
@@ -9700,7 +9982,7 @@ where
                     }
                 } else {
                     let effect = self.accept_consumer_offset_effect(plan, result)?;
-                    self.accept_committed_offset_effect(effect)
+                    self.accept_committed_offset_effect(&plan, effect)
                 }
             }
             (
@@ -9753,6 +10035,7 @@ where
 
     fn accept_committed_offset_effect(
         &mut self,
+        plan: &ConsumerOffsetPlan,
         effect: ConsumerOffsetEffect,
     ) -> Result<(), IggyError> {
         let pending = self
@@ -9772,6 +10055,11 @@ where
         }
         self.consumer_offset_dirs_touched[effect.pending.kind.index()]
             .set(Some((header.op, header.operation)));
+        pending.offset_count = (pending.offset_count + usize::from(effect.state.is_some()))
+            .saturating_sub(usize::from(plan.tracked.is_some()));
+        if let Some(emptied_at) = plan.emptied_at {
+            pending.emptied_at = Some(emptied_at);
+        }
         pending.offset_effects.push(effect);
         pending.next_entry += 1;
         pending.phase = CommitPhase::Offsets;
@@ -9817,6 +10105,9 @@ where
             || headers
                 .windows(2)
                 .any(|pair| pair[0].op.checked_add(1) != Some(pair[1].op))
+            || headers
+                .iter()
+                .any(|header| !self.consensus.view_log_entry_proven(header.op))
         {
             return false;
         }
@@ -9859,6 +10150,12 @@ where
             owner_install: None,
             offset_effects: Vec::new(),
             offset_batch: Vec::new(),
+            offset_count: ConsumerKind::ALL
+                .into_iter()
+                .map(|kind| self.durable_consumer_offsets.count(kind))
+                .sum(),
+            initial_emptied_at: self.durable_consumer_offsets.emptied_at(),
+            emptied_at: None,
             incarnation: self.incarnation,
             poll_history: self.poll_history,
             applied_floor,
@@ -9947,6 +10244,9 @@ where
             self.publish_consumer_offset_effect(*effect);
             self.apply_consumer_offset_commit(effect.pending);
         }
+        if let Some(emptied_at) = pending.emptied_at {
+            self.durable_consumer_offsets.set_emptied_at(emptied_at);
+        }
         let mut retirements = pending.retirements.into_iter().peekable();
         let mut committed = Vec::with_capacity(entries.len());
         let mut offset_keys = HashSet::new();
@@ -10072,7 +10372,7 @@ where
                     | Operation::DeleteConsumerOffset
                     | Operation::InstallConsumerGroupOwner
                     | Operation::RetireConsumerGroupOwners
-            ) && (header.partition_incarnation != self.created_revision
+            ) && (header.partition_incarnation != self.created_revision()
                 || self.history_deleted())
             {
                 self.fence_failed_commit(*header);
@@ -11317,13 +11617,20 @@ where
         nth_oldest_sealed_end(self.log.segments(), count)
     }
 
+    /// Settle the session floor that a `RangeEvicted` reply reports.
+    pub const fn note_repair_floor(&mut self, floor: u64) {
+        if let Some(session) = self.repair.as_mut() {
+            session.floor = Some(floor);
+        }
+    }
+
     /// Ingest one repaired prepare: journal + stage it exactly like a live
     /// replicated op, minus the view fence, the gap check, and the ack (the
     /// op is already committed cluster-wide; there is nobody to ack to). The
     /// commit walk runs at `RepairDone`, after the floor is known.
     pub async fn apply_repaired_prepare(&mut self, message: Message<PrepareHeader>) {
         let header = *message.header();
-        if !self.prepare_history_available(&header) {
+        if self.view_truncation_pending() || !self.prepare_history_available(&header) {
             return;
         }
         let Some(session) = self.repair else {
@@ -11346,7 +11653,10 @@ where
                 pending
                     .headers
                     .iter()
-                    .find(|expected| expected.op == header.op)
+                    .find(|expected| {
+                        expected.op == header.op
+                            && dvc_header_kind(expected) != DvcHeaderKind::Blank
+                    })
                     .map(|expected| expected.checksum)
             })
             .flatten();
@@ -11355,11 +11665,19 @@ where
         {
             return;
         }
-        // Any in-window frame proves the stream is alive; only silence
-        // should age the stall counter or spend the rotation budget.
+        // A held frame is no progress: a peer that re-serves the same chunk
+        // every round must still spend the budget, or it cannot be rotated
+        // away from.
+        if let Some(held) = self.log.journal().inner.header_by_op(header.op) {
+            if held.checksum == header.checksum {
+                self.consensus.note_repaired_view_header(header);
+            }
+            return;
+        }
+        // Any other in-window frame proves the stream is alive; only silence
+        // should age the stall counter.
         if let Some(session) = self.repair.as_mut() {
             session.idle_ticks = 0;
-            self.repair_attempts = 0;
         }
         if self.materialization_missing
             || self.transition.is_some()
@@ -11368,27 +11686,13 @@ where
             || self.maintenance.flush_drain.is_some()
             || self.maintenance.shutdown.is_some()
         {
-            return;
-        }
-        if self.log.journal().inner.holds_op(header.op) {
+            // The pause keeps this frame out of the journal, not a fault in
+            // the frame, so it still refunds the budget.
+            self.restart_repair_budget();
             return;
         }
         let applied = if header.operation == Operation::SendMessages {
-            match self.append_repaired_send_messages(message).await {
-                Ok(base_offset) => {
-                    if header.op <= session.commit_to_op
-                        && let Some(session) = self.repair.as_mut()
-                    {
-                        session.first_batch_offset = Some(
-                            session
-                                .first_batch_offset
-                                .map_or(base_offset, |first| first.min(base_offset)),
-                        );
-                    }
-                    Ok(())
-                }
-                Err(error) => Err(error),
-            }
+            self.append_repaired_send_messages(message).await
         } else {
             self.apply_replicated_operation(message).await.map(|_| ())
         };
@@ -11403,6 +11707,11 @@ where
             );
             return;
         }
+        // A frame the journal refuses is no progress: it stays not held, so
+        // its peer serves it again every round, and a refund per serve would
+        // pin the session to that peer.
+        self.restart_repair_budget();
+        self.consensus.note_repaired_view_header(header);
         self.persist_repaired_prefix();
         // Advance the sequencer only along the CONTIGUOUS journaled
         // frontier. DVC advertises `op = sequencer.current_sequence()` and
@@ -11450,6 +11759,24 @@ where
                 .repaired_window_shape(self.consensus().commit_min(), floor)
                 .complete
         });
+        self.persist_repaired_prefix();
+        // With a prepare WAL every op in `(commit_min, head]` is resident, so a
+        // floor the filter kept is above the head. The WAL grows only at
+        // `head + 1` and the peer retains nothing at or below the floor, so
+        // this floor can never become durable here and its refusal is final.
+        if let Some(floor) = session.floor
+            && self
+                .persistence
+                .as_ref()
+                .is_some_and(|persistence| floor > persistence.head())
+        {
+            self.commit_journal(config).await;
+            self.repair = None;
+            return RepairConclusion::FloorRefused {
+                floor,
+                to_op: session.commit_to_op,
+            };
+        }
         // A floor at or below the live commit point is moot: it cannot move
         // `commit_min`, so nothing below it is skipped and the connection
         // check has no jump to guard. Verifying it could only refuse: below
@@ -11460,6 +11787,15 @@ where
             .floor
             .filter(|&floor| floor > self.consensus().commit_min())
         {
+            // Offset continuity cannot certify that recovered bytes belong to
+            // the adopted history. An evicted proof gap needs state transfer.
+            if self.consensus().view_log_unproven() {
+                self.repair = None;
+                return RepairConclusion::FloorRefused {
+                    floor,
+                    to_op: session.commit_to_op,
+                };
+            }
             // A peer may have evicted past this replica's commit frontier;
             // an unclamped floor would drive commit_min above commit_max and
             // panic the next advance.
@@ -11480,29 +11816,19 @@ where
                 .map(|durable| durable.saturating_add(1))
                 .max(self.installed_frontier);
             let committed_shape = journal.repaired_window_shape(floor, session.commit_to_op);
-            let connected = match (session.first_batch_offset, stand_in) {
+            // Read at check time, not collected per frame: the journal keeps
+            // each batch's own stamps, so a batch journaled before this floor
+            // settled anchors it as well as one served after.
+            let first_batch_offset = journal.first_batch_offset(floor, session.commit_to_op);
+            let connected = match (first_batch_offset, stand_in) {
                 (Some(first), Some(bound)) => first <= bound,
                 (Some(first), None) => first == 0,
-                // No repaired batch arrived, so there is no offset anchor to
-                // verify the floor's continuum claim against. `None` is only
-                // safe when the served window itself proves it carried no
-                // messages: every op in `(floor, to_op]` journaled and none
-                // of them `SendMessages`. Anything less -- dropped frames, or
-                // a fully evicted window -- is indistinguishable from a
-                // message range below the floor that this replica does not
-                // durably own, and accepting it would serve a holed log.
-                // A replica with no committed message transfers too: a jump
-                // would leave its counter at 0 while the group may have
-                // committed messages below the floor. Such a replica then
-                // moves its commit point only by walking each op, by a window
-                // whose first batch starts at offset 0, or by an install, so
-                // its empty offer as primary is real state.
-                (None, _) => {
-                    self.offset_frontier() > 0
-                        && floor < session.commit_to_op
-                        && committed_shape.complete
-                        && !committed_shape.holds_messages
-                }
+                // No batch above the floor, so nothing ties the floor to this
+                // replica's offsets. The floor stands only while this journal
+                // lacks an op up to the reported floor, and no shape of the
+                // window above it shows that the ops the jump skips carried no
+                // messages. Refused, the window goes to state transfer.
+                (None, _) => false,
             };
             if !connected {
                 tracing::error!(
@@ -11510,16 +11836,16 @@ where
                     plane = "partitions",
                     namespace_raw = self.namespace().inner(),
                     floor,
-                    first_batch_offset = ?session.first_batch_offset,
+                    first_batch_offset = ?first_batch_offset,
                     recovered_durable_offset = ?durable_end,
                     "refusing commit floor: repaired window does not connect \
                      to recovered durable state (needs state transfer)"
                 );
                 self.commit_journal(config).await;
                 // A refusal is DEFINITIVE only once the window itself is
-                // fully present (or provably empty): until then more frames
-                // can still lower `first_batch_offset` into connection, so
-                // the session stays armed and the stall retry re-requests.
+                // fully present (or provably empty): until then a frame can
+                // still land a lower first batch that connects, so the
+                // session stays armed and the stall retry re-requests.
                 // A complete window that still cannot connect will never
                 // improve -- the peer retains nothing below the floor and
                 // this replica holds nothing either -- and an EMPTY window
@@ -11540,8 +11866,12 @@ where
                 self.consensus().set_commit_floor(floor);
             }
         }
-        if let Some(conclusion) = self.repair_persistence_pending(session) {
-            return conclusion;
+        if self
+            .persistence
+            .as_ref()
+            .is_some_and(|persistence| !persistence.is_durable_through(session.fetch_to_op))
+        {
+            return RepairConclusion::InProgress;
         }
         let before = self.consensus().commit_min();
         self.commit_journal(config).await;
@@ -11599,26 +11929,6 @@ where
         }
     }
 
-    fn repair_persistence_pending(&mut self, session: RepairSession) -> Option<RepairConclusion> {
-        if let Some(persistence) = &self.persistence {
-            self.persist_repaired_prefix();
-            if session
-                .floor
-                .is_some_and(|floor| floor > persistence.head())
-            {
-                self.repair = None;
-                return Some(RepairConclusion::FloorRefused {
-                    floor: session.floor.unwrap_or(0),
-                    to_op: session.commit_to_op,
-                });
-            }
-            if !persistence.is_durable_through(session.fetch_to_op) {
-                return Some(RepairConclusion::InProgress);
-            }
-        }
-        None
-    }
-
     /// Journal a repaired `SendMessages` prepare, preserving its embedded
     /// batch stamps. A stored prepare was stamped by `append_messages` on
     /// the serving replica BEFORE it was journaled, so its `base_offset` /
@@ -11631,7 +11941,7 @@ where
     async fn append_repaired_send_messages(
         &mut self,
         message: Message<PrepareHeader>,
-    ) -> Result<u64, IggyError> {
+    ) -> Result<(), IggyError> {
         let write_lock = self.write_lock.clone();
         let _guard = write_lock.lock().await;
         if !self.prepare_history_available(message.header()) {
@@ -11689,21 +11999,21 @@ where
             .store(dirty.max(last_offset), Ordering::Relaxed);
         self.log.segments_mut()[segment_index].current_position = next_position;
         self.log.journal_mut().info = journal_info;
-        Ok(base_offset)
+        Ok(())
     }
 
-    /// Admission and queue promotion share this rule. Session retirement
-    /// carries no incarnation, and a lifecycle fence names its incarnation in
-    /// its body, which admission checks.
+    /// Retirement requests name sessions rather than histories; their prepares
+    /// carry this partition's incarnation. A lifecycle fence names its
+    /// incarnation in its body, which admission checks.
     fn request_history_available(&self, header: &RoutedRequestHeader) -> bool {
         header.operation == Operation::RetireSession
             || header.operation.is_partition_lifecycle()
-            || (header.partition_incarnation == self.created_revision && !self.history_deleted())
+            || (header.partition_incarnation == self.created_revision() && !self.history_deleted())
     }
 
-    fn prepare_history_available(&self, header: &PrepareHeader) -> bool {
-        header.operation == Operation::RetireSession
-            || (header.partition_incarnation == self.created_revision && !self.history_deleted())
+    const fn prepare_history_available(&self, header: &PrepareHeader) -> bool {
+        header.partition_incarnation == self.created_revision()
+            && (matches!(header.operation, Operation::RetireSession) || !self.history_deleted())
     }
 
     async fn send_prepare_ok(&self, header: &PrepareHeader) -> bool {
@@ -12212,7 +12522,7 @@ mod tests {
     use super::*;
     use crate::iggy_index::{IGGY_INDEX_SIZE, IggyIndex, IggyIndexCache};
     use crate::iggy_index_reader::IggyIndexReader;
-    use crate::offset_storage::persist_offset;
+    use crate::offset_storage::{OffsetRecord, decode_offset_record, persist_offset};
     use crate::poll_plan::{DiskReadOutcome, SealedSegmentHandle};
     use crate::{IggyIndexWriter, MessagesWriter};
     use bytes::{Bytes, BytesMut};
@@ -12223,7 +12533,9 @@ mod tests {
         Command, CommitHeader, ReplyHeader, StartViewHeader, WireConsumer, WireEncode, WireName,
     };
     use journal::DurableAppend;
-    use journal::durable_storage::DiskStorage;
+    use journal::durable_storage::{
+        DiskStorage, DurableStorage, OpenMode, RegularFiles, StorageEntry,
+    };
     use message_bus::{BusMessage, SendError};
     use server_common::MESSAGE_ALIGN;
     use server_common::iobuf::Owned;
@@ -12744,6 +13056,7 @@ mod tests {
                     prepare_checksum: Some(prepare.header().checksum),
                     checkpoint_prepare: prepare.as_slice().to_vec(),
                     next_offset: 1,
+                    emptied_at: 0,
                     consumers: Vec::new(),
                     groups: Vec::new(),
                     external_groups: Vec::new(),
@@ -12814,6 +13127,7 @@ mod tests {
             prepare_checksum: Some(last.header().checksum),
             checkpoint_prepare: last.as_slice().to_vec(),
             next_offset: 3,
+            emptied_at: 0,
             consumers: Vec::new(),
             groups: Vec::new(),
             external_groups: Vec::new(),
@@ -12863,6 +13177,7 @@ mod tests {
             prepare_checksum: Some(checksum),
             checkpoint_prepare: prepare.as_slice().to_vec(),
             next_offset: 1,
+            emptied_at: 0,
             consumers: Vec::new(),
             groups: Vec::new(),
             external_groups: Vec::new(),
@@ -13046,6 +13361,7 @@ mod tests {
             prepare_checksum: Some(prepare.header().checksum),
             checkpoint_prepare: prepare.as_slice().to_vec(),
             next_offset: 1,
+            emptied_at: 0,
             consumers: Vec::new(),
             groups: Vec::new(),
             external_groups: Vec::new(),
@@ -14057,19 +14373,50 @@ mod tests {
     }
 
     #[compio::test]
-    async fn live_wal_rollback_preserves_replacement_indexes_and_pollable_bodies() {
-        let root = tempfile::tempdir().unwrap();
-        let mut config = repair_config();
-        config.path_layout.streams_root = root.path().to_string_lossy().into_owned();
-        let directory = std::path::PathBuf::from(config.get_partition_path(1, 1, 0));
-        std::fs::create_dir_all(&directory).unwrap();
+    async fn given_a_discarded_suffix_with_an_offset_gap_when_truncated_should_reuse_the_retained_frontier()
+     {
         let mut partition = partition_at_view(0, 0);
-        partition.set_partition_dir(directory.to_string_lossy().into_owned());
-        partition.runtime_options.durability = iggy_common::Durability::Persisted;
-        let first = checksummed_segment_prepare(1, 0, 0, b"first");
-        let parent = first.header().checksum;
-        let segment_size =
-            IggyByteSize::from((first.as_slice().len() - size_of::<PrepareHeader>()) as u64);
+        let retained = checksummed_segment_prepare(1, 0, 0, b"retained");
+        let parent = retained.header().checksum;
+        partition
+            .append_repaired_send_messages(retained)
+            .await
+            .unwrap();
+        let discarded = checksummed_segment_prepare(3, parent, 2, b"discarded");
+        partition
+            .append_repaired_send_messages(discarded)
+            .await
+            .unwrap();
+        partition.consensus.sequencer().set_sequence(3);
+
+        partition.truncate_uncommitted_from(2).await.unwrap();
+
+        assert_eq!(partition.dirty_offset.load(Ordering::Relaxed), 0);
+        let replacement = checksummed_segment_prepare(2, parent, 1, b"replacement");
+        partition
+            .append_received_send_messages_to_journal(replacement)
+            .await
+            .unwrap();
+        assert_eq!(partition.dirty_offset.load(Ordering::Relaxed), 1);
+        assert!(partition.log.journal().inner.holds_op(1));
+        assert!(partition.log.journal().inner.holds_op(2));
+        assert!(!partition.log.journal().inner.holds_op(3));
+    }
+
+    #[compio::test]
+    async fn live_wal_rollback_preserves_replacement_indexes_and_pollable_bodies() {
+        let root = tempfile::tempdir().unwrap();
+        let mut config = repair_config();
+        config.path_layout.streams_root = root.path().to_string_lossy().into_owned();
+        let directory = std::path::PathBuf::from(config.get_partition_path(1, 1, 0));
+        std::fs::create_dir_all(&directory).unwrap();
+        let mut partition = partition_at_view(0, 0);
+        partition.set_partition_dir(directory.to_string_lossy().into_owned());
+        partition.runtime_options.durability = iggy_common::Durability::Persisted;
+        let first = checksummed_segment_prepare(1, 0, 0, b"first");
+        let parent = first.header().checksum;
+        let segment_size =
+            IggyByteSize::from((first.as_slice().len() - size_of::<PrepareHeader>()) as u64);
... 61796 lines suppressed ...